[优化] 格式化

This commit is contained in:
ryan
2026-05-30 13:15:20 +08:00
parent 28b1305906
commit 9ec3d5b42d
110 changed files with 29155 additions and 7642 deletions
+11 -11
View File
@@ -29,12 +29,12 @@ pnpm build
## 构建说明 ## 构建说明
* 构建采用 Next.js 静态导出模式。 - 构建采用 Next.js 静态导出模式。
* `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。 - `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
* 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。 - 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
* 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。 - 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
* `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。 - `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。
* `pnpm start` 用于启动已构建产物,对应 Next 生产模式。 - `pnpm start` 用于启动已构建产物,对应 Next 生产模式。
## 开源协议 ## 开源协议
@@ -42,8 +42,8 @@ pnpm build
## 目录约定 ## 目录约定
* `app/`:路由与布局 - `app/`:路由与布局
* `features/`:业务模块 - `features/`:业务模块
* `components/`:复用组件 - `components/`:复用组件
* `lib/`:请求、环境变量、工具与常量 - `lib/`:请求、环境变量、工具与常量
* `tests/`:测试代码 - `tests/`:测试代码
@@ -2,7 +2,7 @@ import { DashboardOverview } from '@/features/dashboard/components/dashboard-ove
export default function DashboardPage() { export default function DashboardPage() {
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<DashboardOverview /> <DashboardOverview />
</div> </div>
); );
+8 -3
View File
@@ -1,8 +1,8 @@
@import '../styles/tokens.css'; @import '../styles/tokens.css';
@import 'tailwindcss'; @import 'tailwindcss';
@import '@heroui/styles'; @import '@heroui/styles';
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn/tailwind.css"; @import 'shadcn/tailwind.css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -27,7 +27,12 @@ body {
background: var(--body-background); background: var(--body-background);
color: var(--foreground-primary); color: var(--foreground-primary);
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif; sans-serif;
transition: transition:
background 0.2s ease, background 0.2s ease,
+9 -5
View File
@@ -6,10 +6,10 @@ import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme'; import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css'; import './globals.css';
import { Geist } from "next/font/google"; import { Geist } from 'next/font/google';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
const geist = Geist({subsets:['latin'],variable:'--font-sans'}); const geist = Geist({ subsets: ['latin'], variable: '--font-sans' });
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -26,9 +26,13 @@ interface RootLayoutProps {
export default function RootLayout({ children }: RootLayoutProps) { export default function RootLayout({ children }: RootLayoutProps) {
return ( return (
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}> <html
lang="zh-CN"
suppressHydrationWarning
className={cn('font-sans', geist.variable)}
>
<body> <body>
<Script id='theme-init' strategy='beforeInteractive'> <Script id="theme-init" strategy="beforeInteractive">
{getThemeInitScript()} {getThemeInitScript()}
</Script> </Script>
<AppProviders>{children}</AppProviders> <AppProviders>{children}</AppProviders>
+10 -4
View File
@@ -4,11 +4,17 @@ import { AppCard } from '@/components/ui/app-card';
export default function NotFound() { export default function NotFound() {
return ( return (
<main className='flex min-h-screen items-center justify-center px-4'> <main className="flex min-h-screen items-center justify-center px-4">
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'> <AppCard
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'> title="页面不存在"
description="当前请求的页面尚未接入新版管理端路由。"
>
<div className="space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]">
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p> <p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
<Link href='/' className='inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90'> <Link
href="/"
className="inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90"
>
返回总览 返回总览
</Link> </Link>
</div> </div>
@@ -35,7 +35,7 @@ export function RankChart({
color, color,
valueFormatter = defaultFormatter, valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据', emptyMessage = '暂无分布数据',
}: RankChartProps) { }: RankChartProps) {
const option = useMemo<EChartsOption>( const option = useMemo<EChartsOption>(
() => ({ () => ({
animationDuration: 400, animationDuration: 400,
@@ -104,7 +104,8 @@ export function RankChart({
show: true, show: true,
position: 'right', position: 'right',
color: '#e2e8f0', color: '#e2e8f0',
formatter: (params: unknown) => valueFormatter(getChartValue(params)), formatter: (params: unknown) =>
valueFormatter(getChartValue(params)),
}, },
}, },
], ],
@@ -36,7 +36,7 @@ export function TrendChart({
series, series,
height = 220, height = 220,
yAxisValueFormatter, yAxisValueFormatter,
}: TrendChartProps) { }: TrendChartProps) {
const option = useMemo<EChartsOption>(() => { const option = useMemo<EChartsOption>(() => {
const axisFormatter = yAxisValueFormatter ?? defaultFormatter; const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
const maxValue = calculateNiceAxisMax( const maxValue = calculateNiceAxisMax(
@@ -3,15 +3,17 @@ interface EmptyStateProps {
description?: string; description?: string;
} }
export function EmptyState({title, description}: EmptyStateProps) { export function EmptyState({ title, description }: EmptyStateProps) {
return ( return (
<div <div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm">
className='rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm'> <p className="text-base font-semibold text-[var(--foreground-primary)]">
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{title}</p> {title}
{description ? </p>
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p> : null {description ? (
} <p className="mt-2 leading-6 text-[var(--foreground-secondary)]">
{description}
</p>
) : null}
</div> </div>
); );
} }
@@ -5,9 +5,13 @@ interface ErrorStateProps {
export function ErrorState({ title, description }: ErrorStateProps) { export function ErrorState({ title, description }: ErrorStateProps) {
return ( return (
<div className='rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm'> <div className="rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm">
<p className='text-base font-semibold text-[var(--status-danger-foreground)]'>{title}</p> <p className="text-base font-semibold text-[var(--status-danger-foreground)]">
<p className='mt-2 leading-6 text-[var(--status-danger-foreground)]/80'>{description}</p> {title}
</p>
<p className="mt-2 leading-6 text-[var(--status-danger-foreground)]/80">
{description}
</p>
</div> </div>
); );
} }
@@ -16,12 +16,12 @@ export function FeaturePlaceholder({
<AppCard <AppCard
title={title} title={title}
description={description} description={description}
action={<StatusBadge label='阶段 1 骨架完成' variant='success' />} action={<StatusBadge label="阶段 1 骨架完成" variant="success" />}
> >
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'> <ul className="space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]">
{milestones.map((item) => ( {milestones.map((item) => (
<li key={item} className='flex gap-3'> <li key={item} className="flex gap-3">
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]' /> <span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]" />
<span>{item}</span> <span>{item}</span>
</li> </li>
))} ))}
@@ -7,9 +7,9 @@ interface LoadingStateProps {
export function LoadingState({ className }: LoadingStateProps) { export function LoadingState({ className }: LoadingStateProps) {
return ( return (
<div className={cn('space-y-3', className)}> <div className={cn('space-y-3', className)}>
<div className='h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]' /> <div className="h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]" />
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' /> <div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' /> <div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
</div> </div>
); );
} }
@@ -22,7 +22,8 @@ declare global {
} }
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script'; const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; const TURNSTILE_SCRIPT_SRC =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
interface TurnstileWidgetProps { interface TurnstileWidgetProps {
siteKey: string; siteKey: string;
@@ -63,7 +64,9 @@ export function TurnstileWidget({
}); });
}; };
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null; const existingScript = document.getElementById(
TURNSTILE_SCRIPT_ID,
) as HTMLScriptElement | null;
if (window.turnstile) { if (window.turnstile) {
mountWidget(); mountWidget();
@@ -81,7 +84,9 @@ export function TurnstileWidget({
return () => { return () => {
cancelled = true; cancelled = true;
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null; const script = document.getElementById(
TURNSTILE_SCRIPT_ID,
) as HTMLScriptElement | null;
if (script) { if (script) {
script.removeEventListener('load', mountWidget); script.removeEventListener('load', mountWidget);
} }
@@ -92,5 +97,5 @@ export function TurnstileWidget({
}; };
}, [onError, onExpire, onVerify, siteKey]); }, [onError, onExpire, onVerify, siteKey]);
return <div id={elementId} ref={containerRef} className='min-h-16' />; return <div id={elementId} ref={containerRef} className="min-h-16" />;
} }
@@ -9,11 +9,11 @@ interface DashboardShellProps {
export function DashboardShell({ children }: DashboardShellProps) { export function DashboardShell({ children }: DashboardShellProps) {
return ( return (
<div className='flex min-h-screen bg-transparent text-[var(--foreground-primary)]'> <div className="flex min-h-screen bg-transparent text-[var(--foreground-primary)]">
<DashboardSidebar /> <DashboardSidebar />
<div className='flex min-w-0 flex-1 flex-col'> <div className="flex min-w-0 flex-1 flex-col">
<DashboardTopbar /> <DashboardTopbar />
<main className='flex-1 px-4 py-6 md:px-8 md:py-8'>{children}</main> <main className="flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,4 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
@@ -6,21 +6,25 @@ interface PageHeaderProps {
action?: ReactNode; action?: ReactNode;
} }
export function PageHeader({title, description, action}: PageHeaderProps) { export function PageHeader({ title, description, action }: PageHeaderProps) {
return ( return (
<div className='flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className='space-y-3'> <div className="space-y-3">
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>OpenFlare</p> <p className="text-sm font-medium tracking-[0.24em] text-[var(--brand-primary)] uppercase">
<div className='space-y-2'> OpenFlare
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1> </p>
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
{title}
</h1>
{description ? ( {description ? (
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'> <p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
{description} {description}
</p> </p>
) : null} ) : null}
</div> </div>
</div> </div>
{action ? <div className='flex flex-wrap gap-3'>{action}</div> : null} {action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
</div> </div>
); );
} }
@@ -9,27 +9,35 @@ interface PublicShellProps {
export function PublicShell({ children }: PublicShellProps) { export function PublicShell({ children }: PublicShellProps) {
return ( return (
<div className='flex min-h-screen items-center justify-center px-4 py-12'> <div className="flex min-h-screen items-center justify-center px-4 py-12">
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'> <div className="w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur">
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'> <div className="mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between">
<div> <div>
<p className='text-3xl font-medium text-[var(--brand-primary)]'>OpenFlare</p> <p className="text-3xl font-medium text-[var(--brand-primary)]">
OpenFlare
</p>
</div> </div>
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'> <div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'> <span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
{publicEnv.appVersion} {publicEnv.appVersion}
</span> </span>
</div> </div>
</div> </div>
<div className='space-y-6'>{children}</div> <div className="space-y-6">{children}</div>
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'> <div className="mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]">
<div className='flex flex-wrap gap-4'> <div className="flex flex-wrap gap-4">
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'> <Link
href="/"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
返回 返回
</Link> </Link>
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'> <Link
href="/about"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
关于 关于
</Link> </Link>
</div> </div>
@@ -10,7 +10,10 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth'; import {
logout as logoutRequest,
getCurrentUser,
} from '@/features/auth/api/auth';
import type { AuthUser } from '@/types/auth'; import type { AuthUser } from '@/types/auth';
interface AuthContextValue { interface AuthContextValue {
@@ -93,7 +93,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
[resolvedTheme, setThemeMode, themeMode], [resolvedTheme, setThemeMode, themeMode],
); );
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
} }
export function useTheme() { export function useTheme() {
@@ -25,17 +25,23 @@ export function AppCard({
{...props} {...props}
> >
{(title || description || action) && ( {(title || description || action) && (
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'> <header className="flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between">
<div className='space-y-1'> <div className="space-y-1">
{title ? <h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>{title}</h2> : null} {title ? (
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{title}
</h2>
) : null}
{description ? ( {description ? (
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p> <p className="text-sm leading-6 text-[var(--foreground-secondary)]">
{description}
</p>
) : null} ) : null}
</div> </div>
{action ? <div className='shrink-0'>{action}</div> : null} {action ? <div className="shrink-0">{action}</div> : null}
</header> </header>
)} )}
<div className='px-6 py-5'>{children}</div> <div className="px-6 py-5">{children}</div>
</section> </section>
); );
} }
@@ -55,32 +55,52 @@ export function AppModal({
} }
return ( 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}> <div
<button type='button' className='absolute inset-0 cursor-default' onClick={onClose} aria-label='关闭弹窗' /> 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 <div
className={cn( 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', '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], sizeClassNameMap[size],
)} )}
> >
<div className='flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5'> <div className="flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5">
<div className='space-y-2'> <div className="space-y-2">
<h2 className='text-xl font-semibold text-[var(--foreground-primary)]'>{title}</h2> <h2 className="text-xl font-semibold text-[var(--foreground-primary)]">
{title}
</h2>
{description ? ( {description ? (
<p className='max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p> <p className="max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]">
{description}
</p>
) : null} ) : null}
</div> </div>
<button <button
type='button' type="button"
onClick={onClose} 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)]' 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='关闭弹窗' aria-label="关闭弹窗"
> >
× ×
</button> </button>
</div> </div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 py-6'>{children}</div> <div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
{footer ? <div className='border-t border-[var(--border-default)] px-6 py-5'>{footer}</div> : null} {children}
</div>
{footer ? (
<div className="border-t border-[var(--border-default)] px-6 py-5">
{footer}
</div>
) : null}
</div> </div>
</div> </div>
); );
+29 -5
View File
@@ -261,7 +261,9 @@ function useDrawerMetaContext() {
const context = useContext(DrawerMetaContext); const context = useContext(DrawerMetaContext);
if (!context) { if (!context) {
throw new Error('Drawer title and description must be used within DrawerContent.'); throw new Error(
'Drawer title and description must be used within DrawerContent.',
);
} }
return context; return context;
@@ -271,14 +273,30 @@ export function DrawerHeader({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) { }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />; return (
<div
className={cn(
'border-b border-[var(--border-default)] px-6 py-5',
className,
)}
{...props}
/>
);
} }
export function DrawerFooter({ export function DrawerFooter({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) { }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />; return (
<div
className={cn(
'border-t border-[var(--border-default)] px-6 py-4',
className,
)}
{...props}
/>
);
} }
export function DrawerTitle({ export function DrawerTitle({
@@ -290,7 +308,10 @@ export function DrawerTitle({
return ( return (
<h2 <h2
id={titleId} id={titleId}
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)} className={cn(
'text-xl font-semibold text-[var(--foreground-primary)]',
className,
)}
{...props} {...props}
/> />
); );
@@ -305,7 +326,10 @@ export function DrawerDescription({
return ( return (
<p <p
id={descriptionId} id={descriptionId}
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)} className={cn(
'mt-2 text-sm leading-6 text-[var(--foreground-secondary)]',
className,
)}
{...props} {...props}
/> />
); );
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50', 'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+5 -6
View File
@@ -2,19 +2,18 @@ import * as React from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
const Label = React.forwardRef< const Label = React.forwardRef<HTMLLabelElement, React.ComponentProps<'label'>>(
HTMLLabelElement, ({ className, ...props }, ref) => (
React.ComponentProps<'label'>
>(({ className, ...props }, ref) => (
<label <label
ref={ref} ref={ref}
className={cn( className={cn(
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 'text-sm leading-none font-medium text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className, className,
)} )}
{...props} {...props}
/> />
)); ),
);
Label.displayName = 'Label'; Label.displayName = 'Label';
@@ -1,13 +1,13 @@
"use client" 'use client';
import * as React from "react" import * as React from 'react';
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = 'horizontal',
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
@@ -17,12 +17,12 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", 'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
@@ -21,7 +21,8 @@ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
}, },
ref, ref,
) => { ) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked); const [internalChecked, setInternalChecked] =
React.useState(defaultChecked);
const isControlled = checked !== undefined; const isControlled = checked !== undefined;
const isChecked = isControlled ? checked : internalChecked; const isChecked = isControlled ? checked : internalChecked;
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
@@ -11,55 +11,61 @@ interface ThemeToggleProps {
export function ThemeToggle({ className }: ThemeToggleProps) { export function ThemeToggle({ className }: ThemeToggleProps) {
const { themeMode, resolvedTheme, setThemeMode } = useTheme(); const { themeMode, resolvedTheme, setThemeMode } = useTheme();
const currentIndex = themeModes.indexOf(themeMode); const currentIndex = themeModes.indexOf(themeMode);
const nextTheme = themeModes[(currentIndex + 1) % themeModes.length] as ThemeMode; const nextTheme = themeModes[
(currentIndex + 1) % themeModes.length
] as ThemeMode;
const icon = const icon =
themeMode === 'light' ? ( themeMode === 'light' ? (
<svg <svg
className='h-5 w-5' className="h-5 w-5"
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='none' fill="none"
stroke='currentColor' stroke="currentColor"
strokeWidth='1.8' strokeWidth="1.8"
strokeLinecap='round' strokeLinecap="round"
strokeLinejoin='round' strokeLinejoin="round"
> >
<circle cx='12' cy='12' r='4' /> <circle cx="12" cy="12" r="4" />
<path d='M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3' /> <path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3" />
</svg> </svg>
) : themeMode === 'dark' ? ( ) : themeMode === 'dark' ? (
<svg <svg
className='h-5 w-5' className="h-5 w-5"
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='none' fill="none"
stroke='currentColor' stroke="currentColor"
strokeWidth='1.8' strokeWidth="1.8"
strokeLinecap='round' strokeLinecap="round"
strokeLinejoin='round' strokeLinejoin="round"
> >
<path d='M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z' /> <path d="M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z" />
</svg> </svg>
) : ( ) : (
<svg <svg
className='h-5 w-5' className="h-5 w-5"
viewBox='0 0 24 24' viewBox="0 0 24 24"
fill='none' fill="none"
stroke='currentColor' stroke="currentColor"
strokeWidth='1.8' strokeWidth="1.8"
strokeLinecap='round' strokeLinecap="round"
strokeLinejoin='round' strokeLinejoin="round"
> >
<rect x='3.5' y='4' width='17' height='12' rx='2.5' /> <rect x="3.5" y="4" width="17" height="12" rx="2.5" />
<path d='M8 20h8M12 16v4' /> <path d="M8 20h8M12 16v4" />
</svg> </svg>
); );
const label = const label =
themeMode === 'light' ? '浅色模式' : themeMode === 'dark' ? '深色模式' : '跟随系统'; themeMode === 'light'
? '浅色模式'
: themeMode === 'dark'
? '深色模式'
: '跟随系统';
return ( return (
<button <button
type='button' type="button"
onClick={() => setThemeMode(nextTheme)} onClick={() => setThemeMode(nextTheme)}
className={cn( className={cn(
'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]', 'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]',
@@ -33,45 +33,68 @@ export function AboutPage() {
} }
if (aboutQuery.isError) { if (aboutQuery.isError) {
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />; return (
<ErrorState
title="关于页加载失败"
description={getErrorMessage(aboutQuery.error)}
/>
);
} }
if (statusQuery.isError) { if (statusQuery.isError) {
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />; return (
<ErrorState
title="系统状态加载失败"
description={getErrorMessage(statusQuery.error)}
/>
);
} }
const aboutContent = aboutQuery.data?.trim() ?? ''; const aboutContent = aboutQuery.data?.trim() ?? '';
const status = statusQuery.data; const status = statusQuery.data;
if (!status) { if (!status) {
return <EmptyState title='暂无系统信息' description='未能获取 OpenFlare 的公开状态信息。' />; return (
<EmptyState
title="暂无系统信息"
description="未能获取 OpenFlare 的公开状态信息。"
/>
);
} }
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<AppCard <AppCard
title='关于 OpenFlare' title="关于 OpenFlare"
description='公开展示当前系统简介、版本信息与项目入口。' description="公开展示当前系统简介、版本信息与项目入口。"
action={<StatusBadge label={status.version || 'dev'} variant='info' />} action={<StatusBadge label={status.version || 'dev'} variant="info" />}
> >
<div className='grid gap-4 md:grid-cols-3'> <div className="grid gap-4 md:grid-cols-3">
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'OpenFlare'}</p> 系统名称
</p>
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{status.system_name || 'OpenFlare'}
</p>
</div> </div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'> Server 启动时间
</p>
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{formatDateTime(new Date(status.start_time * 1000))} {formatDateTime(new Date(status.start_time * 1000))}
</p> </p>
</div> </div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>项目仓库</p> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
项目仓库
</p>
<a <a
href='https://github.com/Rain-kl/OpenFlare' href="https://github.com/Rain-kl/OpenFlare"
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer"
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80' className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
> >
github.com/Rain-kl/OpenFlare github.com/Rain-kl/OpenFlare
</a> </a>
@@ -80,21 +103,29 @@ export function AboutPage() {
</AppCard> </AppCard>
{aboutContent ? ( {aboutContent ? (
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'> <AppCard
title="项目介绍"
description="以下内容由系统设置中的“关于内容”维护。"
>
<div <div
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]' className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }} dangerouslySetInnerHTML={{
__html: marked.parse(aboutContent) as string,
}}
/> />
</AppCard> </AppCard>
) : ( ) : (
<EmptyState <EmptyState
title='尚未配置关于内容' title="尚未配置关于内容"
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。' description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
/> />
)} )}
<div className='flex flex-wrap gap-3 text-sm'> <div className="flex flex-wrap gap-3 text-sm">
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'> <Link
href="/login"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
前往登录 前往登录
</Link> </Link>
</div> </div>
@@ -29,11 +29,8 @@ import {
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const applyLogsQueryKey = ( const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
nodeId: string, ['apply-logs', nodeId, pageNo, pageSize] as const;
pageNo: number,
pageSize: number,
) => ['apply-logs', nodeId, pageNo, pageSize] as const;
const pageSizeOptions = [20, 50, 100]; const pageSizeOptions = [20, 50, 100];
const emptyApplyLogRows: ApplyLogItem[] = []; const emptyApplyLogRows: ApplyLogItem[] = [];
@@ -64,7 +61,12 @@ function truncateHash(value: string) {
return value.length > 12 ? `${value.slice(0, 12)}...` : value; return value.length > 12 ? `${value.slice(0, 12)}...` : value;
} }
function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) { function buildSummary(
rows: ApplyLogItem[],
total: number,
current: number,
totalPage: number,
) {
const nodeIds = new Set(rows.map((item) => item.node_id)); const nodeIds = new Set(rows.map((item) => item.node_id));
return [ return [
{ label: '总记录数', value: total }, { label: '总记录数', value: total },
@@ -209,14 +211,17 @@ export function ApplyLogsPage() {
<InlineMessage tone={feedback.tone} message={feedback.message} /> <InlineMessage tone={feedback.tone} message={feedback.message} />
) : null} ) : null}
<AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"> <AppCard
title="日志摘要"
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => ( {summary.map((item) => (
<div <div
key={item.label} key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4" className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
> >
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label} {item.label}
</p> </p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]"> <p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
@@ -348,13 +353,16 @@ export function ApplyLogsPage() {
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]"> <p className="text-sm text-[var(--foreground-secondary)]">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。 第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
条记录。
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<SecondaryButton <SecondaryButton
type="button" type="button"
disabled={current <= 1} disabled={current <= 1}
onClick={() => setPageNo((previous) => Math.max(1, previous - 1))} onClick={() =>
setPageNo((previous) => Math.max(1, previous - 1))
}
> >
上一页 上一页
</SecondaryButton> </SecondaryButton>
@@ -363,7 +371,9 @@ export function ApplyLogsPage() {
disabled={totalPage === 0 || current >= totalPage} disabled={totalPage === 0 || current >= totalPage}
onClick={() => onClick={() =>
setPageNo((previous) => setPageNo((previous) =>
totalPage > 0 ? Math.min(totalPage, previous + 1) : previous, totalPage > 0
? Math.min(totalPage, previous + 1)
: previous,
) )
} }
> >
@@ -387,14 +397,30 @@ export function ApplyLogsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} /> <StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge label={`Node:${selectedLog.node_id}`} variant="info" /> <StatusBadge
<StatusBadge label={`版本:${selectedLog.version}`} variant="warning" /> label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<MetricCard label="创建时间" value={formatDateTime(selectedLog.created_at)} /> <MetricCard
<MetricCard label="相对时间" value={formatRelativeTime(selectedLog.created_at)} /> label="创建时间"
<MetricCard label="目标 Checksum" value={selectedLog.checksum || '—'} breakAll /> value={formatDateTime(selectedLog.created_at)}
/>
<MetricCard
label="相对时间"
value={formatRelativeTime(selectedLog.created_at)}
/>
<MetricCard
label="目标 Checksum"
value={selectedLog.checksum || '—'}
breakAll
/>
<MetricCard <MetricCard
label="支持文件数" label="支持文件数"
value={String(selectedLog.support_file_count)} value={String(selectedLog.support_file_count)}
@@ -412,10 +438,10 @@ export function ApplyLogsPage() {
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
消息 消息
</p> </p>
<pre className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]"> <pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '—'} {selectedLog.message || '—'}
</pre> </pre>
</div> </div>
@@ -430,7 +456,10 @@ export function ApplyLogsPage() {
onClose={() => setCleanupModalOpen(false)} onClose={() => setCleanupModalOpen(false)}
footer={ footer={
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}> <SecondaryButton
type="button"
onClick={() => setCleanupModalOpen(false)}
>
取消 取消
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton
@@ -505,7 +534,7 @@ function MetricCard({
}) { }) {
return ( return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label} {label}
</p> </p>
<p <p
@@ -1,4 +1,8 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react'; import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -12,10 +16,16 @@ export function AuthFormField({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<label className='block space-y-2'> <label className="block space-y-2">
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</span> <span className="text-sm font-medium text-[var(--foreground-primary)]">
{label}
</span>
{children} {children}
{hint ? <span className='block text-xs text-[var(--foreground-secondary)]'>{hint}</span> : null} {hint ? (
<span className="block text-xs text-[var(--foreground-secondary)]">
{hint}
</span>
) : null}
</label> </label>
); );
} }
@@ -25,7 +35,7 @@ export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
<input <input
{...props} {...props}
className={cn( className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]', 'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
props.className, props.className,
)} )}
/> />
@@ -18,15 +18,18 @@ export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
const redirect = pathname && pathname !== '/' ? `?redirect=${encodeURIComponent(pathname)}` : ''; const redirect =
pathname && pathname !== '/'
? `?redirect=${encodeURIComponent(pathname)}`
: '';
router.replace(`/login${redirect}`); router.replace(`/login${redirect}`);
} }
}, [isAuthenticated, isLoading, pathname, router]); }, [isAuthenticated, isLoading, pathname, router]);
if (isLoading || !isAuthenticated) { if (isLoading || !isAuthenticated) {
return ( return (
<main className='flex min-h-screen items-center justify-center px-4 py-8'> <main className="flex min-h-screen items-center justify-center px-4 py-8">
<LoadingState className='w-full max-w-md' /> <LoadingState className="w-full max-w-md" />
</main> </main>
); );
} }
@@ -16,7 +16,10 @@ export function GitHubOAuthCallback() {
const { setUser } = useAuth(); const { setUser } = useAuth();
const handledCodeRef = useRef<string | null>(null); const handledCodeRef = useRef<string | null>(null);
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...'); const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null); const [message, setMessage] = useState<{
tone: 'danger' | 'success';
text: string;
} | null>(null);
const code = searchParams?.get('code')?.trim() ?? ''; const code = searchParams?.get('code')?.trim() ?? '';
const mutation = useMutation({ const mutation = useMutation({
@@ -28,14 +31,20 @@ export function GitHubOAuthCallback() {
}, },
onError: (error: Error) => { onError: (error: Error) => {
setPrompt('授权处理失败'); setPrompt('授权处理失败');
setMessage({ tone: 'danger', text: error.message || 'GitHub 授权失败,请稍后重试。' }); setMessage({
tone: 'danger',
text: error.message || 'GitHub 授权失败,请稍后重试。',
});
}, },
}); });
useEffect(() => { useEffect(() => {
if (!code) { if (!code) {
setPrompt('缺少授权 code'); setPrompt('缺少授权 code');
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' }); setMessage({
tone: 'danger',
text: '未收到 GitHub 授权参数,请返回登录页重试。',
});
return; return;
} }
@@ -48,10 +57,12 @@ export function GitHubOAuthCallback() {
}, [code, mutation]); }, [code, mutation]);
return ( return (
<AppCard title='GitHub OAuth 回调' description={prompt}> <AppCard title="GitHub OAuth 回调" description={prompt}>
<div className='space-y-4'> <div className="space-y-4">
{mutation.isPending ? <LoadingState /> : null} {mutation.isPending ? <LoadingState /> : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} {message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
</div> </div>
</AppCard> </AppCard>
); );
@@ -19,45 +19,66 @@ export function PasswordResetConfirmForm() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const email = searchParams?.get('email') || ''; const email = searchParams?.get('email') || '';
const token = searchParams?.get('token') || ''; const token = searchParams?.get('token') || '';
const [message, setMessage] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null); const [message, setMessage] = useState<{
tone: 'success' | 'danger';
text: string;
} | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => resetPassword({ email, token }), mutationFn: () => resetPassword({ email, token }),
onSuccess: async (password) => { onSuccess: async (password) => {
try { try {
await navigator.clipboard.writeText(password); await navigator.clipboard.writeText(password);
setMessage({ tone: 'success', text: `密码已重置,新密码已复制到剪贴板:${password}` }); setMessage({
tone: 'success',
text: `密码已重置,新密码已复制到剪贴板:${password}`,
});
} catch { } catch {
setMessage({ tone: 'success', text: `密码已重置:${password}` }); setMessage({ tone: 'success', text: `密码已重置:${password}` });
} }
}, },
onError: (error: Error) => { onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '密码重置失败,请重新获取链接。' }); setMessage({
tone: 'danger',
text: error.message || '密码重置失败,请重新获取链接。',
});
}, },
}); });
const missingParams = !email || !token; const missingParams = !email || !token;
return ( return (
<AppCard title='密码重置确认' description='确认后,系统会生成新的随机密码。'> <AppCard
<div className='space-y-4'> title="密码重置确认"
<AuthFormField label='邮箱地址'> description="确认后,系统会生成新的随机密码。"
>
<div className="space-y-4">
<AuthFormField label="邮箱地址">
<AuthInput value={email} readOnly /> <AuthInput value={email} readOnly />
</AuthFormField> </AuthFormField>
{missingParams ? ( {missingParams ? (
<InlineMessage tone='danger' message='重置链接缺少必要参数,请重新发起密码重置。' /> <InlineMessage
tone="danger"
message="重置链接缺少必要参数,请重新发起密码重置。"
/>
) : null} ) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} {message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<div className='flex flex-col gap-3 sm:flex-row'> <div className="flex flex-col gap-3 sm:flex-row">
<AuthButton type='button' disabled={missingParams || mutation.isPending} onClick={() => mutation.mutate()}> <AuthButton
type="button"
disabled={missingParams || mutation.isPending}
onClick={() => mutation.mutate()}
>
{mutation.isPending ? '处理中...' : '确认重置密码'} {mutation.isPending ? '处理中...' : '确认重置密码'}
</AuthButton> </AuthButton>
{message?.tone === 'success' ? ( {message?.tone === 'success' ? (
<SecondaryButton <SecondaryButton
type='button' type="button"
onClick={async () => { onClick={async () => {
const password = message.text.split(':').pop() || ''; const password = message.text.split(':').pop() || '';
if (password) { if (password) {
@@ -70,9 +91,12 @@ export function PasswordResetConfirmForm() {
) : null} ) : null}
</div> </div>
<div className='text-sm text-[var(--foreground-secondary)]'> <div className="text-sm text-[var(--foreground-secondary)]">
处理完成后可返回 处理完成后可返回
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'> <Link
href="/login"
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
>
登录页 登录页
</Link> </Link>
</div> </div>
@@ -7,7 +7,13 @@ import { PasswordResetRequestForm } from '@/features/auth/components/password-re
export function PasswordResetFlow() { export function PasswordResetFlow() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const hasConfirmationParams = Boolean(searchParams?.get('email') && searchParams?.get('token')); const hasConfirmationParams = Boolean(
searchParams?.get('email') && searchParams?.get('token'),
);
return hasConfirmationParams ? <PasswordResetConfirmForm /> : <PasswordResetRequestForm />; return hasConfirmationParams ? (
<PasswordResetConfirmForm />
) : (
<PasswordResetRequestForm />
);
} }
@@ -27,7 +27,10 @@ type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
export function PasswordResetRequestForm() { export function PasswordResetRequestForm() {
const [turnstileToken, setTurnstileToken] = useState(''); const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null); const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const form = useForm<ResetRequestFormValues>({ const form = useForm<ResetRequestFormValues>({
resolver: zodResolver(resetRequestSchema), resolver: zodResolver(resetRequestSchema),
@@ -47,7 +50,10 @@ export function PasswordResetRequestForm() {
form.reset(); form.reset();
}, },
onError: (error: Error) => { onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' }); setMessage({
tone: 'danger',
text: error.message || '重置邮件发送失败,请稍后重试。',
});
}, },
}); });
@@ -62,18 +68,26 @@ export function PasswordResetRequestForm() {
return ( return (
<PublicAuthGuard> <PublicAuthGuard>
<AppCard title='密码重置' description='提交后,系统会向你的注册邮箱发送重置链接。'> <AppCard
<form className='space-y-4' onSubmit={handleSubmit}> title="密码重置"
<AuthFormField label='邮箱地址'> description="提交后,系统会向你的注册邮箱发送重置链接。"
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} /> >
<form className="space-y-4" onSubmit={handleSubmit}>
<AuthFormField label="邮箱地址">
<AuthInput
type="email"
placeholder="请输入邮箱地址"
{...form.register('email')}
/>
{form.formState.errors.email ? ( {form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'> <span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.email.message} {form.formState.errors.email.message}
</span> </span>
) : null} ) : null}
</AuthFormField> </AuthFormField>
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? ( {statusQuery.data?.turnstile_check &&
statusQuery.data.turnstile_site_key ? (
<TurnstileWidget <TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key} siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)} onVerify={(token) => setTurnstileToken(token)}
@@ -82,16 +96,21 @@ export function PasswordResetRequestForm() {
/> />
) : null} ) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} {message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type='submit' disabled={mutation.isPending}> <AuthButton type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '提交中...' : '发送重置邮件'} {mutation.isPending ? '提交中...' : '发送重置邮件'}
</AuthButton> </AuthButton>
</form> </form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'> <div className="mt-6 text-sm text-[var(--foreground-secondary)]">
想起密码了? 想起密码了?
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'> <Link
href="/login"
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
>
返回登录 返回登录
</Link> </Link>
</div> </div>
@@ -45,7 +45,12 @@ export function CleanupConfigVersionsModal({
<SecondaryButton type="button" onClick={onClose} disabled={isPending}> <SecondaryButton type="button" onClick={onClose} disabled={isPending}>
取消 取消
</SecondaryButton> </SecondaryButton>
<PrimaryButton type="submit" form="cleanup-form" disabled={isPending} className="bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90 border-[var(--status-danger-foreground)]"> <PrimaryButton
type="submit"
form="cleanup-form"
disabled={isPending}
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
>
{isPending ? '清理中...' : '确认清理'} {isPending ? '清理中...' : '确认清理'}
</PrimaryButton> </PrimaryButton>
</div> </div>
@@ -285,7 +285,10 @@ function PublishPreviewCard({
{isActiveVersionDetailLoading ? ( {isActiveVersionDetailLoading ? (
<LoadingState /> <LoadingState />
) : activeVersionDetailError ? ( ) : activeVersionDetailError ? (
<InlineMessage tone="danger" message={activeVersionDetailError} /> <InlineMessage
tone="danger"
message={activeVersionDetailError}
/>
) : activeVersionDetail ? ( ) : activeVersionDetail ? (
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap"> <CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersionDetail.main_config} {activeVersionDetail.main_config}
@@ -520,7 +523,11 @@ export function ConfigVersionsPage() {
<SecondaryButton <SecondaryButton
type="button" type="button"
onClick={() => { onClick={() => {
if (window.confirm('确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。')) { if (
window.confirm(
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
)
) {
publishMutation.mutate(true); publishMutation.mutate(true);
} }
}} }}
@@ -672,7 +679,9 @@ export function ConfigVersionsPage() {
<CleanupConfigVersionsModal <CleanupConfigVersionsModal
isOpen={isCleanupModalOpen} isOpen={isCleanupModalOpen}
onClose={() => setIsCleanupModalOpen(false)} onClose={() => setIsCleanupModalOpen(false)}
onConfirm={(keepCount) => cleanupMutation.mutate({ keep_count: keepCount })} onConfirm={(keepCount) =>
cleanupMutation.mutate({ keep_count: keepCount })
}
isPending={cleanupMutation.isPending} isPending={cleanupMutation.isPending}
/> />
</> </>
@@ -27,7 +27,9 @@ function isCompactDistributionItem(
} }
function isCompactTrafficTrendPoint( function isCompactTrafficTrendPoint(
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint, value:
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint { ): value is CompactTrafficTrendPoint {
return Array.isArray(value); return Array.isArray(value);
} }
@@ -74,7 +76,10 @@ function normalizeDistributionItems(
function normalizeTrafficTrendPoints( function normalizeTrafficTrendPoints(
items: items:
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint> | Array<
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint
>
| null | null
| undefined, | undefined,
) { ) {
@@ -114,7 +119,8 @@ function normalizeCapacityTrendPoints(
function normalizeNetworkTrendPoints( function normalizeNetworkTrendPoints(
items: items:
| Array< | Array<
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint | DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint
> >
| null | null
| undefined, | undefined,
@@ -136,7 +142,8 @@ function normalizeNetworkTrendPoints(
function normalizeDiskIOTrendPoints( function normalizeDiskIOTrendPoints(
items: items:
| Array< | Array<
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint | DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint
> >
| null | null
| undefined, | undefined,
@@ -154,7 +161,10 @@ function normalizeDiskIOTrendPoints(
} }
function normalizeDashboardNodes( function normalizeDashboardNodes(
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined, items:
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
| null
| undefined,
): DashboardNodeHealth[] { ): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) => return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item) isCompactDashboardNode(item)
@@ -185,11 +195,7 @@ function normalizeDashboardNodes(
} }
function normalizeDashboardOverview( function normalizeDashboardOverview(
overview: overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
| DashboardOverview
| DashboardOverviewCompact
| null
| undefined,
): DashboardOverview | null { ): DashboardOverview | null {
if (!overview) { if (!overview) {
return null; return null;
@@ -209,8 +215,12 @@ function normalizeDashboardOverview(
source_countries: normalizeDistributionItems( source_countries: normalizeDistributionItems(
overview.distributions?.source_countries, overview.distributions?.source_countries,
), ),
status_codes: normalizeDistributionItems(overview.distributions?.status_codes), status_codes: normalizeDistributionItems(
top_domains: normalizeDistributionItems(overview.distributions?.top_domains), overview.distributions?.status_codes,
),
top_domains: normalizeDistributionItems(
overview.distributions?.top_domains,
),
}, },
trends: { trends: {
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h), traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
@@ -222,8 +232,8 @@ function normalizeDashboardOverview(
} }
export async function getDashboardOverview() { export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>( const overview = await apiRequest<
'/dashboard/overview', DashboardOverview | DashboardOverviewCompact
); >('/dashboard/overview');
return normalizeDashboardOverview(overview); return normalizeDashboardOverview(overview);
} }
@@ -249,9 +249,7 @@ export function DashboardOverview() {
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard <AppCard title="24 小时网络与磁盘趋势">
title="24 小时网络与磁盘趋势"
>
<div className="space-y-6"> <div className="space-y-6">
<TrendChart <TrendChart
labels={overview.trends.network_24h.map((point) => labels={overview.trends.network_24h.map((point) =>
@@ -311,9 +309,7 @@ export function DashboardOverview() {
</div> </div>
</AppCard> </AppCard>
<AppCard <AppCard title="Top 节点榜单">
title="Top 节点榜单"
>
<div className="grid gap-6 xl:grid-cols-1"> <div className="grid gap-6 xl:grid-cols-1">
<div> <div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase"> <p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
@@ -352,10 +348,7 @@ export function DashboardOverview() {
</div> </div>
<div className="grid gap-6 xl:grid-cols-3"> <div className="grid gap-6 xl:grid-cols-3">
<AppCard <AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
title="来源分布"
description="聚合最近 24 小时主要来源国家。"
>
<RankChart <RankChart
items={overview.distributions.source_countries.map((item) => ({ items={overview.distributions.source_countries.map((item) => ({
label: item.key, label: item.key,
@@ -380,10 +373,7 @@ export function DashboardOverview() {
/> />
</AppCard> </AppCard>
<AppCard <AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
title="Top Domain"
description="观察主要流量集中在哪些域名。"
>
<RankChart <RankChart
items={overview.distributions.top_domains.map((item) => ({ items={overview.distributions.top_domains.map((item) => ({
label: item.key, label: item.key,
File diff suppressed because one or more lines are too long
@@ -15,7 +15,10 @@ export function createDnsAccount(payload: DnsAccountMutationPayload) {
}); });
} }
export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) { export function updateDnsAccount(
id: number,
payload: DnsAccountMutationPayload,
) {
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, { return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -30,7 +30,10 @@ import { formatDateTime } from '@/lib/utils/date';
export function DnsAccountsPage() { export function DnsAccountsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{ tone: 'info' | 'success' | 'danger'; message: string } | null>(null); const [feedback, setFeedback] = useState<{
tone: 'info' | 'success' | 'danger';
message: string;
} | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const dnsAccountsQuery = useQuery({ const dnsAccountsQuery = useQuery({
@@ -57,7 +60,10 @@ export function DnsAccountsPage() {
deleteMutation.mutate(account.id); deleteMutation.mutate(account.id);
}; };
const accounts = useMemo(() => dnsAccountsQuery.data ?? [], [dnsAccountsQuery.data]); const accounts = useMemo(
() => dnsAccountsQuery.data ?? [],
[dnsAccountsQuery.data],
);
return ( return (
<> <>
@@ -73,30 +79,47 @@ export function DnsAccountsPage() {
> >
返回 返回
</Link> </Link>
<PrimaryButton type="button" onClick={() => setIsCreateOpen(true)}> <PrimaryButton
type="button"
onClick={() => setIsCreateOpen(true)}
>
添加账号 添加账号
</PrimaryButton> </PrimaryButton>
</div> </div>
} }
/> />
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard title="DNS 账号列表"> <AppCard title="DNS 账号列表">
{dnsAccountsQuery.isLoading ? ( {dnsAccountsQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : dnsAccountsQuery.isError ? ( ) : dnsAccountsQuery.isError ? (
<ErrorState title="加载失败" description={getErrorMessage(dnsAccountsQuery.error)} /> <ErrorState
title="加载失败"
description={getErrorMessage(dnsAccountsQuery.error)}
/>
) : accounts.length === 0 ? ( ) : accounts.length === 0 ? (
<EmptyState title="暂无 DNS 账号" description="点击右上角“添加账号”开始录入。" /> <EmptyState
title="暂无 DNS 账号"
description="点击右上角“添加账号”开始录入。"
/>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{accounts.map((account) => ( {accounts.map((account) => (
<div key={account.id} className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div
key={account.id}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]"> <p className="text-sm font-semibold text-[var(--foreground-primary)]">
{account.name} <span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">({account.type})</span> {account.name}{' '}
<span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">
({account.type})
</span>
</p> </p>
<div className="text-xs leading-5 text-[var(--foreground-secondary)]"> <div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>创建于:{formatDateTime(account.created_at)}</p> <p>创建于:{formatDateTime(account.created_at)}</p>
@@ -121,17 +144,29 @@ export function DnsAccountsPage() {
</div> </div>
{isCreateOpen && ( {isCreateOpen && (
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => { <DnsAccountCreateModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onCreated={() => {
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' }); setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
setIsCreateOpen(false); setIsCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] }); queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
}} /> }}
/>
)} )}
</> </>
); );
} }
function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean; onClose: () => void; onCreated: () => void }) { function DnsAccountCreateModal({
isOpen,
onClose,
onCreated,
}: {
isOpen: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const { register, handleSubmit, formState } = useForm({ const { register, handleSubmit, formState } = useForm({
defaultValues: { name: '', type: 'cloudflare', authorization: '' }, defaultValues: { name: '', type: 'cloudflare', authorization: '' },
@@ -157,8 +192,14 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号"> <AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
<form onSubmit={onSubmit} className="space-y-5"> <form onSubmit={onSubmit} className="space-y-5">
{error && <InlineMessage tone="danger" message={error} />} {error && <InlineMessage tone="danger" message={error} />}
<ResourceField label="账号名称" error={formState.errors.name?.message as string}> <ResourceField
<ResourceInput placeholder="Cloudlfare 邮箱账号" {...register('name', { required: '请输入名称' })} /> label="账号名称"
error={formState.errors.name?.message as string}
>
<ResourceInput
placeholder="Cloudlfare 邮箱账号"
{...register('name', { required: '请输入名称' })}
/>
</ResourceField> </ResourceField>
<ResourceField label="DNS 服务商"> <ResourceField label="DNS 服务商">
<ResourceSelect {...register('type')}> <ResourceSelect {...register('type')}>
@@ -166,7 +207,9 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
</ResourceSelect> </ResourceSelect>
</ResourceField> </ResourceField>
<ResourceField label="API Token" hint="请勿使用 Global API Key"> <ResourceField label="API Token" hint="请勿使用 Global API Key">
<ResourceInput {...register('authorization', { required: '请输入 Token' })} /> <ResourceInput
{...register('authorization', { required: '请输入 Token' })}
/>
</ResourceField> </ResourceField>
<PrimaryButton type="submit" disabled={createMutation.isPending}> <PrimaryButton type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? '提交中...' : '提交'} {createMutation.isPending ? '提交中...' : '提交'}
@@ -17,7 +17,10 @@ export function createManagedDomain(payload: ManagedDomainMutationPayload) {
}); });
} }
export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) { export function updateManagedDomain(
id: number,
payload: ManagedDomainMutationPayload,
) {
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, { return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -404,7 +404,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}); });
setHealthEventCleanupModalOpen(false); setHealthEventCleanupModalOpen(false);
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId] }), queryClient.invalidateQueries({
queryKey: ['node-observability', nodeId],
}),
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }), queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
]); ]);
}, },
@@ -869,9 +871,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)} )}
</AppCard> </AppCard>
<AppCard <AppCard title="实时资源">
title="实时资源"
>
{observabilityQuery.isLoading ? ( {observabilityQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : observabilityQuery.isError ? ( ) : observabilityQuery.isError ? (
@@ -930,9 +930,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)} )}
</AppCard> </AppCard>
<AppCard <AppCard title="网络流量">
title="网络流量"
>
{observabilityQuery.isLoading ? ( {observabilityQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : observabilityQuery.isError ? ( ) : observabilityQuery.isError ? (
@@ -1628,7 +1626,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
{nodeInstallCommand ? ( {nodeInstallCommand ? (
<div> <div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">脚本部署 (Linux / macOS)</p> <p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
脚本部署 (Linux / macOS)
</p>
<CodeBlock className="whitespace-pre-wrap"> <CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand} {nodeInstallCommand}
</CodeBlock> </CodeBlock>
@@ -1637,7 +1637,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
{nodeDockerInstallCommand ? ( {nodeDockerInstallCommand ? (
<div> <div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">Docker 部署</p> <p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
Docker 部署
</p>
<CodeBlock className="whitespace-pre-wrap"> <CodeBlock className="whitespace-pre-wrap">
{nodeDockerInstallCommand} {nodeDockerInstallCommand}
</CodeBlock> </CodeBlock>
@@ -1835,8 +1837,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
) : ( ) : (
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]"> <div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
<p>该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。</p> <p>
<p>这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。</p> 该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。
</p>
<p>
这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。
</p>
</div> </div>
)} )}
</AppModal> </AppModal>
@@ -151,8 +151,8 @@ function getRegionCenter(feature: GeoJsonFeature) {
} }
function buildRegionOptions() { function buildRegionOptions() {
const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ?? const features = ((worldGeoJson as { features?: GeoJsonFeature[] })
[]) as GeoJsonFeature[]; .features ?? []) as GeoJsonFeature[];
const options = new Map<string, RegionOption>(); const options = new Map<string, RegionOption>();
for (const feature of features) { for (const feature of features) {
@@ -186,7 +186,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
ip: node.ip ?? '', ip: node.ip ?? '',
auto_update_enabled: node.auto_update_enabled ?? false, auto_update_enabled: node.auto_update_enabled ?? false,
geo_manual_override: node.geo_manual_override ?? false, geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? node.geo_name ?? '' : '', geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
geo_name: node.geo_name ?? '', geo_name: node.geo_name ?? '',
geo_latitude: geo_latitude:
node.geo_latitude === undefined || node.geo_latitude === null node.geo_latitude === undefined || node.geo_latitude === null
+8 -3
View File
@@ -86,7 +86,9 @@ export function getUpdateMode(node: NodeItem) {
return { label: '手动', variant: 'info' as const }; return { label: '手动', variant: 'info' as const };
} }
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) { export function getOpenrestyStatusVariant(
status: NodeItem['openresty_status'],
) {
if (status === 'healthy') { if (status === 'healthy') {
return 'success'; return 'success';
} }
@@ -178,12 +180,15 @@ export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
].join('\n'); ].join('\n');
} }
export function buildNodeDockerInstallCommand(serverUrl: string, agentToken: string) { export function buildNodeDockerInstallCommand(
serverUrl: string,
agentToken: string,
) {
return [ return [
`docker run -d --name openflare-agent --restart unless-stopped \\`, `docker run -d --name openflare-agent --restart unless-stopped \\`,
` -p 80:80 -p 443:443 \\`, ` -p 80:80 -p 443:443 \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`, ` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`, ` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ghcr.io/rain-kl/openflare-agent:latest` ` ghcr.io/rain-kl/openflare-agent:latest`,
].join('\n'); ].join('\n');
} }
@@ -1,19 +1,19 @@
'use client'; 'use client';
import {useEffect, useState} from 'react'; import { useEffect, useState } from 'react';
import {useQuery, useQueryClient} from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import {EmptyState} from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import {useAuth} from '@/components/providers/auth-provider'; import { useAuth } from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import {getConfigVersionPreview} from '@/features/config-versions/api/config-versions'; import { getConfigVersionPreview } from '@/features/config-versions/api/config-versions';
import {getOptions, updateOptions} from '@/features/settings/api/settings'; import { getOptions, updateOptions } from '@/features/settings/api/settings';
import type {OptionItem} from '@/features/settings/types'; import type { OptionItem } from '@/features/settings/types';
import { import {
CodeBlock, CodeBlock,
PrimaryButton, PrimaryButton,
@@ -171,7 +171,7 @@ function isCacheLevelsValue(value: string) {
export function PerformancePage() { export function PerformancePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const {user} = useAuth(); const { user } = useAuth();
const [activeTab, setActiveTab] = useState<PerformanceTab>('settings'); const [activeTab, setActiveTab] = useState<PerformanceTab>('settings');
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [busyKey, setBusyKey] = useState<string | null>(null); const [busyKey, setBusyKey] = useState<string | null>(null);
@@ -271,7 +271,7 @@ export function PerformancePage() {
try { try {
await action(); await action();
} catch (error) { } catch (error) {
setFeedback({tone: 'danger', message: getErrorMessage(error)}); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
} finally { } finally {
setBusyKey(null); setBusyKey(null);
} }
@@ -281,14 +281,14 @@ export function PerformancePage() {
entries: Array<[string, string]>, entries: Array<[string, string]>,
successMessage: string, successMessage: string,
) => { ) => {
await updateOptions(entries.map(([key, value]) => ({key, value}))); await updateOptions(entries.map(([key, value]) => ({ key, value })));
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({queryKey: settingsQueryKey}), queryClient.invalidateQueries({ queryKey: settingsQueryKey }),
queryClient.invalidateQueries({queryKey: previewQueryKey}), queryClient.invalidateQueries({ queryKey: previewQueryKey }),
queryClient.invalidateQueries({queryKey: ['config-versions']}), queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
]); ]);
setFeedback({tone: 'success', message: successMessage}); setFeedback({ tone: 'success', message: successMessage });
}; };
const handleRuntimeSave = () => { const handleRuntimeSave = () => {
@@ -337,10 +337,7 @@ export function PerformancePage() {
'OpenRestyWorkerProcesses', 'OpenRestyWorkerProcesses',
performanceFields.OpenRestyWorkerProcesses.trim(), performanceFields.OpenRestyWorkerProcesses.trim(),
], ],
[ ['OpenRestyResolvers', performanceFields.OpenRestyResolvers.trim()],
'OpenRestyResolvers',
performanceFields.OpenRestyResolvers.trim(),
],
[ [
'OpenRestyWorkerConnections', 'OpenRestyWorkerConnections',
performanceFields.OpenRestyWorkerConnections.trim(), performanceFields.OpenRestyWorkerConnections.trim(),
@@ -576,7 +573,7 @@ export function PerformancePage() {
} }
if (optionsQuery.isLoading || previewQuery.isLoading) { if (optionsQuery.isLoading || previewQuery.isLoading) {
return <LoadingState/>; return <LoadingState />;
} }
if (optionsQuery.isError) { if (optionsQuery.isError) {
@@ -615,7 +612,7 @@ export function PerformancePage() {
/> />
{feedback ? ( {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message}/> <InlineMessage tone={feedback.tone} message={feedback.message} />
) : null} ) : null}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -653,17 +650,15 @@ export function PerformancePage() {
{activeTab === 'settings' ? ( {activeTab === 'settings' ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置校验 主配置校验
</p> </p>
<div className="mt-2"> <div className="mt-2">
<StatusBadge label="发布链路受管" variant="info"/> <StatusBadge label="发布链路受管" variant="info" />
</div> </div>
</div> </div>
<div <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前预览规则数 当前预览规则数
</p> </p>
@@ -671,8 +666,7 @@ export function PerformancePage() {
{preview.route_count} 条 {preview.route_count} 条
</p> </p>
</div> </div>
<div <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase"> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前模板 当前模板
</p> </p>
@@ -908,9 +902,7 @@ export function PerformancePage() {
onClick={handleProxySave} onClick={handleProxySave}
disabled={busyKey === 'performance-proxy'} disabled={busyKey === 'performance-proxy'}
> >
{busyKey === 'performance-proxy' {busyKey === 'performance-proxy' ? '保存中...' : '保存'}
? '保存中...'
: '保存'}
</PrimaryButton> </PrimaryButton>
} }
> >
@@ -1052,9 +1044,7 @@ export function PerformancePage() {
onClick={handleGzipSave} onClick={handleGzipSave}
disabled={busyKey === 'performance-gzip'} disabled={busyKey === 'performance-gzip'}
> >
{busyKey === 'performance-gzip' {busyKey === 'performance-gzip' ? '保存中...' : '保存'}
? '保存中...'
: '保存'}
</PrimaryButton> </PrimaryButton>
} }
> >
@@ -1131,8 +1121,7 @@ export function PerformancePage() {
onClick={handleCacheSave} onClick={handleCacheSave}
disabled={busyKey === 'performance-cache'} disabled={busyKey === 'performance-cache'}
> >
{busyKey === 'performance-cache' {busyKey === 'performance-cache' ? '保存中...' : '保存'}
? '保存中...' : '保存'}
</PrimaryButton> </PrimaryButton>
</div> </div>
} }
@@ -1278,9 +1267,7 @@ export function PerformancePage() {
</div> </div>
</div> </div>
</AppCard> </AppCard>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -1341,7 +1328,8 @@ export function PerformancePage() {
/> />
</div> </div>
<CodeBlock <CodeBlock
className={`${templatePanelClassName} whitespace-pre-wrap break-words`}> className={`${templatePanelClassName} break-words whitespace-pre-wrap`}
>
{preview.main_config} {preview.main_config}
</CodeBlock> </CodeBlock>
</div> </div>
@@ -22,7 +22,10 @@ export function createProxyRoute(payload: ProxyRouteMutationPayload) {
}); });
} }
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) { export function updateProxyRoute(
id: number,
payload: ProxyRouteMutationPayload,
) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, { return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -41,5 +44,7 @@ export function getTlsCertificates() {
export function matchManagedDomainCertificate(domain: string) { export function matchManagedDomainCertificate(domain: string) {
const searchParams = new URLSearchParams({ domain }); const searchParams = new URLSearchParams({ domain });
return apiRequest<ManagedDomainMatchResult>(`/managed-domains/match?${searchParams.toString()}`); return apiRequest<ManagedDomainMatchResult>(
`/managed-domains/match?${searchParams.toString()}`,
);
} }
@@ -100,9 +100,7 @@ export function buildDomainRowsFromRoute(
if (domainCertIDs.length === domains.length) { if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({ return domains.map((domain, index) => ({
domain, domain,
certificateId: domainCertIDs[index] certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
? String(domainCertIDs[index])
: '',
})); }));
} }
@@ -185,7 +183,9 @@ export function DomainListInput({
value={row.domain} value={row.domain}
list={`${listId}-${index}`} list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`} aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'} placeholder={
index === 0 ? domainPlaceholder : 'www.example.com'
}
onBlur={onBlur} onBlur={onBlur}
onChange={(event) => { onChange={(event) => {
const nextRows = safeRows.slice(); const nextRows = safeRows.slice();
@@ -123,7 +123,10 @@ const rateLimitSchema = z
limit_rate: z.string(), limit_rate: z.string(),
}) })
.superRefine((value, context) => { .superRefine((value, context) => {
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) { for (const field of [
'limit_conn_per_server',
'limit_conn_per_ip',
] as const) {
const rawValue = value[field].trim(); const rawValue = value[field].trim();
if (!rawValue) { if (!rawValue) {
continue; continue;
@@ -369,7 +372,9 @@ function DomainSettingsSection({
<ResourceField <ResourceField
label="域名列表" label="域名列表"
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。" hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
error={form.formState.errors.domain_rows?.message as string | undefined} error={
form.formState.errors.domain_rows?.message as string | undefined
}
container="div" container="div"
> >
<Controller <Controller
@@ -490,7 +495,10 @@ function RateLimitSection({
error={form.formState.errors.limit_rate?.message} error={form.formState.errors.limit_rate?.message}
className="md:col-span-2" className="md:col-span-2"
> >
<ResourceInput placeholder="512k/1m" {...form.register('limit_rate')} /> <ResourceInput
placeholder="512k/1m"
{...form.register('limit_rate')}
/>
</ResourceField> </ResourceField>
</form> </form>
</ConfigSectionShell> </ConfigSectionShell>
@@ -538,7 +546,9 @@ function ReverseProxySection({
onSubmit={form.handleSubmit((values) => { onSubmit={form.handleSubmit((values) => {
const { urls } = parseOriginUrls(values.origin_urls_text); const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]); const primaryOrigin = parseOriginUrl(urls[0]);
const { headers } = parseCustomHeadersText(values.custom_headers_text); const { headers } = parseCustomHeadersText(
values.custom_headers_text,
);
onSave( onSave(
buildPayloadFromRoute(route, { buildPayloadFromRoute(route, {
@@ -565,7 +575,9 @@ function ReverseProxySection({
<ResourceTextarea <ResourceTextarea
aria-label="上游地址" aria-label="上游地址"
className="min-h-40" className="min-h-40"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'} placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')} {...form.register('origin_urls_text')}
/> />
</ResourceField> </ResourceField>
@@ -593,7 +605,10 @@ function ReverseProxySection({
/> />
</ResourceField> </ResourceField>
<ResourceField label="备注" error={form.formState.errors.remark?.message}> <ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea <ResourceTextarea
placeholder="例如:多活回源,优先使用上海入口" placeholder="例如:多活回源,优先使用上海入口"
{...form.register('remark')} {...form.register('remark')}
@@ -617,7 +632,8 @@ function CacheSection({
resolver: zodResolver(cacheSchema), resolver: zodResolver(cacheSchema),
defaultValues: { defaultValues: {
cache_enabled: route.cache_enabled, cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'], cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'), cache_rules_text: route.cache_rule_list.join('\n'),
}, },
}); });
@@ -625,7 +641,8 @@ function CacheSection({
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
cache_enabled: route.cache_enabled, cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'], cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'), cache_rules_text: route.cache_rule_list.join('\n'),
}); });
}, [form, route]); }, [form, route]);
@@ -650,7 +667,9 @@ function CacheSection({
cache_enabled: values.cache_enabled, cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled ? values.cache_policy : 'url', cache_policy: values.cache_enabled ? values.cache_policy : 'url',
cache_rules: cache_rules:
values.cache_enabled && values.cache_policy !== 'url' ? rules : [], values.cache_enabled && values.cache_policy !== 'url'
? rules
: [],
}), }),
{ message: '缓存设置已保存。' }, { message: '缓存设置已保存。' },
); );
@@ -770,7 +789,13 @@ type PowValues = z.infer<typeof powSchema>;
function buildPowListFromConfig( function buildPowListFromConfig(
list: list:
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] } | {
ips?: string[];
ip_cidrs?: string[];
paths?: string[];
path_regexes?: string[];
user_agents?: string[];
}
| undefined, | undefined,
): PowListValues { ): PowListValues {
return { return {
@@ -923,7 +948,7 @@ function PowSection({
<div className="grid grid-cols-1 gap-5 md:grid-cols-2"> <div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<fieldset disabled={!watchedEnabled} className="space-y-4"> <fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2"> <legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
白名单(匹配的请求跳过 PoW) 白名单(匹配的请求跳过 PoW)
</legend> </legend>
<ResourceField label="IP" hint="每行一个 IP 地址"> <ResourceField label="IP" hint="每行一个 IP 地址">
@@ -964,7 +989,7 @@ function PowSection({
</fieldset> </fieldset>
<fieldset disabled={!watchedEnabled} className="space-y-4"> <fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2"> <legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
黑名单(匹配的请求必须 PoW) 黑名单(匹配的请求必须 PoW)
</legend> </legend>
<ResourceField label="IP" hint="每行一个 IP 地址"> <ResourceField label="IP" hint="每行一个 IP 地址">
@@ -1178,7 +1203,9 @@ export function ProxyRouteConfigPage({
setFeedback({ tone: 'success', message: context.message }); setFeedback({ tone: 'success', message: context.message });
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]); ]);
}, },
onError: (error) => { onError: (error) => {
@@ -156,7 +156,9 @@ export function ProxyRouteCreateDrawer({
.map((item) => item.domain.trim().toLowerCase()) .map((item) => item.domain.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows); const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows); const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
const { urls } = parseOriginUrls(values.origin_urls_text); const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]); const primaryOrigin = parseOriginUrl(urls[0]);
@@ -182,7 +184,8 @@ export function ProxyRouteCreateDrawer({
cert_id: selectedCertIDs[0] ?? null, cert_id: selectedCertIDs[0] ?? null,
cert_ids: selectedCertIDs, cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs, domain_cert_ids: domainCertIDs,
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false, redirect_http:
selectedCertIDs.length > 0 ? values.redirect_http : false,
limit_conn_per_server: 0, limit_conn_per_server: 0,
limit_conn_per_ip: 0, limit_conn_per_ip: 0,
limit_rate: '', limit_rate: '',
@@ -248,7 +251,9 @@ export function ProxyRouteCreateDrawer({
<ResourceField <ResourceField
label="域名列表" label="域名列表"
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。" hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
error={form.formState.errors.domain_rows?.message as string | undefined} error={
form.formState.errors.domain_rows?.message as string | undefined
}
container="div" container="div"
> >
<Controller <Controller
@@ -287,7 +292,9 @@ export function ProxyRouteCreateDrawer({
> >
<ResourceTextarea <ResourceTextarea
aria-label="上游地址" aria-label="上游地址"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'} placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')} {...form.register('origin_urls_text')}
/> />
</ResourceField> </ResourceField>
@@ -87,7 +87,9 @@ export function ProxyRoutesPage() {
setFeedback({ tone: 'success', message: '网站已删除。' }); setFeedback({ tone: 'success', message: '网站已删除。' });
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]); ]);
}, },
onError: (error) => { onError: (error) => {
@@ -104,7 +106,9 @@ export function ProxyRoutesPage() {
}); });
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['config-versions'] }), queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]); ]);
}, },
onError: (error) => { onError: (error) => {
@@ -135,7 +139,10 @@ export function ProxyRoutesPage() {
}, [keyword, routes]); }, [keyword, routes]);
const diff = diffQuery.data; const diff = diffQuery.data;
const totalDomains = routes.reduce((sum, route) => sum + route.domain_count, 0); const totalDomains = routes.reduce(
(sum, route) => sum + route.domain_count,
0,
);
const enabledCount = routes.filter((route) => route.enabled).length; const enabledCount = routes.filter((route) => route.enabled).length;
const handleDelete = (route: ProxyRouteItem) => { const handleDelete = (route: ProxyRouteItem) => {
@@ -243,11 +250,11 @@ export function ProxyRoutesPage() {
<div className="space-y-3"> <div className="space-y-3">
<StatusBadge <StatusBadge
label={ label={
diff && hasConfigChanges(diff) ? '有待发布变更' : '已与线上一致' diff && hasConfigChanges(diff)
} ? '有待发布变更'
variant={ : '已与线上一致'
diff && hasConfigChanges(diff) ? 'warning' : 'success'
} }
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
/> />
<p className="text-sm text-[var(--foreground-secondary)]"> <p className="text-sm text-[var(--foreground-secondary)]">
{diff {diff
@@ -266,9 +273,7 @@ export function ProxyRoutesPage() {
</AppCard> </AppCard>
</div> </div>
<AppCard <AppCard title="规则列表">
title="规则列表"
>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="max-w-xl"> <div className="max-w-xl">
@@ -356,7 +361,7 @@ export function ProxyRoutesPage() {
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
域名列表 域名列表
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -365,7 +370,7 @@ export function ProxyRoutesPage() {
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
上游摘要 上游摘要
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -393,7 +398,9 @@ export function ProxyRoutesPage() {
onCreated={async (route) => { onCreated={async (route) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }), queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]); ]);
router.push(`/proxy-route/detail?id=${route.id}&section=domains`); router.push(`/proxy-route/detail?id=${route.id}&section=domains`);
}} }}
@@ -386,7 +386,7 @@ export function AuthSourceModal({
} }
/> />
</ResourceField> </ResourceField>
<div className="md:col-span-2 rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)]"> <div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
第三方平台的 Redirect URI / Callback URL 请填写: 第三方平台的 Redirect URI / Callback URL 请填写:
<span className="font-medium"> <span className="font-medium">
{' '} {' '}
@@ -927,7 +927,9 @@ export function SettingsPage() {
) : ( ) : (
<PrimaryButton <PrimaryButton
type="button" type="button"
onClick={() => handleBindAuthSource(source.name)} onClick={() =>
handleBindAuthSource(source.name)
}
disabled={ disabled={
busyKey === `auth-source-bind-${source.name}` busyKey === `auth-source-bind-${source.name}`
} }
@@ -1591,7 +1593,6 @@ export function SettingsPage() {
} }
disabled={busyKey === 'toggle-EmailVerificationEnabled'} disabled={busyKey === 'toggle-EmailVerificationEnabled'}
/> />
</div> </div>
<div className="mt-5 text-sm text-[var(--foreground-secondary)]"> <div className="mt-5 text-sm text-[var(--foreground-secondary)]">
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。 当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
@@ -14,12 +14,12 @@ export function ModulePageSkeleton({
description, description,
}: ModulePageSkeletonProps) { }: ModulePageSkeletonProps) {
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<PageHeader title={title} description={description} /> <PageHeader title={title} description={description} />
<FeaturePlaceholder <FeaturePlaceholder
title={`${title} 页面骨架`} title={`${title} 页面骨架`}
description='当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。' description="当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。"
milestones={[ milestones={[
'路由入口已创建,可纳入统一导航。', '路由入口已创建,可纳入统一导航。',
'页面标题区、说明区与内容容器已统一。', '页面标题区、说明区与内容容器已统一。',
@@ -27,10 +27,16 @@ export function ModulePageSkeleton({
]} ]}
/> />
<div className='grid gap-4 xl:grid-cols-3'> <div className="grid gap-4 xl:grid-cols-3">
<LoadingState /> <LoadingState />
<EmptyState title='空态骨架' description='后续接入查询后,可将资源为空时的提示统一沉淀为此组件。' /> <EmptyState
<ErrorState title='错误态骨架' description='后续请求失败、鉴权失效与重试提示可在此基础上扩展。' /> title="空态骨架"
description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
/>
<ErrorState
title="错误态骨架"
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
/>
</div> </div>
</div> </div>
); );
@@ -176,7 +176,7 @@ export function ToggleField({
onChange, onChange,
}: ToggleFieldProps) { }: ToggleFieldProps) {
return ( return (
<label className="flex self-start cursor-pointer items-start gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"> <label className="flex cursor-pointer items-start gap-3 self-start rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
@@ -1,14 +1,14 @@
'use client'; 'use client';
import {marked} from 'marked'; import { marked } from 'marked';
import {useEffect, useState} from 'react'; import { useEffect, useState } from 'react';
import {EmptyState} from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import {LoadingState} from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import {AppCard} from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import {AppModal} from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import {StatusBadge} from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import type { import type {
LatestReleaseInfo, LatestReleaseInfo,
ReleaseChannel, ReleaseChannel,
@@ -20,7 +20,7 @@ import {
ResourceInput, ResourceInput,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
interface VersionUpgradeModalProps { interface VersionUpgradeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -48,15 +48,15 @@ interface VersionUpgradeModalProps {
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) { function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
if (!release) { if (!release) {
return {label: '未检查', variant: 'info' as const}; return { label: '未检查', variant: 'info' as const };
} }
if (release.in_progress) { if (release.in_progress) {
return {label: '升级中', variant: 'warning' as const}; return { label: '升级中', variant: 'warning' as const };
} }
if (release.has_update) { if (release.has_update) {
return {label: '可升级', variant: 'warning' as const}; return { label: '可升级', variant: 'warning' as const };
} }
return {label: '最新', variant: 'success' as const}; return { label: '最新', variant: 'success' as const };
} }
function formatLogTimestamp(value: string) { function formatLogTimestamp(value: string) {
@@ -103,13 +103,11 @@ export function VersionUpgradeModal({
onUpgrade, onUpgrade,
onUploadBinary, onUploadBinary,
onConfirmManualUpgrade, onConfirmManualUpgrade,
}: VersionUpgradeModalProps) { }: VersionUpgradeModalProps) {
const upgradeBadge = getUpgradeBadge(release); const upgradeBadge = getUpgradeBadge(release);
const [selectedBinary, setSelectedBinary] = useState<File | null>(null); const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
const uploadPhaseLabel = const uploadPhaseLabel =
uploadProgress >= 100 uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
? '已上传,正在服务端校验版本...'
: '上传中...';
const selectedChannelLabel = const selectedChannelLabel =
selectedChannel === 'preview' ? '预览版' : '正式版'; selectedChannel === 'preview' ? '预览版' : '正式版';
const toggleChannel = () => { const toggleChannel = () => {
@@ -137,7 +135,6 @@ export function VersionUpgradeModal({
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
<AppCard title="服务端版本"> <AppCard title="服务端版本">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]"> <p className="text-sm font-medium text-[var(--foreground-primary)]">
@@ -165,7 +162,9 @@ export function VersionUpgradeModal({
selectedChannel === 'preview' ? 'warning' : 'info' selectedChannel === 'preview' ? 'warning' : 'info'
} }
onClick={toggleChannel} onClick={toggleChannel}
disabled={isChecking || isUpgrading || isUploadingBinary} disabled={
isChecking || isUpgrading || isUploadingBinary
}
/> />
</div> </div>
</div> </div>
@@ -183,9 +182,9 @@ export function VersionUpgradeModal({
</AppCard> </AppCard>
</div> </div>
{isLoading ? <LoadingState/> : null} {isLoading ? <LoadingState /> : null}
{!isLoading && releaseErrorMessage ? ( {!isLoading && releaseErrorMessage ? (
<ErrorState title="版本检查失败" description={releaseErrorMessage}/> <ErrorState title="版本检查失败" description={releaseErrorMessage} />
) : null} ) : null}
{!isLoading && !releaseErrorMessage && !release ? ( {!isLoading && !releaseErrorMessage && !release ? (
<EmptyState <EmptyState
@@ -209,9 +208,9 @@ export function VersionUpgradeModal({
variant={release.has_update ? 'warning' : 'success'} variant={release.has_update ? 'warning' : 'success'}
/> />
{release.prerelease ? ( {release.prerelease ? (
<StatusBadge label="Preview 发布" variant="warning"/> <StatusBadge label="Preview 发布" variant="warning" />
) : ( ) : (
<StatusBadge label="正式发布" variant="info"/> <StatusBadge label="正式发布" variant="info" />
)} )}
{!release.upgrade_supported ? ( {!release.upgrade_supported ? (
<StatusBadge <StatusBadge
@@ -220,7 +219,7 @@ export function VersionUpgradeModal({
/> />
) : null} ) : null}
{release.in_progress ? ( {release.in_progress ? (
<StatusBadge label="升级任务执行中" variant="warning"/> <StatusBadge label="升级任务执行中" variant="warning" />
) : null} ) : null}
</div> </div>
<div <div
@@ -241,7 +240,6 @@ export function VersionUpgradeModal({
</a> </a>
{canUpgrade ? ( {canUpgrade ? (
<div className="flex justify-end"> <div className="flex justify-end">
<PrimaryButton <PrimaryButton
type="button" type="button"
onClick={onUpgrade} onClick={onUpgrade}
@@ -269,9 +267,7 @@ export function VersionUpgradeModal({
) : null} ) : null}
{release ? ( {release ? (
<AppCard <AppCard title="升级日志">
title="升级日志"
>
{upgradeLogs.length > 0 ? ( {upgradeLogs.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -310,7 +306,7 @@ export function VersionUpgradeModal({
{formatLogLevel(log.level)} {formatLogLevel(log.level)}
</span> </span>
<span className="text-slate-500"> | </span> <span className="text-slate-500"> | </span>
<span className="whitespace-pre-wrap break-all text-slate-100"> <span className="break-all whitespace-pre-wrap text-slate-100">
{log.message} {log.message}
</span> </span>
</pre> </pre>
@@ -318,17 +314,13 @@ export function VersionUpgradeModal({
</div> </div>
</div> </div>
) : ( ) : (
<EmptyState <EmptyState title="暂无升级日志" />
title="暂无升级日志"
/>
)} )}
</AppCard> </AppCard>
) : null} ) : null}
{canUpgrade ? ( {canUpgrade ? (
<AppCard <AppCard title="手动升级">
title="手动升级"
>
<div className="space-y-4"> <div className="space-y-4">
<ResourceField <ResourceField
label="服务端二进制" label="服务端二进制"
@@ -390,7 +382,7 @@ export function VersionUpgradeModal({
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]"> <div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
<div <div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200" className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
style={{width: `${uploadProgress}%`}} style={{ width: `${uploadProgress}%` }}
/> />
</div> </div>
</div> </div>
@@ -404,15 +396,13 @@ export function VersionUpgradeModal({
) : null} ) : null}
{!manualErrorMessage && manualStatusMessage ? ( {!manualErrorMessage && manualStatusMessage ? (
<div <div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
{manualStatusMessage} {manualStatusMessage}
</div> </div>
) : null} ) : null}
{uploadedBinary ? ( {uploadedBinary ? (
<div <div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<StatusBadge <StatusBadge
label={ label={
@@ -476,9 +466,7 @@ export function VersionUpgradeModal({
</p> </p>
</div> </div>
) : ( ) : (
<EmptyState <EmptyState title="尚未上传升级包" />
title="尚未上传升级包"
/>
)} )}
</div> </div>
</AppCard> </AppCard>
@@ -12,7 +12,9 @@ export function getUsers(page: number) {
} }
export function searchUsers(keyword: string) { export function searchUsers(keyword: string) {
return apiRequest<UserItem[]>(`/user/search?keyword=${encodeURIComponent(keyword)}`); return apiRequest<UserItem[]>(
`/user/search?keyword=${encodeURIComponent(keyword)}`,
);
} }
export function getUser(id: number) { export function getUser(id: number) {
@@ -40,12 +40,19 @@ const ITEMS_PER_PAGE = 10;
const usersListQueryKey = ['users', 'list'] as const; const usersListQueryKey = ['users', 'list'] as const;
const userSchema = z.object({ const userSchema = z.object({
username: z.string().trim().min(1, '请输入用户名').max(12, '用户名不能超过 12 个字符'), username: z
.string()
.trim()
.min(1, '请输入用户名')
.max(12, '用户名不能超过 12 个字符'),
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'), display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
password: z password: z
.string() .string()
.max(20, '密码不能超过 20 个字符') .max(20, '密码不能超过 20 个字符')
.refine((value) => value.length === 0 || value.length >= 8, '密码至少需要 8 个字符'), .refine(
(value) => value.length === 0 || value.length >= 8,
'密码至少需要 8 个字符',
),
}); });
type UserFormValues = z.infer<typeof userSchema>; type UserFormValues = z.infer<typeof userSchema>;
@@ -171,7 +178,13 @@ export function UsersPage() {
}); });
const manageMutation = useMutation({ const manageMutation = useMutation({
mutationFn: async ({ username, action }: { username: string; action: ManageUserAction }) => { mutationFn: async ({
username,
action,
}: {
username: string;
action: ManageUserAction;
}) => {
await manageUser(username, action); await manageUser(username, action);
return action; return action;
}, },
@@ -240,11 +253,17 @@ export function UsersPage() {
enable: '启用', enable: '启用',
}[action]; }[action];
if (action === 'delete' && !window.confirm(`确认删除用户“${targetUser.username}”吗?`)) { if (
action === 'delete' &&
!window.confirm(`确认删除用户“${targetUser.username}”吗?`)
) {
return; return;
} }
if ((action === 'disable' || action === 'enable') && !window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)) { if (
(action === 'disable' || action === 'enable') &&
!window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)
) {
return; return;
} }
@@ -259,14 +278,14 @@ export function UsersPage() {
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className='space-y-6'> <div className="space-y-6">
<PageHeader <PageHeader
title='用户管理' title="用户管理"
description='阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。' description="阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。"
/> />
<EmptyState <EmptyState
title='权限不足' title="权限不足"
description='用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。' description="用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。"
/> />
</div> </div>
); );
@@ -274,60 +293,72 @@ export function UsersPage() {
return ( return (
<> <>
<div className='space-y-6'> <div className="space-y-6">
<PageHeader <PageHeader
title='用户管理' title="用户管理"
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。' description="支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。"
action={ action={
<PrimaryButton type='button' onClick={handleCreate}> <PrimaryButton type="button" onClick={handleCreate}>
新建用户 新建用户
</PrimaryButton> </PrimaryButton>
} }
/> />
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard <AppCard
title='用户列表' title="用户列表"
action={ action={
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<SecondaryButton <SecondaryButton
type='button' type="button"
onClick={() => void queryClient.invalidateQueries({ queryKey: usersListQueryKey })} onClick={() =>
void queryClient.invalidateQueries({
queryKey: usersListQueryKey,
})
}
> >
刷新 刷新
</SecondaryButton> </SecondaryButton>
</div> </div>
} }
> >
<div className='space-y-5'> <div className="space-y-5">
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'> <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'> <div className="flex w-full flex-col gap-3 md:flex-row">
<ResourceInput <ResourceInput
value={searchInput} value={searchInput}
onChange={(event) => setSearchInput(event.target.value)} onChange={(event) => setSearchInput(event.target.value)}
placeholder='搜索用户 ID、用户名、显示名称或邮箱地址' placeholder="搜索用户 ID、用户名、显示名称或邮箱地址"
className='md:max-w-xl' className="md:max-w-xl"
/> />
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<PrimaryButton type='button' onClick={handleSearchSubmit}> <PrimaryButton type="button" onClick={handleSearchSubmit}>
搜索 搜索
</PrimaryButton> </PrimaryButton>
<SecondaryButton type='button' onClick={handleResetSearch}> <SecondaryButton type="button" onClick={handleResetSearch}>
清空 清空
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>
{searchKeyword.length === 0 ? ( {searchKeyword.length === 0 ? (
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<SecondaryButton type='button' onClick={() => setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}> <SecondaryButton
type="button"
onClick={() => setPage((value) => Math.max(value - 1, 0))}
disabled={page === 0 || activeQuery.isLoading}
>
上一页 上一页
</SecondaryButton> </SecondaryButton>
<SecondaryButton <SecondaryButton
type='button' type="button"
onClick={() => setPage((value) => value + 1)} onClick={() => setPage((value) => value + 1)}
disabled={activeQuery.isLoading || users.length < ITEMS_PER_PAGE} disabled={
activeQuery.isLoading || users.length < ITEMS_PER_PAGE
}
> >
下一页 下一页
</SecondaryButton> </SecondaryButton>
@@ -338,73 +369,119 @@ export function UsersPage() {
{activeQuery.isLoading ? ( {activeQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : activeQuery.isError ? ( ) : activeQuery.isError ? (
<ErrorState title='用户列表加载失败' description={getErrorMessage(activeQuery.error)} /> <ErrorState
title="用户列表加载失败"
description={getErrorMessage(activeQuery.error)}
/>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<EmptyState title='暂无用户' description='当前条件下没有可展示的用户记录。' /> <EmptyState
title="暂无用户"
description="当前条件下没有可展示的用户记录。"
/>
) : ( ) : (
<div className='overflow-x-auto'> <div className="overflow-x-auto">
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead> <thead>
<tr className='text-[var(--foreground-secondary)]'> <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>
<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> </tr>
</thead> </thead>
<tbody className='divide-y divide-[var(--border-default)]'> <tbody className="divide-y divide-[var(--border-default)]">
{users.map((targetUser) => { {users.map((targetUser) => {
const roleMeta = getRoleMeta(targetUser.role); const roleMeta = getRoleMeta(targetUser.role);
const statusMeta = getStatusMeta(targetUser.status); const statusMeta = getStatusMeta(targetUser.status);
const canManage = (currentUser?.role ?? 0) > targetUser.role; const canManage =
(currentUser?.role ?? 0) > targetUser.role;
return ( return (
<tr key={targetUser.id} className='align-top'> <tr key={targetUser.id} className="align-top">
<td className='px-3 py-4'> <td className="px-3 py-4">
<div className='space-y-1'> <div className="space-y-1">
<p className='font-medium text-[var(--foreground-primary)]'>{targetUser.username}</p> <p className="font-medium text-[var(--foreground-primary)]">
<p className='text-xs text-[var(--foreground-secondary)]'> {targetUser.username}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
显示名称:{targetUser.display_name || '未设置'} 显示名称:{targetUser.display_name || '未设置'}
</p> </p>
<p className='text-xs text-[var(--foreground-secondary)]'>ID:{targetUser.id}</p> <p className="text-xs text-[var(--foreground-secondary)]">
ID:{targetUser.id}
</p>
</div> </div>
</td> </td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'> <td className="px-3 py-4 text-[var(--foreground-secondary)]">
{targetUser.email || '未绑定'} {targetUser.email || '未绑定'}
</td> </td>
<td className='px-3 py-4'> <td className="px-3 py-4">
<StatusBadge label={roleMeta.label} variant={roleMeta.variant} /> <StatusBadge
label={roleMeta.label}
variant={roleMeta.variant}
/>
</td> </td>
<td className='px-3 py-4'> <td className="px-3 py-4">
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} /> <StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td> </td>
<td className='px-3 py-4'> <td className="px-3 py-4">
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<SecondaryButton type='button' onClick={() => handleEdit(targetUser)} className='px-3 py-2 text-xs' disabled={!canManage}> <SecondaryButton
type="button"
onClick={() => handleEdit(targetUser)}
className="px-3 py-2 text-xs"
disabled={!canManage}
>
编辑 编辑
</SecondaryButton> </SecondaryButton>
<SecondaryButton <SecondaryButton
type='button' type="button"
onClick={() => handleManage(targetUser, targetUser.status === 1 ? 'disable' : 'enable')} onClick={() =>
className='px-3 py-2 text-xs' handleManage(
disabled={!canManage || manageMutation.isPending} targetUser,
targetUser.status === 1
? 'disable'
: 'enable',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
> >
{targetUser.status === 1 ? '禁用' : '启用'} {targetUser.status === 1 ? '禁用' : '启用'}
</SecondaryButton> </SecondaryButton>
<SecondaryButton <SecondaryButton
type='button' type="button"
onClick={() => handleManage(targetUser, targetUser.role >= 10 ? 'demote' : 'promote')} onClick={() =>
className='px-3 py-2 text-xs' handleManage(
disabled={!canManage || manageMutation.isPending || (!isRoot && targetUser.role < 10)} targetUser,
targetUser.role >= 10
? 'demote'
: 'promote',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage ||
manageMutation.isPending ||
(!isRoot && targetUser.role < 10)
}
> >
{targetUser.role >= 10 ? '降级' : '提升'} {targetUser.role >= 10 ? '降级' : '提升'}
</SecondaryButton> </SecondaryButton>
<DangerButton <DangerButton
type='button' type="button"
onClick={() => handleManage(targetUser, 'delete')} onClick={() =>
className='px-3 py-2 text-xs' handleManage(targetUser, 'delete')
disabled={!canManage || manageMutation.isPending} }
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
> >
删除 删除
</DangerButton> </DangerButton>
@@ -424,14 +501,30 @@ export function UsersPage() {
isOpen={isEditorOpen} isOpen={isEditorOpen}
onClose={handleResetForm} onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'} title={editingUserId ? '编辑用户' : '创建新用户'}
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'} description={
editingUserId
? '可更新用户名、显示名称与密码。留空密码表示不修改。'
: '新建用户默认创建为普通用户。'
}
footer={ footer={
<div className='flex flex-wrap justify-end gap-3'> <div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}> <SecondaryButton
type="button"
onClick={handleResetForm}
disabled={saveMutation.isPending}
>
取消 取消
</SecondaryButton> </SecondaryButton>
<PrimaryButton type='submit' form='user-editor-form' disabled={saveMutation.isPending}> <PrimaryButton
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'} type="submit"
form="user-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingUserId
? '保存修改'
: '创建用户'}
</PrimaryButton> </PrimaryButton>
</div> </div>
} }
@@ -439,39 +532,70 @@ export function UsersPage() {
{editingUserId && editingUserQuery.isLoading ? ( {editingUserId && editingUserQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : editingUserId && editingUserQuery.isError ? ( ) : editingUserId && editingUserQuery.isError ? (
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} /> <ErrorState
title="用户详情加载失败"
description={getErrorMessage(editingUserQuery.error)}
/>
) : ( ) : (
<form id='user-editor-form' className='space-y-5' onSubmit={handleSubmit}> <form
<ResourceField label='用户名' error={form.formState.errors.username?.message}> id="user-editor-form"
<ResourceInput placeholder='请输入用户名' {...form.register('username')} /> className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="用户名"
error={form.formState.errors.username?.message}
>
<ResourceInput
placeholder="请输入用户名"
{...form.register('username')}
/>
</ResourceField> </ResourceField>
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}> <ResourceField
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} /> label="显示名称"
error={form.formState.errors.display_name?.message}
>
<ResourceInput
placeholder="请输入显示名称"
{...form.register('display_name')}
/>
</ResourceField> </ResourceField>
<ResourceField <ResourceField
label={editingUserId ? '新密码' : '密码'} label={editingUserId ? '新密码' : '密码'}
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'} hint={
editingUserId
? '留空表示保持原密码不变。'
: '密码长度需为 8 到 20 个字符。'
}
error={form.formState.errors.password?.message} error={form.formState.errors.password?.message}
> >
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} /> <ResourceInput
type="password"
placeholder="请输入密码"
{...form.register('password')}
/>
</ResourceField> </ResourceField>
{editingUserId && editingUserQuery.data ? ( {editingUserId && editingUserQuery.data ? (
<div className='grid gap-4 md:grid-cols-2'> <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'> <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="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'> 绑定邮箱
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{editingUserQuery.data.email || '未绑定'} {editingUserQuery.data.email || '未绑定'}
</p> </p>
</div> </div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> <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="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className='mt-2 text-sm text-[var(--foreground-primary)]'> 第三方账号
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
GitHub:{editingUserQuery.data.github_id || '未绑定'} GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p> </p>
<p className='mt-1 text-sm text-[var(--foreground-primary)]'> <p className="mt-1 text-sm text-[var(--foreground-primary)]">
微信:{editingUserQuery.data.wechat_id || '未绑定'} 微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p> </p>
</div> </div>
+6 -1
View File
@@ -16,7 +16,12 @@ export interface UserMutationPayload {
password: string; password: string;
} }
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable'; export type ManageUserAction =
| 'promote'
| 'demote'
| 'delete'
| 'disable'
| 'enable';
export interface ManageUserResult { export interface ManageUserResult {
role: number; role: number;
@@ -1,20 +1,30 @@
'use client'; 'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
import {useEffect, useMemo, useState} from 'react'; import { useEffect, useMemo, useState } from 'react';
import {Check, Globe2, ListFilter, type LucideIcon, Plus, Save, Search, ShieldCheck, Trash2,} from 'lucide-react'; import {
Check,
Globe2,
ListFilter,
type LucideIcon,
Plus,
Save,
Search,
ShieldCheck,
Trash2,
} from 'lucide-react';
import {EmptyState} from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import {AppModal} from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import {Drawer} from '@/components/ui/drawer'; import { Drawer } from '@/components/ui/drawer';
import {getProxyRoutes} from '@/features/proxy-routes/api/proxy-routes'; import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import type {ProxyRouteItem} from '@/features/proxy-routes/types'; import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { import {
DangerButton, DangerButton,
PrimaryButton, PrimaryButton,
@@ -31,8 +41,8 @@ import {
replaceWAFRuleGroupSites, replaceWAFRuleGroupSites,
updateWAFRuleGroup, updateWAFRuleGroup,
} from '@/features/waf/api/waf'; } from '@/features/waf/api/waf';
import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/features/waf/types'; import type { WAFRuleGroup, WAFRuleGroupPayload } from '@/features/waf/types';
import {cn} from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
type FeedbackState = { type FeedbackState = {
tone: 'success' | 'danger' | 'info'; tone: 'success' | 'danger' | 'info';
@@ -126,17 +136,13 @@ function textToList(text: string) {
function normalizeItems(items: string[]) { function normalizeItems(items: string[]) {
return Array.from( return Array.from(
new Set( new Set(items.map((item) => item.trim()).filter(Boolean)),
items
.map((item) => item.trim())
.filter(Boolean),
),
).sort((left, right) => left.localeCompare(right)); ).sort((left, right) => left.localeCompare(right));
} }
function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload { function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
if (!group) { if (!group) {
return {...emptyDraft}; return { ...emptyDraft };
} }
return { return {
name: group.name, name: group.name,
@@ -165,8 +171,8 @@ function countRuleEntries(group: RuleListRenderable) {
} }
function buildCountryOptions() { function buildCountryOptions() {
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], {type: 'region'}); const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
const enDisplayNames = new Intl.DisplayNames(['en'], {type: 'region'}); const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
const options: CountryOption[] = []; const options: CountryOption[] = [];
for (let first = 65; first <= 90; first += 1) { for (let first = 65; first <= 90; first += 1) {
@@ -215,13 +221,13 @@ function updateDraftList(
) { ) {
switch (key) { switch (key) {
case 'ip_whitelist': case 'ip_whitelist':
return {...draft, ip_whitelist: updater(draft.ip_whitelist)}; return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case 'ip_blacklist': case 'ip_blacklist':
return {...draft, ip_blacklist: updater(draft.ip_blacklist)}; return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
case 'country_whitelist': case 'country_whitelist':
return {...draft, country_whitelist: updater(draft.country_whitelist)}; return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist': case 'country_blacklist':
return {...draft, country_blacklist: updater(draft.country_blacklist)}; return { ...draft, country_blacklist: updater(draft.country_blacklist) };
} }
} }
@@ -234,7 +240,7 @@ function StatCard({
label, label,
value, value,
hint, hint,
}: { }: {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
value: string; value: string;
@@ -244,14 +250,15 @@ function StatCard({
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"> <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.22em] text-[var(--foreground-muted)]"> <p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
{label} {label}
</p> </p>
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">{value}</p> <p className="text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div> </div>
<span <span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]"> <Icon className="h-5 w-5" />
<Icon className="h-5 w-5"/>
</span> </span>
</div> </div>
{hint && ( {hint && (
@@ -267,7 +274,7 @@ function TabButton({
label, label,
active, active,
onClick, onClick,
}: { }: {
label: string; label: string;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
@@ -283,7 +290,9 @@ function TabButton({
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]', : 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
)} )}
> >
<p className=" text-sm font-semibold text-[var(--foreground-primary)]">{label}</p> <p className="text-sm font-semibold text-[var(--foreground-primary)]">
{label}
</p>
</button> </button>
); );
} }
@@ -292,7 +301,7 @@ function RuleChip({
label, label,
tone, tone,
onRemove, onRemove,
}: { }: {
label: string; label: string;
tone: 'whitelist' | 'blacklist'; tone: 'whitelist' | 'blacklist';
onRemove: () => void; onRemove: () => void;
@@ -326,7 +335,7 @@ function RuleListSection({
tone, tone,
emptyText, emptyText,
onRemove, onRemove,
}: { }: {
title: string; title: string;
description: string; description: string;
items: string[]; items: string[];
@@ -338,25 +347,30 @@ function RuleListSection({
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"> <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">{title}</h3> <h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]"> <p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
{description} {description}
</p> </p>
</div> </div>
<span <span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
{items.length} {items.length}
</span> </span>
</div> </div>
{items.length > 0 ? ( {items.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{items.map((item) => ( {items.map((item) => (
<RuleChip key={item} label={item} tone={tone} onRemove={() => onRemove(item)}/> <RuleChip
key={item}
label={item}
tone={tone}
onRemove={() => onRemove(item)}
/>
))} ))}
</div> </div>
) : ( ) : (
<div <div className="mt-4 rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-6 text-sm text-[var(--foreground-secondary)]">
className="mt-4 rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-6 text-sm text-[var(--foreground-secondary)]">
{emptyText} {emptyText}
</div> </div>
)} )}
@@ -371,7 +385,7 @@ function RuleEntryModal({
onClose, onClose,
onChange, onChange,
onSubmit, onSubmit,
}: { }: {
state: RuleModalState; state: RuleModalState;
countryOptions: CountryOption[]; countryOptions: CountryOption[];
pending: boolean; pending: boolean;
@@ -401,7 +415,9 @@ function RuleEntryModal({
.sort((left, right) => { .sort((left, right) => {
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0; const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0; const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
return rightSelected - leftSelected || left.code.localeCompare(right.code); return (
rightSelected - leftSelected || left.code.localeCompare(right.code)
);
}); });
}, [countryOptions, keyword, selectedCountrySet]); }, [countryOptions, keyword, selectedCountrySet]);
@@ -409,7 +425,7 @@ function RuleEntryModal({
const values = selectedCountrySet.has(code) const values = selectedCountrySet.has(code)
? state.countryValues.filter((item) => item !== code) ? state.countryValues.filter((item) => item !== code)
: normalizeItems([...state.countryValues, code]); : normalizeItems([...state.countryValues, code]);
onChange({countryValues: values}); onChange({ countryValues: values });
}; };
const selectFiltered = () => { const selectFiltered = () => {
@@ -421,7 +437,7 @@ function RuleEntryModal({
}); });
}; };
const clearCountries = () => onChange({countryValues: []}); const clearCountries = () => onChange({ countryValues: [] });
const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单'; const typeLabel = state.listType === 'whitelist' ? '白名单' : '黑名单';
const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域'; const dimensionLabel = state.dimension === 'ip' ? 'IP' : '地域';
@@ -449,13 +465,15 @@ function RuleEntryModal({
<ResourceField label="类型" container="div"> <ResourceField label="类型" container="div">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
{value: 'whitelist', label: '白名单'}, { value: 'whitelist', label: '白名单' },
{value: 'blacklist', label: '黑名单'}, { value: 'blacklist', label: '黑名单' },
].map((option) => ( ].map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => onChange({listType: option.value as RuleListType})} onClick={() =>
onChange({ listType: option.value as RuleListType })
}
className={cn( className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition', 'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.listType === option.value state.listType === option.value
@@ -471,13 +489,15 @@ function RuleEntryModal({
<ResourceField label="维度" container="div"> <ResourceField label="维度" container="div">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{[ {[
{value: 'ip', label: 'IP'}, { value: 'ip', label: 'IP' },
{value: 'country', label: '地域'}, { value: 'country', label: '地域' },
].map((option) => ( ].map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => onChange({dimension: option.value as RuleDimension})} onClick={() =>
onChange({ dimension: option.value as RuleDimension })
}
className={cn( className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition', 'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.dimension === option.value state.dimension === option.value
@@ -493,18 +513,20 @@ function RuleEntryModal({
</div> </div>
{state.dimension === 'ip' ? ( {state.dimension === 'ip' ? (
<ResourceField label="IP / IP 段" hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"> <ResourceField
label="IP / IP 段"
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
>
<ResourceTextarea <ResourceTextarea
value={state.ipValue} value={state.ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24" placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) => onChange({ipValue: event.target.value})} onChange={(event) => onChange({ ipValue: event.target.value })}
/> />
</ResourceField> </ResourceField>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div <div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"> <Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<Search className="h-4 w-4 text-[var(--foreground-secondary)]"/>
<input <input
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
@@ -527,8 +549,7 @@ function RuleEntryModal({
</button> </button>
</div> </div>
<div <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]"> <h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
@@ -538,8 +559,7 @@ function RuleEntryModal({
选项显示为「国家代码 国家中文名」。 选项显示为「国家代码 国家中文名」。
</p> </p>
</div> </div>
<span <span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.countryValues.length} 已选 {state.countryValues.length}
</span> </span>
</div> </div>
@@ -587,7 +607,7 @@ function SiteApplyDrawer({
onOpenChange, onOpenChange,
onSave, onSave,
pending, pending,
}: { }: {
group: WAFRuleGroup | null; group: WAFRuleGroup | null;
routes: ProxyRouteItem[]; routes: ProxyRouteItem[];
open: boolean; open: boolean;
@@ -654,9 +674,8 @@ function SiteApplyDrawer({
} }
> >
<div className="space-y-4"> <div className="space-y-4">
<div <div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"> <Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<Search className="h-4 w-4 text-[var(--foreground-secondary)]"/>
<input <input
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
@@ -692,7 +711,9 @@ function SiteApplyDrawer({
: 'border-[var(--border-default)]', : 'border-[var(--border-default)]',
)} )}
> >
{selectedSet.has(route.id) ? <Check className="h-3 w-3"/> : null} {selectedSet.has(route.id) ? (
<Check className="h-3 w-3" />
) : null}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]"> <span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
@@ -717,7 +738,9 @@ export function WAFPage() {
const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft); const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null); const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null);
const [ruleModal, setRuleModal] = useState<RuleModalState>(defaultRuleModalState); const [ruleModal, setRuleModal] = useState<RuleModalState>(
defaultRuleModalState,
);
const groupsQuery = useQuery({ const groupsQuery = useQuery({
queryKey: ['waf', 'rule-groups'], queryKey: ['waf', 'rule-groups'],
@@ -740,7 +763,9 @@ export function WAFPage() {
() => () =>
selectedID === 0 selectedID === 0
? null ? null
: (groups.find((group) => group.id === selectedID) ?? groups[0] ?? null), : (groups.find((group) => group.id === selectedID) ??
groups[0] ??
null),
[groups, selectedID], [groups, selectedID],
); );
@@ -753,8 +778,8 @@ export function WAFPage() {
const invalidate = async () => { const invalidate = async () => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({queryKey: ['waf', 'rule-groups']}), queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({queryKey: ['config-versions', 'diff']}), queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
]); ]);
}; };
@@ -767,11 +792,11 @@ export function WAFPage() {
}, },
onSuccess: async (group) => { onSuccess: async (group) => {
setSelectedID(group.id); setSelectedID(group.id);
setFeedback({tone: 'success', message: 'WAF 规则组已保存。'}); setFeedback({ tone: 'success', message: 'WAF 规则组已保存。' });
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
setFeedback({tone: 'danger', message: getErrorMessage(error)}); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}, },
}); });
@@ -779,52 +804,75 @@ export function WAFPage() {
mutationFn: deleteWAFRuleGroup, mutationFn: deleteWAFRuleGroup,
onSuccess: async () => { onSuccess: async () => {
setSelectedID(null); setSelectedID(null);
setFeedback({tone: 'success', message: 'WAF 规则组已删除。'}); setFeedback({ tone: 'success', message: 'WAF 规则组已删除。' });
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
setFeedback({tone: 'danger', message: getErrorMessage(error)}); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}, },
}); });
const applyMutation = useMutation({ const applyMutation = useMutation({
mutationFn: ({id, ids}: { id: number; ids: number[] }) => mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
replaceWAFRuleGroupSites(id, ids), replaceWAFRuleGroupSites(id, ids),
onSuccess: async () => { onSuccess: async () => {
setApplyGroup(null); setApplyGroup(null);
setFeedback({tone: 'success', message: '规则组应用范围已更新。'}); setFeedback({ tone: 'success', message: '规则组应用范围已更新。' });
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
setFeedback({tone: 'danger', message: getErrorMessage(error)}); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}, },
}); });
if (groupsQuery.isLoading || routesQuery.isLoading) { if (groupsQuery.isLoading || routesQuery.isLoading) {
return <LoadingState/>; return <LoadingState />;
} }
if (groupsQuery.isError) { if (groupsQuery.isError) {
return <ErrorState title="WAF 加载失败" description={getErrorMessage(groupsQuery.error)}/>; return (
<ErrorState
title="WAF 加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
} }
if (routesQuery.isError) { if (routesQuery.isError) {
return <ErrorState title="网站列表加载失败" description={getErrorMessage(routesQuery.error)}/>; return (
<ErrorState
title="网站列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
);
} }
if (!selectedGroup && groups.length === 0) { if (!selectedGroup && groups.length === 0) {
return <EmptyState title="WAF 尚未初始化" description="刷新页面后系统会自动创建全局规则组。"/>; return (
<EmptyState
title="WAF 尚未初始化"
description="刷新页面后系统会自动创建全局规则组。"
/>
);
} }
const enabledGroups = groups.filter((group) => group.enabled); const enabledGroups = groups.filter((group) => group.enabled);
const protectedSites = new Set(groups.flatMap((group) => group.applied_site_ids)); const protectedSites = new Set(
const totalRules = groups.reduce((sum, group) => sum + countRuleEntries(group), 0); groups.flatMap((group) => group.applied_site_ids),
);
const totalRules = groups.reduce(
(sum, group) => sum + countRuleEntries(group),
0,
);
const currentRuleCount = countRuleEntries(draft); const currentRuleCount = countRuleEntries(draft);
const appliedSiteNames = selectedGroup?.is_global const appliedSiteNames = selectedGroup?.is_global
? ['全部网站'] ? ['全部网站']
: (selectedGroup?.applied_site_ids ?? []) : (selectedGroup?.applied_site_ids ?? [])
.map((id) => routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`) .map(
(id) =>
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
)
.sort((left, right) => left.localeCompare(right)); .sort((left, right) => left.localeCompare(right));
const openRuleModal = () => { const openRuleModal = () => {
setRuleModal({...defaultRuleModalState, open: true}); setRuleModal({ ...defaultRuleModalState, open: true });
}; };
const closeRuleModal = () => { const closeRuleModal = () => {
@@ -840,33 +888,48 @@ export function WAFPage() {
if (values.length === 0) { if (values.length === 0) {
setFeedback({ setFeedback({
tone: 'danger', tone: 'danger',
message: ruleModal.dimension === 'ip' ? '请先输入 IP 或 IP 段。' : '请先选择地域。', message:
ruleModal.dimension === 'ip'
? '请先输入 IP 或 IP 段。'
: '请先选择地域。',
}); });
return; return;
} }
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension); const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
setDraft((current) => setDraft((current) =>
updateDraftList(current, listKey, (items) => normalizeItems([...items, ...values])), updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
); );
setFeedback({tone: 'info', message: '名单项已添加到当前草稿,保存后生效。'}); setFeedback({
tone: 'info',
message: '名单项已添加到当前草稿,保存后生效。',
});
closeRuleModal(); closeRuleModal();
setActiveTab('lists'); setActiveTab('lists');
}; };
const removeRuleItem = (key: ListFieldKey, value: string) => { const removeRuleItem = (key: ListFieldKey, value: string) => {
setDraft((current) => setDraft((current) =>
updateDraftList(current, key, (items) => items.filter((item) => item !== value)), updateDraftList(current, key, (items) =>
items.filter((item) => item !== value),
),
); );
}; };
const overviewItems: Array<{ label: string; value: ReactNode }> = [ const overviewItems: Array<{ label: string; value: ReactNode }> = [
{label: '当前规则组', value: selectedGroup ? selectedGroup.name : '新建规则组'}, {
{label: '启用状态', value: draft.enabled ? '启用中' : '已停用'}, label: '当前规则组',
{label: '当前规则数', value: `${currentRuleCount} 条`}, value: selectedGroup ? selectedGroup.name : '新建规则组',
},
{ label: '启用状态', value: draft.enabled ? '启用中' : '已停用' },
{ label: '当前规则数', value: `${currentRuleCount} 条` },
{ {
label: '生效范围', label: '生效范围',
value: selectedGroup?.is_global ? '全部网站' : `${selectedGroup?.applied_site_count ?? 0} 个网站`, value: selectedGroup?.is_global
? '全部网站'
: `${selectedGroup?.applied_site_count ?? 0} 个网站`,
}, },
{ {
label: '拦截返回', label: '拦截返回',
@@ -894,16 +957,18 @@ export function WAFPage() {
onClick={() => { onClick={() => {
setSelectedID(0); setSelectedID(0);
setActiveTab('basic'); setActiveTab('basic');
setDraft({...emptyDraft, name: '自定义规则组'}); setDraft({ ...emptyDraft, name: '自定义规则组' });
}} }}
> >
<Plus className="mr-2 h-4 w-4"/> <Plus className="mr-2 h-4 w-4" />
新建规则组 新建规则组
</PrimaryButton> </PrimaryButton>
} }
/> />
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message}/> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]"> <div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="规则组"> <AppCard title="规则组">
@@ -926,9 +991,9 @@ export function WAFPage() {
<span className="flex items-center justify-between gap-3"> <span className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
{group.is_global ? ( {group.is_global ? (
<Globe2 className="h-4 w-4"/> <Globe2 className="h-4 w-4" />
) : ( ) : (
<ShieldCheck className="h-4 w-4"/> <ShieldCheck className="h-4 w-4" />
)} )}
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]"> <span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name} {group.name}
@@ -939,8 +1004,10 @@ export function WAFPage() {
</span> </span>
</span> </span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]"> <span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.is_global ? '应用全部网站' : `已应用 ${group.applied_site_count} 个网站`} ·{' '} {group.is_global
{countRuleEntries(group)} 条规则 ? '应用全部网站'
: `已应用 ${group.applied_site_count} 个网站`}{' '}
· {countRuleEntries(group)} 条规则
</span> </span>
</button> </button>
))} ))}
@@ -953,7 +1020,10 @@ export function WAFPage() {
action={ action={
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{selectedGroup && !selectedGroup.is_global ? ( {selectedGroup && !selectedGroup.is_global ? (
<SecondaryButton type="button" onClick={() => setApplyGroup(selectedGroup)}> <SecondaryButton
type="button"
onClick={() => setApplyGroup(selectedGroup)}
>
一键应用 一键应用
</SecondaryButton> </SecondaryButton>
) : null} ) : null}
@@ -962,11 +1032,10 @@ export function WAFPage() {
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)} onClick={() => saveMutation.mutate(draft)}
> >
<Save className="mr-2 h-4 w-4"/> <Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存规则组'} {saveMutation.isPending ? '保存中...' : '保存规则组'}
</PrimaryButton> </PrimaryButton>
</div> </div>
} }
> >
<div className="space-y-6"> <div className="space-y-6">
@@ -1013,7 +1082,7 @@ export function WAFPage() {
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
name: event.target.value name: event.target.value,
})) }))
} }
/> />
@@ -1023,7 +1092,10 @@ export function WAFPage() {
description="关闭后保留配置,但不会参与匹配。" description="关闭后保留配置,但不会参与匹配。"
checked={draft.enabled} checked={draft.enabled}
onChange={(checked) => onChange={(checked) =>
setDraft((current) => ({...current, enabled: checked})) setDraft((current) => ({
...current,
enabled: checked,
}))
} }
/> />
<ResourceField <ResourceField
@@ -1036,24 +1108,22 @@ export function WAFPage() {
onChange={(event) => onChange={(event) =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
remark: event.target.value remark: event.target.value,
})) }))
} }
/> />
</ResourceField> </ResourceField>
</div> </div>
<div className="grid gap-5 "> <div className="grid gap-5">
<div className="space-y-5"> <div className="space-y-5">
<div <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]"> <h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
配置总览 配置总览
</h3> </h3>
</div> </div>
<span <span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
{selectedGroup?.is_global ? '全局' : '自定义'} {selectedGroup?.is_global ? '全局' : '自定义'}
</span> </span>
</div> </div>
@@ -1064,7 +1134,7 @@ export function WAFPage() {
key={item.label} key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-4" className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-4"
> >
<dt className="text-xs font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <dt className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{item.label} {item.label}
</dt> </dt>
<dd className="mt-2 text-sm font-medium text-[var(--foreground-primary)]"> <dd className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
@@ -1075,7 +1145,7 @@ export function WAFPage() {
</dl> </dl>
<div className="mt-5"> <div className="mt-5">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
当前应用网站 当前应用网站
</p> </p>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
@@ -1089,16 +1159,13 @@ export function WAFPage() {
</span> </span>
)) ))
) : ( ) : (
<span <span className="rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
className="rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
尚未绑定网站,可点击右上角「一键应用」进行配置。 尚未绑定网站,可点击右上角「一键应用」进行配置。
</span> </span>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -1113,7 +1180,7 @@ export function WAFPage() {
</h3> </h3>
</div> </div>
<PrimaryButton type="button" onClick={openRuleModal}> <PrimaryButton type="button" onClick={openRuleModal}>
<Plus className="mr-2 h-4 w-4"/> <Plus className="mr-2 h-4 w-4" />
添加 添加
</PrimaryButton> </PrimaryButton>
</div> </div>
@@ -1168,8 +1235,7 @@ export function WAFPage() {
{activeTab === 'block' ? ( {activeTab === 'block' ? (
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]"> <div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-5"> <div className="space-y-5">
<div <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]"> <h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
拦截返回状态码 拦截返回状态码
</h3> </h3>
@@ -1199,7 +1265,7 @@ export function WAFPage() {
onClick={() => onClick={() =>
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
block_status_code: code block_status_code: code,
})) }))
} }
className={cn( className={cn(
@@ -1215,11 +1281,9 @@ export function WAFPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div <div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<ResourceField <ResourceField
label="拦截页面" label="拦截页面"
hint="支持直接输入 HTML 或纯文本。留空时只返回状态码。" hint="支持直接输入 HTML 或纯文本。留空时只返回状态码。"
@@ -1236,31 +1300,31 @@ export function WAFPage() {
} }
/> />
</ResourceField> </ResourceField>
</div> </div>
</div> </div>
) : null} ) : null}
<div <div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div> <div>
{selectedGroup && !selectedGroup.is_global ? ( {selectedGroup && !selectedGroup.is_global ? (
<DangerButton <DangerButton
type="button" type="button"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => { onClick={() => {
if (window.confirm(`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`)) { if (
window.confirm(
`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id); deleteMutation.mutate(selectedGroup.id);
} }
}} }}
> >
<Trash2 className="mr-2 h-4 w-4"/> <Trash2 className="mr-2 h-4 w-4" />
删除 删除
</DangerButton> </DangerButton>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
</AppCard> </AppCard>
@@ -1272,7 +1336,9 @@ export function WAFPage() {
countryOptions={countryOptions} countryOptions={countryOptions}
pending={saveMutation.isPending} pending={saveMutation.isPending}
onClose={closeRuleModal} onClose={closeRuleModal}
onChange={(patch) => setRuleModal((current) => ({...current, ...patch, open: true}))} onChange={(patch) =>
setRuleModal((current) => ({ ...current, ...patch, open: true }))
}
onSubmit={applyRuleModal} onSubmit={applyRuleModal}
/> />
@@ -1288,7 +1354,7 @@ export function WAFPage() {
}} }}
onSave={(ids) => { onSave={(ids) => {
if (applyGroup) { if (applyGroup) {
applyMutation.mutate({id: applyGroup.id, ids}); applyMutation.mutate({ id: applyGroup.id, ids });
} }
}} }}
/> />
@@ -13,7 +13,10 @@ import {
getTlsCertificate, getTlsCertificate,
getTlsCertificateContent, getTlsCertificateContent,
} from '@/features/tls-certificates/api/tls-certificates'; } from '@/features/tls-certificates/api/tls-certificates';
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils'; import {
getCertificateStatus,
getErrorMessage,
} from '@/features/websites/utils';
import { import {
CodeBlock, CodeBlock,
DangerButton, DangerButton,
@@ -81,11 +84,7 @@ export function CertificateDetailModal({
<SecondaryButton type="button" onClick={onClose}> <SecondaryButton type="button" onClick={onClose}>
关闭 关闭
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton type="button" onClick={onEdit} disabled={!certificate}>
type="button"
onClick={onEdit}
disabled={!certificate}
>
编辑证书 编辑证书
</PrimaryButton> </PrimaryButton>
<DangerButton <DangerButton
@@ -112,15 +111,12 @@ export function CertificateDetailModal({
)} )}
/> />
) : !certificate || !content ? ( ) : !certificate || !content ? (
<EmptyState <EmptyState title="证书不存在" description="当前证书可能已被删除。" />
title="证书不存在"
description="当前证书可能已被删除。"
/>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2"> <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"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
证书名称 证书名称
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -128,7 +124,7 @@ export function CertificateDetailModal({
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
状态 状态
</p> </p>
<div className="mt-2"> <div className="mt-2">
@@ -138,7 +134,7 @@ export function CertificateDetailModal({
</div> </div>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
生效时间 生效时间
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -146,7 +142,7 @@ export function CertificateDetailModal({
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
到期时间 到期时间
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -156,7 +152,7 @@ export function CertificateDetailModal({
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
备注 备注
</p> </p>
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]"> <p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
@@ -180,7 +176,7 @@ export function CertificateDetailModal({
复制 复制
</SecondaryButton> </SecondaryButton>
</div> </div>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all"> <CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
{content.cert_pem} {content.cert_pem}
</CodeBlock> </CodeBlock>
</div> </div>
@@ -199,7 +195,7 @@ export function CertificateDetailModal({
复制 复制
</SecondaryButton> </SecondaryButton>
</div> </div>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all"> <CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
{content.key_pem} {content.key_pem}
</CodeBlock> </CodeBlock>
</div> </div>
@@ -52,8 +52,9 @@ export function CertificateImportModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual'); const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [fileForm, setFileForm] = const [fileForm, setFileForm] = useState<FileImportFormValues>(
useState<FileImportFormValues>(defaultFileImportValues); defaultFileImportValues,
);
const [certFile, setCertFile] = useState<File | null>(null); const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null); const [keyFile, setKeyFile] = useState<File | null>(null);
const [fileInputNonce, setFileInputNonce] = useState(0); const [fileInputNonce, setFileInputNonce] = useState(0);
@@ -280,9 +281,7 @@ export function CertificateImportModal({
<ResourceField <ResourceField
label="私钥文件" label="私钥文件"
hint={ hint={
keyFile keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'
} }
> >
<ResourceInput <ResourceInput
@@ -143,13 +143,16 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
enabled: Boolean(wafRouteID), enabled: Boolean(wafRouteID),
}); });
const wafMutation = useMutation({ const wafMutation = useMutation({
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids), mutationFn: (ids: number[]) =>
replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
onSuccess: async (view) => { onSuccess: async (view) => {
setWafSelectedIDs(view.applied_ids); setWafSelectedIDs(view.applied_ids);
setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' }); setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' });
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf'] }), queryClient.invalidateQueries({ queryKey: ['waf'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }), queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]); ]);
}, },
onError: (error) => { onError: (error) => {
@@ -90,7 +90,7 @@ export function WebsiteEditorModal({
}); });
const currentCertificate = watchedCertId const currentCertificate = watchedCertId
? certificates.find((item) => item.id === Number(watchedCertId)) ?? null ? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
: null; : null;
const handleSubmit = form.handleSubmit((values) => { const handleSubmit = form.handleSubmit((values) => {
@@ -215,7 +215,7 @@ export function WebsiteEditorModal({
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <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 className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前域名 当前域名
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -223,7 +223,7 @@ export function WebsiteEditorModal({
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <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 className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
匹配类型 匹配类型
</p> </p>
<div className="mt-2"> <div className="mt-2">
@@ -235,12 +235,14 @@ export function WebsiteEditorModal({
</div> </div>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <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 className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前证书 当前证书
</p> </p>
<div className="mt-2"> <div className="mt-2">
<StatusBadge <StatusBadge
label={currentCertificate ? currentCertificate.name : '未绑定证书'} label={
currentCertificate ? currentCertificate.name : '未绑定证书'
}
variant={currentCertificate ? 'success' : 'warning'} variant={currentCertificate ? 'success' : 'warning'}
/> />
</div> </div>
@@ -136,7 +136,7 @@ export function WebsitesPage() {
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{domains.map((domain) => { {domains.map((domain) => {
const certificate = domain.cert_id const certificate = domain.cert_id
? certificateMap.get(domain.cert_id) ?? null ? (certificateMap.get(domain.cert_id) ?? null)
: null; : null;
const matchType = getMatchTypeMeta(domain.domain); const matchType = getMatchTypeMeta(domain.domain);
@@ -168,7 +168,7 @@ export function WebsitesPage() {
<div className="grid gap-3 md:grid-cols-1"> <div className="grid gap-3 md:grid-cols-1">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
绑定证书 绑定证书
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
@@ -97,4 +97,3 @@ export const defaultAcmeApplyValues: AcmeApplyFormValues = {
dns2: '', dns2: '',
remark: '', remark: '',
}; };
+3 -1
View File
@@ -6,7 +6,9 @@ export const themeModes = ['light', 'dark', 'system'] as const;
export type ThemeMode = (typeof themeModes)[number]; export type ThemeMode = (typeof themeModes)[number];
export type ResolvedTheme = Exclude<ThemeMode, 'system'>; export type ResolvedTheme = Exclude<ThemeMode, 'system'>;
export function isThemeMode(value: string | null | undefined): value is ThemeMode { export function isThemeMode(
value: string | null | undefined,
): value is ThemeMode {
return themeModes.includes(value as ThemeMode); return themeModes.includes(value as ThemeMode);
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx';
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+6 -2
View File
@@ -17,8 +17,12 @@ function toDate(value: string | Date | null | undefined) {
return null; return null;
} }
const normalizedValue = typeof value === 'string' ? value.replace(' ', 'T') : value; const normalizedValue =
const date = normalizedValue instanceof Date ? normalizedValue : new Date(normalizedValue); typeof value === 'string' ? value.replace(' ', 'T') : value;
const date =
normalizedValue instanceof Date
? normalizedValue
: new Date(normalizedValue);
return Number.isNaN(date.getTime()) ? null : date; return Number.isNaN(date.getTime()) ? null : date;
} }
+12 -2
View File
@@ -1,5 +1,6 @@
const devBackendUrl = const devBackendUrl =
process.env.NEXT_DEV_BACKEND_URL?.replace(/\/+$/, '') || 'http://127.0.0.1:3000'; process.env.NEXT_DEV_BACKEND_URL?.replace(/\/+$/, '') ||
'http://127.0.0.1:3000';
const enableDevProxy = process.env.NEXT_DEV_PROXY === 'true'; const enableDevProxy = process.env.NEXT_DEV_PROXY === 'true';
export default function createNextConfig() { export default function createNextConfig() {
@@ -10,7 +11,16 @@ export default function createNextConfig() {
unoptimized: true, unoptimized: true,
}, },
eslint: { eslint: {
dirs: ['app', 'components', 'features', 'hooks', 'lib', 'store', 'tests', 'types'], dirs: [
'app',
'components',
'features',
'hooks',
'lib',
'store',
'tests',
'types',
],
}, },
}; };
+4761 -1626
View File
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -6,7 +6,10 @@ import next from 'next';
const port = Number.parseInt(process.env.PORT ?? '3001', 10); const port = Number.parseInt(process.env.PORT ?? '3001', 10);
const hostname = process.env.HOSTNAME || '127.0.0.1'; const hostname = process.env.HOSTNAME || '127.0.0.1';
const backendBaseUrl = new URL( const backendBaseUrl = new URL(
(process.env.NEXT_DEV_BACKEND_URL || 'http://127.0.0.1:3000').replace(/\/+$/, ''), (process.env.NEXT_DEV_BACKEND_URL || 'http://127.0.0.1:3000').replace(
/\/+$/,
'',
),
); );
const app = next({ const app = next({
@@ -55,11 +58,19 @@ function writeProxyError(res, error) {
function proxyHttpRequest(req, res) { function proxyHttpRequest(req, res) {
const targetUrl = new URL(req.url || '/', backendBaseUrl); const targetUrl = new URL(req.url || '/', backendBaseUrl);
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request; const requestImpl =
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl), (proxyRes) => { targetUrl.protocol === 'https:' ? https.request : http.request;
res.writeHead(proxyRes.statusCode || 502, proxyRes.statusMessage, proxyRes.headers); const proxyReq = requestImpl(
createProxyRequestOptions(req, targetUrl),
(proxyRes) => {
res.writeHead(
proxyRes.statusCode || 502,
proxyRes.statusMessage,
proxyRes.headers,
);
proxyRes.pipe(res); proxyRes.pipe(res);
}); },
);
proxyReq.on('error', (error) => { proxyReq.on('error', (error) => {
if (!res.headersSent) { if (!res.headersSent) {
@@ -92,7 +103,8 @@ function formatUpgradeResponseHeaders(statusCode, statusMessage, headers) {
function proxyWebSocketUpgrade(req, socket, head) { function proxyWebSocketUpgrade(req, socket, head) {
const targetUrl = new URL(req.url || '/', backendBaseUrl); const targetUrl = new URL(req.url || '/', backendBaseUrl);
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request; const requestImpl =
targetUrl.protocol === 'https:' ? https.request : http.request;
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl)); const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl));
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
@@ -156,6 +168,10 @@ server.on('upgrade', (req, socket, head) => {
}); });
server.listen(port, hostname, () => { server.listen(port, hostname, () => {
console.log(`OpenFlare web dev server listening on http://${hostname}:${port}`); console.log(
console.log(`Proxying /api/* and websocket upgrades to ${backendBaseUrl.href}`); `OpenFlare web dev server listening on http://${hostname}:${port}`,
);
console.log(
`Proxying /api/* and websocket upgrades to ${backendBaseUrl.href}`,
);
}); });
@@ -180,7 +180,10 @@ describe('Proxy route website pages', () => {
primary_domain: payload.domain, primary_domain: payload.domain,
domain_count: payload.domains.length, domain_count: payload.domains.length,
origin_url: payload.origin_url, origin_url: payload.origin_url,
upstreams: JSON.stringify([payload.origin_url, ...payload.upstreams]), upstreams: JSON.stringify([
payload.origin_url,
...payload.upstreams,
]),
upstream_list: [payload.origin_url, ...payload.upstreams], upstream_list: [payload.origin_url, ...payload.upstreams],
enabled: payload.enabled, enabled: payload.enabled,
enable_https: payload.enable_https, enable_https: payload.enable_https,
@@ -242,7 +245,9 @@ describe('Proxy route website pages', () => {
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: '', message: '',
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }], data: [
{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true },
],
}), }),
), ),
); );
@@ -377,7 +382,9 @@ describe('Proxy route website pages', () => {
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: '', message: '',
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }], data: [
{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true },
],
}), }),
), ),
); );