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. 继续迁移节点、域名、证书与应用记录页面。
- - 2. 为核心动作补齐确认、反馈与错误提示的一致体验。
- - 3. 补充模块测试与静态构建回归验证。
+ - 1. 对照旧前端完成设置、用户、文件与关于页的联调回归。
+ - 2. 补齐关键页面的测试覆盖与构建验收。
+ - 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.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 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ 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: {}