mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Refactor code structure and remove redundant sections for improved readability and maintainability
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
import { FilesPage } from '@/features/files/components/files-page';
|
||||
|
||||
export default function FilesRoute() {
|
||||
return <FilesPage />;
|
||||
}
|
||||
@@ -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 (
|
||||
<svg {...commonProps}>
|
||||
<path d='M3 10.5 12 3l9 7.5' />
|
||||
<path d='M5.5 9.5V21h13V9.5' />
|
||||
<path d='M9.5 21v-6h5v6' />
|
||||
</svg>
|
||||
);
|
||||
case 'node':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x='4' y='4' width='6' height='6' rx='1.5' />
|
||||
<rect x='14' y='4' width='6' height='6' rx='1.5' />
|
||||
<rect x='9' y='14' width='6' height='6' rx='1.5' />
|
||||
<path d='M10 7h4M12 10v4' />
|
||||
</svg>
|
||||
);
|
||||
case 'website':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx='12' cy='12' r='8.5' />
|
||||
<path d='M3.5 12h17' />
|
||||
<path d='M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z' />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d='M4 8.5h16' />
|
||||
<path d='M4 15.5h16' />
|
||||
<rect x='3.5' y='5' width='17' height='14' rx='3' />
|
||||
</svg>
|
||||
);
|
||||
case 'certificate':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d='M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z' />
|
||||
<path d='M9 10h6' />
|
||||
<path d='M10 14h4' />
|
||||
</svg>
|
||||
);
|
||||
case 'proxy':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d='M7 7h10' />
|
||||
<path d='M7 12h10' />
|
||||
<path d='M7 17h6' />
|
||||
<path d='m14 15 3 2-3 2' />
|
||||
</svg>
|
||||
);
|
||||
case 'release':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d='M12 4v10' />
|
||||
<path d='m8.5 7.5 3.5-3.5 3.5 3.5' />
|
||||
<path d='M5 19.5h14' />
|
||||
</svg>
|
||||
);
|
||||
case 'user':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx='12' cy='8' r='3.25' />
|
||||
<path d='M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5' />
|
||||
</svg>
|
||||
);
|
||||
case 'setting':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx='12' cy='12' r='3.5' />
|
||||
<path d='M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='space-y-2'>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-2xl border px-3 py-3 transition-colors',
|
||||
depth > 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)]',
|
||||
)}
|
||||
>
|
||||
<span className='flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
|
||||
<SidebarIcon icon={item.icon} />
|
||||
</span>
|
||||
{!isSidebarCollapsed ? (
|
||||
<span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{!isSidebarCollapsed && hasChildren ? (
|
||||
<div className='space-y-2'>
|
||||
{item.children?.map((child) => (
|
||||
<SidebarNavItem
|
||||
key={child.href}
|
||||
item={child}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const currentPath = pathname ?? '/';
|
||||
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'sticky top-0 hidden h-screen shrink-0 border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-6 backdrop-blur lg:block',
|
||||
isSidebarCollapsed ? 'w-24' : 'w-72',
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<aside
|
||||
className={cn(
|
||||
'sticky top-0 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-6 backdrop-blur lg:block',
|
||||
isSidebarCollapsed ? 'w-24' : 'w-72',
|
||||
)}
|
||||
>
|
||||
<div className='flex h-full flex-col gap-6'>
|
||||
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
|
||||
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-lg font-semibold text-[var(--brand-primary)]'>
|
||||
AF
|
||||
@@ -33,43 +171,25 @@ export function DashboardSidebar() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className='flex-1 space-y-2'>
|
||||
{dashboardNavigation.map((item) => {
|
||||
const active = isPathActive(currentPath, item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors',
|
||||
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)]',
|
||||
)}
|
||||
>
|
||||
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-xs font-semibold'>
|
||||
{item.shortLabel.slice(0, 1)}
|
||||
</span>
|
||||
{!isSidebarCollapsed ? (
|
||||
<span className='min-w-0'>
|
||||
<span className='block text-sm font-medium'>{item.label}</span>
|
||||
<span className='mt-1 block text-xs leading-5 text-inherit/80'>
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<nav className='flex-1 space-y-2'>
|
||||
<div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'>
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{!isSidebarCollapsed ? (
|
||||
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
|
||||
新版管理端已覆盖认证、核心链路和边缘模块,旧版前端代码已完成移除。
|
||||
新版控制台已切换为生产导向的信息架构,核心操作统一收敛到列表与弹窗。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className='flex items-center gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
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='切换侧边栏'
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
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='切换侧边栏'
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
|
||||
<h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description }: PageHeaderProps) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||
<div className='space-y-2'>
|
||||
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1>
|
||||
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
|
||||
<div className='space-y-3'>
|
||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||
<div className='space-y-2'>
|
||||
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1>
|
||||
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className='flex flex-wrap gap-3'>{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NonNullable<AppModalProps['size']>, 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 (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6' role='dialog' aria-modal='true' aria-label={title}>
|
||||
<button type='button' className='absolute inset-0 cursor-default' onClick={onClose} aria-label='关闭弹窗' />
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex max-h-[calc(100vh-3rem)] w-full flex-col overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
sizeClassNameMap[size],
|
||||
)}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-xl font-semibold text-[var(--foreground-primary)]'>{title}</h2>
|
||||
{description ? (
|
||||
<p className='max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='inline-flex h-10 w-10 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='关闭弹窗'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 py-6'>{children}</div>
|
||||
{footer ? <div className='border-t border-[var(--border-default)] px-6 py-5'>{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className='space-y-6'>
|
||||
<AppCard
|
||||
title='前端迁移完成'
|
||||
description='新版管理端已承接全部页面入口、旧路由兼容和核心交互,当前可继续推进联调与测试补齐。'
|
||||
description='新版管理端已承接主链路页面与核心运维动作,当前重点转向生产环境信息架构与操作效率优化。'
|
||||
action={<StatusBadge label='统一 Next.js 前端' variant='success' />}
|
||||
>
|
||||
>
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
{readinessItems.map((item) => (
|
||||
<div
|
||||
@@ -41,28 +47,26 @@ export function DashboardOverview() {
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
|
||||
<AppCard title='模块入口' description='新版导航中的业务页面均已接入真实数据,设置与边缘模块也已可直接使用。'>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{dashboardNavigation.slice(1).map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--accent-soft)]'
|
||||
>
|
||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{item.label}</p>
|
||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
{item.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='下一步建议' description='当前重点转向联调回归、测试覆盖和发布验收。'>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
|
||||
<AppCard title='模块入口' description='当前导航已收敛为首页、节点、网站、发布、用户和设置,网站下再细分业务对象。'>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{moduleLinks.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--accent-soft)]'
|
||||
>
|
||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{item.label}</p>
|
||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>进入 {item.label} 页面,继续完成管理与发布操作。</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='下一步建议' description='当前重点转向弹窗化操作、联调回归和发布验收。'>
|
||||
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
<li>1. 对照后端接口完成设置、节点、证书和用户链路联调。</li>
|
||||
<li>1. 对照后端接口继续压缩页面认知负担,保持核心动作在列表页附近完成。</li>
|
||||
<li>2. 补齐关键页面的测试覆盖与构建验收。</li>
|
||||
<li>3. 清理迁移期文档和发布说明中的旧前端表述。</li>
|
||||
</ol>
|
||||
|
||||
@@ -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<FileItem[]>(`/file/?p=${page}`);
|
||||
}
|
||||
|
||||
export function searchFiles(keyword: string) {
|
||||
return apiRequest<FileItem[]>(`/file/search?keyword=${encodeURIComponent(keyword)}`);
|
||||
}
|
||||
|
||||
export function deleteFile(id: number) {
|
||||
return apiRequest<void>(`/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})`);
|
||||
}
|
||||
}
|
||||
@@ -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<File[]>([]);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(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 (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='文件管理'
|
||||
description='阶段 4 已迁移文件管理页面,但当前账户没有管理员权限。'
|
||||
/>
|
||||
<EmptyState
|
||||
title='权限不足'
|
||||
description='文件上传、删除与下载链接管理仅对管理员开放,请使用管理员账号登录后继续。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='文件管理'
|
||||
description='支持上传、搜索、分页、下载、复制外链和删除文件,覆盖常见日常文件分发场景。'
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
||||
<AppCard title='文件概览' description='统计信息会随当前视图和待上传队列同步变化。'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title='上传文件'
|
||||
description='支持一次选择多个文件并附加统一描述信息。文件上传后将立即可下载。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={handleUpload} disabled={uploadMutation.isPending}>
|
||||
{uploadMutation.isPending ? '上传中...' : '开始上传'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className='space-y-5'>
|
||||
<ResourceField label='上传描述' hint='可选,适用于同批次文件的统一说明。'>
|
||||
<ResourceInput
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder='例如:节点安装包 / 测试证书 / 宣传素材'
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label='文件选择' hint='按住 Ctrl 或 Shift 可批量选择多个文件。'>
|
||||
<input
|
||||
type='file'
|
||||
multiple
|
||||
onChange={(event) => 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)]'
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{selectedFiles.length > 0 ? (
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-sm font-medium text-[var(--foreground-primary)]'>待上传文件</p>
|
||||
<ul className='mt-3 space-y-2 text-sm text-[var(--foreground-secondary)]'>
|
||||
{selectedFiles.map((file) => (
|
||||
<li key={`${file.name}-${file.size}`} className='break-all'>
|
||||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title='尚未选择文件' description='选择文件后即可直接上传,无需离开当前页面。' />
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title='文件列表'
|
||||
description='分页模式下按服务器每页 10 条加载;搜索模式会返回全部匹配结果。'
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: filesQueryKey })}
|
||||
>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className='space-y-5'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex w-full flex-col gap-3 md:flex-row'>
|
||||
<ResourceInput
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder='搜索文件名、上传者或上传者 ID'
|
||||
className='md:max-w-xl'
|
||||
/>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PrimaryButton type='button' onClick={handleSearchSubmit}>
|
||||
搜索
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type='button' onClick={handleResetSearch}>
|
||||
清空
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchKeyword.length === 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<SecondaryButton type='button' onClick={() => setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
disabled={activeQuery.isLoading || files.length < ITEMS_PER_PAGE}
|
||||
>
|
||||
下一页
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : activeQuery.isError ? (
|
||||
<ErrorState title='文件列表加载失败' description={getErrorMessage(activeQuery.error)} />
|
||||
) : files.length === 0 ? (
|
||||
<EmptyState title='暂无文件' description='当前条件下没有可展示的文件记录。' />
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||
<thead>
|
||||
<tr className='text-[var(--foreground-secondary)]'>
|
||||
<th className='px-3 py-3 font-medium'>文件</th>
|
||||
<th className='px-3 py-3 font-medium'>上传者</th>
|
||||
<th className='px-3 py-3 font-medium'>上传时间</th>
|
||||
<th className='px-3 py-3 font-medium'>下载量</th>
|
||||
<th className='px-3 py-3 font-medium'>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id} className='align-top'>
|
||||
<td className='px-3 py-4'>
|
||||
<div className='space-y-1'>
|
||||
<a
|
||||
href={buildFileDownloadUrl(file.link)}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
>
|
||||
{file.filename}
|
||||
</a>
|
||||
<p className='max-w-80 text-xs leading-5 text-[var(--foreground-secondary)]'>
|
||||
{file.description || '无描述信息'}
|
||||
</p>
|
||||
<p className='text-xs text-[var(--foreground-secondary)]'>
|
||||
存储链接:{file.link}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
<div className='space-y-1'>
|
||||
<p>{file.uploader}</p>
|
||||
<p className='text-xs'>上传者 ID:{file.uploader_id}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
{formatRelativeTime(file.upload_time)} · {formatDateTime(file.upload_time)}
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
{file.download_counter || 0}
|
||||
</td>
|
||||
<td className='px-3 py-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PrimaryButton
|
||||
type='button'
|
||||
onClick={() => window.open(buildFileDownloadUrl(file.link), '_blank', 'noopener,noreferrer')}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
下载
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void handleCopy(file.link)}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
复制链接
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type='button'
|
||||
onClick={() => handleDelete(file)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
const form = useForm<ManagedDomainFormValues>({
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='域名管理'
|
||||
description='维护精确域名与通配符域名,为其绑定默认证书,并控制是否参与自动匹配与发布。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={handleCreate}>
|
||||
新增域名
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.15fr_0.85fr]'>
|
||||
<AppCard
|
||||
title={editingDomainId ? '编辑域名规则' : '新增域名规则'}
|
||||
description='支持精确域名与单层通配符域名,证书可留空以仅维护资产关系。'
|
||||
>
|
||||
<form className='space-y-5' onSubmit={handleSubmit}>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<ResourceField
|
||||
label='域名'
|
||||
hint='示例:example.com 或 *.example.com'
|
||||
error={form.formState.errors.domain?.message}
|
||||
>
|
||||
<ResourceInput placeholder='example.com 或 *.example.com' {...form.register('domain')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label='默认证书'
|
||||
hint='证书用于域名自动匹配与规则推荐,可不选择。'
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedCertId}
|
||||
disabled={certificatesQuery.isLoading}
|
||||
onChange={(event) =>
|
||||
form.setValue('cert_id', event.target.value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>不绑定证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ToggleField
|
||||
label='启用规则'
|
||||
description='停用后该域名不会参与证书自动匹配,但记录会保留。'
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceField
|
||||
label='备注'
|
||||
hint='可选,用于记录归属、用途或生效说明。'
|
||||
error={form.formState.errors.remark?.message}
|
||||
<AppCard title='规则摘要' description='域名维护动作已改为弹窗操作,列表专注展示状态与证书绑定关系。'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<ResourceInput placeholder='例如:主站泛域名证书' {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
{editingDomainId ? '取消编辑' : '重置表单'}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppCard>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<AppCard title='规则摘要' description='帮助区分精确与通配符规则覆盖情况。'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-1'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='匹配提示' description='自动证书推荐时,精确域名优先级高于通配符域名。'>
|
||||
<div className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
<p>
|
||||
当前输入:
|
||||
<span className='ml-2 font-medium text-[var(--foreground-primary)]'>
|
||||
{watchedDomain.trim() || '未填写域名'}
|
||||
</span>
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{watchedDomain.trim() ? (
|
||||
<StatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
|
||||
) : (
|
||||
<StatusBadge label='等待输入' variant='warning' />
|
||||
)}
|
||||
<StatusBadge
|
||||
label={currentCertificate ? `证书:${currentCertificate.name}` : '未绑定证书'}
|
||||
variant={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
建议只为真正需要默认推荐的域名绑定证书;如果同一 hostname 同时命中精确和通配符规则,
|
||||
系统会优先使用精确匹配。
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title='域名规则列表'
|
||||
@@ -417,5 +336,103 @@ export function ManagedDomainsPage() {
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleReset}
|
||||
title={editingDomainId ? '编辑域名规则' : '新增域名规则'}
|
||||
description='支持精确域名与单层通配符域名,证书可留空以仅维护资产关系。'
|
||||
footer={
|
||||
<div className='flex flex-wrap justify-end gap-3'>
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type='submit' form='managed-domain-editor-form' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingDomainId ? '保存修改' : '新增域名'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id='managed-domain-editor-form' className='space-y-5' onSubmit={handleSubmit}>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<ResourceField
|
||||
label='域名'
|
||||
hint='示例:example.com 或 *.example.com'
|
||||
error={form.formState.errors.domain?.message}
|
||||
>
|
||||
<ResourceInput placeholder='example.com 或 *.example.com' {...form.register('domain')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label='默认证书'
|
||||
hint='证书用于域名自动匹配与规则推荐,可不选择。'
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedCertId}
|
||||
disabled={certificatesQuery.isLoading}
|
||||
onChange={(event) =>
|
||||
form.setValue('cert_id', event.target.value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>不绑定证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ToggleField
|
||||
label='启用规则'
|
||||
description='停用后该域名不会参与证书自动匹配,但记录会保留。'
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) =>
|
||||
form.setValue('enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<AppCard title='匹配提示' description='自动证书推荐时,精确域名优先级高于通配符域名。'>
|
||||
<div className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
<p>
|
||||
当前输入:
|
||||
<span className='ml-2 font-medium text-[var(--foreground-primary)]'>
|
||||
{watchedDomain.trim() || '未填写域名'}
|
||||
</span>
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{watchedDomain.trim() ? (
|
||||
<StatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
|
||||
) : (
|
||||
<StatusBadge label='等待输入' variant='warning' />
|
||||
)}
|
||||
<StatusBadge
|
||||
label={currentCertificate ? `证书:${currentCertificate.name}` : '未绑定证书'}
|
||||
variant={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
建议只为真正需要默认推荐的域名绑定证书;如果同一 hostname 同时命中精确和通配符规则,
|
||||
系统会优先使用精确匹配。
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<ResourceField
|
||||
label='备注'
|
||||
hint='可选,用于记录归属、用途或生效说明。'
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceInput placeholder='例如:主站泛域名证书' {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FeedbackState | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [, setRefreshTick] = useState(0);
|
||||
export function NodesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [, setRefreshTick] = useState(0);
|
||||
|
||||
const form = useForm<NodeFormValues>({
|
||||
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 (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='节点管理'
|
||||
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
||||
<AppCard
|
||||
title='节点摘要'
|
||||
description='阶段 3 已接入状态标签、部署命令、自动更新开关与手动更新动作。'
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
|
||||
>
|
||||
刷新列表
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title='创建或编辑节点'
|
||||
description='预创建节点后会立即生成节点专属 Token,可直接复制专属安装命令。'
|
||||
>
|
||||
<form className='space-y-5' onSubmit={handleSubmit}>
|
||||
<ResourceField label='节点名' hint='示例:shanghai-edge-1' error={form.formState.errors.name?.message}>
|
||||
<ResourceInput placeholder='shanghai-edge-1' {...form.register('name')} />
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label='启用自动更新'
|
||||
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
|
||||
</PrimaryButton>
|
||||
{editingNodeId ? (
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
取消编辑
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</AppCard>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='节点管理'
|
||||
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
|
||||
action={
|
||||
<>
|
||||
<SecondaryButton type='button' onClick={handleCreate}>
|
||||
新增节点
|
||||
</SecondaryButton>
|
||||
<Link
|
||||
href='/apply-log'
|
||||
className='inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
|
||||
>
|
||||
应用记录
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<AppCard
|
||||
title='节点摘要'
|
||||
description='节点创建、编辑和更新动作均已收敛到列表页与弹窗,便于在生产环境快速处理。'
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
|
||||
>
|
||||
刷新列表
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
||||
<AppCard
|
||||
@@ -503,7 +494,7 @@ export function NodesPage() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title='节点列表' description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'>
|
||||
<AppCard title='节点列表' description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'>
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : nodesQuery.isError ? (
|
||||
@@ -628,6 +619,41 @@ export function NodesPage() {
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleReset}
|
||||
title={editingNodeId ? '编辑节点' : '新增节点'}
|
||||
description='预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。'
|
||||
footer={
|
||||
<div className='flex flex-wrap justify-end gap-3'>
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type='submit' form='node-editor-form' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id='node-editor-form' className='space-y-5' onSubmit={handleSubmit}>
|
||||
<ResourceField label='节点名' hint='示例:shanghai-edge-1' error={form.formState.errors.name?.message}>
|
||||
<ResourceInput placeholder='shanghai-edge-1' {...form.register('name')} />
|
||||
</ResourceField>
|
||||
|
||||
<ToggleField
|
||||
label='启用自动更新'
|
||||
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
|
||||
checked={watchedAutoUpdate}
|
||||
onChange={(checked) =>
|
||||
form.setValue('auto_update_enabled', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FeedbackState | null>(null);
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [matchResult, setMatchResult] = useState<ManagedDomainMatchResult | null>(null);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
export function ProxyRoutesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [matchResult, setMatchResult] = useState<ManagedDomainMatchResult | null>(null);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const form = useForm<ProxyRouteFormValues>({
|
||||
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 (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='反代规则'
|
||||
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。'
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.7fr]'>
|
||||
<AppCard
|
||||
title={editingRouteId ? '编辑规则' : '新增规则'}
|
||||
description='新增或修改反代规则后,可直接在当前页面完成发布。'
|
||||
>
|
||||
<form className='space-y-5' onSubmit={handleSubmit}>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<ResourceField
|
||||
label='域名'
|
||||
hint='示例:example.com'
|
||||
error={form.formState.errors.domain?.message}
|
||||
>
|
||||
<ResourceInput placeholder='example.com' {...form.register('domain')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label='源站地址'
|
||||
hint='示例:https://origin.internal'
|
||||
error={form.formState.errors.origin_url?.message}
|
||||
>
|
||||
<ResourceInput placeholder='https://origin.internal' {...form.register('origin_url')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<ToggleField
|
||||
label='启用规则'
|
||||
description='关闭后该规则不会参与配置渲染与发布。'
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) => form.setValue('enabled', checked, { shouldDirty: true })}
|
||||
/>
|
||||
<ToggleField
|
||||
label='启用 HTTPS'
|
||||
description='启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。'
|
||||
checked={watchedEnableHttps}
|
||||
onChange={(checked) => {
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||
<ResourceField
|
||||
label='TLS 证书'
|
||||
hint='启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。'
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedCertId}
|
||||
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
|
||||
onChange={(event) =>
|
||||
form.setValue('cert_id', event.target.value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>请选择证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label='HTTP 跳转 HTTPS'
|
||||
description='仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。'
|
||||
checked={watchedRedirectHttp}
|
||||
disabled={!watchedEnableHttps}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>自定义请求头</p>
|
||||
<p className='mt-1 text-xs leading-5 text-[var(--foreground-secondary)]'>
|
||||
可为空。若填写,请保证 Header 名称合法且不包含换行。
|
||||
</p>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
添加请求头
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 space-y-4'>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className='grid gap-3 md:grid-cols-[1fr_1fr_auto]'>
|
||||
<ResourceField
|
||||
label={index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`}
|
||||
error={form.formState.errors.custom_headers?.[index]?.key?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder='X-Trace-Id'
|
||||
{...form.register(`custom_headers.${index}.key`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
|
||||
error={form.formState.errors.custom_headers?.[index]?.value?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder='$request_id'
|
||||
{...form.register(`custom_headers.${index}.value`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className='flex items-end'>
|
||||
<DangerButton
|
||||
type='button'
|
||||
onClick={() => handleRemoveHeader(index)}
|
||||
className='w-full px-3 py-3 text-xs md:w-auto'
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label='备注'
|
||||
hint='可选,便于标记用途、责任人或特殊说明。'
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
|
||||
</PrimaryButton>
|
||||
{editingRouteId ? (
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
取消编辑
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</AppCard>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<AppCard
|
||||
title='发布与摘要'
|
||||
description='规则变更完成后,可直接发布为新的配置版本。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
|
||||
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-1'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='证书匹配提示' description='根据域名自动匹配托管证书,优先使用精确匹配规则。'>
|
||||
<div className='space-y-3'>
|
||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
{getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
|
||||
</p>
|
||||
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{matchResult.candidates.map((candidate) => (
|
||||
<StatusBadge
|
||||
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
|
||||
label={`${candidate.domain} → ${candidate.certificate_name}`}
|
||||
variant={candidate.match_type === 'exact' ? 'success' : 'info'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]'>
|
||||
当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='反代规则'
|
||||
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={handleCreate}>
|
||||
新增规则
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<AppCard
|
||||
title='发布与摘要'
|
||||
description='规则维护动作改为按钮+弹窗模式,列表不再被长表单挤压。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
|
||||
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='规则列表' description='支持查看启用状态、HTTPS 配置、自定义请求头数量及最近更新时间。'>
|
||||
{routesQuery.isLoading ? (
|
||||
@@ -688,6 +527,184 @@ export function ProxyRoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleReset}
|
||||
title={editingRouteId ? '编辑规则' : '新增规则'}
|
||||
description='新增或修改反代规则后,可直接回到列表页继续发布。'
|
||||
size='xl'
|
||||
footer={
|
||||
<div className='flex flex-wrap justify-end gap-3'>
|
||||
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type='submit' form='proxy-route-editor-form' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id='proxy-route-editor-form' className='space-y-5' onSubmit={handleSubmit}>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<ResourceField
|
||||
label='域名'
|
||||
hint='示例:example.com'
|
||||
error={form.formState.errors.domain?.message}
|
||||
>
|
||||
<ResourceInput placeholder='example.com' {...form.register('domain')} />
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label='源站地址'
|
||||
hint='示例:https://origin.internal'
|
||||
error={form.formState.errors.origin_url?.message}
|
||||
>
|
||||
<ResourceInput placeholder='https://origin.internal' {...form.register('origin_url')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<ToggleField
|
||||
label='启用规则'
|
||||
description='关闭后该规则不会参与配置渲染与发布。'
|
||||
checked={watchedEnabled}
|
||||
onChange={(checked) => form.setValue('enabled', checked, { shouldDirty: true })}
|
||||
/>
|
||||
<ToggleField
|
||||
label='启用 HTTPS'
|
||||
description='启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。'
|
||||
checked={watchedEnableHttps}
|
||||
onChange={(checked) => {
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||
<ResourceField
|
||||
label='TLS 证书'
|
||||
hint='启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。'
|
||||
error={form.formState.errors.cert_id?.message}
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedCertId}
|
||||
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
|
||||
onChange={(event) =>
|
||||
form.setValue('cert_id', event.target.value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value=''>请选择证书</option>
|
||||
{certificates.map((certificate) => (
|
||||
<option key={certificate.id} value={certificate.id}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ToggleField
|
||||
label='HTTP 跳转 HTTPS'
|
||||
description='仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。'
|
||||
checked={watchedRedirectHttp}
|
||||
disabled={!watchedEnableHttps}
|
||||
onChange={(checked) =>
|
||||
form.setValue('redirect_http', checked, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>自定义请求头</p>
|
||||
<p className='mt-1 text-xs leading-5 text-[var(--foreground-secondary)]'>
|
||||
可为空。若填写,请保证 Header 名称合法且不包含换行。
|
||||
</p>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
添加请求头
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 space-y-4'>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className='grid gap-3 md:grid-cols-[1fr_1fr_auto]'>
|
||||
<ResourceField
|
||||
label={index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`}
|
||||
error={form.formState.errors.custom_headers?.[index]?.key?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder='X-Trace-Id'
|
||||
{...form.register(`custom_headers.${index}.key`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
|
||||
error={form.formState.errors.custom_headers?.[index]?.value?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder='$request_id'
|
||||
{...form.register(`custom_headers.${index}.value`)}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className='flex items-end'>
|
||||
<DangerButton
|
||||
type='button'
|
||||
onClick={() => handleRemoveHeader(index)}
|
||||
className='w-full px-3 py-3 text-xs md:w-auto'
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppCard title='证书匹配提示' description='根据域名自动匹配托管证书,优先使用精确匹配规则。'>
|
||||
<div className='space-y-3'>
|
||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||
{getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
|
||||
</p>
|
||||
{matchResult?.matched && matchResult.candidates.length > 1 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{matchResult.candidates.map((candidate) => (
|
||||
<StatusBadge
|
||||
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
|
||||
label={`${candidate.domain} → ${candidate.certificate_name}`}
|
||||
variant={candidate.match_type === 'exact' ? 'success' : 'info'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]'>
|
||||
当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<ResourceField
|
||||
label='备注'
|
||||
hint='可选,便于标记用途、责任人或特殊说明。'
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} />
|
||||
</ResourceField>
|
||||
</form>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FeedbackState | null>(null);
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [fileForm, setFileForm] = useState<FileImportFormValues>(defaultFileValues);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(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 (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='TLS 证书'
|
||||
description='支持手动粘贴 PEM 导入和文件上传导入,并展示证书有效期与到期风险。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={() => setIsImportModalOpen(true)}>
|
||||
导入证书
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
@@ -245,6 +259,83 @@ export function TlsCertificatesPage() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title='证书列表'
|
||||
description='展示证书有效期、到期状态与最近更新时间。删除前会提示确认。'
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
|
||||
>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
{certificatesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : certificatesQuery.isError ? (
|
||||
<ErrorState title='证书列表加载失败' description={getErrorMessage(certificatesQuery.error)} />
|
||||
) : certificates.length === 0 ? (
|
||||
<EmptyState title='暂无证书' description='请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。' />
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||
<thead>
|
||||
<tr className='text-[var(--foreground-secondary)]'>
|
||||
<th className='px-3 py-3 font-medium'>名称</th>
|
||||
<th className='px-3 py-3 font-medium'>状态</th>
|
||||
<th className='px-3 py-3 font-medium'>有效期</th>
|
||||
<th className='px-3 py-3 font-medium'>备注</th>
|
||||
<th className='px-3 py-3 font-medium'>更新时间</th>
|
||||
<th className='px-3 py-3 font-medium'>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||
{certificates.map((certificate) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
|
||||
return (
|
||||
<tr key={certificate.id} className='align-top'>
|
||||
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{certificate.name}</td>
|
||||
<td className='px-3 py-4'>
|
||||
<StatusBadge label={status.label} variant={status.variant} />
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
<div className='space-y-1'>
|
||||
<p>{formatDateTime(certificate.not_before)}</p>
|
||||
<p>{formatDateTime(certificate.not_after)}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{certificate.remark || '—'}</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
{formatDateTime(certificate.updated_at)}
|
||||
</td>
|
||||
<td className='px-3 py-4'>
|
||||
<DangerButton
|
||||
type='button'
|
||||
onClick={() => handleDelete(certificate)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={handleCloseImportModal}
|
||||
title='导入证书'
|
||||
description='手动粘贴和文件导入都保留在同一个弹窗中,避免压缩主列表空间。'
|
||||
size='xl'
|
||||
>
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<AppCard title='手动导入' description='直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。'>
|
||||
<form className='space-y-5' onSubmit={handleManualSubmit}>
|
||||
@@ -351,76 +442,7 @@ export function TlsCertificatesPage() {
|
||||
</form>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title='证书列表'
|
||||
description='展示证书有效期、到期状态与最近更新时间。删除前会提示确认。'
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
|
||||
>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
{certificatesQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : certificatesQuery.isError ? (
|
||||
<ErrorState title='证书列表加载失败' description={getErrorMessage(certificatesQuery.error)} />
|
||||
) : certificates.length === 0 ? (
|
||||
<EmptyState title='暂无证书' description='请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。' />
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||
<thead>
|
||||
<tr className='text-[var(--foreground-secondary)]'>
|
||||
<th className='px-3 py-3 font-medium'>名称</th>
|
||||
<th className='px-3 py-3 font-medium'>状态</th>
|
||||
<th className='px-3 py-3 font-medium'>有效期</th>
|
||||
<th className='px-3 py-3 font-medium'>备注</th>
|
||||
<th className='px-3 py-3 font-medium'>更新时间</th>
|
||||
<th className='px-3 py-3 font-medium'>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||
{certificates.map((certificate) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
|
||||
return (
|
||||
<tr key={certificate.id} className='align-top'>
|
||||
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{certificate.name}</td>
|
||||
<td className='px-3 py-4'>
|
||||
<StatusBadge label={status.label} variant={status.variant} />
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
<div className='space-y-1'>
|
||||
<p>{formatDateTime(certificate.not_before)}</p>
|
||||
<p>{formatDateTime(certificate.not_after)}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{certificate.remark || '—'}</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
{formatDateTime(certificate.updated_at)}
|
||||
</td>
|
||||
<td className='px-3 py-4'>
|
||||
<DangerButton
|
||||
type='button'
|
||||
onClick={() => handleDelete(certificate)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className='px-3 py-2 text-xs'
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
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 (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='用户管理'
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='用户管理'
|
||||
description='阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。'
|
||||
/>
|
||||
<EmptyState
|
||||
@@ -269,90 +281,36 @@ export function UsersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='用户管理'
|
||||
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
|
||||
<AppCard title='用户概览' description='当前视图会根据分页或搜索结果实时刷新。'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title={editingUserId ? '编辑用户' : '创建新用户'}
|
||||
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
|
||||
>
|
||||
{editingUserId && editingUserQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : editingUserId && editingUserQuery.isError ? (
|
||||
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
|
||||
) : (
|
||||
<form className='space-y-5' onSubmit={handleSubmit}>
|
||||
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
|
||||
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
|
||||
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label={editingUserId ? '新密码' : '密码'}
|
||||
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
|
||||
error={form.formState.errors.password?.message}
|
||||
>
|
||||
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
|
||||
</ResourceField>
|
||||
|
||||
{editingUserId && editingUserQuery.data ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
|
||||
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
||||
{editingUserQuery.data.email || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>第三方账号</p>
|
||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||
GitHub:{editingUserQuery.data.github_id || '未绑定'}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
|
||||
微信:{editingUserQuery.data.wechat_id || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
|
||||
{editingUserId ? '取消编辑' : '重置表单'}
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<PageHeader
|
||||
title='用户管理'
|
||||
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
|
||||
action={
|
||||
<PrimaryButton type='button' onClick={handleCreate}>
|
||||
新建用户
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||
|
||||
<AppCard title='用户概览' description='当前视图会根据分页或搜索结果实时刷新,新增和编辑统一在弹窗中完成。'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title='用户列表'
|
||||
@@ -487,6 +445,67 @@ export function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={handleResetForm}
|
||||
title={editingUserId ? '编辑用户' : '创建新用户'}
|
||||
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
|
||||
footer={
|
||||
<div className='flex flex-wrap justify-end gap-3'>
|
||||
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type='submit' form='user-editor-form' disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editingUserId && editingUserQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : editingUserId && editingUserQuery.isError ? (
|
||||
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
|
||||
) : (
|
||||
<form id='user-editor-form' className='space-y-5' onSubmit={handleSubmit}>
|
||||
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
|
||||
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
|
||||
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label={editingUserId ? '新密码' : '密码'}
|
||||
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
|
||||
error={form.formState.errors.password?.message}
|
||||
>
|
||||
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
|
||||
</ResourceField>
|
||||
|
||||
{editingUserId && editingUserQuery.data ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
|
||||
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
||||
{editingUserQuery.data.email || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>第三方账号</p>
|
||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||
GitHub:{editingUserQuery.data.github_id || '未绑定'}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
|
||||
微信:{editingUserQuery.data.wechat_id || '未绑定'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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('设置');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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` 等模块
|
||||
* 重构表单项、标签页、操作区布局
|
||||
* 增加部署命令复制、时间友好显示、状态颜色体系
|
||||
|
||||
|
||||
Reference in New Issue
Block a user