From 4f8970ff5cff34a88b3608ac648d9c7aa2c2db1e Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 11 Mar 2026 15:40:41 +0800 Subject: [PATCH] feat: add user management features including user creation, editing, and role management - Implemented user API functions for fetching, creating, updating, and managing users. - Created a UsersPage component for displaying and managing users with search and pagination. - Added types for user data and mutation payloads. - Introduced a LegacyRouteRedirect component for handling legacy routes. - Defined settings-related types for better type safety in settings management. --- .../web/app/(dashboard)/files/page.tsx | 11 +- atsf_server/web/app/(dashboard)/page.tsx | 2 +- .../web/app/(dashboard)/settings/page.tsx | 11 +- .../web/app/(dashboard)/users/page.tsx | 11 +- atsf_server/web/app/(public)/about/page.tsx | 5 + atsf_server/web/app/file/page.tsx | 5 + atsf_server/web/app/setting/page.tsx | 5 + atsf_server/web/app/user/add/page.tsx | 5 + atsf_server/web/app/user/edit/[id]/page.tsx | 5 + atsf_server/web/app/user/edit/page.tsx | 5 + atsf_server/web/app/user/page.tsx | 5 + .../components/layout/dashboard-sidebar.tsx | 2 +- .../web/components/layout/public-shell.tsx | 13 +- .../features/about/components/about-page.tsx | 106 ++ .../components/dashboard-overview.tsx | 20 +- atsf_server/web/features/files/api/files.ts | 56 + .../features/files/components/files-page.tsx | 374 +++++ atsf_server/web/features/files/types.ts | 10 + .../web/features/settings/api/settings.ts | 62 + .../settings/components/settings-page.tsx | 1224 +++++++++++++++++ atsf_server/web/features/settings/types.ts | 25 + .../components/legacy-route-redirect.tsx | 24 + atsf_server/web/features/users/api/users.ts | 41 + .../features/users/components/users-page.tsx | 492 +++++++ atsf_server/web/features/users/types.ts | 24 + atsf_server/web/lib/api/client.ts | 11 +- atsf_server/web/lib/constants/navigation.ts | 6 +- atsf_server/web/lib/utils/date.ts | 3 +- atsf_server/web/package.json | 103 +- atsf_server/web/pnpm-lock.yaml | 10 + 30 files changed, 2577 insertions(+), 99 deletions(-) create mode 100644 atsf_server/web/app/(public)/about/page.tsx create mode 100644 atsf_server/web/app/file/page.tsx create mode 100644 atsf_server/web/app/setting/page.tsx create mode 100644 atsf_server/web/app/user/add/page.tsx create mode 100644 atsf_server/web/app/user/edit/[id]/page.tsx create mode 100644 atsf_server/web/app/user/edit/page.tsx create mode 100644 atsf_server/web/app/user/page.tsx create mode 100644 atsf_server/web/features/about/components/about-page.tsx create mode 100644 atsf_server/web/features/files/api/files.ts create mode 100644 atsf_server/web/features/files/components/files-page.tsx create mode 100644 atsf_server/web/features/files/types.ts create mode 100644 atsf_server/web/features/settings/api/settings.ts create mode 100644 atsf_server/web/features/settings/components/settings-page.tsx create mode 100644 atsf_server/web/features/settings/types.ts create mode 100644 atsf_server/web/features/shared/components/legacy-route-redirect.tsx create mode 100644 atsf_server/web/features/users/api/users.ts create mode 100644 atsf_server/web/features/users/components/users-page.tsx create mode 100644 atsf_server/web/features/users/types.ts diff --git a/atsf_server/web/app/(dashboard)/files/page.tsx b/atsf_server/web/app/(dashboard)/files/page.tsx index 11186587..2c0eb36c 100644 --- a/atsf_server/web/app/(dashboard)/files/page.tsx +++ b/atsf_server/web/app/(dashboard)/files/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { FilesPage } from '@/features/files/components/files-page'; -export default function FilesPage() { - return ( - - ); +export default function FilesRoute() { + return ; } diff --git a/atsf_server/web/app/(dashboard)/page.tsx b/atsf_server/web/app/(dashboard)/page.tsx index 7e1a0bd6..e23f6a89 100644 --- a/atsf_server/web/app/(dashboard)/page.tsx +++ b/atsf_server/web/app/(dashboard)/page.tsx @@ -6,7 +6,7 @@ export default function DashboardPage() {
diff --git a/atsf_server/web/app/(dashboard)/settings/page.tsx b/atsf_server/web/app/(dashboard)/settings/page.tsx index aaf7a690..d39a5433 100644 --- a/atsf_server/web/app/(dashboard)/settings/page.tsx +++ b/atsf_server/web/app/(dashboard)/settings/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { SettingsPage } from '@/features/settings/components/settings-page'; -export default function SettingsPage() { - return ( - - ); +export default function SettingsRoute() { + return ; } diff --git a/atsf_server/web/app/(dashboard)/users/page.tsx b/atsf_server/web/app/(dashboard)/users/page.tsx index acb93af9..ac90dac5 100644 --- a/atsf_server/web/app/(dashboard)/users/page.tsx +++ b/atsf_server/web/app/(dashboard)/users/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { UsersPage } from '@/features/users/components/users-page'; -export default function UsersPage() { - return ( - - ); +export default function UsersRoute() { + return ; } diff --git a/atsf_server/web/app/(public)/about/page.tsx b/atsf_server/web/app/(public)/about/page.tsx new file mode 100644 index 00000000..5240390b --- /dev/null +++ b/atsf_server/web/app/(public)/about/page.tsx @@ -0,0 +1,5 @@ +import { AboutPage } from '@/features/about/components/about-page'; + +export default function AboutRoute() { + return ; +} diff --git a/atsf_server/web/app/file/page.tsx b/atsf_server/web/app/file/page.tsx new file mode 100644 index 00000000..4b0c859d --- /dev/null +++ b/atsf_server/web/app/file/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyFileRoute() { + return ; +} diff --git a/atsf_server/web/app/setting/page.tsx b/atsf_server/web/app/setting/page.tsx new file mode 100644 index 00000000..f610c844 --- /dev/null +++ b/atsf_server/web/app/setting/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacySettingRoute() { + return ; +} diff --git a/atsf_server/web/app/user/add/page.tsx b/atsf_server/web/app/user/add/page.tsx new file mode 100644 index 00000000..487e4853 --- /dev/null +++ b/atsf_server/web/app/user/add/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyAddUserRoute() { + return ; +} diff --git a/atsf_server/web/app/user/edit/[id]/page.tsx b/atsf_server/web/app/user/edit/[id]/page.tsx new file mode 100644 index 00000000..80be4575 --- /dev/null +++ b/atsf_server/web/app/user/edit/[id]/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyEditUserRoute() { + return ; +} diff --git a/atsf_server/web/app/user/edit/page.tsx b/atsf_server/web/app/user/edit/page.tsx new file mode 100644 index 00000000..f84636e4 --- /dev/null +++ b/atsf_server/web/app/user/edit/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyEditCurrentUserRoute() { + return ; +} diff --git a/atsf_server/web/app/user/page.tsx b/atsf_server/web/app/user/page.tsx new file mode 100644 index 00000000..e970dff6 --- /dev/null +++ b/atsf_server/web/app/user/page.tsx @@ -0,0 +1,5 @@ +import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect'; + +export default function LegacyUserRoute() { + return ; +} diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx index e8be5f04..31da3041 100644 --- a/atsf_server/web/components/layout/dashboard-sidebar.tsx +++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx @@ -66,7 +66,7 @@ export function DashboardSidebar() { {!isSidebarCollapsed ? (
- 阶段 1 已完成目录骨架、静态导出与基础布局初始化。 + 阶段 4 已接入设置、用户、文件等边缘模块,旧前端仅保留少量兼容兜底价值。
) : null} diff --git a/atsf_server/web/components/layout/public-shell.tsx b/atsf_server/web/components/layout/public-shell.tsx index 188438a4..3ee87950 100644 --- a/atsf_server/web/components/layout/public-shell.tsx +++ b/atsf_server/web/components/layout/public-shell.tsx @@ -17,7 +17,7 @@ export function PublicShell({ children }: PublicShellProps) {

ATSFlare

欢迎使用 ATSFlare 管理端

- 当前已接入登录、注册、密码重置与 GitHub OAuth 基础流程,后续将继续完善鉴权与业务模块体验。 + 当前已接入认证入口、核心业务模块以及阶段 4 的设置、用户、文件与关于页迁移。

@@ -31,9 +31,14 @@ export function PublicShell({ children }: PublicShellProps) {
{children}
- - 返回新版总览 - +
+ + 返回新版总览 + + + 查看关于页 + +
diff --git a/atsf_server/web/features/about/components/about-page.tsx b/atsf_server/web/features/about/components/about-page.tsx new file mode 100644 index 00000000..bc15680b --- /dev/null +++ b/atsf_server/web/features/about/components/about-page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { marked } from 'marked'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getPublicStatus } from '@/features/auth/api/public'; +import { getAboutContent } from '@/features/settings/api/settings'; +import { formatDateTime } from '@/lib/utils/date'; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +export function AboutPage() { + const aboutQuery = useQuery({ + queryKey: ['about'], + queryFn: getAboutContent, + }); + + const statusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, + }); + + if (aboutQuery.isLoading || statusQuery.isLoading) { + return ; + } + + if (aboutQuery.isError) { + return ; + } + + if (statusQuery.isError) { + return ; + } + + const aboutContent = aboutQuery.data?.trim() ?? ''; + const status = statusQuery.data; + + if (!status) { + return ; + } + + return ( +
+ } + > +
+
+

系统名称

+

{status.system_name || 'ATSFlare'}

+
+
+

Server 启动时间

+

+ {formatDateTime(new Date(status.start_time * 1000))} +

+
+ +
+
+ + {aboutContent ? ( + +
+ + ) : ( + + )} + +
+ + 返回新版总览 + + + 前往登录 + +
+
+ ); +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index c1c39cdd..9a0c08b6 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -14,8 +14,8 @@ const readinessItems = [ description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。', }, { - title: '核心模块', - description: '阶段 3 已接入反代规则、配置版本与节点页面,其余核心模块可继续按优先级接入。', + title: '业务模块', + description: '核心链路与阶段 4 的设置、用户、文件模块都已接入真实数据与交互。', }, ]; @@ -23,9 +23,9 @@ export function DashboardOverview() { return (
} + title='阶段 4 进行中' + description='当前已完成新版管理端基础工程、核心模块和大部分边缘模块迁移,可继续推进联调与回归。' + action={} >
{readinessItems.map((item) => ( @@ -43,7 +43,7 @@ export function DashboardOverview() {
- +
{dashboardNavigation.slice(1).map((item) => ( - +
    -
  1. 1. 继续迁移节点、域名、证书与应用记录页面。
  2. -
  3. 2. 为核心动作补齐确认、反馈与错误提示的一致体验。
  4. -
  5. 3. 补充模块测试与静态构建回归验证。
  6. +
  7. 1. 对照旧前端完成设置、用户、文件与关于页的联调回归。
  8. +
  9. 2. 补齐关键页面的测试覆盖与构建验收。
  10. +
  11. 3. 评估默认入口切换与旧前端回滚预案。
diff --git a/atsf_server/web/features/files/api/files.ts b/atsf_server/web/features/files/api/files.ts new file mode 100644 index 00000000..11e9a1eb --- /dev/null +++ b/atsf_server/web/features/files/api/files.ts @@ -0,0 +1,56 @@ +import { apiRequest, getApiUrl } from '@/lib/api/client'; + +import type { FileItem } from '@/features/files/types'; + +export function getFiles(page: number) { + return apiRequest(`/file/?p=${page}`); +} + +export function searchFiles(keyword: string) { + return apiRequest(`/file/search?keyword=${encodeURIComponent(keyword)}`); +} + +export function deleteFile(id: number) { + return apiRequest(`/file/${id}`, { + method: 'DELETE', + }); +} + +export function buildFileDownloadUrl(link: string) { + return `/upload/${link}`; +} + +export function buildFileAbsoluteUrl(link: string) { + if (typeof window === 'undefined') { + return buildFileDownloadUrl(link); + } + + return new URL(buildFileDownloadUrl(link), window.location.origin).toString(); +} + +export async function uploadFiles(files: File[], description?: string) { + const formData = new FormData(); + + files.forEach((file) => { + formData.append('file', file); + }); + + if (description) { + formData.append('description', description); + } + + const response = await fetch(getApiUrl('/file/'), { + method: 'POST', + credentials: 'include', + body: formData, + }); + + const payload = (await response.json()) as { + success: boolean; + message: string; + }; + + if (!response.ok || !payload.success) { + throw new Error(payload.message || `上传失败(${response.status})`); + } +} diff --git a/atsf_server/web/features/files/components/files-page.tsx b/atsf_server/web/features/files/components/files-page.tsx new file mode 100644 index 00000000..e1abed4b --- /dev/null +++ b/atsf_server/web/features/files/components/files-page.tsx @@ -0,0 +1,374 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { useAuth } from '@/components/providers/auth-provider'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { + buildFileAbsoluteUrl, + buildFileDownloadUrl, + deleteFile, + getFiles, + searchFiles, + uploadFiles, +} from '@/features/files/api/files'; +import type { FileItem } from '@/features/files/types'; +import { + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const ITEMS_PER_PAGE = 10; +const filesQueryKey = ['files', 'list'] as const; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +async function copyToClipboard(value: string) { + await navigator.clipboard.writeText(value); +} + +export function FilesPage() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [page, setPage] = useState(0); + const [searchInput, setSearchInput] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [description, setDescription] = useState(''); + const [selectedFiles, setSelectedFiles] = useState([]); + const [feedback, setFeedback] = useState(null); + + const isAdmin = (user?.role ?? 0) >= 10; + + const filesPageQuery = useQuery({ + queryKey: [...filesQueryKey, page], + queryFn: () => getFiles(page), + enabled: isAdmin && searchKeyword.length === 0, + }); + + const filesSearchQuery = useQuery({ + queryKey: [...filesQueryKey, 'search', searchKeyword], + queryFn: () => searchFiles(searchKeyword), + enabled: isAdmin && searchKeyword.length > 0, + }); + + const uploadMutation = useMutation({ + mutationFn: async () => uploadFiles(selectedFiles, description.trim()), + onSuccess: async () => { + setFeedback({ tone: 'success', message: `已上传 ${selectedFiles.length} 个文件。` }); + setSelectedFiles([]); + setDescription(''); + await queryClient.invalidateQueries({ queryKey: filesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteFile, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '文件已删除。' }); + await queryClient.invalidateQueries({ queryKey: filesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const activeQuery = searchKeyword.length > 0 ? filesSearchQuery : filesPageQuery; + const files = activeQuery.data ?? []; + + const summary = useMemo(() => { + return [ + { label: searchKeyword ? '搜索结果' : '当前页文件', value: files.length }, + { label: '当前上传批次', value: selectedFiles.length }, + { + label: '下载总次数', + value: files.reduce((total, item) => total + (item.download_counter || 0), 0), + }, + { + label: '上传者数', + value: new Set(files.map((item) => item.uploader_id)).size, + }, + ]; + }, [files, searchKeyword, selectedFiles.length]); + + const handleUpload = () => { + if (selectedFiles.length === 0) { + setFeedback({ tone: 'info', message: '请先选择要上传的文件。' }); + return; + } + + setFeedback(null); + uploadMutation.mutate(); + }; + + const handleDelete = (file: FileItem) => { + if (!window.confirm(`确认删除文件“${file.filename}”吗?`)) { + return; + } + + setFeedback(null); + deleteMutation.mutate(file.id); + }; + + const handleCopy = async (link: string) => { + try { + await copyToClipboard(buildFileAbsoluteUrl(link)); + setFeedback({ tone: 'success', message: '文件链接已复制到剪贴板。' }); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + + const handleSearchSubmit = () => { + setFeedback(null); + setPage(0); + setSearchKeyword(searchInput.trim()); + }; + + const handleResetSearch = () => { + setSearchInput(''); + setSearchKeyword(''); + setPage(0); + setFeedback(null); + }; + + if (!isAdmin) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + {feedback ? : null} + +
+ +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + + {uploadMutation.isPending ? '上传中...' : '开始上传'} + + } + > +
+ + setDescription(event.target.value)} + placeholder='例如:节点安装包 / 测试证书 / 宣传素材' + /> + + + + setSelectedFiles(Array.from(event.target.files ?? []))} + className='block w-full rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-6 text-sm text-[var(--foreground-secondary)] file:mr-4 file:rounded-xl file:border-0 file:bg-[var(--brand-primary)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[var(--foreground-inverse)]' + /> + + + {selectedFiles.length > 0 ? ( +
+

待上传文件

+
    + {selectedFiles.map((file) => ( +
  • + {file.name} · {(file.size / 1024).toFixed(1)} KB +
  • + ))} +
+
+ ) : ( + + )} +
+
+
+ + void queryClient.invalidateQueries({ queryKey: filesQueryKey })} + > + 刷新 + + } + > +
+
+
+ setSearchInput(event.target.value)} + placeholder='搜索文件名、上传者或上传者 ID' + className='md:max-w-xl' + /> +
+ + 搜索 + + + 清空 + +
+
+ + {searchKeyword.length === 0 ? ( +
+ setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}> + 上一页 + + setPage((value) => value + 1)} + disabled={activeQuery.isLoading || files.length < ITEMS_PER_PAGE} + > + 下一页 + +
+ ) : null} +
+ + {activeQuery.isLoading ? ( + + ) : activeQuery.isError ? ( + + ) : files.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {files.map((file) => ( + + + + + + + + ))} + +
文件上传者上传时间下载量操作
+
+ + {file.filename} + +

+ {file.description || '无描述信息'} +

+

+ 存储链接:{file.link} +

+
+
+
+

{file.uploader}

+

上传者 ID:{file.uploader_id}

+
+
+ {formatRelativeTime(file.upload_time)} · {formatDateTime(file.upload_time)} + + {file.download_counter || 0} + +
+ window.open(buildFileDownloadUrl(file.link), '_blank', 'noopener,noreferrer')} + className='px-3 py-2 text-xs' + > + 下载 + + void handleCopy(file.link)} + className='px-3 py-2 text-xs' + > + 复制链接 + + handleDelete(file)} + disabled={deleteMutation.isPending} + className='px-3 py-2 text-xs' + > + 删除 + +
+
+
+ )} +
+
+
+ ); +} diff --git a/atsf_server/web/features/files/types.ts b/atsf_server/web/features/files/types.ts new file mode 100644 index 00000000..1fd5dbca --- /dev/null +++ b/atsf_server/web/features/files/types.ts @@ -0,0 +1,10 @@ +export interface FileItem { + id: number; + filename: string; + description: string; + uploader: string; + uploader_id: number; + link: string; + upload_time: string; + download_counter: number; +} diff --git a/atsf_server/web/features/settings/api/settings.ts b/atsf_server/web/features/settings/api/settings.ts new file mode 100644 index 00000000..e0d4e1b2 --- /dev/null +++ b/atsf_server/web/features/settings/api/settings.ts @@ -0,0 +1,62 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + BootstrapTokenPayload, + LatestReleaseInfo, + OptionItem, + SettingsProfile, + UpdateSelfPayload, +} from '@/features/settings/types'; + +export function getOptions() { + return apiRequest('/option/'); +} + +export function updateOption(key: string, value: string) { + return apiRequest('/option/', { + method: 'PUT', + body: JSON.stringify({ key, value }), + }); +} + +export function getBootstrapToken() { + return apiRequest('/nodes/bootstrap-token'); +} + +export function rotateBootstrapToken() { + return apiRequest('/nodes/bootstrap-token/rotate', { + method: 'POST', + }); +} + +export function getLatestRelease() { + return apiRequest('/update/latest-release'); +} + +export function getSettingsProfile() { + return apiRequest('/user/self'); +} + +export function updateSelf(payload: UpdateSelfPayload) { + return apiRequest('/user/self', { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function generateAccessToken() { + return apiRequest('/user/token'); +} + +export function bindWeChat(code: string) { + return apiRequest(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`); +} + +export function bindEmail(email: string, code: string) { + const searchParams = new URLSearchParams({ email, code }); + return apiRequest(`/oauth/email/bind?${searchParams.toString()}`); +} + +export function getAboutContent() { + return apiRequest('/about'); +} diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx new file mode 100644 index 00000000..9f0b3f26 --- /dev/null +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -0,0 +1,1224 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { marked } from 'marked'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { useAuth } from '@/components/providers/auth-provider'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { sendEmailVerification } from '@/features/auth/api/auth'; +import { getPublicStatus } from '@/features/auth/api/public'; +import { + bindEmail, + bindWeChat, + generateAccessToken, + getBootstrapToken, + getLatestRelease, + getOptions, + getSettingsProfile, + rotateBootstrapToken, + updateOption, + updateSelf, +} from '@/features/settings/api/settings'; +import type { + LatestReleaseInfo, + OptionItem, + UpdateSelfPayload, +} from '@/features/settings/types'; +import { + CodeBlock, + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { publicEnv } from '@/lib/env/public-env'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const installerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; +const settingsQueryKey = ['settings', 'options'] as const; + +const defaultSystemFields = { + ServerAddress: '', + PasswordLoginEnabled: true, + PasswordRegisterEnabled: true, + EmailVerificationEnabled: false, + GitHubOAuthEnabled: false, + WeChatAuthEnabled: false, + TurnstileCheckEnabled: false, + RegisterEnabled: true, + SMTPServer: '', + SMTPPort: '587', + SMTPAccount: '', + SMTPToken: '', + GitHubClientId: '', + GitHubClientSecret: '', + WeChatServerAddress: '', + WeChatServerToken: '', + WeChatAccountQRCodeImageURL: '', + TurnstileSiteKey: '', + TurnstileSecretKey: '', +}; + +const defaultOperationFields = { + AgentHeartbeatInterval: '30000', + AgentSyncInterval: '30000', + NodeOfflineThreshold: '120000', + AgentUpdateRepo: 'Rain-kl/ATSFlare', + ServerAddress: '', +}; + +const defaultOtherFields = { + Notice: '', + SystemName: '', + HomePageLink: '', + About: '', + Footer: '', +}; + +const defaultProfileFields: UpdateSelfPayload = { + username: '', + display_name: '', + password: '', +}; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +type SettingsTab = 'personal' | 'operation' | 'system' | 'other'; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function optionsToMap(options: OptionItem[] | undefined) { + return (options ?? []).reduce>((accumulator, option) => { + accumulator[option.key] = option.value; + return accumulator; + }, {}); +} + +function toBoolean(value: string | undefined, fallback: boolean) { + if (value === undefined) { + return fallback; + } + + return value === 'true'; +} + +function normalizeServerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function formatDurationLabel(value: string) { + const milliseconds = Number.parseInt(value, 10); + if (Number.isNaN(milliseconds)) { + return value; + } + + if (milliseconds >= 60000) { + return `${milliseconds / 60000} 分钟`; + } + + return `${milliseconds / 1000} 秒`; +} + +function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { + return [ + `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, + ` --server-url ${normalizeServerUrl(serverUrl)} \\`, + ` --discovery-token ${discoveryToken}`, + ].join('\n'); +} + +async function copyToClipboard(value: string) { + await navigator.clipboard.writeText(value); +} + +export function SettingsPage() { + const queryClient = useQueryClient(); + const { refreshUser, user } = useAuth(); + const [activeTab, setActiveTab] = useState('personal'); + const [feedback, setFeedback] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [profileFields, setProfileFields] = useState(defaultProfileFields); + const [systemFields, setSystemFields] = useState(defaultSystemFields); + const [operationFields, setOperationFields] = useState(defaultOperationFields); + const [otherFields, setOtherFields] = useState(defaultOtherFields); + const [accessToken, setAccessToken] = useState(''); + const [wechatCode, setWeChatCode] = useState(''); + const [emailAddress, setEmailAddress] = useState(''); + const [emailCode, setEmailCode] = useState(''); + const [latestRelease, setLatestRelease] = useState(null); + + const isRoot = (user?.role ?? 0) >= 100; + + const publicStatusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, + }); + + const profileQuery = useQuery({ + queryKey: ['settings', 'profile'], + queryFn: getSettingsProfile, + }); + + const optionsQuery = useQuery({ + queryKey: settingsQueryKey, + queryFn: getOptions, + enabled: isRoot, + }); + + const bootstrapQuery = useQuery({ + queryKey: ['settings', 'bootstrap-token'], + queryFn: getBootstrapToken, + enabled: isRoot, + }); + + useEffect(() => { + if (profileQuery.data) { + setProfileFields({ + username: profileQuery.data.username, + display_name: profileQuery.data.display_name || '', + password: '', + }); + setEmailAddress(profileQuery.data.email || ''); + } + }, [profileQuery.data]); + + useEffect(() => { + const publicStatus = publicStatusQuery.data; + if (!publicStatus) { + return; + } + + setSystemFields((previous) => ({ + ...previous, + ServerAddress: publicStatus.server_address || previous.ServerAddress, + GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId, + WeChatAccountQRCodeImageURL: publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL, + TurnstileSiteKey: publicStatus.turnstile_site_key || previous.TurnstileSiteKey, + })); + setOtherFields((previous) => ({ + ...previous, + SystemName: publicStatus.system_name || previous.SystemName, + HomePageLink: publicStatus.home_page_link || previous.HomePageLink, + Footer: publicStatus.footer_html || previous.Footer, + })); + setOperationFields((previous) => ({ + ...previous, + ServerAddress: publicStatus.server_address || previous.ServerAddress, + })); + }, [publicStatusQuery.data]); + + useEffect(() => { + if (!optionsQuery.data) { + return; + } + + const optionMap = optionsToMap(optionsQuery.data); + + setSystemFields({ + ServerAddress: optionMap.ServerAddress ?? '', + PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), + PasswordRegisterEnabled: toBoolean(optionMap.PasswordRegisterEnabled, true), + EmailVerificationEnabled: toBoolean(optionMap.EmailVerificationEnabled, false), + GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false), + WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false), + TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false), + RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true), + SMTPServer: optionMap.SMTPServer ?? '', + SMTPPort: optionMap.SMTPPort ?? '587', + SMTPAccount: optionMap.SMTPAccount ?? '', + SMTPToken: '', + GitHubClientId: optionMap.GitHubClientId ?? '', + GitHubClientSecret: '', + WeChatServerAddress: optionMap.WeChatServerAddress ?? '', + WeChatServerToken: '', + WeChatAccountQRCodeImageURL: optionMap.WeChatAccountQRCodeImageURL ?? '', + TurnstileSiteKey: optionMap.TurnstileSiteKey ?? '', + TurnstileSecretKey: '', + }); + + setOperationFields({ + AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '30000', + AgentSyncInterval: optionMap.AgentSyncInterval ?? '30000', + NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', + AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare', + ServerAddress: optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '', + }); + + setOtherFields({ + Notice: optionMap.Notice ?? '', + SystemName: optionMap.SystemName ?? '', + HomePageLink: optionMap.HomePageLink ?? '', + About: optionMap.About ?? '', + Footer: optionMap.Footer ?? '', + }); + }, [optionsQuery.data, publicStatusQuery.data?.server_address]); + + const rotateTokenMutation = useMutation({ + mutationFn: rotateBootstrapToken, + onSuccess: async (data) => { + setFeedback({ tone: 'success', message: 'Discovery Token 已重新生成。' }); + await queryClient.invalidateQueries({ queryKey: ['settings', 'bootstrap-token'] }); + if (data.discovery_token) { + try { + await copyToClipboard(data.discovery_token); + } catch { + // ignore clipboard errors + } + } + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const accessTokenMutation = useMutation({ + mutationFn: generateAccessToken, + onSuccess: (token) => { + setAccessToken(token); + setFeedback({ tone: 'success', message: '访问令牌已重置,并已在当前页面展示。' }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; + const discoveryCommand = + isRoot && operationFields.ServerAddress && discoveryToken + ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) + : ''; + + const tabs = useMemo( + () => [ + { key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。' }, + ...(isRoot + ? [ + { key: 'operation' as const, label: '运维设置', description: 'Agent 参数、Discovery Token 与部署命令。' }, + { key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、OAuth 与风控开关。' }, + { key: 'other' as const, label: '其他设置', description: '公告、关于、品牌信息与版本检查。' }, + ] + : []), + ], + [isRoot], + ); + + const runBusyAction = async (key: string, action: () => Promise) => { + setBusyKey(key); + setFeedback(null); + + try { + await action(); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } finally { + setBusyKey(null); + } + }; + + const saveOptionEntries = async (entries: Array<[string, string]>, successMessage: string) => { + for (const [key, value] of entries) { + await updateOption(key, value); + } + + await queryClient.invalidateQueries({ queryKey: settingsQueryKey }); + await queryClient.invalidateQueries({ queryKey: ['public-status'] }); + setFeedback({ tone: 'success', message: successMessage }); + }; + + const handleProfileSave = () => { + void runBusyAction('profile', async () => { + await updateSelf({ + username: profileFields.username.trim(), + display_name: profileFields.display_name.trim(), + password: profileFields.password, + }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setProfileFields((previous) => ({ ...previous, password: '' })); + setFeedback({ tone: 'success', message: '个人资料已更新。' }); + }); + }; + + const handleEmailVerification = () => { + if (!emailAddress.trim()) { + setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' }); + return; + } + + void runBusyAction('email-send', async () => { + await sendEmailVerification(emailAddress.trim()); + setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' }); + }); + }; + + const handleBindEmail = () => { + if (!emailAddress.trim() || !emailCode.trim()) { + setFeedback({ tone: 'danger', message: '请输入邮箱地址和验证码。' }); + return; + } + + void runBusyAction('email-bind', async () => { + await bindEmail(emailAddress.trim(), emailCode.trim()); + setEmailCode(''); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setFeedback({ tone: 'success', message: '邮箱已绑定。' }); + }); + }; + + const handleBindWeChat = () => { + if (!wechatCode.trim()) { + setFeedback({ tone: 'danger', message: '请输入微信验证码。' }); + return; + } + + void runBusyAction('wechat-bind', async () => { + await bindWeChat(wechatCode.trim()); + setWeChatCode(''); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setFeedback({ tone: 'success', message: '微信账号已绑定。' }); + }); + }; + + const handleToggleOption = (key: keyof typeof systemFields, nextValue: boolean) => { + setSystemFields((previous) => ({ ...previous, [key]: nextValue })); + + void runBusyAction(`toggle-${key}`, async () => { + await saveOptionEntries([[key, String(nextValue)]], '系统开关已更新。'); + }); + }; + + const handleCheckLatestRelease = () => { + void runBusyAction('latest-release', async () => { + const release = await getLatestRelease(); + setLatestRelease(release); + setFeedback({ tone: 'success', message: `已获取最新版本信息:${release.tag_name}` }); + }); + }; + + const renderTabContent = () => { + if (profileQuery.isLoading || publicStatusQuery.isLoading) { + return ; + } + + if (profileQuery.isError) { + return ; + } + + if (publicStatusQuery.isError) { + return ; + } + + const publicStatus = publicStatusQuery.data; + const profile = profileQuery.data; + + if (!publicStatus || !profile) { + return ; + } + + if (activeTab === 'personal') { + return ( +
+
+ + {busyKey === 'profile' ? '保存中...' : '保存资料'} + + } + > +
+ + + setProfileFields((previous) => ({ ...previous, username: event.target.value })) + } + placeholder='请输入用户名' + /> + + + + + setProfileFields((previous) => ({ ...previous, display_name: event.target.value })) + } + placeholder='请输入显示名称' + /> + + + + + setProfileFields((previous) => ({ ...previous, password: event.target.value })) + } + placeholder='请输入新密码' + /> + +
+
+ + accessTokenMutation.mutate()} + disabled={accessTokenMutation.isPending} + > + {accessTokenMutation.isPending ? '生成中...' : '重置令牌'} + + } + > +
+
+
+

当前角色

+
+ +
+
+
+

已绑定邮箱

+

+ {profile.email || '未绑定'} +

+
+
+ + {accessToken ? ( +
+ {accessToken} + void copyToClipboard(accessToken)} + > + 复制令牌 + +
+ ) : ( + + )} +
+
+
+ + +
+
+
+

GitHub 账号

+

+ 当前状态:{profile.github_id ? `已绑定 ${profile.github_id}` : '未绑定'} +

+
+ + window.open( + `https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`, + '_blank', + 'noopener,noreferrer', + ) + } + disabled={!publicStatus.github_oauth || !publicStatus.github_client_id} + > + {publicStatus.github_oauth ? '绑定 GitHub' : '未启用 GitHub OAuth'} + +
+ +
+
+

微信账号

+

+ 当前状态:{profile.wechat_id ? `已绑定 ${profile.wechat_id}` : '未绑定'} +

+
+ {publicStatus.wechat_login && publicStatus.wechat_qrcode ? ( + 微信绑定二维码 + ) : null} + + setWeChatCode(event.target.value)} + placeholder='请输入微信验证码' + /> + + + {busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'} + +
+ +
+
+

邮箱地址

+

+ 当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'} +

+
+ {publicStatus.turnstile_check ? ( + + ) : ( +
+ + setEmailAddress(event.target.value)} + placeholder='请输入邮箱地址' + /> + + + setEmailCode(event.target.value)} + placeholder='请输入邮箱验证码' + /> + +
+ + {busyKey === 'email-send' ? '发送中...' : '发送验证码'} + + + {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'} + +
+
+ )} +
+
+
+
+ ); + } + + if (!isRoot) { + return ; + } + + if (optionsQuery.isLoading) { + return ; + } + + if (optionsQuery.isError) { + return ; + } + + if (activeTab === 'operation') { + return ( +
+
+ + window.open('/swagger/index.html', '_blank', 'noopener,noreferrer')}> + 打开接口文档 + + + void runBusyAction('operation-intervals', async () => { + const heartbeat = Number.parseInt(operationFields.AgentHeartbeatInterval, 10); + const sync = Number.parseInt(operationFields.AgentSyncInterval, 10); + const offline = Number.parseInt(operationFields.NodeOfflineThreshold, 10); + + if (Number.isNaN(heartbeat) || heartbeat < 5000) { + throw new Error('心跳间隔不能小于 5000 毫秒。'); + } + if (Number.isNaN(sync) || sync < 5000) { + throw new Error('同步间隔不能小于 5000 毫秒。'); + } + if (Number.isNaN(offline) || offline < 10000) { + throw new Error('离线阈值不能小于 10000 毫秒。'); + } + + await saveOptionEntries( + [ + ['AgentHeartbeatInterval', String(heartbeat)], + ['AgentSyncInterval', String(sync)], + ['NodeOfflineThreshold', String(offline)], + ], + 'Agent 运行参数已保存。', + ); + }) + } + disabled={busyKey === 'operation-intervals'} + > + {busyKey === 'operation-intervals' ? '保存中...' : '保存运行参数'} + +
+ } + > +
+ + + setOperationFields((previous) => ({ ...previous, AgentHeartbeatInterval: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, AgentSyncInterval: event.target.value })) + } + /> + + + + setOperationFields((previous) => ({ ...previous, NodeOfflineThreshold: event.target.value })) + } + /> + +
+ + + + void runBusyAction('operation-repo', async () => { + await saveOptionEntries( + [['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()]], + 'Agent 更新仓库已保存。', + ); + }) + } + disabled={busyKey === 'operation-repo'} + > + {busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'} + + } + > + + + setOperationFields((previous) => ({ ...previous, AgentUpdateRepo: event.target.value })) + } + placeholder='Rain-kl/ATSFlare' + /> + + +
+ +
+ + rotateTokenMutation.mutate()} + disabled={rotateTokenMutation.isPending} + > + {rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'} + + {discoveryCommand ? ( + void copyToClipboard(discoveryCommand)}> + 复制命令 + + ) : null} +
+ } + > + {bootstrapQuery.isLoading ? ( + + ) : bootstrapQuery.isError ? ( + + ) : ( +
+ + + setOperationFields((previous) => ({ ...previous, ServerAddress: event.target.value })) + } + /> + +
+

Discovery Token

+

+ {discoveryToken || '未生成'} +

+
+ {discoveryCommand ? {discoveryCommand} : null} +
+ )} +
+ + +
+
+

前端版本

+

{publicEnv.appVersion}

+
+
+

服务端版本

+

{publicStatus.version}

+
+
+

Server 启动时间

+

+ {formatDateTime(new Date(publicStatus.start_time * 1000))} +

+
+
+

运行模式

+

静态导出 + Go Server 托管

+
+
+
+
+
+ ); + } + + if (activeTab === 'system') { + return ( +
+ +
+ handleToggleOption('PasswordLoginEnabled', checked)} + disabled={busyKey === 'toggle-PasswordLoginEnabled'} + /> + handleToggleOption('PasswordRegisterEnabled', checked)} + disabled={busyKey === 'toggle-PasswordRegisterEnabled'} + /> + handleToggleOption('EmailVerificationEnabled', checked)} + disabled={busyKey === 'toggle-EmailVerificationEnabled'} + /> + handleToggleOption('GitHubOAuthEnabled', checked)} + disabled={busyKey === 'toggle-GitHubOAuthEnabled'} + /> + handleToggleOption('WeChatAuthEnabled', checked)} + disabled={busyKey === 'toggle-WeChatAuthEnabled'} + /> + handleToggleOption('TurnstileCheckEnabled', checked)} + disabled={busyKey === 'toggle-TurnstileCheckEnabled'} + /> + handleToggleOption('RegisterEnabled', checked)} + disabled={busyKey === 'toggle-RegisterEnabled'} + /> +
+
+ +
+ + void runBusyAction('system-core', async () => { + await saveOptionEntries( + [ + ['ServerAddress', normalizeServerUrl(systemFields.ServerAddress)], + ['SMTPServer', systemFields.SMTPServer.trim()], + ['SMTPPort', systemFields.SMTPPort.trim()], + ['SMTPAccount', systemFields.SMTPAccount.trim()], + ['SMTPToken', systemFields.SMTPToken.trim()], + ], + '通用与 SMTP 设置已保存。', + ); + }) + } + disabled={busyKey === 'system-core'} + > + {busyKey === 'system-core' ? '保存中...' : '保存通用设置'} + + } + > +
+ + + setSystemFields((previous) => ({ ...previous, ServerAddress: event.target.value })) + } + placeholder='https://yourdomain.com' + /> + +
+ + + setSystemFields((previous) => ({ ...previous, SMTPServer: event.target.value })) + } + placeholder='smtp.qq.com' + /> + + + + setSystemFields((previous) => ({ ...previous, SMTPPort: event.target.value })) + } + placeholder='587' + /> + + + + setSystemFields((previous) => ({ ...previous, SMTPAccount: event.target.value })) + } + placeholder='name@example.com' + /> + + + + setSystemFields((previous) => ({ ...previous, SMTPToken: event.target.value })) + } + placeholder='请输入新的 SMTP 凭证' + /> + +
+
+
+ + + void runBusyAction('system-integrations', async () => { + await saveOptionEntries( + [ + ['GitHubClientId', systemFields.GitHubClientId.trim()], + ['GitHubClientSecret', systemFields.GitHubClientSecret.trim()], + ['WeChatServerAddress', normalizeServerUrl(systemFields.WeChatServerAddress)], + ['WeChatServerToken', systemFields.WeChatServerToken.trim()], + ['WeChatAccountQRCodeImageURL', systemFields.WeChatAccountQRCodeImageURL.trim()], + ['TurnstileSiteKey', systemFields.TurnstileSiteKey.trim()], + ['TurnstileSecretKey', systemFields.TurnstileSecretKey.trim()], + ], + '第三方集成设置已保存。', + ); + }) + } + disabled={busyKey === 'system-integrations'} + > + {busyKey === 'system-integrations' ? '保存中...' : '保存集成设置'} + + } + > +
+
+ + + setSystemFields((previous) => ({ ...previous, GitHubClientId: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, GitHubClientSecret: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, WeChatServerAddress: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, WeChatServerToken: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, WeChatAccountQRCodeImageURL: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, TurnstileSiteKey: event.target.value })) + } + /> + + + + setSystemFields((previous) => ({ ...previous, TurnstileSecretKey: event.target.value })) + } + /> + +
+
+
+
+
+ ); + } + + return ( +
+
+ + void runBusyAction('other-brand', async () => { + await saveOptionEntries( + [ + ['Notice', otherFields.Notice], + ['SystemName', otherFields.SystemName.trim()], + ['HomePageLink', otherFields.HomePageLink.trim()], + ['Footer', otherFields.Footer], + ], + '公告与品牌设置已保存。', + ); + }) + } + disabled={busyKey === 'other-brand'} + > + {busyKey === 'other-brand' ? '保存中...' : '保存基础信息'} + + } + > +
+ + + setOtherFields((previous) => ({ ...previous, SystemName: event.target.value })) + } + placeholder='ATSFlare' + /> + + + + setOtherFields((previous) => ({ ...previous, HomePageLink: event.target.value })) + } + placeholder='https://example.com' + /> + + + + setOtherFields((previous) => ({ ...previous, Notice: event.target.value })) + } + placeholder='可在此编写首页公告内容' + /> + + + + setOtherFields((previous) => ({ ...previous, Footer: event.target.value })) + } + placeholder='留空则使用默认页脚' + /> + +
+
+ + + + {busyKey === 'latest-release' ? '检查中...' : '检查更新'} + + + void runBusyAction('other-about', async () => { + await saveOptionEntries([['About', otherFields.About]], '关于页内容已保存。'); + }) + } + disabled={busyKey === 'other-about'} + > + {busyKey === 'other-about' ? '保存中...' : '保存关于内容'} + +
+ } + > +
+ + + setOtherFields((previous) => ({ ...previous, About: event.target.value })) + } + placeholder='在这里编写关于 ATSFlare 的介绍内容' + className='min-h-48' + /> + + + {latestRelease ? ( +
+
+

最新版本:{latestRelease.tag_name}

+ +
+

+ 发布时间:{formatRelativeTime(latestRelease.published_at)} · {formatDateTime(latestRelease.published_at)} +

+ + ) : ( + + )} +
+ +
+
+ ); + }; + + return ( +
+ + + {feedback ? : null} + +
+ {tabs.map((tab) => ( + + ))} +
+ + {renderTabContent()} +
+ ); +} diff --git a/atsf_server/web/features/settings/types.ts b/atsf_server/web/features/settings/types.ts new file mode 100644 index 00000000..08b582fe --- /dev/null +++ b/atsf_server/web/features/settings/types.ts @@ -0,0 +1,25 @@ +import type { AuthUser } from '@/types/auth'; + +export interface OptionItem { + key: string; + value: string; +} + +export interface LatestReleaseInfo { + tag_name: string; + body: string; + html_url: string; + published_at: string; +} + +export interface BootstrapTokenPayload { + discovery_token: string; +} + +export interface UpdateSelfPayload { + username: string; + display_name: string; + password: string; +} + +export interface SettingsProfile extends AuthUser {} diff --git a/atsf_server/web/features/shared/components/legacy-route-redirect.tsx b/atsf_server/web/features/shared/components/legacy-route-redirect.tsx new file mode 100644 index 00000000..147e0d60 --- /dev/null +++ b/atsf_server/web/features/shared/components/legacy-route-redirect.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import { LoadingState } from '@/components/feedback/loading-state'; + +interface LegacyRouteRedirectProps { + href: string; +} + +export function LegacyRouteRedirect({ href }: LegacyRouteRedirectProps) { + const router = useRouter(); + + useEffect(() => { + router.replace(href); + }, [href, router]); + + return ( +
+ +
+ ); +} diff --git a/atsf_server/web/features/users/api/users.ts b/atsf_server/web/features/users/api/users.ts new file mode 100644 index 00000000..f8723e21 --- /dev/null +++ b/atsf_server/web/features/users/api/users.ts @@ -0,0 +1,41 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + ManageUserAction, + ManageUserResult, + UserItem, + UserMutationPayload, +} from '@/features/users/types'; + +export function getUsers(page: number) { + return apiRequest(`/user/?p=${page}`); +} + +export function searchUsers(keyword: string) { + return apiRequest(`/user/search?keyword=${encodeURIComponent(keyword)}`); +} + +export function getUser(id: number) { + return apiRequest(`/user/${id}`); +} + +export function createUser(payload: UserMutationPayload) { + return apiRequest('/user/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateUser(payload: UserMutationPayload & { id: number }) { + return apiRequest('/user/', { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function manageUser(username: string, action: ManageUserAction) { + return apiRequest('/user/manage', { + method: 'POST', + body: JSON.stringify({ username, action }), + }); +} diff --git a/atsf_server/web/features/users/components/users-page.tsx b/atsf_server/web/features/users/components/users-page.tsx new file mode 100644 index 00000000..dbd2b818 --- /dev/null +++ b/atsf_server/web/features/users/components/users-page.tsx @@ -0,0 +1,492 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { useAuth } from '@/components/providers/auth-provider'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + createUser, + getUser, + getUsers, + manageUser, + searchUsers, + updateUser, +} from '@/features/users/api/users'; +import type { + ManageUserAction, + UserItem, + UserMutationPayload, +} from '@/features/users/types'; +import { + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; + +const ITEMS_PER_PAGE = 10; +const usersListQueryKey = ['users', 'list'] as const; + +const userSchema = z.object({ + username: z.string().trim().min(1, '请输入用户名').max(12, '用户名不能超过 12 个字符'), + display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'), + password: z + .string() + .max(20, '密码不能超过 20 个字符') + .refine((value) => value.length === 0 || value.length >= 8, '密码至少需要 8 个字符'), +}); + +type UserFormValues = z.infer; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +const defaultValues: UserFormValues = { + username: '', + display_name: '', + password: '', +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function getRoleMeta(role: number) { + if (role >= 100) { + return { label: '超级管理员', variant: 'warning' as const }; + } + + if (role >= 10) { + return { label: '管理员', variant: 'info' as const }; + } + + return { label: '普通用户', variant: 'success' as const }; +} + +function getStatusMeta(status: number) { + if (status === 1) { + return { label: '已激活', variant: 'success' as const }; + } + + return { label: '已封禁', variant: 'danger' as const }; +} + +function toPayload(values: UserFormValues): UserMutationPayload { + return { + username: values.username.trim(), + display_name: values.display_name.trim(), + password: values.password, + }; +} + +export function UsersPage() { + const queryClient = useQueryClient(); + const { user: currentUser } = useAuth(); + const [page, setPage] = useState(0); + const [searchInput, setSearchInput] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [editingUserId, setEditingUserId] = useState(null); + const [feedback, setFeedback] = useState(null); + + const form = useForm({ + resolver: zodResolver(userSchema), + defaultValues, + }); + + const isAdmin = (currentUser?.role ?? 0) >= 10; + const isRoot = (currentUser?.role ?? 0) >= 100; + + const usersQuery = useQuery({ + queryKey: [...usersListQueryKey, page], + queryFn: () => getUsers(page), + enabled: isAdmin && searchKeyword.length === 0, + }); + + const searchQuery = useQuery({ + queryKey: [...usersListQueryKey, 'search', searchKeyword], + queryFn: () => searchUsers(searchKeyword), + enabled: isAdmin && searchKeyword.length > 0, + }); + + const editingUserQuery = useQuery({ + queryKey: ['users', 'detail', editingUserId], + queryFn: () => getUser(editingUserId as number), + enabled: isAdmin && editingUserId !== null, + }); + + useEffect(() => { + if (editingUserQuery.data) { + form.reset({ + username: editingUserQuery.data.username, + display_name: editingUserQuery.data.display_name || '', + password: '', + }); + } + }, [editingUserQuery.data, form]); + + const saveMutation = useMutation({ + mutationFn: async (values: UserFormValues) => { + if (!editingUserId && values.password.length === 0) { + throw new Error('创建用户时必须填写密码。'); + } + + const payload = toPayload(values); + + if (editingUserId) { + await updateUser({ id: editingUserId, ...payload }); + return '用户信息已更新。'; + } + + await createUser(payload); + return '用户已创建。'; + }, + onSuccess: async (message) => { + setFeedback({ tone: 'success', message }); + setEditingUserId(null); + form.reset(defaultValues); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: usersListQueryKey }), + queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const manageMutation = useMutation({ + mutationFn: async ({ username, action }: { username: string; action: ManageUserAction }) => { + await manageUser(username, action); + return action; + }, + onSuccess: async (action) => { + const actionLabel = { + promote: '提升权限', + demote: '降级权限', + delete: '删除用户', + disable: '禁用用户', + enable: '启用用户', + }[action]; + + setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: usersListQueryKey }), + queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery; + const users = activeQuery.data ?? []; + + const summary = useMemo(() => { + return [ + { label: searchKeyword ? '搜索结果' : '当前页用户', value: users.length }, + { label: '管理员', value: users.filter((item) => item.role >= 10).length }, + { label: '已激活', value: users.filter((item) => item.status === 1).length }, + { label: '已封禁', value: users.filter((item) => item.status !== 1).length }, + ]; + }, [searchKeyword, users]); + + const handleSearchSubmit = () => { + setFeedback(null); + setPage(0); + setSearchKeyword(searchInput.trim()); + }; + + const handleResetSearch = () => { + setSearchInput(''); + setSearchKeyword(''); + setPage(0); + setFeedback(null); + }; + + const handleResetForm = () => { + setEditingUserId(null); + setFeedback(null); + form.reset(defaultValues); + }; + + const handleEdit = (targetUser: UserItem) => { + setFeedback(null); + setEditingUserId(targetUser.id); + }; + + const handleManage = (targetUser: UserItem, action: ManageUserAction) => { + const actionText = { + promote: '提升', + demote: '降级', + delete: '删除', + disable: '禁用', + enable: '启用', + }[action]; + + if (action === 'delete' && !window.confirm(`确认删除用户“${targetUser.username}”吗?`)) { + return; + } + + if ((action === 'disable' || action === 'enable') && !window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)) { + return; + } + + setFeedback(null); + manageMutation.mutate({ username: targetUser.username, action }); + }; + + const handleSubmit = form.handleSubmit((values) => { + setFeedback(null); + saveMutation.mutate(values); + }); + + if (!isAdmin) { + return ( +
+ + +
+ ); + } + + return ( +
+ + + {feedback ? : null} + +
+ +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + + {editingUserId && editingUserQuery.isLoading ? ( + + ) : editingUserId && editingUserQuery.isError ? ( + + ) : ( +
+ + + + + + + + + + + + + {editingUserId && editingUserQuery.data ? ( +
+
+

绑定邮箱

+

+ {editingUserQuery.data.email || '未绑定'} +

+
+
+

第三方账号

+

+ GitHub:{editingUserQuery.data.github_id || '未绑定'} +

+

+ 微信:{editingUserQuery.data.wechat_id || '未绑定'} +

+
+
+ ) : null} + +
+ + {saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'} + + + {editingUserId ? '取消编辑' : '重置表单'} + +
+
+ )} +
+
+ + + void queryClient.invalidateQueries({ queryKey: usersListQueryKey })} + > + 刷新 + +
+ } + > +
+
+
+ setSearchInput(event.target.value)} + placeholder='搜索用户 ID、用户名、显示名称或邮箱地址' + className='md:max-w-xl' + /> +
+ + 搜索 + + + 清空 + +
+
+ + {searchKeyword.length === 0 ? ( +
+ setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}> + 上一页 + + setPage((value) => value + 1)} + disabled={activeQuery.isLoading || users.length < ITEMS_PER_PAGE} + > + 下一页 + +
+ ) : null} +
+ + {activeQuery.isLoading ? ( + + ) : activeQuery.isError ? ( + + ) : users.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {users.map((targetUser) => { + const roleMeta = getRoleMeta(targetUser.role); + const statusMeta = getStatusMeta(targetUser.status); + const canManage = (currentUser?.role ?? 0) > targetUser.role; + + return ( + + + + + + + + ); + })} + +
用户邮箱角色状态操作
+
+

{targetUser.username}

+

+ 显示名称:{targetUser.display_name || '未设置'} +

+

ID:{targetUser.id}

+
+
+ {targetUser.email || '未绑定'} + + + + + +
+ handleEdit(targetUser)} className='px-3 py-2 text-xs' disabled={!canManage}> + 编辑 + + handleManage(targetUser, targetUser.status === 1 ? 'disable' : 'enable')} + className='px-3 py-2 text-xs' + disabled={!canManage || manageMutation.isPending} + > + {targetUser.status === 1 ? '禁用' : '启用'} + + handleManage(targetUser, targetUser.role >= 10 ? 'demote' : 'promote')} + className='px-3 py-2 text-xs' + disabled={!canManage || manageMutation.isPending || (!isRoot && targetUser.role < 10)} + > + {targetUser.role >= 10 ? '降级' : '提升'} + + handleManage(targetUser, 'delete')} + className='px-3 py-2 text-xs' + disabled={!canManage || manageMutation.isPending} + > + 删除 + +
+
+
+ )} +
+
+
+ ); +} diff --git a/atsf_server/web/features/users/types.ts b/atsf_server/web/features/users/types.ts new file mode 100644 index 00000000..3caf68dd --- /dev/null +++ b/atsf_server/web/features/users/types.ts @@ -0,0 +1,24 @@ +export interface UserItem { + id: number; + username: string; + display_name: string; + role: number; + status: number; + email?: string; + github_id?: string; + wechat_id?: string; +} + +export interface UserMutationPayload { + id?: number; + username: string; + display_name: string; + password: string; +} + +export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable'; + +export interface ManageUserResult { + role: number; + status: number; +} diff --git a/atsf_server/web/lib/api/client.ts b/atsf_server/web/lib/api/client.ts index 66123fcc..b873d394 100644 --- a/atsf_server/web/lib/api/client.ts +++ b/atsf_server/web/lib/api/client.ts @@ -17,12 +17,15 @@ export function getApiUrl(path: string) { } export async function apiRequest(path: string, init?: RequestInit) { + const headers = new Headers(init?.headers ?? {}); + + if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + const response = await fetch(getApiUrl(path), { credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...(init?.headers ?? {}), - }, + headers, ...init, }); diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts index 7e280fd0..c728799a 100644 --- a/atsf_server/web/lib/constants/navigation.ts +++ b/atsf_server/web/lib/constants/navigation.ts @@ -47,18 +47,18 @@ export const dashboardNavigation: NavigationItem[] = [ href: '/files', label: '文件管理', shortLabel: '文件', - description: '阶段 4 接入列表、下载与管理动作。', + description: '已接入上传、下载、复制链接、搜索与删除动作。', }, { href: '/users', label: '用户管理', shortLabel: '用户', - description: '阶段 4 接入用户列表、角色与搜索体验。', + description: '已接入搜索、创建、编辑、封禁与权限调整。', }, { href: '/settings', label: '设置', shortLabel: '设置', - description: '阶段 4 接入系统设置、运维设置与个人设置。', + description: '已接入个人、运维、系统与关于内容维护。', }, ]; diff --git a/atsf_server/web/lib/utils/date.ts b/atsf_server/web/lib/utils/date.ts index 78b1c148..218db01a 100644 --- a/atsf_server/web/lib/utils/date.ts +++ b/atsf_server/web/lib/utils/date.ts @@ -17,7 +17,8 @@ function toDate(value: string | Date | null | undefined) { return null; } - const date = value instanceof Date ? value : new Date(value); + const normalizedValue = typeof value === 'string' ? value.replace(' ', 'T') : value; + const date = normalizedValue instanceof Date ? normalizedValue : new Date(normalizedValue); return Number.isNaN(date.getTime()) ? null : date; } diff --git a/atsf_server/web/package.json b/atsf_server/web/package.json index 4283d8f5..b737a71c 100644 --- a/atsf_server/web/package.json +++ b/atsf_server/web/package.json @@ -1,51 +1,52 @@ -{ - "name": "atsflare-web", - "version": "0.1.0", - "private": true, - "packageManager": "pnpm@10.6.2", - "scripts": { - "dev": "next dev", - "build": "next build", - "postbuild": "node scripts/prepare-static-export.mjs", - "start": "next start", - "lint": "eslint . --max-warnings=0", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "format": "prettier --write .", - "format:check": "prettier --check ." - }, - "dependencies": { - "@hookform/resolvers": "^4.1.3", - "@tanstack/react-query": "^5.66.8", - "clsx": "^2.1.1", - "next": "15.2.2", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-hook-form": "^7.54.2", - "tailwind-merge": "^3.0.2", - "zod": "^3.24.2", - "zustand": "^5.0.3" - }, - "devDependencies": { - "@playwright/test": "^1.50.1", - "@tailwindcss/postcss": "^4.0.9", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.4", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", - "eslint": "^8.57.1", - "eslint-config-next": "15.2.2", - "jsdom": "^26.0.0", - "prettier": "^3.5.2", - "prettier-plugin-tailwindcss": "^0.6.11", - "tailwindcss": "^4.0.9", - "typescript": "^5.8.2", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.5" - } -} +{ + "name": "atsflare-web", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.6.2", + "scripts": { + "dev": "next dev", + "build": "next build", + "postbuild": "node scripts/prepare-static-export.mjs", + "start": "next start", + "lint": "eslint . --max-warnings=0", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@hookform/resolvers": "^4.1.3", + "@tanstack/react-query": "^5.66.8", + "clsx": "^2.1.1", + "marked": "^17.0.4", + "next": "15.2.2", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-hook-form": "^7.54.2", + "tailwind-merge": "^3.0.2", + "zod": "^3.24.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tailwindcss/postcss": "^4.0.9", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.13.4", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^8.57.1", + "eslint-config-next": "15.2.2", + "jsdom": "^26.0.0", + "prettier": "^3.5.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "tailwindcss": "^4.0.9", + "typescript": "^5.8.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.0.5" + } +} diff --git a/atsf_server/web/pnpm-lock.yaml b/atsf_server/web/pnpm-lock.yaml index 3538edb5..a8f40b60 100644 --- a/atsf_server/web/pnpm-lock.yaml +++ b/atsf_server/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + marked: + specifier: ^17.0.4 + version: 17.0.4 next: specifier: 15.2.2 version: 15.2.2(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2095,6 +2098,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@17.0.4: + resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4979,6 +4987,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@17.0.4: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {}