+ );
+}
diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx
index d07d26a9..ae3b759d 100644
--- a/atsf_server/web/components/layout/dashboard-topbar.tsx
+++ b/atsf_server/web/components/layout/dashboard-topbar.tsx
@@ -1,11 +1,11 @@
'use client';
-import { useRouter } from 'next/navigation';
-import { useState } from 'react';
-
-import { useAuth } from '@/components/providers/auth-provider';
-import { ThemeToggle } from '@/components/ui/theme-toggle';
-import { getCurrentNavigationItem } from '@/lib/utils/navigation';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+import { useAuth } from '@/components/providers/auth-provider';
+import { ThemeToggle } from '@/components/ui/theme-toggle';
+import { getCurrentNavigationItem } from '@/lib/utils/navigation';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
import { usePathname } from 'next/navigation';
@@ -31,12 +31,12 @@ export function DashboardTopbar() {
+ onClick={toggleSidebar}
+ className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
+ aria-label='切换侧边栏'
+ >
+ ☰
+
当前模块
diff --git a/atsf_server/web/components/layout/page-header.tsx b/atsf_server/web/components/layout/page-header.tsx
index a83dd323..6fe895b2 100644
--- a/atsf_server/web/components/layout/page-header.tsx
+++ b/atsf_server/web/components/layout/page-header.tsx
@@ -1,18 +1,24 @@
-interface PageHeaderProps {
- title: string;
- description: string;
-}
-
-export function PageHeader({ title, description }: PageHeaderProps) {
- return (
-
-
ATSFlare
-
-
{title}
-
- {description}
-
-
-
- );
-}
+import type { ReactNode } from 'react';
+
+interface PageHeaderProps {
+ title: string;
+ description: string;
+ action?: ReactNode;
+}
+
+export function PageHeader({ title, description, action }: PageHeaderProps) {
+ return (
+
+
+
ATSFlare
+
+
{title}
+
+ {description}
+
+
+
+ {action ?
{action}
: null}
+
+ );
+}
diff --git a/atsf_server/web/components/ui/app-modal.tsx b/atsf_server/web/components/ui/app-modal.tsx
new file mode 100644
index 00000000..3dea741b
--- /dev/null
+++ b/atsf_server/web/components/ui/app-modal.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { useEffect, type ReactNode } from 'react';
+
+import { cn } from '@/lib/utils/cn';
+
+interface AppModalProps {
+ isOpen: boolean;
+ title: string;
+ description?: string;
+ children: ReactNode;
+ footer?: ReactNode;
+ onClose: () => void;
+ size?: 'md' | 'lg' | 'xl';
+}
+
+const sizeClassNameMap = {
+ md: 'max-w-2xl',
+ lg: 'max-w-4xl',
+ xl: 'max-w-5xl',
+} satisfies Record, string>;
+
+export function AppModal({
+ isOpen,
+ title,
+ description,
+ children,
+ footer,
+ onClose,
+ size = 'lg',
+}: AppModalProps) {
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
{children}
+ {footer ?
{footer}
: null}
+
+
+ );
+}
diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
index dfa85a0c..71d2633a 100644
--- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
+++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
@@ -1,32 +1,38 @@
-import Link from 'next/link';
-
-import { AppCard } from '@/components/ui/app-card';
-import { StatusBadge } from '@/components/ui/status-badge';
-import { dashboardNavigation } from '@/lib/constants/navigation';
-
-const readinessItems = [
- {
- title: '工程底座',
+import Link from 'next/link';
+
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
+import { dashboardNavigation } from '@/lib/constants/navigation';
+import { flattenNavigationItems } from '@/lib/utils/navigation';
+import type { NavigationItem } from '@/types/navigation';
+
+const readinessItems = [
+ {
+ title: '工程底座',
description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已稳定运行。',
},
{
title: '认证骨架',
description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。',
- },
- {
- title: '业务模块',
- description: '核心链路与阶段 4 的设置、用户、文件模块都已接入真实数据与交互。',
- },
-];
-
+ },
+ {
+ title: '业务模块',
+ description: '核心链路、用户和设置模块都已接入真实数据与交互,正在继续生产化打磨。',
+ },
+];
+
+const moduleLinks = flattenNavigationItems(dashboardNavigation).filter(
+ (item: NavigationItem) => item.href !== '/' && !item.children?.length,
+);
+
export function DashboardOverview() {
return (
}
- >
+ >
{readinessItems.map((item) => (
-
-
-
-
- {dashboardNavigation.slice(1).map((item) => (
-
-
{item.label}
-
- {item.description}
-
-
- ))}
-
-
-
-
+
+
+
+
+ {moduleLinks.map((item) => (
+
+
{item.label}
+
进入 {item.label} 页面,继续完成管理与发布操作。
+
+ ))}
+
+
+
+
- - 1. 对照后端接口完成设置、节点、证书和用户链路联调。
+ - 1. 对照后端接口继续压缩页面认知负担,保持核心动作在列表页附近完成。
- 2. 补齐关键页面的测试覆盖与构建验收。
- 3. 清理迁移期文档和发布说明中的旧前端表述。
diff --git a/atsf_server/web/features/files/api/files.ts b/atsf_server/web/features/files/api/files.ts
deleted file mode 100644
index 11e9a1eb..00000000
--- a/atsf_server/web/features/files/api/files.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index 3f4a3a04..00000000
--- a/atsf_server/web/features/files/components/files-page.tsx
+++ /dev/null
@@ -1,374 +0,0 @@
-'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 = useMemo(() => activeQuery.data ?? [], [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
deleted file mode 100644
index 1fd5dbca..00000000
--- a/atsf_server/web/features/files/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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/managed-domains/components/managed-domains-page.tsx b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx
index 24d5e3a9..5eafd120 100644
--- a/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx
+++ b/atsf_server/web/features/managed-domains/components/managed-domains-page.tsx
@@ -11,6 +11,7 @@ import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
@@ -110,6 +111,7 @@ function toFormValues(domain: ManagedDomainItem): ManagedDomainFormValues {
export function ManagedDomainsPage() {
const queryClient = useQueryClient();
const [editingDomainId, setEditingDomainId] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState(null);
const form = useForm({
@@ -144,6 +146,7 @@ export function ManagedDomainsPage() {
message: editingDomainId ? '域名规则已更新。' : '域名规则已创建。',
});
setEditingDomainId(null);
+ setIsEditorOpen(false);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey });
},
@@ -185,14 +188,23 @@ export function ManagedDomainsPage() {
const handleReset = () => {
setEditingDomainId(null);
+ setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
+ const handleCreate = () => {
+ setEditingDomainId(null);
+ setFeedback(null);
+ form.reset(defaultValues);
+ setIsEditorOpen(true);
+ };
+
const handleEdit = (domain: ManagedDomainItem) => {
setEditingDomainId(domain.id);
setFeedback(null);
form.reset(toFormValues(domain));
+ setIsEditorOpen(true);
};
const handleDelete = (domain: ManagedDomainItem) => {
@@ -210,128 +222,35 @@ export function ManagedDomainsPage() {
});
return (
+ <>
+ 新增域名
+
+ }
/>
{feedback ? : null}
-
+ }
+ >
+
+
+ >
);
}
diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx
index deca4cfa..4259e05d 100644
--- a/atsf_server/web/features/nodes/components/nodes-page.tsx
+++ b/atsf_server/web/features/nodes/components/nodes-page.tsx
@@ -1,18 +1,20 @@
-'use client';
+'use client';
+
+import Link from 'next/link';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect, useMemo, useState } from 'react';
+import { useForm, useWatch } from 'react-hook-form';
+import { z } from 'zod';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useMemo, useState } from 'react';
-import { useForm, useWatch } 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 { PageHeader } from '@/components/layout/page-header';
-import { AppCard } from '@/components/ui/app-card';
-import { StatusBadge } from '@/components/ui/status-badge';
+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 { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
import {
createNode,
deleteNode,
@@ -155,13 +157,14 @@ async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
-export function NodesPage() {
- const queryClient = useQueryClient();
- const [feedback, setFeedback] = useState(null);
- const [editingNodeId, setEditingNodeId] = useState(null);
- const [selectedNode, setSelectedNode] = useState(null);
- const [serverUrl, setServerUrl] = useState('');
- const [, setRefreshTick] = useState(0);
+export function NodesPage() {
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [editingNodeId, setEditingNodeId] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [serverUrl, setServerUrl] = useState('');
+ const [, setRefreshTick] = useState(0);
const form = useForm({
resolver: zodResolver(nodeSchema),
@@ -206,14 +209,15 @@ export function NodesPage() {
return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload);
},
onSuccess: async (node) => {
- setFeedback({
- tone: 'success',
- message: editingNodeId ? '节点已更新。' : '节点已创建。',
- });
- setEditingNodeId(null);
- setSelectedNode(node);
- form.reset(defaultValues);
- await Promise.all([
+ setFeedback({
+ tone: 'success',
+ message: editingNodeId ? '节点已更新。' : '节点已创建。',
+ });
+ setEditingNodeId(null);
+ setIsEditorOpen(false);
+ setSelectedNode(node);
+ form.reset(defaultValues);
+ await Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }),
]);
@@ -281,21 +285,30 @@ export function NodesPage() {
];
}, [nodes]);
- const handleReset = () => {
- setFeedback(null);
- setEditingNodeId(null);
- form.reset(defaultValues);
- };
-
- const handleEdit = (node: NodeItem) => {
- setFeedback(null);
- setEditingNodeId(node.id);
- setSelectedNode(node);
- form.reset({
- name: node.name,
- auto_update_enabled: node.auto_update_enabled,
- });
- };
+ const handleReset = () => {
+ setFeedback(null);
+ setEditingNodeId(null);
+ setIsEditorOpen(false);
+ form.reset(defaultValues);
+ };
+
+ const handleCreate = () => {
+ setFeedback(null);
+ setEditingNodeId(null);
+ form.reset(defaultValues);
+ setIsEditorOpen(true);
+ };
+
+ const handleEdit = (node: NodeItem) => {
+ setFeedback(null);
+ setEditingNodeId(node.id);
+ setSelectedNode(node);
+ form.reset({
+ name: node.name,
+ auto_update_enabled: node.auto_update_enabled,
+ });
+ setIsEditorOpen(true);
+ };
const handleDelete = (node: NodeItem) => {
if (
@@ -341,77 +354,55 @@ export function NodesPage() {
? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token)
: '';
- return (
-
-
-
- {feedback ?
: null}
-
-
-
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
- >
- 刷新列表
-
- }
- >
-
- {summary.map((item) => (
-
-
- {item.label}
-
-
{item.value}
-
- ))}
-
-
-
-
-
-
-
+ return (
+ <>
+
+
+
+ 新增节点
+
+
+ 应用记录
+
+ >
+ }
+ />
+
+ {feedback ? : null}
+
+ void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
+ >
+ 刷新列表
+
+ }
+ >
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
-
+
{nodesQuery.isLoading ? (
) : nodesQuery.isError ? (
@@ -628,6 +619,41 @@ export function NodesPage() {
)}
-
- );
-}
+
+
+
+ 取消
+
+
+ {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
+
+
+ }
+ >
+
+
+ >
+ );
+}
diff --git a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
index e7fea2d0..18b2a1a6 100644
--- a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
+++ b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
@@ -7,12 +7,13 @@ import { useFieldArray, useForm, useWatch } 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 { PageHeader } from '@/components/layout/page-header';
-import { AppCard } from '@/components/ui/app-card';
-import { StatusBadge } from '@/components/ui/status-badge';
+import { ErrorState } from '@/components/feedback/error-state';
+import { InlineMessage } from '@/components/feedback/inline-message';
+import { LoadingState } from '@/components/feedback/loading-state';
+import { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
import { publishConfigVersion } from '@/features/config-versions/api/config-versions';
import {
createProxyRoute,
@@ -211,12 +212,13 @@ function getMatchMessage(
return '未找到匹配证书,可继续手动选择。';
}
-export function ProxyRoutesPage() {
- const queryClient = useQueryClient();
- const [feedback, setFeedback] = useState(null);
- const [editingRouteId, setEditingRouteId] = useState(null);
- const [matchResult, setMatchResult] = useState(null);
- const [isMatching, setIsMatching] = useState(false);
+export function ProxyRoutesPage() {
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [editingRouteId, setEditingRouteId] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const [matchResult, setMatchResult] = useState(null);
+ const [isMatching, setIsMatching] = useState(false);
const form = useForm({
resolver: zodResolver(proxyRouteSchema),
@@ -251,12 +253,13 @@ export function ProxyRoutesPage() {
? updateProxyRoute(editingRouteId, payload)
: createProxyRoute(payload);
},
- onSuccess: async () => {
- setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' });
- setEditingRouteId(null);
- setMatchResult(null);
- form.reset(defaultValues);
- await queryClient.invalidateQueries({ queryKey: routesQueryKey });
+ onSuccess: async () => {
+ setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' });
+ setEditingRouteId(null);
+ setIsEditorOpen(false);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ await queryClient.invalidateQueries({ queryKey: routesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -350,24 +353,34 @@ export function ProxyRoutesPage() {
];
}, [routesQuery.data]);
- const handleReset = () => {
- setFeedback(null);
- setEditingRouteId(null);
- setMatchResult(null);
- form.reset(defaultValues);
- };
+ const handleReset = () => {
+ setFeedback(null);
+ setEditingRouteId(null);
+ setIsEditorOpen(false);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ };
+
+ const handleCreate = () => {
+ setFeedback(null);
+ setEditingRouteId(null);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ setIsEditorOpen(true);
+ };
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
- const handleEdit = (route: ProxyRouteItem) => {
- setFeedback(null);
- setEditingRouteId(route.id);
- setMatchResult(null);
- form.reset(toFormValues(route));
- };
+ const handleEdit = (route: ProxyRouteItem) => {
+ setFeedback(null);
+ setEditingRouteId(route.id);
+ setMatchResult(null);
+ form.reset(toFormValues(route));
+ setIsEditorOpen(true);
+ };
const handleDelete = (route: ProxyRouteItem) => {
if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
@@ -389,218 +402,44 @@ export function ProxyRoutesPage() {
const routes = routesQuery.data || [];
- return (
-
-
-
- {feedback ?
: null}
-
-
-
-
-
-
-
-
publishMutation.mutate()} disabled={publishMutation.isPending}>
- {publishMutation.isPending ? '发布中...' : '发布当前规则'}
-
- }
- >
-
- {summary.map((item) => (
-
-
- {item.label}
-
-
{item.value}
-
- ))}
-
-
-
-
-
-
- {getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
-
- {matchResult?.matched && matchResult.candidates.length > 1 ? (
-
- {matchResult.candidates.map((candidate) => (
-
- ))}
-
- ) : null}
-
- 当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
-
-
-
-
-
+ return (
+ <>
+
+
+ 新增规则
+
+ }
+ />
+
+ {feedback ? : null}
+
+ publishMutation.mutate()} disabled={publishMutation.isPending}>
+ {publishMutation.isPending ? '发布中...' : '发布当前规则'}
+
+ }
+ >
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
{routesQuery.isLoading ? (
@@ -688,6 +527,184 @@ export function ProxyRoutesPage() {
)}
-
- );
-}
+
+
+
+ 取消
+
+
+ {saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
+
+
+ }
+ >
+
+
+ >
+ );
+}
diff --git a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
index dadfc876..1bf5f961 100644
--- a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
+++ b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
@@ -11,6 +11,7 @@ import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
@@ -120,6 +121,7 @@ function toFilePayload(
export function TlsCertificatesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState
(null);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [fileForm, setFileForm] = useState(defaultFileValues);
const [certFile, setCertFile] = useState(null);
const [keyFile, setKeyFile] = useState(null);
@@ -139,6 +141,7 @@ export function TlsCertificatesPage() {
mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已导入。' });
+ setIsImportModalOpen(false);
manualForm.reset(defaultManualValues);
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey });
},
@@ -152,6 +155,7 @@ export function TlsCertificatesPage() {
importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书文件已导入。' });
+ setIsImportModalOpen(false);
setFileForm(defaultFileValues);
setCertFile(null);
setKeyFile(null);
@@ -222,11 +226,21 @@ export function TlsCertificatesPage() {
deleteMutation.mutate(certificate.id);
};
+ const handleCloseImportModal = () => {
+ setIsImportModalOpen(false);
+ };
+
return (
+ <>
setIsImportModalOpen(true)}>
+ 导入证书
+
+ }
/>
{feedback ? : null}
@@ -245,6 +259,83 @@ export function TlsCertificatesPage() {
+ void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
+ >
+ 刷新
+
+ }
+ >
+ {certificatesQuery.isLoading ? (
+
+ ) : certificatesQuery.isError ? (
+
+ ) : certificates.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 名称 |
+ 状态 |
+ 有效期 |
+ 备注 |
+ 更新时间 |
+ 操作 |
+
+
+
+ {certificates.map((certificate) => {
+ const status = getCertificateStatus(certificate);
+
+ return (
+
+ | {certificate.name} |
+
+
+ |
+
+
+ {formatDateTime(certificate.not_before)}
+ {formatDateTime(certificate.not_after)}
+
+ |
+ {certificate.remark || '—'} |
+
+ {formatDateTime(certificate.updated_at)}
+ |
+
+ handleDelete(certificate)}
+ disabled={deleteMutation.isPending}
+ className='px-3 py-2 text-xs'
+ >
+ 删除
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+
-
- void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
- >
- 刷新
-
- }
- >
- {certificatesQuery.isLoading ? (
-
- ) : certificatesQuery.isError ? (
-
- ) : certificates.length === 0 ? (
-
- ) : (
-
-
-
-
- | 名称 |
- 状态 |
- 有效期 |
- 备注 |
- 更新时间 |
- 操作 |
-
-
-
- {certificates.map((certificate) => {
- const status = getCertificateStatus(certificate);
-
- return (
-
- | {certificate.name} |
-
-
- |
-
-
- {formatDateTime(certificate.not_before)}
- {formatDateTime(certificate.not_after)}
-
- |
- {certificate.remark || '—'} |
-
- {formatDateTime(certificate.updated_at)}
- |
-
- handleDelete(certificate)}
- disabled={deleteMutation.isPending}
- className='px-3 py-2 text-xs'
- >
- 删除
-
- |
-
- );
- })}
-
-
-
- )}
-
-
+
+ >
);
}
diff --git a/atsf_server/web/features/users/components/users-page.tsx b/atsf_server/web/features/users/components/users-page.tsx
index 954b13b5..2d331012 100644
--- a/atsf_server/web/features/users/components/users-page.tsx
+++ b/atsf_server/web/features/users/components/users-page.tsx
@@ -8,12 +8,13 @@ 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 { 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 { AppModal } from '@/components/ui/app-modal';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
import {
createUser,
getUser,
@@ -96,10 +97,11 @@ 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 [searchInput, setSearchInput] = useState('');
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [editingUserId, setEditingUserId] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const [feedback, setFeedback] = useState(null);
const form = useForm({
resolver: zodResolver(userSchema),
@@ -153,12 +155,13 @@ export function UsersPage() {
await createUser(payload);
return '用户已创建。';
},
- onSuccess: async (message) => {
- setFeedback({ tone: 'success', message });
- setEditingUserId(null);
- form.reset(defaultValues);
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
+ onSuccess: async (message) => {
+ setFeedback({ tone: 'success', message });
+ setEditingUserId(null);
+ setIsEditorOpen(false);
+ form.reset(defaultValues);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
@@ -217,16 +220,25 @@ export function UsersPage() {
setFeedback(null);
};
- const handleResetForm = () => {
- setEditingUserId(null);
- setFeedback(null);
- form.reset(defaultValues);
- };
-
- const handleEdit = (targetUser: UserItem) => {
- setFeedback(null);
- setEditingUserId(targetUser.id);
- };
+ const handleResetForm = () => {
+ setEditingUserId(null);
+ setIsEditorOpen(false);
+ setFeedback(null);
+ form.reset(defaultValues);
+ };
+
+ const handleCreate = () => {
+ setEditingUserId(null);
+ setFeedback(null);
+ form.reset(defaultValues);
+ setIsEditorOpen(true);
+ };
+
+ const handleEdit = (targetUser: UserItem) => {
+ setFeedback(null);
+ setEditingUserId(targetUser.id);
+ setIsEditorOpen(true);
+ };
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
const actionText = {
@@ -255,10 +267,10 @@ export function UsersPage() {
});
if (!isAdmin) {
- return (
-
-
+
-
-
- {feedback ? : null}
-
-
-
-
- {summary.map((item) => (
-
-
- {item.label}
-
-
{item.value}
-
- ))}
-
-
-
-
- {editingUserId && editingUserQuery.isLoading ? (
-
- ) : editingUserId && editingUserQuery.isError ? (
-
- ) : (
-
- )}
-
-
+ return (
+ <>
+
+
+ 新建用户
+
+ }
+ />
+
+ {feedback ? : null}
+
+
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
-
- );
-}
+
+
+
+ 取消
+
+
+ {saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
+
+
+ }
+ >
+ {editingUserId && editingUserQuery.isLoading ? (
+
+ ) : editingUserId && editingUserQuery.isError ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts
index 97d232ea..d3b925b9 100644
--- a/atsf_server/web/lib/constants/navigation.ts
+++ b/atsf_server/web/lib/constants/navigation.ts
@@ -1,64 +1,51 @@
-import type { NavigationItem } from '@/types/navigation';
-
-export const dashboardNavigation: NavigationItem[] = [
- {
- href: '/',
- label: '总览',
- shortLabel: '总览',
- description: '查看阶段推进情况、系统入口与模块骨架状态。',
- },
+import type { NavigationItem } from '@/types/navigation';
+
+export const dashboardNavigation: NavigationItem[] = [
{
- href: '/proxy-route',
- label: '反代规则',
- shortLabel: '规则',
- description: '已接入列表、表单、证书匹配与发布动作。',
- },
- {
- href: '/config-version',
- label: '配置版本',
- shortLabel: '版本',
- description: '已接入版本预览、发布前 diff 与重新激活。',
- },
+ href: '/',
+ label: '总览',
+ icon: 'home',
+ },
{
href: '/node',
- label: '节点管理',
- shortLabel: '节点',
- description: '已接入状态标签、部署命令、心跳与更新动作。',
- },
+ label: '节点',
+ icon: 'node',
+ },
{
- href: '/apply-log',
- label: '应用记录',
- shortLabel: '记录',
- description: '已接入 node_id 筛选、结果状态展示与单条详情查看。',
+ href: '/config-version',
+ label: '发布',
+ icon: 'release',
},
{
href: '/managed-domain',
- label: '域名管理',
- shortLabel: '域名',
- description: '已接入列表、证书绑定、启停控制与编辑删除。',
+ label: '网站',
+ icon: 'website',
+ children: [
+ {
+ href: '/managed-domain',
+ label: '域名',
+ icon: 'domain',
+ },
+ {
+ href: '/tls-certificate',
+ label: '证书',
+ icon: 'certificate',
+ },
+ {
+ href: '/proxy-route',
+ label: '反向代理',
+ icon: 'proxy',
+ },
+ ],
},
- {
- href: '/tls-certificate',
- label: 'TLS 证书',
- shortLabel: '证书',
- description: '已接入 PEM 导入、文件上传、到期展示与删除。',
- },
- {
- href: '/file',
- label: '文件管理',
- shortLabel: '文件',
- description: '已接入上传、下载、复制链接、搜索与删除动作。',
- },
{
href: '/user',
- label: '用户管理',
- shortLabel: '用户',
- description: '已接入搜索、创建、编辑、封禁与权限调整。',
- },
+ label: '用户',
+ icon: 'user',
+ },
{
href: '/setting',
- label: '设置',
- shortLabel: '设置',
- description: '已接入个人、运维、系统与关于内容维护。',
- },
-];
+ label: '设置',
+ icon: 'setting',
+ },
+];
diff --git a/atsf_server/web/lib/utils/navigation.ts b/atsf_server/web/lib/utils/navigation.ts
index 8cb3b4dd..7f2a178b 100644
--- a/atsf_server/web/lib/utils/navigation.ts
+++ b/atsf_server/web/lib/utils/navigation.ts
@@ -1,14 +1,39 @@
-import { dashboardNavigation } from '@/lib/constants/navigation';
-import type { NavigationItem } from '@/types/navigation';
-
-export function isPathActive(pathname: string, href: string) {
+import { dashboardNavigation } from '@/lib/constants/navigation';
+import type { NavigationItem } from '@/types/navigation';
+
+export function isPathActive(pathname: string, href: string) {
if (href === '/') {
return pathname === '/';
}
- return pathname === href || pathname.startsWith(`${href}/`);
-}
-
-export function getCurrentNavigationItem(pathname: string): NavigationItem | undefined {
- return dashboardNavigation.find((item) => isPathActive(pathname, item.href));
-}
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+export function isNavigationItemActive(pathname: string, item: NavigationItem): boolean {
+ return isPathActive(pathname, item.href) || item.children?.some((child) => isNavigationItemActive(pathname, child)) || false;
+}
+
+export function flattenNavigationItems(items: NavigationItem[]): NavigationItem[] {
+ return items.flatMap((item) => [item, ...(item.children ? flattenNavigationItems(item.children) : [])]);
+}
+
+export function getCurrentNavigationItem(pathname: string): NavigationItem | undefined {
+ const findMatch = (items: NavigationItem[]): NavigationItem | undefined => {
+ for (const item of items) {
+ if (item.children) {
+ const childMatch = findMatch(item.children);
+ if (childMatch) {
+ return childMatch;
+ }
+ }
+
+ if (isPathActive(pathname, item.href)) {
+ return item;
+ }
+ }
+
+ return undefined;
+ };
+
+ return findMatch(dashboardNavigation);
+}
diff --git a/atsf_server/web/tests/unit/navigation-utils.test.ts b/atsf_server/web/tests/unit/navigation-utils.test.ts
index ceb093b5..0b371bea 100644
--- a/atsf_server/web/tests/unit/navigation-utils.test.ts
+++ b/atsf_server/web/tests/unit/navigation-utils.test.ts
@@ -9,7 +9,8 @@ describe('navigation utils', () => {
});
it('resolves current navigation item for nested paths', () => {
- expect(getCurrentNavigationItem('/node/abc')?.label).toBe('节点管理');
+ expect(getCurrentNavigationItem('/node/abc')?.label).toBe('节点');
+ expect(getCurrentNavigationItem('/managed-domain')?.label).toBe('域名');
expect(getCurrentNavigationItem('/setting')?.label).toBe('设置');
});
-});
+});
diff --git a/atsf_server/web/types/navigation.ts b/atsf_server/web/types/navigation.ts
index 48f74adc..09eac919 100644
--- a/atsf_server/web/types/navigation.ts
+++ b/atsf_server/web/types/navigation.ts
@@ -1,6 +1,17 @@
-export interface NavigationItem {
- href: string;
- label: string;
- shortLabel: string;
- description: string;
-}
+export type NavigationIconKey =
+ | 'home'
+ | 'node'
+ | 'website'
+ | 'domain'
+ | 'certificate'
+ | 'proxy'
+ | 'release'
+ | 'user'
+ | 'setting';
+
+export interface NavigationItem {
+ href: string;
+ label: string;
+ icon: NavigationIconKey;
+ children?: NavigationItem[];
+}
diff --git a/docs/frontend-revamp-plan.md b/docs/frontend-revamp-plan.md
index 6e63ce57..7512b267 100644
--- a/docs/frontend-revamp-plan.md
+++ b/docs/frontend-revamp-plan.md
@@ -31,9 +31,8 @@
* 应用记录 `/apply-log`
* 域名管理 `/managed-domain`
* TLS 证书 `/tls-certificate`
-* 文件管理 `/file`
-* 用户管理 `/user`
-* 设置 `/setting`
+* 用户管理 `/user`
+* 设置 `/setting`
* 登录、注册、重置密码、GitHub OAuth 等认证页面
现状判断:
@@ -158,7 +157,6 @@ atsf_server/web/
apply-log/
managed-domain/
tls-certificate/
- file/
user/
setting/
not-found.tsx
@@ -176,7 +174,6 @@ atsf_server/web/
apply-log/
managed-domain/
tls-certificate/
- file/
user/
setting/
lib/
@@ -216,7 +213,6 @@ atsf_server/web/
| `/apply-log` | `/apply-log` | 过滤器、结果状态可视化、分页与详情展示 |
| `/managed-domain` | `/managed-domain` | 通配符匹配提示、证书绑定状态、启用状态切换 |
| `/tls-certificate` | `/tls-certificate` | 导入、上传、有效期展示、到期提醒样式 |
-| `/file` | `/file` | 文件列表与下载交互优化 |
| `/user` | `/user` | 用户列表、角色管理、搜索与编辑体验 |
| `/setting` | `/setting` | 系统设置、运维设置、个人设置按信息架构重组 |
| `/login` 等 | `/login` 等 | 统一认证页视觉与表单规范 |
@@ -319,7 +315,7 @@ atsf_server/web/
任务:
-* 迁移 `setting`、`user`、`file`、`about` 等模块
+* 迁移 `setting`、`user`、`about` 等模块
* 重构表单项、标签页、操作区布局
* 增加部署命令复制、时间友好显示、状态颜色体系