[优化] 格式化

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
+12 -12
View File
@@ -1,12 +1,12 @@
{ {
"extends": ["next/core-web-vitals", "next/typescript"], "extends": ["next/core-web-vitals", "next/typescript"],
"ignorePatterns": [ "ignorePatterns": [
".next", ".next",
"build", "build",
"out", "out",
"coverage", "coverage",
"playwright-report", "playwright-report",
"test-results", "test-results",
"src" "src"
] ]
} }
+35 -35
View File
@@ -1,49 +1,49 @@
# OpenFlare Web # OpenFlare Web
OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。 OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
## 常用命令 ## 常用命令
```shell ```shell
corepack enable corepack enable
pnpm install pnpm install
# 本地开发(3001 前端同源代理后端 HTTP/WS) # 本地开发(3001 前端同源代理后端 HTTP/WS)
pnpm dev pnpm dev
# 生产模式启动 # 生产模式启动
pnpm start pnpm start
# 类型检查 # 类型检查
pnpm typecheck pnpm typecheck
# 代码检查 # 代码检查
pnpm lint pnpm lint
# 单元测试 # 单元测试
pnpm test pnpm test
# 生成静态构建产物到 build/ # 生成静态构建产物到 build/
pnpm build 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 生产模式。
## 开源协议 ## 开源协议
`openflare_server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。 `openflare_server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。
## 目录约定 ## 目录约定
* `app/`:路由与布局 - `app/`:路由与布局
* `features/`:业务模块 - `features/`:业务模块
* `components/`:复用组件 - `components/`:复用组件
* `lib/`:请求、环境变量、工具与常量 - `lib/`:请求、环境变量、工具与常量
* `tests/`:测试代码 - `tests/`:测试代码
@@ -1,5 +1,5 @@
import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page'; import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page';
export default function ConfigVersionsRoute() { export default function ConfigVersionsRoute() {
return <ConfigVersionsPage />; return <ConfigVersionsPage />;
} }
+16 -16
View File
@@ -1,16 +1,16 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { DashboardShell } from '@/components/layout/dashboard-shell'; import { DashboardShell } from '@/components/layout/dashboard-shell';
import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard'; import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard';
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: ReactNode; children: ReactNode;
} }
export default function DashboardLayout({ children }: DashboardLayoutProps) { export default function DashboardLayout({ children }: DashboardLayoutProps) {
return ( return (
<DashboardAuthGuard> <DashboardAuthGuard>
<DashboardShell>{children}</DashboardShell> <DashboardShell>{children}</DashboardShell>
</DashboardAuthGuard> </DashboardAuthGuard>
); );
} }
@@ -1,5 +1,5 @@
import { NodesPage } from '@/features/nodes/components/nodes-page'; import { NodesPage } from '@/features/nodes/components/nodes-page';
export default function NodesRoute() { export default function NodesRoute() {
return <NodesPage />; return <NodesPage />;
} }
@@ -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>
); );
@@ -1,5 +1,5 @@
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page'; import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
export default function ProxyRoutesRoute() { export default function ProxyRoutesRoute() {
return <ProxyRoutesPage />; return <ProxyRoutesPage />;
} }
@@ -1,5 +1,5 @@
import { SettingsPage } from '@/features/settings/components/settings-page'; import { SettingsPage } from '@/features/settings/components/settings-page';
export default function SettingsRoute() { export default function SettingsRoute() {
return <SettingsPage />; return <SettingsPage />;
} }
@@ -1,5 +1,5 @@
import { UsersPage } from '@/features/users/components/users-page'; import { UsersPage } from '@/features/users/components/users-page';
export default function UsersRoute() { export default function UsersRoute() {
return <UsersPage />; return <UsersPage />;
} }
@@ -1,5 +1,5 @@
import { AboutPage } from '@/features/about/components/about-page'; import { AboutPage } from '@/features/about/components/about-page';
export default function AboutRoute() { export default function AboutRoute() {
return <AboutPage />; return <AboutPage />;
} }
+11 -11
View File
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PublicShell } from '@/components/layout/public-shell'; import { PublicShell } from '@/components/layout/public-shell';
interface PublicLayoutProps { interface PublicLayoutProps {
children: ReactNode; children: ReactNode;
} }
export default function PublicLayout({ children }: PublicLayoutProps) { export default function PublicLayout({ children }: PublicLayoutProps) {
return <PublicShell>{children}</PublicShell>; return <PublicShell>{children}</PublicShell>;
} }
@@ -1,12 +1,12 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { LoginForm } from '@/features/auth/components/login-form'; import { LoginForm } from '@/features/auth/components/login-form';
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<LoginForm /> <LoginForm />
</Suspense> </Suspense>
); );
} }
@@ -1,12 +1,12 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback'; import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback';
export default function GithubOAuthPage() { export default function GithubOAuthPage() {
return ( return (
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<GitHubOAuthCallback /> <GitHubOAuthCallback />
</Suspense> </Suspense>
); );
} }
@@ -1,12 +1,12 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow'; import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<PasswordResetFlow /> <PasswordResetFlow />
</Suspense> </Suspense>
); );
} }
@@ -1,12 +1,12 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow'; import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
export default function LegacyResetPasswordPage() { export default function LegacyResetPasswordPage() {
return ( return (
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<PasswordResetFlow /> <PasswordResetFlow />
</Suspense> </Suspense>
); );
} }
+155 -150
View File
@@ -1,173 +1,178 @@
@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 *));
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html { html {
background: var(--surface-canvas); background: var(--surface-canvas);
} }
html[data-theme='light'] { html[data-theme='light'] {
color-scheme: light; color-scheme: light;
} }
html[data-theme='dark'] { html[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
} }
body { body {
min-height: 100vh; min-height: 100vh;
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,
sans-serif; ui-sans-serif,
transition: system-ui,
background 0.2s ease, -apple-system,
color 0.2s ease; BlinkMacSystemFont,
} 'Segoe UI',
sans-serif;
transition:
background 0.2s ease,
color 0.2s ease;
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
button { button {
cursor: pointer; cursor: pointer;
} }
::selection { ::selection {
background: var(--selection-background); background: var(--selection-background);
color: var(--selection-foreground); color: var(--selection-foreground);
} }
@theme inline { @theme inline {
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
+42 -38
View File
@@ -1,38 +1,42 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Script from 'next/script'; import Script from 'next/script';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers'; 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: {
default: 'OpenFlare 控制台', default: 'OpenFlare 控制台',
template: '%s | OpenFlare', template: '%s | OpenFlare',
}, },
description: 'OpenFlare 管理端', description: 'OpenFlare 管理端',
applicationName: 'OpenFlare', applicationName: 'OpenFlare',
}; };
interface RootLayoutProps { interface RootLayoutProps {
children: ReactNode; children: ReactNode;
} }
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
<body> lang="zh-CN"
<Script id='theme-init' strategy='beforeInteractive'> suppressHydrationWarning
{getThemeInitScript()} className={cn('font-sans', geist.variable)}
</Script> >
<AppProviders>{children}</AppProviders> <body>
</body> <Script id="theme-init" strategy="beforeInteractive">
</html> {getThemeInitScript()}
); </Script>
} <AppProviders>{children}</AppProviders>
</body>
</html>
);
}
+24 -18
View File
@@ -1,18 +1,24 @@
import Link from 'next/link'; import Link from 'next/link';
import { AppCard } from '@/components/ui/app-card'; 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="页面不存在"
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p> description="当前请求的页面尚未接入新版管理端路由。"
<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'> >
返回总览 <div className="space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]">
</Link> <p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
</div> <Link
</AppCard> href="/"
</main> className="inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90"
); >
} 返回总览
</Link>
</div>
</AppCard>
</main>
);
}
@@ -31,101 +31,102 @@ function getChartValue(params: unknown) {
} }
export function RankChart({ export function RankChart({
items, items,
color, color,
valueFormatter = defaultFormatter, valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据', emptyMessage = '暂无分布数据',
}: RankChartProps) { }: RankChartProps) {
const option = useMemo<EChartsOption>( const option = useMemo<EChartsOption>(
() => ({ () => ({
animationDuration: 400, animationDuration: 400,
grid: { grid: {
left: 16, left: 16,
right: 24, right: 24,
top: 12, top: 12,
bottom: 12, bottom: 12,
containLabel: true, containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
}, },
tooltip: { backgroundColor: 'rgba(15, 23, 42, 0.92)',
trigger: 'axis', borderWidth: 0,
axisPointer: { textStyle: {
type: 'shadow', color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const item = Array.isArray(params) ? params[0] : params;
const data = item as { name?: string; value?: number };
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
},
},
xAxis: {
type: 'value',
axisLabel: {
color: '#94a3b8',
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
}, },
backgroundColor: 'rgba(15, 23, 42, 0.92)', },
borderWidth: 0, },
textStyle: { yAxis: {
type: 'category',
data: items.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#cbd5e1',
width: 120,
overflow: 'truncate',
},
},
series: [
{
type: 'bar',
data: items.map((item) => item.value),
barWidth: 12,
showBackground: true,
backgroundStyle: {
color: 'rgba(148, 163, 184, 0.12)',
borderRadius: 999,
},
itemStyle: {
color,
borderRadius: 999,
},
label: {
show: true,
position: 'right',
color: '#e2e8f0', color: '#e2e8f0',
fontSize: 12, formatter: (params: unknown) =>
}, valueFormatter(getChartValue(params)),
formatter: (params: unknown) => {
const item = Array.isArray(params) ? params[0] : params;
const data = item as { name?: string; value?: number };
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
}, },
}, },
xAxis: { ],
type: 'value', }),
axisLabel: { [color, items, valueFormatter],
color: '#94a3b8',
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
yAxis: {
type: 'category',
data: items.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#cbd5e1',
width: 120,
overflow: 'truncate',
},
},
series: [
{
type: 'bar',
data: items.map((item) => item.value),
barWidth: 12,
showBackground: true,
backgroundStyle: {
color: 'rgba(148, 163, 184, 0.12)',
borderRadius: 999,
},
itemStyle: {
color,
borderRadius: 999,
},
label: {
show: true,
position: 'right',
color: '#e2e8f0',
formatter: (params: unknown) => valueFormatter(getChartValue(params)),
},
},
],
}),
[color, items, valueFormatter],
); );
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]"> <div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
{emptyMessage} {emptyMessage}
</div> </div>
); );
} }
return ( return (
<ReactECharts <ReactECharts
option={option} option={option}
notMerge notMerge
lazyUpdate lazyUpdate
style={{ height: Math.max(220, items.length * 44), width: '100%' }} style={{ height: Math.max(220, items.length * 44), width: '100%' }}
/> />
); );
} }
@@ -32,15 +32,15 @@ type TooltipParam = {
const defaultFormatter = (value: number) => formatCompactNumber(value); const defaultFormatter = (value: number) => formatCompactNumber(value);
export function TrendChart({ export function TrendChart({
labels, labels,
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(
series.flatMap((item) => item.values), series.flatMap((item) => item.values),
); );
return { return {
@@ -70,17 +70,17 @@ export function TrendChart({
const header = items[0]?.axisValueLabel ?? ''; const header = items[0]?.axisValueLabel ?? '';
const rows = items.map((item) => { const rows = items.map((item) => {
const matchedSeries = series.find( const matchedSeries = series.find(
(seriesItem) => seriesItem.label === item.seriesName, (seriesItem) => seriesItem.label === item.seriesName,
); );
const formatter = const formatter =
matchedSeries?.valueFormatter ?? matchedSeries?.valueFormatter ??
yAxisValueFormatter ?? yAxisValueFormatter ??
defaultFormatter; defaultFormatter;
const rawValue = Array.isArray(item.value) const rawValue = Array.isArray(item.value)
? item.value[1] ? item.value[1]
: item.value; : item.value;
const numericValue = const numericValue =
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0); typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
return [ return [
'<span style="display:inline-flex;align-items:center;gap:8px;">', '<span style="display:inline-flex;align-items:center;gap:8px;">',
@@ -145,11 +145,11 @@ export function TrendChart({
color: item.color, color: item.color,
}, },
areaStyle: areaStyle:
item.variant === 'area' item.variant === 'area'
? { ? {
color: item.fillColor ?? `${item.color}33`, color: item.fillColor ?? `${item.color}33`,
} }
: undefined, : undefined,
emphasis: { emphasis: {
focus: 'series', focus: 'series',
scale: true, scale: true,
@@ -161,48 +161,48 @@ export function TrendChart({
if (labels.length === 0 || series.length === 0) { if (labels.length === 0 || series.length === 0) {
return ( return (
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]"> <div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
暂无趋势数据 暂无趋势数据
</div> </div>
); );
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{series.map((item) => { {series.map((item) => {
const latestValue = item.values[item.values.length - 1] ?? 0; const latestValue = item.values[item.values.length - 1] ?? 0;
const formatter = item.valueFormatter ?? defaultFormatter; const formatter = item.valueFormatter ?? defaultFormatter;
return ( return (
<div <div
key={item.label} key={item.label}
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3" className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="h-2.5 w-2.5 rounded-full" className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color }} style={{ backgroundColor: item.color }}
/> />
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase"> <p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{item.label} {item.label}
</p> </p>
</div> </div>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]"> <p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{formatter(latestValue)} {formatter(latestValue)}
</p> </p>
</div> </div>
); );
})} })}
</div>
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height, width: '100%' }}
/>
</div>
</div> </div>
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height, width: '100%' }}
/>
</div>
</div>
); );
} }
@@ -1,17 +1,19 @@
interface EmptyStateProps { interface EmptyStateProps {
title: string; title: string;
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}
</div> </p>
); ) : null}
} </div>
);
}
@@ -1,13 +1,17 @@
interface ErrorStateProps { interface ErrorStateProps {
title: string; title: string;
description: string; description: string;
} }
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}
</div> </p>
); <p className="mt-2 leading-6 text-[var(--status-danger-foreground)]/80">
} {description}
</p>
</div>
);
}
@@ -1,31 +1,31 @@
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';
interface FeaturePlaceholderProps { interface FeaturePlaceholderProps {
title: string; title: string;
description: string; description: string;
milestones: string[]; milestones: string[];
} }
export function FeaturePlaceholder({ export function FeaturePlaceholder({
title, title,
description, description,
milestones, milestones,
}: FeaturePlaceholderProps) { }: FeaturePlaceholderProps) {
return ( return (
<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>
))} ))}
</ul> </ul>
</AppCard> </AppCard>
); );
} }
@@ -1,35 +1,35 @@
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
type InlineMessageTone = 'info' | 'success' | 'danger'; type InlineMessageTone = 'info' | 'success' | 'danger';
const toneClasses: Record<InlineMessageTone, string> = { const toneClasses: Record<InlineMessageTone, string> = {
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]', info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
success: success:
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]', 'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
danger: danger:
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]', 'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
}; };
interface InlineMessageProps { interface InlineMessageProps {
tone?: InlineMessageTone; tone?: InlineMessageTone;
message: string; message: string;
className?: string; className?: string;
} }
export function InlineMessage({ export function InlineMessage({
tone = 'info', tone = 'info',
message, message,
className, className,
}: InlineMessageProps) { }: InlineMessageProps) {
return ( return (
<div <div
className={cn( className={cn(
'rounded-2xl border px-4 py-3 text-sm leading-6', 'rounded-2xl border px-4 py-3 text-sm leading-6',
toneClasses[tone], toneClasses[tone],
className, className,
)} )}
> >
{message} {message}
</div> </div>
); );
} }
@@ -1,15 +1,15 @@
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
interface LoadingStateProps { interface LoadingStateProps {
className?: string; className?: string;
} }
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>
); );
} }
@@ -1,96 +1,101 @@
'use client'; 'use client';
import { useEffect, useId, useRef } from 'react'; import { useEffect, useId, useRef } from 'react';
declare global { declare global {
interface Window { interface Window {
turnstile?: { turnstile?: {
render: ( render: (
container: string | HTMLElement, container: string | HTMLElement,
options: { options: {
sitekey: string; sitekey: string;
callback: (token: string) => void; callback: (token: string) => void;
'expired-callback'?: () => void; 'expired-callback'?: () => void;
'error-callback'?: () => void; 'error-callback'?: () => void;
theme?: 'auto' | 'light' | 'dark'; theme?: 'auto' | 'light' | 'dark';
}, },
) => string; ) => string;
remove: (widgetId: string) => void; remove: (widgetId: string) => void;
reset: (widgetId?: string) => void; reset: (widgetId?: string) => void;
}; };
} }
} }
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 {
siteKey: string; interface TurnstileWidgetProps {
onVerify: (token: string) => void; siteKey: string;
onExpire?: () => void; onVerify: (token: string) => void;
onError?: () => void; onExpire?: () => void;
} onError?: () => void;
}
export function TurnstileWidget({
siteKey, export function TurnstileWidget({
onVerify, siteKey,
onExpire, onVerify,
onError, onExpire,
}: TurnstileWidgetProps) { onError,
const containerRef = useRef<HTMLDivElement | null>(null); }: TurnstileWidgetProps) {
const widgetIdRef = useRef<string | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const elementId = useId().replace(/:/g, '-'); const widgetIdRef = useRef<string | null>(null);
const elementId = useId().replace(/:/g, '-');
useEffect(() => {
let cancelled = false; useEffect(() => {
let cancelled = false;
const mountWidget = () => {
if (cancelled || !containerRef.current || !window.turnstile) { const mountWidget = () => {
return; if (cancelled || !containerRef.current || !window.turnstile) {
} return;
}
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current); if (widgetIdRef.current) {
widgetIdRef.current = null; window.turnstile.remove(widgetIdRef.current);
} widgetIdRef.current = null;
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey, widgetIdRef.current = window.turnstile.render(containerRef.current, {
callback: onVerify, sitekey: siteKey,
'expired-callback': onExpire, callback: onVerify,
'error-callback': onError, 'expired-callback': onExpire,
theme: 'auto', 'error-callback': onError,
}); theme: 'auto',
}; });
};
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
const existingScript = document.getElementById(
if (window.turnstile) { TURNSTILE_SCRIPT_ID,
mountWidget(); ) as HTMLScriptElement | null;
} else if (existingScript) {
existingScript.addEventListener('load', mountWidget); if (window.turnstile) {
} else { mountWidget();
const script = document.createElement('script'); } else if (existingScript) {
script.id = TURNSTILE_SCRIPT_ID; existingScript.addEventListener('load', mountWidget);
script.src = TURNSTILE_SCRIPT_SRC; } else {
script.async = true; const script = document.createElement('script');
script.defer = true; script.id = TURNSTILE_SCRIPT_ID;
script.addEventListener('load', mountWidget); script.src = TURNSTILE_SCRIPT_SRC;
document.head.appendChild(script); script.async = true;
} script.defer = true;
script.addEventListener('load', mountWidget);
return () => { document.head.appendChild(script);
cancelled = true; }
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
if (script) { return () => {
script.removeEventListener('load', mountWidget); cancelled = true;
} const script = document.getElementById(
if (widgetIdRef.current && window.turnstile) { TURNSTILE_SCRIPT_ID,
window.turnstile.remove(widgetIdRef.current); ) as HTMLScriptElement | null;
widgetIdRef.current = null; if (script) {
} script.removeEventListener('load', mountWidget);
}; }
}, [onError, onExpire, onVerify, siteKey]); if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
return <div id={elementId} ref={containerRef} className='min-h-16' />; widgetIdRef.current = null;
} }
};
}, [onError, onExpire, onVerify, siteKey]);
return <div id={elementId} ref={containerRef} className="min-h-16" />;
}
@@ -1,20 +1,20 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar'; import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
import { DashboardTopbar } from '@/components/layout/dashboard-topbar'; import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
interface DashboardShellProps { interface DashboardShellProps {
children: ReactNode; children: ReactNode;
} }
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,26 +1,30 @@
import type {ReactNode} from 'react'; import type { ReactNode } from 'react';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
description?: string; description?: string;
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>
{description ? ( <div className="space-y-2">
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'> <h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
{description} {title}
</p> </h1>
) : null} {description ? (
</div> <p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
</div> {description}
{action ? <div className='flex flex-wrap gap-3'>{action}</div> : null} </p>
) : null}
</div> </div>
); </div>
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
</div>
);
} }
@@ -2,39 +2,47 @@ import Link from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { publicEnv } from '@/lib/env/public-env'; import { publicEnv } from '@/lib/env/public-env';
interface PublicShellProps { interface PublicShellProps {
children: ReactNode; children: ReactNode;
} }
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)]">
</div> OpenFlare
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'> </p>
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'> </div>
{publicEnv.appVersion} <div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
</span> <span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
</div> {publicEnv.appVersion}
</div> </span>
</div>
<div className='space-y-6'>{children}</div> </div>
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'> <div className="space-y-6">{children}</div>
<div className='flex flex-wrap gap-4'>
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'> <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">
</Link> <Link
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'> href="/"
关于 className="text-[var(--brand-primary)] transition hover:opacity-80"
</Link> >
</div> 返回
</div> </Link>
</div> <Link
</div> href="/about"
); className="text-[var(--brand-primary)] transition hover:opacity-80"
} >
关于
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -1,37 +1,37 @@
'use client'; 'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type ReactNode, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { AuthProvider } from '@/components/providers/auth-provider'; import { AuthProvider } from '@/components/providers/auth-provider';
import { ThemeProvider } from '@/components/providers/theme-provider'; import { ThemeProvider } from '@/components/providers/theme-provider';
interface AppProvidersProps { interface AppProvidersProps {
children: ReactNode; children: ReactNode;
} }
export function AppProviders({ children }: AppProvidersProps) { export function AppProviders({ children }: AppProvidersProps) {
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1, retry: 1,
staleTime: 30_000, staleTime: 30_000,
}, },
mutations: { mutations: {
retry: 0, retry: 0,
}, },
}, },
}), }),
); );
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<AuthProvider>{children}</AuthProvider> <AuthProvider>{children}</AuthProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
@@ -1,89 +1,92 @@
'use client'; 'use client';
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth'; import {
import type { AuthUser } from '@/types/auth'; logout as logoutRequest,
getCurrentUser,
interface AuthContextValue { } from '@/features/auth/api/auth';
user: AuthUser | null; import type { AuthUser } from '@/types/auth';
isLoading: boolean;
isAuthenticated: boolean; interface AuthContextValue {
refreshUser: () => Promise<AuthUser | null>; user: AuthUser | null;
setUser: (user: AuthUser | null) => void; isLoading: boolean;
logout: () => Promise<void>; isAuthenticated: boolean;
} refreshUser: () => Promise<AuthUser | null>;
setUser: (user: AuthUser | null) => void;
const AuthContext = createContext<AuthContextValue | null>(null); logout: () => Promise<void>;
}
interface AuthProviderProps {
children: ReactNode; const AuthContext = createContext<AuthContextValue | null>(null);
}
interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) { children: ReactNode;
const [user, setUserState] = useState<AuthUser | null>(null); }
const [isLoading, setIsLoading] = useState(true);
export function AuthProvider({ children }: AuthProviderProps) {
const refreshUser = useCallback(async () => { const [user, setUserState] = useState<AuthUser | null>(null);
try { const [isLoading, setIsLoading] = useState(true);
const nextUser = await getCurrentUser();
setUserState(nextUser); const refreshUser = useCallback(async () => {
return nextUser; try {
} catch { const nextUser = await getCurrentUser();
setUserState(null); setUserState(nextUser);
return null; return nextUser;
} finally { } catch {
setIsLoading(false); setUserState(null);
} return null;
}, []); } finally {
setIsLoading(false);
useEffect(() => { }
void refreshUser(); }, []);
}, [refreshUser]);
useEffect(() => {
const setUser = useCallback((nextUser: AuthUser | null) => { void refreshUser();
setUserState(nextUser); }, [refreshUser]);
setIsLoading(false);
}, []); const setUser = useCallback((nextUser: AuthUser | null) => {
setUserState(nextUser);
const logout = useCallback(async () => { setIsLoading(false);
try { }, []);
await logoutRequest();
} finally { const logout = useCallback(async () => {
setUserState(null); try {
setIsLoading(false); await logoutRequest();
} } finally {
}, []); setUserState(null);
setIsLoading(false);
const value = useMemo( }
() => ({ }, []);
user,
isLoading, const value = useMemo(
isAuthenticated: user !== null, () => ({
refreshUser, user,
setUser, isLoading,
logout, isAuthenticated: user !== null,
}), refreshUser,
[isLoading, logout, refreshUser, setUser, user], setUser,
); logout,
}),
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; [isLoading, logout, refreshUser, setUser, user],
} );
export function useAuth() { return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
const context = useContext(AuthContext); }
if (!context) { export function useAuth() {
throw new Error('useAuth must be used within AuthProvider'); const context = useContext(AuthContext);
}
if (!context) {
return context; throw new Error('useAuth must be used within AuthProvider');
} }
return context;
}
@@ -1,107 +1,109 @@
'use client'; 'use client';
import { import {
createContext, createContext,
type ReactNode, type ReactNode,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { import {
isThemeMode, isThemeMode,
resolveTheme, resolveTheme,
THEME_MEDIA_QUERY, THEME_MEDIA_QUERY,
THEME_STORAGE_KEY, THEME_STORAGE_KEY,
type ResolvedTheme, type ResolvedTheme,
type ThemeMode, type ThemeMode,
} from '@/lib/theme/theme'; } from '@/lib/theme/theme';
interface ThemeContextValue { interface ThemeContextValue {
themeMode: ThemeMode; themeMode: ThemeMode;
resolvedTheme: ResolvedTheme; resolvedTheme: ResolvedTheme;
setThemeMode: (mode: ThemeMode) => void; setThemeMode: (mode: ThemeMode) => void;
} }
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
interface ThemeProviderProps { interface ThemeProviderProps {
children: ReactNode; children: ReactNode;
} }
function getSystemPreference() { function getSystemPreference() {
return window.matchMedia(THEME_MEDIA_QUERY).matches; return window.matchMedia(THEME_MEDIA_QUERY).matches;
} }
function applyTheme(mode: ThemeMode) { function applyTheme(mode: ThemeMode) {
const resolvedTheme = resolveTheme(mode, getSystemPreference()); const resolvedTheme = resolveTheme(mode, getSystemPreference());
const root = document.documentElement; const root = document.documentElement;
root.dataset.themeMode = mode; root.dataset.themeMode = mode;
root.dataset.theme = resolvedTheme; root.dataset.theme = resolvedTheme;
root.style.colorScheme = resolvedTheme; root.style.colorScheme = resolvedTheme;
window.localStorage.setItem(THEME_STORAGE_KEY, mode); window.localStorage.setItem(THEME_STORAGE_KEY, mode);
return resolvedTheme; return resolvedTheme;
} }
export function ThemeProvider({ children }: ThemeProviderProps) { export function ThemeProvider({ children }: ThemeProviderProps) {
const [themeMode, setThemeModeState] = useState<ThemeMode>('system'); const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark'); const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
const domThemeMode = root.dataset.themeMode; const domThemeMode = root.dataset.themeMode;
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system'; const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
const nextResolvedTheme = applyTheme(nextThemeMode); const nextResolvedTheme = applyTheme(nextThemeMode);
setThemeModeState(nextThemeMode); setThemeModeState(nextThemeMode);
setResolvedTheme(nextResolvedTheme); setResolvedTheme(nextResolvedTheme);
}, []); }, []);
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY); const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
const handleChange = () => { const handleChange = () => {
if (themeMode !== 'system') { if (themeMode !== 'system') {
return; return;
} }
setResolvedTheme(applyTheme('system')); setResolvedTheme(applyTheme('system'));
}; };
mediaQuery.addEventListener('change', handleChange); mediaQuery.addEventListener('change', handleChange);
return () => { return () => {
mediaQuery.removeEventListener('change', handleChange); mediaQuery.removeEventListener('change', handleChange);
}; };
}, [themeMode]); }, [themeMode]);
const setThemeMode = useCallback((mode: ThemeMode) => { const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode); setThemeModeState(mode);
setResolvedTheme(applyTheme(mode)); setResolvedTheme(applyTheme(mode));
}, []); }, []);
const value = useMemo( const value = useMemo(
() => ({ () => ({
themeMode, themeMode,
resolvedTheme, resolvedTheme,
setThemeMode, setThemeMode,
}), }),
[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() { }
const context = useContext(ThemeContext);
export function useTheme() {
if (!context) { const context = useContext(ThemeContext);
throw new Error('useTheme must be used within ThemeProvider');
} if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
return context; }
}
return context;
}
+47 -41
View File
@@ -1,41 +1,47 @@
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
interface AppCardProps extends HTMLAttributes<HTMLDivElement> { interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
title?: string; title?: string;
description?: string; description?: string;
action?: ReactNode; action?: ReactNode;
} }
export function AppCard({ export function AppCard({
title, title,
description, description,
action, action,
className, className,
children, children,
...props ...props
}: AppCardProps) { }: AppCardProps) {
return ( return (
<section <section
className={cn( className={cn(
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur', 'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
className, className,
)} )}
{...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 ? (
{description ? ( <h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p> {title}
) : null} </h2>
</div> ) : null}
{action ? <div className='shrink-0'>{action}</div> : null} {description ? (
</header> <p className="text-sm leading-6 text-[var(--foreground-secondary)]">
)} {description}
<div className='px-6 py-5'>{children}</div> </p>
</section> ) : null}
); </div>
} {action ? <div className="shrink-0">{action}</div> : null}
</header>
)}
<div className="px-6 py-5">{children}</div>
</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}
+12 -13
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'> <label
>(({ className, ...props }, ref) => ( ref={ref}
<label className={cn(
ref={ref} 'text-sm leading-none font-medium text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className={cn( className,
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70', )}
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)]',
@@ -1,103 +1,134 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { marked } from 'marked'; import { marked } from 'marked';
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 { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public'; import { getPublicStatus } from '@/features/auth/api/public';
import { getAboutContent } from '@/features/settings/api/settings'; import { getAboutContent } from '@/features/settings/api/settings';
import { formatDateTime } from '@/lib/utils/date'; import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; return error instanceof Error ? error.message : '请求失败,请稍后重试。';
} }
export function AboutPage() { export function AboutPage() {
const aboutQuery = useQuery({ const aboutQuery = useQuery({
queryKey: ['about'], queryKey: ['about'],
queryFn: getAboutContent, queryFn: getAboutContent,
}); });
const statusQuery = useQuery({ const statusQuery = useQuery({
queryKey: ['public-status'], queryKey: ['public-status'],
queryFn: getPublicStatus, queryFn: getPublicStatus,
}); });
if (aboutQuery.isLoading || statusQuery.isLoading) { if (aboutQuery.isLoading || statusQuery.isLoading) {
return <LoadingState />; return <LoadingState />;
} }
if (aboutQuery.isError) { if (aboutQuery.isError) {
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />; return (
} <ErrorState
title="关于页加载失败"
if (statusQuery.isError) { description={getErrorMessage(aboutQuery.error)}
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />; />
} );
}
const aboutContent = aboutQuery.data?.trim() ?? '';
const status = statusQuery.data; if (statusQuery.isError) {
return (
if (!status) { <ErrorState
return <EmptyState title='暂无系统信息' description='未能获取 OpenFlare 的公开状态信息。' />; title="系统状态加载失败"
} description={getErrorMessage(statusQuery.error)}
/>
return ( );
<div className='space-y-6'> }
<AppCard
title='关于 OpenFlare' const aboutContent = aboutQuery.data?.trim() ?? '';
description='公开展示当前系统简介、版本信息与项目入口。' const status = statusQuery.data;
action={<StatusBadge label={status.version || 'dev'} variant='info' />}
> if (!status) {
<div className='grid gap-4 md:grid-cols-3'> return (
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'> <EmptyState
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p> title="暂无系统信息"
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'OpenFlare'}</p> description="未能获取 OpenFlare 的公开状态信息。"
</div> />
<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='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>
{formatDateTime(new Date(status.start_time * 1000))} return (
</p> <div className="space-y-6">
</div> <AppCard
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'> title="关于 OpenFlare"
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>项目仓库</p> description="公开展示当前系统简介、版本信息与项目入口。"
<a action={<StatusBadge label={status.version || 'dev'} variant="info" />}
href='https://github.com/Rain-kl/OpenFlare' >
target='_blank' <div className="grid gap-4 md:grid-cols-3">
rel='noreferrer' <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80' <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
> 系统名称
github.com/Rain-kl/OpenFlare </p>
</a> <p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
</div> {status.system_name || 'OpenFlare'}
</div> </p>
</AppCard> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
{aboutContent ? ( <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'> Server 启动时间
<div </p>
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]' <p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }} {formatDateTime(new Date(status.start_time * 1000))}
/> </p>
</AppCard> </div>
) : ( <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<EmptyState <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
title='尚未配置关于内容' 项目仓库
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。' </p>
/> <a
)} href="https://github.com/Rain-kl/OpenFlare"
target="_blank"
<div className='flex flex-wrap gap-3 text-sm'> rel="noreferrer"
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'> className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
前往登录 >
</Link> github.com/Rain-kl/OpenFlare
</div> </a>
</div> </div>
); </div>
} </AppCard>
{aboutContent ? (
<AppCard
title="项目介绍"
description="以下内容由系统设置中的“关于内容”维护。"
>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(aboutContent) as string,
}}
/>
</AppCard>
) : (
<EmptyState
title="尚未配置关于内容"
description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
/>
)}
<div className="flex flex-wrap gap-3 text-sm">
<Link
href="/login"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
前往登录
</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,6 +1,6 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { PublicStatus } from '@/types/public-status'; import type { PublicStatus } from '@/types/public-status';
export function getPublicStatus() { export function getPublicStatus() {
return apiRequest<PublicStatus>('/status'); return apiRequest<PublicStatus>('/status');
} }
@@ -1,63 +1,73 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react'; import type {
ButtonHTMLAttributes,
import { cn } from '@/lib/utils/cn'; InputHTMLAttributes,
ReactNode,
export function AuthFormField({ } from 'react';
label,
hint, import { cn } from '@/lib/utils/cn';
children,
}: { export function AuthFormField({
label: string; label,
hint?: string; hint,
children: ReactNode; children,
}) { }: {
return ( label: string;
<label className='block space-y-2'> hint?: string;
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</span> children: ReactNode;
{children} }) {
{hint ? <span className='block text-xs text-[var(--foreground-secondary)]'>{hint}</span> : null} return (
</label> <label className="block space-y-2">
); <span className="text-sm font-medium text-[var(--foreground-primary)]">
} {label}
</span>
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) { {children}
return ( {hint ? (
<input <span className="block text-xs text-[var(--foreground-secondary)]">
{...props} {hint}
className={cn( </span>
'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)]', ) : null}
props.className, </label>
)} );
/> }
);
} export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
return (
export function AuthButton({ <input
className, {...props}
...props className={cn(
}: ButtonHTMLAttributes<HTMLButtonElement>) { '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)]',
return ( props.className,
<button )}
{...props} />
className={cn( );
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60', }
className,
)} export function AuthButton({
/> className,
); ...props
} }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
export function SecondaryButton({ <button
className, {...props}
...props className={cn(
}: ButtonHTMLAttributes<HTMLButtonElement>) { 'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
return ( className,
<button )}
{...props} />
className={cn( );
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60', }
className,
)} export function SecondaryButton({
/> className,
); ...props
} }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={cn(
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
/>
);
}
@@ -1,35 +1,38 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider'; import { useAuth } from '@/components/providers/auth-provider';
interface DashboardAuthGuardProps { interface DashboardAuthGuardProps {
children: ReactNode; children: ReactNode;
} }
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) { export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
const redirect = pathname && pathname !== '/' ? `?redirect=${encodeURIComponent(pathname)}` : ''; const redirect =
router.replace(`/login${redirect}`); pathname && pathname !== '/'
} ? `?redirect=${encodeURIComponent(pathname)}`
}, [isAuthenticated, isLoading, pathname, router]); : '';
router.replace(`/login${redirect}`);
if (isLoading || !isAuthenticated) { }
return ( }, [isAuthenticated, isLoading, pathname, router]);
<main className='flex min-h-screen items-center justify-center px-4 py-8'>
<LoadingState className='w-full max-w-md' /> if (isLoading || !isAuthenticated) {
</main> return (
); <main className="flex min-h-screen items-center justify-center px-4 py-8">
} <LoadingState className="w-full max-w-md" />
</main>
return <>{children}</>; );
} }
return <>{children}</>;
}
@@ -1,44 +1,53 @@
'use client'; 'use client';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
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 { 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 { exchangeGitHubCode } from '@/features/auth/api/auth'; import { exchangeGitHubCode } from '@/features/auth/api/auth';
export function GitHubOAuthCallback() { export function GitHubOAuthCallback() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
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({
mutationFn: exchangeGitHubCode, mutationFn: exchangeGitHubCode,
onSuccess: (user) => { onSuccess: (user) => {
setUser(user); setUser(user);
setMessage({ tone: 'success', text: '登录成功,正在跳转...' }); setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
router.replace('/'); router.replace('/');
}, },
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({
return; tone: 'danger',
} text: '未收到 GitHub 授权参数,请返回登录页重试。',
});
return;
}
if (handledCodeRef.current === code) { if (handledCodeRef.current === code) {
return; return;
} }
@@ -46,13 +55,15 @@ export function GitHubOAuthCallback() {
handledCodeRef.current = code; handledCodeRef.current = code;
mutation.mutate(code); mutation.mutate(code);
}, [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 ? (
</div> <InlineMessage tone={message.tone} message={message.text} />
</AppCard> ) : null}
); </div>
} </AppCard>
);
}
@@ -1,82 +1,106 @@
'use client'; 'use client';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { resetPassword } from '@/features/auth/api/auth'; import { resetPassword } from '@/features/auth/api/auth';
import { import {
AuthButton, AuthButton,
AuthFormField, AuthFormField,
AuthInput, AuthInput,
SecondaryButton, SecondaryButton,
} from '@/features/auth/components/auth-form-primitives'; } from '@/features/auth/components/auth-form-primitives';
export function PasswordResetConfirmForm() { 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';
const mutation = useMutation({ text: string;
mutationFn: () => resetPassword({ email, token }), } | null>(null);
onSuccess: async (password) => {
try { const mutation = useMutation({
await navigator.clipboard.writeText(password); mutationFn: () => resetPassword({ email, token }),
setMessage({ tone: 'success', text: `密码已重置,新密码已复制到剪贴板:${password}` }); onSuccess: async (password) => {
} catch { try {
setMessage({ tone: 'success', text: `密码已重置:${password}` }); await navigator.clipboard.writeText(password);
} setMessage({
}, tone: 'success',
onError: (error: Error) => { text: `密码已重置,新密码已复制到剪贴板:${password}`,
setMessage({ tone: 'danger', text: error.message || '密码重置失败,请重新获取链接。' }); });
}, } catch {
}); setMessage({ tone: 'success', text: `密码已重置:${password}` });
}
const missingParams = !email || !token; },
onError: (error: Error) => {
return ( setMessage({
<AppCard title='密码重置确认' description='确认后,系统会生成新的随机密码。'> tone: 'danger',
<div className='space-y-4'> text: error.message || '密码重置失败,请重新获取链接。',
<AuthFormField label='邮箱地址'> });
<AuthInput value={email} readOnly /> },
</AuthFormField> });
{missingParams ? ( const missingParams = !email || !token;
<InlineMessage tone='danger' message='重置链接缺少必要参数,请重新发起密码重置。' />
) : null} return (
<AppCard
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} title="密码重置确认"
description="确认后,系统会生成新的随机密码。"
<div className='flex flex-col gap-3 sm:flex-row'> >
<AuthButton type='button' disabled={missingParams || mutation.isPending} onClick={() => mutation.mutate()}> <div className="space-y-4">
{mutation.isPending ? '处理中...' : '确认重置密码'} <AuthFormField label="邮箱地址">
</AuthButton> <AuthInput value={email} readOnly />
{message?.tone === 'success' ? ( </AuthFormField>
<SecondaryButton
type='button' {missingParams ? (
onClick={async () => { <InlineMessage
const password = message.text.split(':').pop() || ''; tone="danger"
if (password) { message="重置链接缺少必要参数,请重新发起密码重置。"
await navigator.clipboard.writeText(password); />
} ) : null}
}}
> {message ? (
再次复制密码 <InlineMessage tone={message.tone} message={message.text} />
</SecondaryButton> ) : null}
) : null}
</div> <div className="flex flex-col gap-3 sm:flex-row">
<AuthButton
<div className='text-sm text-[var(--foreground-secondary)]'> type="button"
处理完成后可返回 disabled={missingParams || mutation.isPending}
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'> onClick={() => mutation.mutate()}
登录页 >
</Link> {mutation.isPending ? '处理中...' : '确认重置密码'}
</div> </AuthButton>
</div> {message?.tone === 'success' ? (
</AppCard> <SecondaryButton
); type="button"
} onClick={async () => {
const password = message.text.split(':').pop() || '';
if (password) {
await navigator.clipboard.writeText(password);
}
}}
>
再次复制密码
</SecondaryButton>
) : null}
</div>
<div className="text-sm text-[var(--foreground-secondary)]">
处理完成后可返回
<Link
href="/login"
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
>
登录页
</Link>
</div>
</div>
</AppCard>
);
}
@@ -1,13 +1,19 @@
'use client'; 'use client';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form'; import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form'; import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
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 />
);
}
@@ -1,101 +1,120 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget'; import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { sendPasswordResetEmail } from '@/features/auth/api/auth'; import { sendPasswordResetEmail } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public'; import { getPublicStatus } from '@/features/auth/api/public';
import { import {
AuthButton, AuthButton,
AuthFormField, AuthFormField,
AuthInput, AuthInput,
} from '@/features/auth/components/auth-form-primitives'; } from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard'; import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const resetRequestSchema = z.object({ const resetRequestSchema = z.object({
email: z.string().email('请输入有效邮箱地址'), email: z.string().email('请输入有效邮箱地址'),
}); });
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>; 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';
const form = useForm<ResetRequestFormValues>({ text: string;
resolver: zodResolver(resetRequestSchema), } | null>(null);
defaultValues: { email: '' },
}); const form = useForm<ResetRequestFormValues>({
resolver: zodResolver(resetRequestSchema),
const statusQuery = useQuery({ defaultValues: { email: '' },
queryKey: ['public-status'], });
queryFn: getPublicStatus,
}); const statusQuery = useQuery({
queryKey: ['public-status'],
const mutation = useMutation({ queryFn: getPublicStatus,
mutationFn: (values: ResetRequestFormValues) => });
sendPasswordResetEmail(values.email, turnstileToken || undefined),
onSuccess: () => { const mutation = useMutation({
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' }); mutationFn: (values: ResetRequestFormValues) =>
form.reset(); sendPasswordResetEmail(values.email, turnstileToken || undefined),
}, onSuccess: () => {
onError: (error: Error) => { setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' }); form.reset();
}, },
}); onError: (error: Error) => {
setMessage({
const handleSubmit = form.handleSubmit((values) => { tone: 'danger',
setMessage(null); text: error.message || '重置邮件发送失败,请稍后重试。',
if (statusQuery.data?.turnstile_check && !turnstileToken) { });
setMessage({ tone: 'info', text: '请先完成人机验证。' }); },
return; });
}
mutation.mutate(values); const handleSubmit = form.handleSubmit((values) => {
}); setMessage(null);
if (statusQuery.data?.turnstile_check && !turnstileToken) {
return ( setMessage({ tone: 'info', text: '请先完成人机验证。' });
<PublicAuthGuard> return;
<AppCard title='密码重置' description='提交后,系统会向你的注册邮箱发送重置链接。'> }
<form className='space-y-4' onSubmit={handleSubmit}> mutation.mutate(values);
<AuthFormField label='邮箱地址'> });
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
{form.formState.errors.email ? ( return (
<span className='text-xs text-[var(--status-danger-foreground)]'> <PublicAuthGuard>
{form.formState.errors.email.message} <AppCard
</span> title="密码重置"
) : null} description="提交后,系统会向你的注册邮箱发送重置链接。"
</AuthFormField> >
<form className="space-y-4" onSubmit={handleSubmit}>
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? ( <AuthFormField label="邮箱地址">
<TurnstileWidget <AuthInput
siteKey={statusQuery.data.turnstile_site_key} type="email"
onVerify={(token) => setTurnstileToken(token)} placeholder="请输入邮箱地址"
onExpire={() => setTurnstileToken('')} {...form.register('email')}
onError={() => setTurnstileToken('')} />
/> {form.formState.errors.email ? (
) : null} <span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.email.message}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null} </span>
) : null}
<AuthButton type='submit' disabled={mutation.isPending}> </AuthFormField>
{mutation.isPending ? '提交中...' : '发送重置邮件'}
</AuthButton> {statusQuery.data?.turnstile_check &&
</form> statusQuery.data.turnstile_site_key ? (
<TurnstileWidget
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'> siteKey={statusQuery.data.turnstile_site_key}
想起密码了? onVerify={(token) => setTurnstileToken(token)}
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'> onExpire={() => setTurnstileToken('')}
返回登录 onError={() => setTurnstileToken('')}
</Link> />
</div> ) : null}
</AppCard>
</PublicAuthGuard> {message ? (
); <InlineMessage tone={message.tone} message={message.text} />
} ) : null}
<AuthButton type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '提交中...' : '发送重置邮件'}
</AuthButton>
</form>
<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>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -1,35 +1,35 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider'; import { useAuth } from '@/components/providers/auth-provider';
interface PublicAuthGuardProps { interface PublicAuthGuardProps {
children: ReactNode; children: ReactNode;
} }
export function PublicAuthGuard({ children }: PublicAuthGuardProps) { export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
useEffect(() => { useEffect(() => {
if (!isLoading && isAuthenticated) { if (!isLoading && isAuthenticated) {
const redirect = searchParams?.get('redirect'); const redirect = searchParams?.get('redirect');
router.replace(redirect || '/'); router.replace(redirect || '/');
} }
}, [isAuthenticated, isLoading, router, searchParams]); }, [isAuthenticated, isLoading, router, searchParams]);
if (isLoading) { if (isLoading) {
return <LoadingState />; return <LoadingState />;
} }
if (isAuthenticated) { if (isAuthenticated) {
return null; return null;
} }
return <>{children}</>; return <>{children}</>;
} }
@@ -1,23 +1,23 @@
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder'; import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
interface PublicRoutePlaceholderProps { interface PublicRoutePlaceholderProps {
title: string; title: string;
description: string; description: string;
} }
export function PublicRoutePlaceholder({ export function PublicRoutePlaceholder({
title, title,
description, description,
}: PublicRoutePlaceholderProps) { }: PublicRoutePlaceholderProps) {
return ( return (
<FeaturePlaceholder <FeaturePlaceholder
title={title} title={title}
description={description} description={description}
milestones={[ milestones={[
'认证页路由入口已建立,便于阶段 2 接入真实表单。', '认证页路由入口已建立,便于阶段 2 接入真实表单。',
'公共布局已统一,可复用品牌区、说明区与回跳入口。', '公共布局已统一,可复用品牌区、说明区与回跳入口。',
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。', '后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
]} ]}
/> />
); );
} }
@@ -18,15 +18,15 @@ export function getConfigVersion(id: number) {
export function getActiveConfigVersion() { export function getActiveConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/active'); return apiRequest<ConfigVersionDetail>('/config-versions/active');
} }
export function getConfigVersionPreview() { export function getConfigVersionPreview() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview'); return apiRequest<ConfigPreviewResult>('/config-versions/preview');
} }
export function getConfigVersionDiff() { export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff'); return apiRequest<ConfigDiffResult>('/config-versions/diff');
} }
export function publishConfigVersion(force?: boolean) { export function publishConfigVersion(force?: boolean) {
const query = force ? '?force=true' : ''; const query = force ? '?force=true' : '';
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, { return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
@@ -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);
} }
@@ -137,8 +137,8 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? ' WS 已连接' ? ' WS 已连接'
: node.last_seen_at : node.last_seen_at
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` ? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'} : ' 暂无'}
</p> </p>
</div> </div>
</Link> </Link>
@@ -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>
@@ -119,19 +142,31 @@ export function DnsAccountsPage() {
)} )}
</AppCard> </AppCard>
</div> </div>
{isCreateOpen && ( {isCreateOpen && (
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => { <DnsAccountCreateModal
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' }); isOpen={isCreateOpen}
setIsCreateOpen(false); onClose={() => setIsCreateOpen(false)}
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] }); onCreated={() => {
}} /> setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
setIsCreateOpen(false);
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),
@@ -64,9 +64,9 @@ export function requestNodeAgentUpdate(
} }
export function requestNodeForceSync(id: number) { export function requestNodeForceSync(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, { return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
method: 'POST', method: 'POST',
}); });
} }
export function requestNodeOpenrestyRestart(id: number) { export function requestNodeOpenrestyRestart(id: number) {
@@ -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
@@ -354,10 +354,10 @@ export function NodesPage() {
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at) : isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime( ? `${formatRelativeTime(
node.last_seen_at, node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}` )} · ${formatDateTime(node.last_seen_at)}`
: '暂无'} : '暂无'}
</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">
+195 -195
View File
@@ -1,188 +1,188 @@
import type { ReleaseChannel } from '@/features/update/types'; import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem { export interface NodeItem {
id: number; id: number;
node_id: string; node_id: string;
name: string; name: string;
ip: string; ip: string;
geo_name: string; geo_name: string;
geo_latitude?: number | null; geo_latitude?: number | null;
geo_longitude?: number | null; geo_longitude?: number | null;
geo_manual_override: boolean; geo_manual_override: boolean;
agent_token: string; agent_token: string;
auto_update_enabled: boolean; auto_update_enabled: boolean;
update_requested: boolean; update_requested: boolean;
update_channel: ReleaseChannel; update_channel: ReleaseChannel;
update_tag: string; update_tag: string;
restart_openresty_requested: boolean; restart_openresty_requested: boolean;
agent_version: string; agent_version: string;
nginx_version: string; nginx_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown'; openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string; openresty_message: string;
status: 'online' | 'offline' | 'pending'; status: 'online' | 'offline' | 'pending';
current_version: string; current_version: string;
last_seen_at: string; last_seen_at: string;
last_error: string; last_error: string;
latest_apply_result: 'success' | 'warning' | 'failed' | ''; latest_apply_result: 'success' | 'warning' | 'failed' | '';
latest_apply_message: string; latest_apply_message: string;
latest_apply_checksum: string; latest_apply_checksum: string;
latest_main_config_checksum: string; latest_main_config_checksum: string;
latest_route_config_checksum: string; latest_route_config_checksum: string;
latest_support_file_count: number; latest_support_file_count: number;
latest_apply_at?: string | null; latest_apply_at?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface NodeBootstrapToken { export interface NodeBootstrapToken {
discovery_token: string; discovery_token: string;
} }
export interface NodeMutationPayload { export interface NodeMutationPayload {
name: string; name: string;
ip: string; ip: string;
auto_update_enabled: boolean; auto_update_enabled: boolean;
geo_name: string; geo_name: string;
geo_latitude?: number | null; geo_latitude?: number | null;
geo_longitude?: number | null; geo_longitude?: number | null;
geo_manual_override: boolean; geo_manual_override: boolean;
} }
export interface NodeAgentReleaseInfo { export interface NodeAgentReleaseInfo {
tag_name: string; tag_name: string;
body: string; body: string;
html_url: string; html_url: string;
published_at: string; published_at: string;
current_version: string; current_version: string;
has_update: boolean; has_update: boolean;
channel: ReleaseChannel; channel: ReleaseChannel;
prerelease: boolean; prerelease: boolean;
update_requested: boolean; update_requested: boolean;
requested_channel: ReleaseChannel; requested_channel: ReleaseChannel;
requested_tag: string; requested_tag: string;
} }
export interface NodeAgentUpdatePayload { export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel; channel?: ReleaseChannel;
tag_name?: string; tag_name?: string;
} }
export interface NodeSystemProfile { export interface NodeSystemProfile {
hostname: string; hostname: string;
os_name: string; os_name: string;
os_version: string; os_version: string;
kernel_version: string; kernel_version: string;
architecture: string; architecture: string;
cpu_model: string; cpu_model: string;
cpu_cores: number; cpu_cores: number;
total_memory_bytes: number; total_memory_bytes: number;
total_disk_bytes: number; total_disk_bytes: number;
uptime_seconds: number; uptime_seconds: number;
reported_at: string; reported_at: string;
} }
export interface NodeMetricSnapshot { export interface NodeMetricSnapshot {
captured_at: string; captured_at: string;
cpu_usage_percent: number; cpu_usage_percent: number;
memory_used_bytes: number; memory_used_bytes: number;
memory_total_bytes: number; memory_total_bytes: number;
storage_used_bytes: number; storage_used_bytes: number;
storage_total_bytes: number; storage_total_bytes: number;
disk_read_bytes: number; disk_read_bytes: number;
disk_write_bytes: number; disk_write_bytes: number;
network_rx_bytes: number; network_rx_bytes: number;
network_tx_bytes: number; network_tx_bytes: number;
openresty_rx_bytes: number; openresty_rx_bytes: number;
openresty_tx_bytes: number; openresty_tx_bytes: number;
openresty_connections: number; openresty_connections: number;
} }
export interface NodeTrafficReport { export interface NodeTrafficReport {
window_started_at: string; window_started_at: string;
window_ended_at: string; window_ended_at: string;
request_count: number; request_count: number;
error_count: number; error_count: number;
unique_visitor_count: number; unique_visitor_count: number;
status_codes_json: string; status_codes_json: string;
top_domains_json: string; top_domains_json: string;
source_countries_json: string; source_countries_json: string;
} }
export interface NodeTrafficTrendPoint { export interface NodeTrafficTrendPoint {
bucket_started_at: string; bucket_started_at: string;
request_count: number; request_count: number;
error_count: number; error_count: number;
unique_visitor_count: number; unique_visitor_count: number;
} }
export interface NodeCapacityTrendPoint { export interface NodeCapacityTrendPoint {
bucket_started_at: string; bucket_started_at: string;
average_cpu_usage_percent: number; average_cpu_usage_percent: number;
average_memory_usage_percent: number; average_memory_usage_percent: number;
reported_nodes: number; reported_nodes: number;
} }
export interface NodeNetworkTrendPoint { export interface NodeNetworkTrendPoint {
bucket_started_at: string; bucket_started_at: string;
network_rx_bytes: number; network_rx_bytes: number;
network_tx_bytes: number; network_tx_bytes: number;
openresty_rx_bytes: number; openresty_rx_bytes: number;
openresty_tx_bytes: number; openresty_tx_bytes: number;
reported_nodes: number; reported_nodes: number;
} }
export interface NodeDiskIOTrendPoint { export interface NodeDiskIOTrendPoint {
bucket_started_at: string; bucket_started_at: string;
disk_read_bytes: number; disk_read_bytes: number;
disk_write_bytes: number; disk_write_bytes: number;
reported_nodes: number; reported_nodes: number;
} }
export interface NodeDistributionItem { export interface NodeDistributionItem {
key: string; key: string;
value: number; value: number;
} }
export interface NodeTrafficDistributions { export interface NodeTrafficDistributions {
status_codes: NodeDistributionItem[]; status_codes: NodeDistributionItem[];
top_domains: NodeDistributionItem[]; top_domains: NodeDistributionItem[];
source_countries: NodeDistributionItem[]; source_countries: NodeDistributionItem[];
} }
export interface NodeTrafficSummary { export interface NodeTrafficSummary {
window_started_at: string; window_started_at: string;
window_ended_at: string; window_ended_at: string;
request_count: number; request_count: number;
unique_visitor_count: number; unique_visitor_count: number;
error_count: number; error_count: number;
estimated_qps: number; estimated_qps: number;
error_rate_percent: number; error_rate_percent: number;
} }
export interface NodeHealthSummary { export interface NodeHealthSummary {
active_alerts: number; active_alerts: number;
critical_alerts: number; critical_alerts: number;
warning_alerts: number; warning_alerts: number;
info_alerts: number; info_alerts: number;
resolved_alerts: number; resolved_alerts: number;
has_capacity_risk: boolean; has_capacity_risk: boolean;
has_traffic_risk: boolean; has_traffic_risk: boolean;
has_runtime_risk: boolean; has_runtime_risk: boolean;
} }
export interface NodeObservabilityAnalytics { export interface NodeObservabilityAnalytics {
traffic: NodeTrafficSummary; traffic: NodeTrafficSummary;
distributions: NodeTrafficDistributions; distributions: NodeTrafficDistributions;
health: NodeHealthSummary; health: NodeHealthSummary;
} }
export interface NodeObservabilityTrends { export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[]; traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[]; capacity_24h: NodeCapacityTrendPoint[];
network_24h: NodeNetworkTrendPoint[]; network_24h: NodeNetworkTrendPoint[];
disk_io_24h: NodeDiskIOTrendPoint[]; disk_io_24h: NodeDiskIOTrendPoint[];
} }
export interface NodeHealthEvent { export interface NodeHealthEvent {
event_type: string; event_type: string;
severity: string; severity: string;
@@ -194,13 +194,13 @@ export interface NodeHealthEvent {
reported_at: string; reported_at: string;
resolved_at?: string | null; resolved_at?: string | null;
} }
export interface NodeObservability { export interface NodeObservability {
node_id: string; node_id: string;
profile: NodeSystemProfile | null; profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[]; metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[]; traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[]; health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics; analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends; trends: NodeObservabilityTrends;
} }
+194 -189
View File
@@ -1,189 +1,194 @@
import type { NodeItem } from '@/features/nodes/types'; import type { NodeItem } from '@/features/nodes/types';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__'; export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export function isWSConnectedLastSeen(value: string | null | undefined) { export function isWSConnectedLastSeen(value: string | null | undefined) {
return value === WS_CONNECTED_LAST_SEEN; return value === WS_CONNECTED_LAST_SEEN;
} }
export function isMeaningfulTime(value: string | null | undefined) { export function isMeaningfulTime(value: string | null | undefined) {
return ( return (
Boolean(value) && Boolean(value) &&
!isWSConnectedLastSeen(value) && !isWSConnectedLastSeen(value) &&
!String(value).startsWith('0001-01-01') !String(value).startsWith('0001-01-01')
); );
} }
export function getNodeStatusVariant(status: NodeItem['status']) { export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') { if (status === 'online') {
return 'success'; return 'success';
} }
if (status === 'pending') { if (status === 'pending') {
return 'warning'; return 'warning';
} }
return 'danger'; return 'danger';
} }
export function getNodeStatusLabel(status: NodeItem['status']) { export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') { if (status === 'online') {
return '在线'; return '在线';
} }
if (status === 'pending') { if (status === 'pending') {
return '待接入'; return '待接入';
} }
return '离线'; return '离线';
} }
export function getApplyVariant(result: NodeItem['latest_apply_result']) { export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') { if (result === 'success') {
return 'success'; return 'success';
} }
if (result === 'warning') { if (result === 'warning') {
return 'warning'; return 'warning';
} }
if (result === 'failed') { if (result === 'failed') {
return 'danger'; return 'danger';
} }
return 'warning'; return 'warning';
} }
export function getApplyLabel(result: NodeItem['latest_apply_result']) { export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') { if (result === 'success') {
return '成功'; return '成功';
} }
if (result === 'warning') { if (result === 'warning') {
return '警告'; return '警告';
} }
if (result === 'failed') { if (result === 'failed') {
return '失败'; return '失败';
} }
return '暂无'; return '暂无';
} }
export function getUpdateMode(node: NodeItem) { export function getUpdateMode(node: NodeItem) {
if (node.update_requested) { if (node.update_requested) {
if (node.update_channel === 'preview') { if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const }; return { label: '等待预览更新', variant: 'warning' as const };
} }
return { label: '等待更新', variant: 'warning' as const }; return { label: '等待更新', variant: 'warning' as const };
} }
if (node.auto_update_enabled) { if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const }; return { label: '自动', variant: 'success' as const };
} }
return { label: '手动', variant: 'info' as const }; return { label: '手动', variant: 'info' as const };
} }
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) { export function getOpenrestyStatusVariant(
if (status === 'healthy') { status: NodeItem['openresty_status'],
return 'success'; ) {
} if (status === 'healthy') {
return 'success';
if (status === 'unhealthy') { }
return 'danger';
} if (status === 'unhealthy') {
return 'danger';
return 'warning'; }
}
return 'warning';
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) { }
if (status === 'healthy') {
return '健康'; export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
} if (status === 'healthy') {
return '健康';
if (status === 'unhealthy') { }
return '异常';
} if (status === 'unhealthy') {
return '异常';
return '未知'; }
}
return '未知';
function parseVersionParts(version: string) { }
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') { function parseVersionParts(version: string) {
return null; const normalized = version.trim().replace(/^v/i, '');
} if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
return normalized.split('.').map((segment) => { }
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0; return normalized.split('.').map((segment) => {
}); const matched = segment.trim().match(/^\d+/);
} return matched ? Number.parseInt(matched[0], 10) : 0;
});
function isOlderVersion(current: string, target: string) { }
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target); function isOlderVersion(current: string, target: string) {
if (!currentParts || !targetParts) { const currentParts = parseVersionParts(current);
return false; const targetParts = parseVersionParts(target);
} if (!currentParts || !targetParts) {
return false;
const maxLength = Math.max(currentParts.length, targetParts.length); }
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0; const maxLength = Math.max(currentParts.length, targetParts.length);
const targetPart = targetParts[index] ?? 0; for (let index = 0; index < maxLength; index += 1) {
if (currentPart < targetPart) { const currentPart = currentParts[index] ?? 0;
return true; const targetPart = targetParts[index] ?? 0;
} if (currentPart < targetPart) {
if (currentPart > targetPart) { return true;
return false; }
} if (currentPart > targetPart) {
} return false;
}
return false; }
}
return false;
export function shouldShowManualUpdate( }
agentVersion: string,
serverVersion: string, export function shouldShowManualUpdate(
) { agentVersion: string,
const normalizedServerVersion = serverVersion.trim(); serverVersion: string,
const normalizedAgentVersion = agentVersion.trim(); ) {
const normalizedServerVersion = serverVersion.trim();
if ( const normalizedAgentVersion = agentVersion.trim();
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' || if (
!normalizedAgentVersion || !normalizedServerVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown' normalizedServerVersion.toLowerCase() === 'dev' ||
) { !normalizedAgentVersion ||
return false; normalizedAgentVersion.toLowerCase() === 'unknown'
} ) {
return false;
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion); }
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
export function getServerUrl(value: string) { }
return value.trim().replace(/\/+$/, '');
} export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
const installerScriptUrl = }
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
const installerScriptUrl =
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) { 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`, export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
` --server-url ${serverUrl} \\`, return [
` --agent-token ${agentToken}`, `curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
].join('\n'); ` --server-url ${serverUrl} \\`,
} ` --agent-token ${agentToken}`,
].join('\n');
export function buildNodeDockerInstallCommand(serverUrl: string, agentToken: string) { }
return [
`docker run -d --name openflare-agent --restart unless-stopped \\`, export function buildNodeDockerInstallCommand(
` -p 80:80 -p 443:443 \\`, serverUrl: string,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`, agentToken: string,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`, ) {
` ghcr.io/rain-kl/openflare-agent:latest` return [
].join('\n'); `docker run -d --name openflare-agent --restart unless-stopped \\`,
} ` -p 80:80 -p 443:443 \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ghcr.io/rain-kl/openflare-agent:latest`,
].join('\n');
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,12 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { import type {
ManagedDomainMatchResult, ManagedDomainMatchResult,
ProxyRouteItem, ProxyRouteItem,
ProxyRouteMutationPayload, ProxyRouteMutationPayload,
TlsCertificateItem, TlsCertificateItem,
} from '@/features/proxy-routes/types'; } from '@/features/proxy-routes/types';
export function getProxyRoutes() { export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/'); return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
} }
@@ -17,12 +17,15 @@ export function getProxyRoute(id: number) {
export function createProxyRoute(payload: ProxyRouteMutationPayload) { export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', { return apiRequest<ProxyRouteItem>('/proxy-routes/', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
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),
@@ -34,12 +37,14 @@ export function deleteProxyRoute(id: number) {
method: 'POST', method: 'POST',
}); });
} }
export function getTlsCertificates() { export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/'); return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
} }
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} 个认证源。
@@ -1,37 +1,43 @@
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 { FeaturePlaceholder } from '@/components/feedback/feature-placeholder'; import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
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';
interface ModulePageSkeletonProps { interface ModulePageSkeletonProps {
title: string; title: string;
description: string; description: string;
} }
export function ModulePageSkeleton({ export function ModulePageSkeleton({
title, title,
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={[
'路由入口已创建,可纳入统一导航。', '路由入口已创建,可纳入统一导航。',
'页面标题区、说明区与内容容器已统一。', '页面标题区、说明区与内容容器已统一。',
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。', '加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
]} ]}
/> />
<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="空态骨架"
</div> description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
</div> />
); <ErrorState
} title="错误态骨架"
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
/>
</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,489 +1,477 @@
'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,
UploadedServerBinaryInfo, UploadedServerBinaryInfo,
} from '@/features/update/types'; } from '@/features/update/types';
import { import {
PrimaryButton, PrimaryButton,
ResourceField, ResourceField,
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;
onClose: () => void; onClose: () => void;
currentVersion: string; currentVersion: string;
release: LatestReleaseInfo | null | undefined; release: LatestReleaseInfo | null | undefined;
selectedChannel: ReleaseChannel; selectedChannel: ReleaseChannel;
uploadedBinary: UploadedServerBinaryInfo | null; uploadedBinary: UploadedServerBinaryInfo | null;
isLoading: boolean; isLoading: boolean;
releaseErrorMessage?: string; releaseErrorMessage?: string;
manualStatusMessage?: string; manualStatusMessage?: string;
manualErrorMessage?: string; manualErrorMessage?: string;
canUpgrade: boolean; canUpgrade: boolean;
isChecking: boolean; isChecking: boolean;
isUpgrading: boolean; isUpgrading: boolean;
isUploadingBinary: boolean; isUploadingBinary: boolean;
uploadProgress: number; uploadProgress: number;
isConfirmingManualUpgrade: boolean; isConfirmingManualUpgrade: boolean;
onChannelChange: (channel: ReleaseChannel) => void; onChannelChange: (channel: ReleaseChannel) => void;
onCheck: () => void; onCheck: () => void;
onUpgrade: () => void; onUpgrade: () => void;
onUploadBinary: (file: File) => void; onUploadBinary: (file: File) => void;
onConfirmManualUpgrade: () => void; onConfirmManualUpgrade: () => void;
} }
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) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return '0000-00-00 00:00:00.000'; return '0000-00-00 00:00:00.000';
} }
const year = date.getFullYear(); const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0'); const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0'); const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0'); const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0'); const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0'); const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0'); const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
} }
function formatLogLevel(level: string) { function formatLogLevel(level: string) {
const normalized = (level || 'info').toUpperCase(); const normalized = (level || 'info').toUpperCase();
return normalized.padEnd(8, ' '); return normalized.padEnd(8, ' ');
} }
export function VersionUpgradeModal({ export function VersionUpgradeModal({
isOpen, isOpen,
onClose, onClose,
currentVersion, currentVersion,
release, release,
selectedChannel, selectedChannel,
uploadedBinary, uploadedBinary,
isLoading, isLoading,
releaseErrorMessage, releaseErrorMessage,
manualStatusMessage, manualStatusMessage,
manualErrorMessage, manualErrorMessage,
canUpgrade, canUpgrade,
isChecking, isChecking,
isUpgrading, isUpgrading,
isUploadingBinary, isUploadingBinary,
uploadProgress, uploadProgress,
isConfirmingManualUpgrade, isConfirmingManualUpgrade,
onChannelChange, onChannelChange,
onCheck, onCheck,
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 =
: '上传中...'; selectedChannel === 'preview' ? '预览版' : '正式版';
const selectedChannelLabel = const toggleChannel = () => {
selectedChannel === 'preview' ? '预览版' : '正式版'; onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
const toggleChannel = () => { };
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview'); const canConfirmManualUpgrade = Boolean(
}; uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
const canConfirmManualUpgrade = Boolean( );
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token, const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
); const upgradeLogs = release?.upgrade_logs ?? [];
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
const upgradeLogs = release?.upgrade_logs ?? [];
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setSelectedBinary(null); setSelectedBinary(null);
} }
}, [isOpen]); }, [isOpen]);
return ( return (
<AppModal <AppModal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title="版本" title="版本"
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。" description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
size="lg" size="lg"
> >
<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)]"> {currentVersion || 'unknown'}
{currentVersion || 'unknown'} </p>
</p> <StatusBadge
<StatusBadge label={upgradeBadge.label}
label={upgradeBadge.label} variant={upgradeBadge.variant}
variant={upgradeBadge.variant} />
/>
</div>
</AppCard>
<AppCard title="最新版本">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{release?.tag_name || '未检查'}
</p>
{canUpgrade ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={selectedChannelLabel}
variant={
selectedChannel === 'preview' ? 'warning' : 'info'
}
onClick={toggleChannel}
disabled={isChecking || isUpgrading || isUploadingBinary}
/>
</div>
</div>
) : null}
</div>
<SecondaryButton
type="button"
onClick={onCheck}
disabled={isChecking || isUpgrading || isUploadingBinary}
className="w-full md:w-auto"
>
{isChecking ? '检查中...' : '检查'}
</SecondaryButton>
</div>
</AppCard>
</div>
{isLoading ? <LoadingState/> : null}
{!isLoading && releaseErrorMessage ? (
<ErrorState title="版本检查失败" description={releaseErrorMessage}/>
) : null}
{!isLoading && !releaseErrorMessage && !release ? (
<EmptyState
title={`尚未检查${selectedChannelLabel}`}
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
/>
) : null}
{!isLoading && !releaseErrorMessage && release ? (
<AppCard
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
description={
release.published_at
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '未提供发布时间'
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={release.has_update ? '发现新版本' : '已经是最新版本'}
variant={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<StatusBadge label="Preview 发布" variant="warning"/>
) : (
<StatusBadge label="正式发布" variant="info"/>
)}
{!release.upgrade_supported ? (
<StatusBadge
label="当前平台不支持自动升级"
variant="danger"
/>
) : null}
{release.in_progress ? (
<StatusBadge label="升级任务执行中" variant="warning"/>
) : null}
</div>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(
release.body || '暂无更新说明',
) as string,
}}
/>
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
查看发布详情
</a>
{canUpgrade ? (
<div className="flex justify-end">
<PrimaryButton
type="button"
onClick={onUpgrade}
disabled={
!release.has_update ||
release.in_progress ||
isUpgrading ||
!release.upgrade_supported ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUpgrading
? '升级中...'
: release.in_progress
? '升级中...'
: selectedChannel === 'preview'
? '升级预览版'
: '升级正式版'}
</PrimaryButton>
</div>
) : null}
</div>
</AppCard>
) : null}
{release ? (
<AppCard
title="升级日志"
>
{upgradeLogs.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
release.upgrade_status === 'failed'
? '升级失败'
: release.upgrade_status === 'succeeded'
? '准备重启'
: release.in_progress
? '升级中'
: '空闲'
}
variant={
release.upgrade_status === 'failed'
? 'danger'
: release.in_progress
? 'warning'
: release.upgrade_status === 'succeeded'
? 'success'
: 'info'
}
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
{upgradeLogs.map((log, index) => (
<pre
key={`${log.created_at}-${index}`}
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
>
<span className="text-slate-400">
{formatLogTimestamp(log.created_at)}
</span>
<span className="text-slate-500"> | </span>
<span className="text-cyan-300">
{formatLogLevel(log.level)}
</span>
<span className="text-slate-500"> | </span>
<span className="whitespace-pre-wrap break-all text-slate-100">
{log.message}
</span>
</pre>
))}
</div>
</div>
) : (
<EmptyState
title="暂无升级日志"
/>
)}
</AppCard>
) : null}
{canUpgrade ? (
<AppCard
title="手动升级"
>
<div className="space-y-4">
<ResourceField
label="服务端二进制"
hint="支持上传已编译好的当前平台 Server 可执行文件。"
>
<ResourceInput
type="file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedBinary(file);
}}
disabled={isUploadingBinary || isConfirmingManualUpgrade}
/>
</ResourceField>
<div className="flex justify-end">
{showConfirmManualUpgradeAction ? (
<PrimaryButton
type="button"
onClick={onConfirmManualUpgrade}
disabled={
!canConfirmManualUpgrade ||
isConfirmingManualUpgrade ||
isUploadingBinary ||
isUpgrading
}
>
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
</PrimaryButton>
) : (
<PrimaryButton
type="button"
onClick={() => {
if (selectedBinary) {
onUploadBinary(selectedBinary);
}
}}
disabled={
!selectedBinary ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUploadingBinary
? uploadProgress >= 100
? '服务端校验中...'
: '上传中...'
: '上传并检查'}
</PrimaryButton>
)}
</div>
{isUploadingBinary ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
<span>{uploadPhaseLabel}</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
style={{width: `${uploadProgress}%`}}
/>
</div>
</div>
) : null}
{manualErrorMessage ? (
<ErrorState
title="手动升级检查失败"
description={manualErrorMessage}
/>
) : null}
{!manualErrorMessage && manualStatusMessage ? (
<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)]">
{manualStatusMessage}
</div>
) : null}
{uploadedBinary ? (
<div
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">
<StatusBadge
label={
uploadedBinary.ready_to_upgrade
? '可确认升级'
: uploadedBinary.has_update
? '待确认'
: '不可升级'
}
variant={
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
}
/>
{!uploadedBinary.upgrade_supported ? (
<StatusBadge
label="当前版本不支持手动升级"
variant="danger"
/>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
文件名
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.file_name}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传时间
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.uploaded_at
? formatDateTime(uploadedBinary.uploaded_at)
: '未知'}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
当前版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.current_version}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.detected_version}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{uploadedBinary.comparison_message}
</p>
</div>
) : (
<EmptyState
title="尚未上传升级包"
/>
)}
</div>
</AppCard>
) : null}
</div> </div>
</AppModal> </AppCard>
);
<AppCard title="最新版本">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{release?.tag_name || '未检查'}
</p>
{canUpgrade ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={selectedChannelLabel}
variant={
selectedChannel === 'preview' ? 'warning' : 'info'
}
onClick={toggleChannel}
disabled={
isChecking || isUpgrading || isUploadingBinary
}
/>
</div>
</div>
) : null}
</div>
<SecondaryButton
type="button"
onClick={onCheck}
disabled={isChecking || isUpgrading || isUploadingBinary}
className="w-full md:w-auto"
>
{isChecking ? '检查中...' : '检查'}
</SecondaryButton>
</div>
</AppCard>
</div>
{isLoading ? <LoadingState /> : null}
{!isLoading && releaseErrorMessage ? (
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
) : null}
{!isLoading && !releaseErrorMessage && !release ? (
<EmptyState
title={`尚未检查${selectedChannelLabel}`}
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
/>
) : null}
{!isLoading && !releaseErrorMessage && release ? (
<AppCard
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
description={
release.published_at
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '未提供发布时间'
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={release.has_update ? '发现新版本' : '已经是最新版本'}
variant={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<StatusBadge label="Preview 发布" variant="warning" />
) : (
<StatusBadge label="正式发布" variant="info" />
)}
{!release.upgrade_supported ? (
<StatusBadge
label="当前平台不支持自动升级"
variant="danger"
/>
) : null}
{release.in_progress ? (
<StatusBadge label="升级任务执行中" variant="warning" />
) : null}
</div>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(
release.body || '暂无更新说明',
) as string,
}}
/>
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
查看发布详情
</a>
{canUpgrade ? (
<div className="flex justify-end">
<PrimaryButton
type="button"
onClick={onUpgrade}
disabled={
!release.has_update ||
release.in_progress ||
isUpgrading ||
!release.upgrade_supported ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUpgrading
? '升级中...'
: release.in_progress
? '升级中...'
: selectedChannel === 'preview'
? '升级预览版'
: '升级正式版'}
</PrimaryButton>
</div>
) : null}
</div>
</AppCard>
) : null}
{release ? (
<AppCard title="升级日志">
{upgradeLogs.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
release.upgrade_status === 'failed'
? '升级失败'
: release.upgrade_status === 'succeeded'
? '准备重启'
: release.in_progress
? '升级中'
: '空闲'
}
variant={
release.upgrade_status === 'failed'
? 'danger'
: release.in_progress
? 'warning'
: release.upgrade_status === 'succeeded'
? 'success'
: 'info'
}
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
{upgradeLogs.map((log, index) => (
<pre
key={`${log.created_at}-${index}`}
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
>
<span className="text-slate-400">
{formatLogTimestamp(log.created_at)}
</span>
<span className="text-slate-500"> | </span>
<span className="text-cyan-300">
{formatLogLevel(log.level)}
</span>
<span className="text-slate-500"> | </span>
<span className="break-all whitespace-pre-wrap text-slate-100">
{log.message}
</span>
</pre>
))}
</div>
</div>
) : (
<EmptyState title="暂无升级日志" />
)}
</AppCard>
) : null}
{canUpgrade ? (
<AppCard title="手动升级">
<div className="space-y-4">
<ResourceField
label="服务端二进制"
hint="支持上传已编译好的当前平台 Server 可执行文件。"
>
<ResourceInput
type="file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedBinary(file);
}}
disabled={isUploadingBinary || isConfirmingManualUpgrade}
/>
</ResourceField>
<div className="flex justify-end">
{showConfirmManualUpgradeAction ? (
<PrimaryButton
type="button"
onClick={onConfirmManualUpgrade}
disabled={
!canConfirmManualUpgrade ||
isConfirmingManualUpgrade ||
isUploadingBinary ||
isUpgrading
}
>
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
</PrimaryButton>
) : (
<PrimaryButton
type="button"
onClick={() => {
if (selectedBinary) {
onUploadBinary(selectedBinary);
}
}}
disabled={
!selectedBinary ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUploadingBinary
? uploadProgress >= 100
? '服务端校验中...'
: '上传中...'
: '上传并检查'}
</PrimaryButton>
)}
</div>
{isUploadingBinary ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
<span>{uploadPhaseLabel}</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : null}
{manualErrorMessage ? (
<ErrorState
title="手动升级检查失败"
description={manualErrorMessage}
/>
) : null}
{!manualErrorMessage && manualStatusMessage ? (
<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)]">
{manualStatusMessage}
</div>
) : null}
{uploadedBinary ? (
<div 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">
<StatusBadge
label={
uploadedBinary.ready_to_upgrade
? '可确认升级'
: uploadedBinary.has_update
? '待确认'
: '不可升级'
}
variant={
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
}
/>
{!uploadedBinary.upgrade_supported ? (
<StatusBadge
label="当前版本不支持手动升级"
variant="danger"
/>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
文件名
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.file_name}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传时间
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.uploaded_at
? formatDateTime(uploadedBinary.uploaded_at)
: '未知'}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
当前版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.current_version}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.detected_version}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{uploadedBinary.comparison_message}
</p>
</div>
) : (
<EmptyState title="尚未上传升级包" />
)}
</div>
</AppCard>
) : null}
</div>
</AppModal>
);
} }
@@ -1,41 +1,43 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { import type {
ManageUserAction, ManageUserAction,
ManageUserResult, ManageUserResult,
UserItem, UserItem,
UserMutationPayload, UserMutationPayload,
} from '@/features/users/types'; } from '@/features/users/types';
export function getUsers(page: number) { export function getUsers(page: number) {
return apiRequest<UserItem[]>(`/user/?p=${page}`); return apiRequest<UserItem[]>(`/user/?p=${page}`);
} }
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) { }
return apiRequest<UserItem>(`/user/${id}`);
} export function getUser(id: number) {
return apiRequest<UserItem>(`/user/${id}`);
export function createUser(payload: UserMutationPayload) { }
return apiRequest<void>('/user/', {
method: 'POST', export function createUser(payload: UserMutationPayload) {
body: JSON.stringify(payload), return apiRequest<void>('/user/', {
}); method: 'POST',
} body: JSON.stringify(payload),
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) { export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/update', { return apiRequest<void>('/user/update', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export function manageUser(username: string, action: ManageUserAction) { export function manageUser(username: string, action: ManageUserAction) {
return apiRequest<ManageUserResult>('/user/manage', { return apiRequest<ManageUserResult>('/user/manage', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, action }), body: JSON.stringify({ username, action }),
}); });
} }
File diff suppressed because it is too large Load Diff
+29 -24
View File
@@ -1,24 +1,29 @@
export interface UserItem { export interface UserItem {
id: number; id: number;
username: string; username: string;
display_name: string; display_name: string;
role: number; role: number;
status: number; status: number;
email?: string; email?: string;
github_id?: string; github_id?: string;
wechat_id?: string; wechat_id?: string;
} }
export interface UserMutationPayload { export interface UserMutationPayload {
id?: number; id?: number;
username: string; username: string;
display_name: string; display_name: string;
password: string; password: string;
} }
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable'; export type ManageUserAction =
| 'promote'
export interface ManageUserResult { | 'demote'
role: number; | 'delete'
status: number; | 'disable'
} | 'enable';
export interface ManageUserResult {
role: number;
status: number;
}
File diff suppressed because it is too large Load Diff
@@ -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: '',
}; };
+13 -13
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
const publicEnvSchema = z.object({ const publicEnvSchema = z.object({
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'), NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'), NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
@@ -9,17 +9,17 @@ const rawEnv = {
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL, NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION, NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
}; };
const parsedEnv = publicEnvSchema.parse(rawEnv); const parsedEnv = publicEnvSchema.parse(rawEnv);
function normalizeBaseUrl(value: string) { function normalizeBaseUrl(value: string) {
if (value === '/') { if (value === '/') {
return ''; return '';
} }
return value.replace(/\/+$/, ''); return value.replace(/\/+$/, '');
} }
export const publicEnv = { export const publicEnv = {
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL), apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION, appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
+42 -40
View File
@@ -1,40 +1,42 @@
export const THEME_STORAGE_KEY = 'openflare-theme-mode'; export const THEME_STORAGE_KEY = 'openflare-theme-mode';
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)'; export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
export const themeModes = ['light', 'dark', 'system'] as const; 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(
return themeModes.includes(value as ThemeMode); value: string | null | undefined,
} ): value is ThemeMode {
return themeModes.includes(value as ThemeMode);
export function resolveTheme( }
mode: ThemeMode,
systemPrefersDark: boolean, export function resolveTheme(
): ResolvedTheme { mode: ThemeMode,
if (mode === 'system') { systemPrefersDark: boolean,
return systemPrefersDark ? 'dark' : 'light'; ): ResolvedTheme {
} if (mode === 'system') {
return systemPrefersDark ? 'dark' : 'light';
return mode; }
}
return mode;
export function getThemeInitScript() { }
return `(() => {
const storageKey = '${THEME_STORAGE_KEY}'; export function getThemeInitScript() {
const mediaQuery = '${THEME_MEDIA_QUERY}'; return `(() => {
const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system'; const storageKey = '${THEME_STORAGE_KEY}';
const storedValue = window.localStorage.getItem(storageKey); const mediaQuery = '${THEME_MEDIA_QUERY}';
const themeMode = isThemeMode(storedValue) ? storedValue : 'system'; const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system';
const resolvedTheme = themeMode === 'system' const storedValue = window.localStorage.getItem(storageKey);
? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light') const themeMode = isThemeMode(storedValue) ? storedValue : 'system';
: themeMode; const resolvedTheme = themeMode === 'system'
const root = document.documentElement; ? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light')
: themeMode;
root.dataset.themeMode = themeMode; const root = document.documentElement;
root.dataset.theme = resolvedTheme;
root.style.colorScheme = resolvedTheme; root.dataset.themeMode = themeMode;
})();`; root.dataset.theme = resolvedTheme;
} root.style.colorScheme = resolvedTheme;
})();`;
}
+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 -6
View File
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } 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));
} }
+76 -72
View File
@@ -1,72 +1,76 @@
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', { const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false, hour12: false,
}); });
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', { const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', {
numeric: 'auto', numeric: 'auto',
}); });
function toDate(value: string | Date | null | undefined) { function toDate(value: string | Date | null | undefined) {
if (!value) { if (!value) {
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;
return Number.isNaN(date.getTime()) ? null : date; const date =
} normalizedValue instanceof Date
? normalizedValue
export function formatDateTime(value: string | Date | null | undefined) { : new Date(normalizedValue);
const date = toDate(value); return Number.isNaN(date.getTime()) ? null : date;
}
if (!date) {
return '—'; export function formatDateTime(value: string | Date | null | undefined) {
} const date = toDate(value);
return dateTimeFormatter.format(date).replace(/\//g, '-'); if (!date) {
} return '—';
}
export function formatRelativeTime(value: string | Date | null | undefined) {
const date = toDate(value); return dateTimeFormatter.format(date).replace(/\//g, '-');
}
if (!date) {
return '—'; export function formatRelativeTime(value: string | Date | null | undefined) {
} const date = toDate(value);
const diffMs = date.getTime() - Date.now(); if (!date) {
const diffSeconds = Math.round(diffMs / 1000); return '—';
const absSeconds = Math.abs(diffSeconds); }
if (absSeconds < 60) { const diffMs = date.getTime() - Date.now();
return relativeTimeFormatter.format(diffSeconds, 'second'); const diffSeconds = Math.round(diffMs / 1000);
} const absSeconds = Math.abs(diffSeconds);
const diffMinutes = Math.round(diffSeconds / 60); if (absSeconds < 60) {
if (Math.abs(diffMinutes) < 60) { return relativeTimeFormatter.format(diffSeconds, 'second');
return relativeTimeFormatter.format(diffMinutes, 'minute'); }
}
const diffMinutes = Math.round(diffSeconds / 60);
const diffHours = Math.round(diffMinutes / 60); if (Math.abs(diffMinutes) < 60) {
if (Math.abs(diffHours) < 24) { return relativeTimeFormatter.format(diffMinutes, 'minute');
return relativeTimeFormatter.format(diffHours, 'hour'); }
}
const diffHours = Math.round(diffMinutes / 60);
const diffDays = Math.round(diffHours / 24); if (Math.abs(diffHours) < 24) {
if (Math.abs(diffDays) < 30) { return relativeTimeFormatter.format(diffHours, 'hour');
return relativeTimeFormatter.format(diffDays, 'day'); }
}
const diffDays = Math.round(diffHours / 24);
const diffMonths = Math.round(diffDays / 30); if (Math.abs(diffDays) < 30) {
if (Math.abs(diffMonths) < 12) { return relativeTimeFormatter.format(diffDays, 'day');
return relativeTimeFormatter.format(diffMonths, 'month'); }
}
const diffMonths = Math.round(diffDays / 30);
const diffYears = Math.round(diffMonths / 12); if (Math.abs(diffMonths) < 12) {
return relativeTimeFormatter.format(diffYears, 'year'); return relativeTimeFormatter.format(diffMonths, 'month');
} }
const diffYears = Math.round(diffMonths / 12);
return relativeTimeFormatter.format(diffYears, 'year');
}
+5 -5
View File
@@ -1,5 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+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',
],
}, },
}; };
+16 -16
View File
@@ -1,16 +1,16 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: './tests/e2e',
fullyParallel: true, fullyParallel: true,
use: { use: {
baseURL: 'http://127.0.0.1:3000', baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry', trace: 'on-first-retry',
}, },
projects: [ projects: [
{ {
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
], ],
}); });
+4761 -1626
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
}, },
}; };
+8 -8
View File
@@ -1,8 +1,8 @@
const config = { const config = {
singleQuote: true, singleQuote: true,
semi: true, semi: true,
trailingComma: 'all', trailingComma: 'all',
plugins: ['prettier-plugin-tailwindcss'], plugins: ['prettier-plugin-tailwindcss'],
}; };
export default config; export default config;
+25 -9
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(
proxyRes.pipe(res); createProxyRequestOptions(req, targetUrl),
}); (proxyRes) => {
res.writeHead(
proxyRes.statusCode || 502,
proxyRes.statusMessage,
proxyRes.headers,
);
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}`,
);
}); });

Some files were not shown because too many files have changed in this diff Show More