From d40291d6d2d2029bc3430e5686d4a5d88c508f39 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 11 Mar 2026 18:03:37 +0800 Subject: [PATCH] Refactor code structure and remove redundant sections for improved readability and maintainability --- atsf_server/web/app/(dashboard)/file/page.tsx | 5 - .../components/layout/dashboard-sidebar.tsx | 232 ++++++-- .../components/layout/dashboard-topbar.tsx | 24 +- .../web/components/layout/page-header.tsx | 42 +- atsf_server/web/components/ui/app-modal.tsx | 87 +++ .../components/dashboard-overview.tsx | 82 +-- atsf_server/web/features/files/api/files.ts | 56 -- .../features/files/components/files-page.tsx | 374 ------------- atsf_server/web/features/files/types.ts | 10 - .../components/managed-domains-page.tsx | 237 ++++---- .../features/nodes/components/nodes-page.tsx | 264 +++++---- .../components/proxy-routes-page.tsx | 507 +++++++++--------- .../components/tls-certificates-page.tsx | 164 +++--- .../features/users/components/users-page.tsx | 253 +++++---- atsf_server/web/lib/constants/navigation.ts | 91 ++-- atsf_server/web/lib/utils/navigation.ts | 45 +- .../web/tests/unit/navigation-utils.test.ts | 5 +- atsf_server/web/types/navigation.ts | 23 +- docs/frontend-revamp-plan.md | 10 +- 19 files changed, 1202 insertions(+), 1309 deletions(-) delete mode 100644 atsf_server/web/app/(dashboard)/file/page.tsx create mode 100644 atsf_server/web/components/ui/app-modal.tsx delete mode 100644 atsf_server/web/features/files/api/files.ts delete mode 100644 atsf_server/web/features/files/components/files-page.tsx delete mode 100644 atsf_server/web/features/files/types.ts diff --git a/atsf_server/web/app/(dashboard)/file/page.tsx b/atsf_server/web/app/(dashboard)/file/page.tsx deleted file mode 100644 index 2c0eb36c..00000000 --- a/atsf_server/web/app/(dashboard)/file/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { FilesPage } from '@/features/files/components/files-page'; - -export default function FilesRoute() { - return ; -} diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx index f1247da8..af9f2936 100644 --- a/atsf_server/web/components/layout/dashboard-sidebar.tsx +++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx @@ -1,26 +1,164 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { dashboardNavigation } from '@/lib/constants/navigation'; -import { cn } from '@/lib/utils/cn'; -import { isPathActive } from '@/lib/utils/navigation'; -import { useAppShellStore } from '@/store/app-shell'; - -export function DashboardSidebar() { - const pathname = usePathname(); - const currentPath = pathname ?? '/'; +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { dashboardNavigation } from '@/lib/constants/navigation'; +import { cn } from '@/lib/utils/cn'; +import { isNavigationItemActive } from '@/lib/utils/navigation'; +import { useAppShellStore } from '@/store/app-shell'; +import type { NavigationIconKey, NavigationItem } from '@/types/navigation'; + +function SidebarIcon({ icon }: { icon: NavigationIconKey }) { + const commonProps = { + className: 'h-[18px] w-[18px]', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.8, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + viewBox: '0 0 24 24', + }; + + switch (icon) { + case 'home': + return ( + + + + + + ); + case 'node': + return ( + + + + + + + ); + case 'website': + return ( + + + + + + ); + case 'domain': + return ( + + + + + + ); + case 'certificate': + return ( + + + + + + ); + case 'proxy': + return ( + + + + + + + ); + case 'release': + return ( + + + + + + ); + case 'user': + return ( + + + + + ); + case 'setting': + return ( + + + + + ); + } +} + +function SidebarNavItem({ + item, + currentPath, + isSidebarCollapsed, + depth = 0, +}: { + item: NavigationItem; + currentPath: string; + isSidebarCollapsed: boolean; + depth?: number; +}) { + const active = isNavigationItemActive(currentPath, item); + const hasChildren = Boolean(item.children?.length); + + return ( +
+ 0 && 'ml-3 rounded-xl py-2.5', + active + ? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]' + : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]', + )} + > + + + + {!isSidebarCollapsed ? ( + {item.label} + ) : null} + + {!isSidebarCollapsed && hasChildren ? ( +
+ {item.children?.map((child) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function DashboardSidebar() { + const pathname = usePathname(); + const currentPath = pathname ?? '/'; const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed); return ( - + ); +} 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 ( +
+ +
+
{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. 1. 对照后端接口继续压缩页面认知负担,保持核心动作在列表页附近完成。
  4. 2. 补齐关键页面的测试覆盖与构建验收。
  5. 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.filename} - -

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

-

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

-
-
-
-

{file.uploader}

-

上传者 ID:{file.uploader_id}

-
-
- {formatRelativeTime(file.upload_time)} · {formatDateTime(file.upload_time)} - - {file.download_counter || 0} - -
- window.open(buildFileDownloadUrl(file.link), '_blank', 'noopener,noreferrer')} - className='px-3 py-2 text-xs' - > - 下载 - - void handleCopy(file.link)} - className='px-3 py-2 text-xs' - > - 复制链接 - - handleDelete(file)} - disabled={deleteMutation.isPending} - className='px-3 py-2 text-xs' - > - 删除 - -
-
-
- )} -
-
-
- ); -} diff --git a/atsf_server/web/features/files/types.ts b/atsf_server/web/features/files/types.ts 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} -
- -
-
- - - - - - form.setValue('cert_id', event.target.value, { - shouldDirty: true, - shouldValidate: true, - }) - } - > - - {certificates.map((certificate) => ( - - ))} - - -
- - - form.setValue('enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - - +
+ {summary.map((item) => ( +
- - - -
- - {saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'} - - - {editingDomainId ? '取消编辑' : '重置表单'} - -
- - - -
- -
- {summary.map((item) => ( -
-

- {item.label} -

-

{item.value}

-
- ))} -
-
- - -
-

- 当前输入: - - {watchedDomain.trim() || '未填写域名'} - -

-
- {watchedDomain.trim() ? ( - - ) : ( - - )} - -
-

- 建议只为真正需要默认推荐的域名绑定证书;如果同一 hostname 同时命中精确和通配符规则, - 系统会优先使用精确匹配。 +

+ {item.label}

+

{item.value}

-
+ ))}
-
+
+ + + 取消 + + + {saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'} + +
+ } + > +
+
+ + + + + + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + > + + {certificates.map((certificate) => ( + + ))} + + +
+ + + form.setValue('enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + +
+

+ 当前输入: + + {watchedDomain.trim() || '未填写域名'} + +

+
+ {watchedDomain.trim() ? ( + + ) : ( + + )} + +
+

+ 建议只为真正需要默认推荐的域名绑定证书;如果同一 hostname 同时命中精确和通配符规则, + 系统会优先使用精确匹配。 +

+
+
+ + + + + + + ); } 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}

-
- ))} -
-
- - -
- - - - - - form.setValue('auto_update_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - -
- - {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} - - {editingNodeId ? ( - - 取消编辑 - - ) : null} -
- -
-
+ 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 ? '保存修改' : '新增节点'} + +
+ } + > +
+ + + + + + form.setValue('auto_update_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + + + ); +} 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} - -
- -
-
- - - - - - -
- -
- form.setValue('enabled', checked, { shouldDirty: true })} - /> - { - form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true }); - if (!checked) { - form.setValue('cert_id', '', { shouldDirty: true, shouldValidate: true }); - form.setValue('redirect_http', false, { shouldDirty: true, shouldValidate: true }); - } - }} - /> -
- -
- - - form.setValue('cert_id', event.target.value, { - shouldDirty: true, - shouldValidate: true, - }) - } - > - - {certificates.map((certificate) => ( - - ))} - - - - form.setValue('redirect_http', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> -
- -
-
-
-

自定义请求头

-

- 可为空。若填写,请保证 Header 名称合法且不包含换行。 -

-
- append({ key: '', value: '' })} - className='px-3 py-2 text-xs' - > - 添加请求头 - -
- -
- {fields.map((field, index) => ( -
- - - - - - -
- handleRemoveHeader(index)} - className='w-full px-3 py-3 text-xs md:w-auto' - > - 删除 - -
-
- ))} -
-
- - - - - -
- - {saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'} - - {editingRouteId ? ( - - 取消编辑 - - ) : 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 ? '保存修改' : '新增规则'} + +
+ } + > +
+
+ + + + + + +
+ +
+ form.setValue('enabled', checked, { shouldDirty: true })} + /> + { + form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true }); + if (!checked) { + form.setValue('cert_id', '', { shouldDirty: true, shouldValidate: true }); + form.setValue('redirect_http', false, { shouldDirty: true, shouldValidate: true }); + } + }} + /> +
+ +
+ + + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + > + + {certificates.map((certificate) => ( + + ))} + + + + form.setValue('redirect_http', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> +
+ +
+
+
+

自定义请求头

+

+ 可为空。若填写,请保证 Header 名称合法且不包含换行。 +

+
+ append({ key: '', value: '' })} + className='px-3 py-2 text-xs' + > + 添加请求头 + +
+ +
+ {fields.map((field, index) => ( +
+ + + + + + +
+ handleRemoveHeader(index)} + className='w-full px-3 py-3 text-xs md:w-auto' + > + 删除 + +
+
+ ))} +
+
+ + +
+

+ {getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)} +

+ {matchResult?.matched && matchResult.candidates.length > 1 ? ( +
+ {matchResult.candidates.map((candidate) => ( + + ))} +
+ ) : null} +
+ 当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`} +
+
+
+ + + + +
+ + + ); +} 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' + > + 删除 + +
+
+ )} +
+
+
@@ -351,76 +442,7 @@ 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' - > - 删除 - -
-
- )} -
-
+ + ); } 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 ? ( - - ) : ( -
- - - - - - - - - - - - - {editingUserId && editingUserQuery.data ? ( -
-
-

绑定邮箱

-

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

-
-
-

第三方账号

-

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

-

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

-
-
- ) : null} - -
- - {saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'} - - - {editingUserId ? '取消编辑' : '重置表单'} - -
-
- )} -
-
+ return ( + <> +
+ + 新建用户 + + } + /> + + {feedback ? : null} + + +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
-
- ); -} +
+ + + 取消 + + + {saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'} + + + } + > + {editingUserId && editingUserQuery.isLoading ? ( + + ) : editingUserId && editingUserQuery.isError ? ( + + ) : ( +
+ + + + + + + + + + + + + {editingUserId && editingUserQuery.data ? ( +
+
+

绑定邮箱

+

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

+
+
+

第三方账号

+

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

+

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

+
+
+ ) : null} +
+ )} +
+ + ); +} 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` 等模块 * 重构表单项、标签页、操作区布局 * 增加部署命令复制、时间友好显示、状态颜色体系