mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"ignorePatterns": [
|
||||
".next",
|
||||
"build",
|
||||
"out",
|
||||
"coverage",
|
||||
"playwright-report",
|
||||
"test-results",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# build outputs
|
||||
/.next
|
||||
/out
|
||||
/build
|
||||
/coverage
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# local env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.idea
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
*.db
|
||||
/tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,49 @@
|
||||
# OpenFlare Web
|
||||
|
||||
OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```shell
|
||||
corepack enable
|
||||
pnpm install
|
||||
|
||||
# 本地开发(3001 前端同源代理后端 HTTP/WS)
|
||||
pnpm dev
|
||||
|
||||
# 生产模式启动
|
||||
pnpm start
|
||||
|
||||
# 类型检查
|
||||
pnpm typecheck
|
||||
|
||||
# 代码检查
|
||||
pnpm lint
|
||||
|
||||
# 单元测试
|
||||
pnpm test
|
||||
|
||||
# 生成静态构建产物到 build/
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 构建说明
|
||||
|
||||
- 构建采用 Next.js 静态导出模式。
|
||||
- `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
|
||||
- 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
|
||||
- 构建版本号可通过 `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 start` 用于启动已构建产物,对应 Next 生产模式。
|
||||
|
||||
## 开源协议
|
||||
|
||||
`openflare-server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。
|
||||
|
||||
## 目录约定
|
||||
|
||||
- `app/`:路由与布局
|
||||
- `features/`:业务模块
|
||||
- `components/`:复用组件
|
||||
- `lib/`:请求、环境变量、工具与常量
|
||||
- `tests/`:测试代码
|
||||
@@ -0,0 +1,5 @@
|
||||
import {AccessLogsPage} from '@/features/access-logs/components/access-logs-page';
|
||||
|
||||
export default function AccessLogsRoute() {
|
||||
return <AccessLogsPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ApplyLogsPage as ApplyLogsFeaturePage} from '@/features/apply-logs/components/apply-logs-page';
|
||||
|
||||
export default function ApplyLogsPage() {
|
||||
return <ApplyLogsFeaturePage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ConfigVersionsPage} from '@/features/config-versions/components/config-versions-page';
|
||||
|
||||
export default function ConfigVersionsRoute() {
|
||||
return <ConfigVersionsPage />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {DashboardShell} from '@/components/layout/dashboard-shell';
|
||||
import {DashboardAuthGuard} from '@/features/auth/components/dashboard-auth-guard';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<DashboardAuthGuard>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DashboardAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {NodeDetailPage} from '@/features/nodes/components/node-detail-page';
|
||||
|
||||
export default function NodeDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <NodeDetailPage nodeId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {NodesPage} from '@/features/nodes/components/nodes-page';
|
||||
|
||||
export default function NodesRoute() {
|
||||
return <NodesPage />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {OriginDetailPage} from '@/features/origins/components/origin-detail-page';
|
||||
|
||||
export default function OriginDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {OriginsPage} from '@/features/origins/components/origins-page';
|
||||
|
||||
export default function OriginRoute() {
|
||||
return <OriginsPage />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {DashboardOverview} from '@/features/dashboard/components/dashboard-overview';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DashboardOverview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {PagesProjectDetailPage} from '@/features/pages/components/pages-page';
|
||||
|
||||
export default function PagesProjectDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <PagesProjectDetailPage projectId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {PagesPage} from '@/features/pages/components/pages-page';
|
||||
|
||||
export default function Page() {
|
||||
return <PagesPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {PerformancePage} from '@/features/performance/components/performance-page';
|
||||
|
||||
export default function PerformanceRoute() {
|
||||
return <PerformancePage />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {ProxyRouteConfigPage} from '@/features/proxy-routes/components/proxy-route-config-page';
|
||||
|
||||
export default function ProxyRouteDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<ProxyRouteConfigPage
|
||||
routeId={searchParams.get('id') ?? ''}
|
||||
initialSection={searchParams.get('section') ?? 'domains'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ProxyRoutesPage} from '@/features/proxy-routes/components/proxy-routes-page';
|
||||
|
||||
export default function ProxyRoutesRoute() {
|
||||
return <ProxyRoutesPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {SettingsPage} from '@/features/settings/components/settings-page';
|
||||
|
||||
export default function SettingsRoute() {
|
||||
return <SettingsPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {UsersPage} from '@/features/users/components/users-page';
|
||||
|
||||
export default function UsersRoute() {
|
||||
return <UsersPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {WAFIPGroupsPage} from '@/features/waf/components/ip-groups-page';
|
||||
|
||||
export default function WAFIPGroupsRoute() {
|
||||
return <WAFIPGroupsPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {WAFPage} from '@/features/waf/components/waf-page';
|
||||
|
||||
export default function WAFRoute() {
|
||||
return <WAFPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {TlsCertificatesPage} from '@/features/tls-certificates/components/tls-certificates-page';
|
||||
|
||||
export default function WebsiteCertificateRoute() {
|
||||
return <TlsCertificatesPage />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {WebsiteDetailPage} from '@/features/websites/components/website-detail-page';
|
||||
|
||||
export default function WebsiteDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <WebsiteDetailPage websiteId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {DnsAccountsPage} from '@/features/dns-accounts/components/dns-accounts-page';
|
||||
|
||||
export default function Page() {
|
||||
return <DnsAccountsPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {WebsitesPage} from '@/features/websites/components/websites-page';
|
||||
|
||||
export default function WebsiteRoute() {
|
||||
return <WebsitesPage />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {AboutPage} from '@/features/about/components/about-page';
|
||||
|
||||
export default function AboutRoute() {
|
||||
return <AboutPage />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {PublicShell} from '@/components/layout/public-shell';
|
||||
|
||||
interface PublicLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PublicLayout({ children }: PublicLayoutProps) {
|
||||
return <PublicShell>{children}</PublicShell>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {LoginForm} from '@/features/auth/components/login-form';
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {OAuthCallback} from '@/features/auth/components/oauth-callback';
|
||||
|
||||
export default function OAuthSourceCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<OAuthCallback />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {GitHubOAuthCallback} from '@/features/auth/components/github-oauth-callback';
|
||||
|
||||
export default function GithubOAuthPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<GitHubOAuthCallback />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {OAuthLinkExistingForm} from '@/features/auth/components/oauth-link-existing-form';
|
||||
|
||||
export default function OAuthLinkPage() {
|
||||
return <OAuthLinkExistingForm />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PasswordResetFlow} from '@/features/auth/components/password-reset-flow';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<PasswordResetFlow />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PasswordResetFlow} from '@/features/auth/components/password-reset-flow';
|
||||
|
||||
export default function LegacyResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<PasswordResetFlow />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
@import '../styles/tokens.css';
|
||||
@import 'tailwindcss';
|
||||
@import '@heroui/styles';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--body-background);
|
||||
color: var(--foreground-primary);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-background);
|
||||
color: var(--selection-foreground);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {Metadata} from 'next';
|
||||
import Script from 'next/script';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {AppProviders} from '@/components/providers/app-providers';
|
||||
import {getThemeInitScript} from '@/lib/theme/theme';
|
||||
|
||||
import './globals.css';
|
||||
import {Geist} from 'next/font/google';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const geist = Geist({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'OpenFlare 控制台',
|
||||
template: '%s | OpenFlare',
|
||||
},
|
||||
description: 'OpenFlare 管理端',
|
||||
applicationName: 'OpenFlare',
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
suppressHydrationWarning
|
||||
className={cn('font-sans', geist.variable)}
|
||||
>
|
||||
<body>
|
||||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{getThemeInitScript()}
|
||||
</Script>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4">
|
||||
<AppCard
|
||||
title="页面不存在"
|
||||
description="当前请求的页面尚未接入新版管理端路由。"
|
||||
>
|
||||
<div className="space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90"
|
||||
>
|
||||
返回总览
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {EChartsOption} from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
type RankChartItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type RankChartProps = {
|
||||
items: RankChartItem[];
|
||||
color: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||
|
||||
function getChartValue(params: unknown) {
|
||||
if (typeof params !== 'object' || params === null || !('value' in params)) {
|
||||
return 0;
|
||||
}
|
||||
const rawValue = (params as { value?: unknown }).value;
|
||||
if (Array.isArray(rawValue)) {
|
||||
const candidate = rawValue[0];
|
||||
return typeof candidate === 'number' ? candidate : 0;
|
||||
}
|
||||
return typeof rawValue === 'number' ? rawValue : 0;
|
||||
}
|
||||
|
||||
export function RankChart({
|
||||
items,
|
||||
color,
|
||||
valueFormatter = defaultFormatter,
|
||||
emptyMessage = '暂无分布数据',
|
||||
}: RankChartProps) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
animationDuration: 400,
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 24,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
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) {
|
||||
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)]">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
|
||||
type TrendChartSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
fillColor?: string;
|
||||
values: number[];
|
||||
variant?: 'line' | 'area';
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TrendChartProps = {
|
||||
labels: string[];
|
||||
series: TrendChartSeries[];
|
||||
height?: number;
|
||||
yAxisValueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TooltipParam = {
|
||||
axisValueLabel?: string;
|
||||
color?: string;
|
||||
seriesName?: string;
|
||||
value?: number | string | Array<number | string>;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => formatCompactNumber(value);
|
||||
|
||||
export function TrendChart({
|
||||
labels,
|
||||
series,
|
||||
height = 220,
|
||||
yAxisValueFormatter,
|
||||
}: TrendChartProps) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
|
||||
const maxValue = calculateNiceAxisMax(
|
||||
series.flatMap((item) => item.values),
|
||||
);
|
||||
|
||||
return {
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const header = items[0]?.axisValueLabel ?? '';
|
||||
const rows = items.map((item) => {
|
||||
const matchedSeries = series.find(
|
||||
(seriesItem) => seriesItem.label === item.seriesName,
|
||||
);
|
||||
const formatter =
|
||||
matchedSeries?.valueFormatter ??
|
||||
yAxisValueFormatter ??
|
||||
defaultFormatter;
|
||||
const rawValue = Array.isArray(item.value)
|
||||
? item.value[1]
|
||||
: item.value;
|
||||
const numericValue =
|
||||
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
|
||||
|
||||
return [
|
||||
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
|
||||
`<span>${item.seriesName ?? ''}</span>`,
|
||||
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: labels,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.24)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: (value: number) => axisFormatter(value),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
name: item.label,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: item.color,
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
},
|
||||
areaStyle:
|
||||
item.variant === 'area'
|
||||
? {
|
||||
color: item.fillColor ?? `${item.color}33`,
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
scale: true,
|
||||
},
|
||||
data: item.values,
|
||||
})),
|
||||
};
|
||||
}, [labels, series, yAxisValueFormatter]);
|
||||
|
||||
if (labels.length === 0 || series.length === 0) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{series.map((item) => {
|
||||
const latestValue = item.values[item.values.length - 1] ?? 0;
|
||||
const formatter = item.valueFormatter ?? defaultFormatter;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
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">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{formatter(latestValue)}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, children }: EmptyStateProps) {
|
||||
return (
|
||||
<div 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)]">
|
||||
{title}
|
||||
</p>
|
||||
{description ? (
|
||||
<p className="mt-2 leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{children ? <div className="mt-4">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface ErrorStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ErrorState({ title, description }: ErrorStateProps) {
|
||||
return (
|
||||
<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="mt-2 leading-6 text-[var(--status-danger-foreground)]/80">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {StatusBadge} from '@/components/ui/status-badge';
|
||||
|
||||
interface FeaturePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
milestones: string[];
|
||||
}
|
||||
|
||||
export function FeaturePlaceholder({
|
||||
title,
|
||||
description,
|
||||
milestones,
|
||||
}: FeaturePlaceholderProps) {
|
||||
return (
|
||||
<AppCard
|
||||
title={title}
|
||||
description={description}
|
||||
action={<StatusBadge label="阶段 1 骨架完成" variant="success" />}
|
||||
>
|
||||
<ul className="space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{milestones.map((item) => (
|
||||
<li key={item} className="flex gap-3">
|
||||
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
type InlineMessageTone = 'info' | 'success' | 'danger';
|
||||
|
||||
const toneClasses: Record<InlineMessageTone, string> = {
|
||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||
success:
|
||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||
danger:
|
||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||
};
|
||||
|
||||
interface InlineMessageProps {
|
||||
tone?: InlineMessageTone;
|
||||
message: string;
|
||||
className?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function InlineMessage({
|
||||
tone = 'info',
|
||||
message,
|
||||
className,
|
||||
onClear,
|
||||
}: InlineMessageProps) {
|
||||
const onClearRef = useRef(onClear);
|
||||
|
||||
useEffect(() => {
|
||||
onClearRef.current = onClear;
|
||||
}, [onClear]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only trigger toast if onClear is provided (dynamic feedback)
|
||||
if (!onClearRef.current || !message) return;
|
||||
|
||||
const options = {
|
||||
position: 'bottom-right' as const,
|
||||
};
|
||||
|
||||
if (tone === 'success') {
|
||||
toast.success(message, options);
|
||||
} else if (tone === 'danger') {
|
||||
toast.error(message, options);
|
||||
} else {
|
||||
toast(message, options);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClearRef.current?.();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [tone, message]);
|
||||
|
||||
// If onClear is provided, this is a toast feedback notice, so render nothing inline
|
||||
if (onClear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, render as a static/persistent inline alert banner (original behavior)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
||||
toneClasses[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
interface LoadingStateProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ className }: LoadingStateProps) {
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
|
||||
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
|
||||
|
||||
interface DashboardShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-transparent text-[var(--foreground-primary)]">
|
||||
<DashboardSidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<DashboardTopbar />
|
||||
<main className="flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { dashboardNavigation } from '@/lib/constants/navigation';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { isNavigationItemActive } from '@/lib/utils/navigation';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
import type { NavigationIconKey, NavigationItem } from '@/types/navigation';
|
||||
|
||||
function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
const commonProps = {
|
||||
className: 'h-[18px] w-[18px]',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
viewBox: '0 0 24 24',
|
||||
};
|
||||
|
||||
switch (icon) {
|
||||
case 'home':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5.5 9.5V21h13V9.5" />
|
||||
<path d="M9.5 21v-6h5v6" />
|
||||
</svg>
|
||||
);
|
||||
case 'node':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x="4" y="4" width="6" height="6" rx="1.5" />
|
||||
<rect x="14" y="4" width="6" height="6" rx="1.5" />
|
||||
<rect x="9" y="14" width="6" height="6" rx="1.5" />
|
||||
<path d="M10 7h4M12 10v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'website':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M3.5 12h17" />
|
||||
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'origin':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
|
||||
<circle cx="12" cy="10.1" r="1.9" />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4 8.5h16" />
|
||||
<path d="M4 15.5h16" />
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="3" />
|
||||
</svg>
|
||||
);
|
||||
case 'certificate':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z" />
|
||||
<path d="M9 10h6" />
|
||||
<path d="M10 14h4" />
|
||||
</svg>
|
||||
);
|
||||
case 'proxy':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M7 7h10" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 17h6" />
|
||||
<path d="m14 15 3 2-3 2" />
|
||||
</svg>
|
||||
);
|
||||
case 'pages':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M5 5.5h14v13H5z" />
|
||||
<path d="M8 9h8" />
|
||||
<path d="M8 12h5" />
|
||||
<path d="M8 15h7" />
|
||||
</svg>
|
||||
);
|
||||
case 'waf':
|
||||
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
|
||||
case 'release':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4v10" />
|
||||
<path d="m8.5 7.5 3.5-3.5 3.5 3.5" />
|
||||
<path d="M5 19.5h14" />
|
||||
</svg>
|
||||
);
|
||||
case 'log':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<rect x="5" y="4" width="14" height="16" rx="2.5" />
|
||||
<path d="M8 8h8" />
|
||||
<path d="M8 12h8" />
|
||||
<path d="M8 16h5" />
|
||||
</svg>
|
||||
);
|
||||
case 'performance':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M4.5 19.5h15" />
|
||||
<path d="M7 16l3-4 3 2 4-6" />
|
||||
<path d="m15.5 8 1.5 0 0 1.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'user':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="8" r="3.25" />
|
||||
<path d="M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'setting':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<circle cx="12" cy="12" r="3.5" />
|
||||
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarNavItem({
|
||||
item,
|
||||
currentPath,
|
||||
isSidebarCollapsed,
|
||||
forceExpanded,
|
||||
onNavigate,
|
||||
depth = 0,
|
||||
}: {
|
||||
item: NavigationItem;
|
||||
currentPath: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
forceExpanded?: boolean;
|
||||
onNavigate?: () => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const active = isNavigationItemActive(currentPath, item);
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const showLabel = forceExpanded || !isSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
|
||||
depth > 0 && 'ml-3 rounded-xl',
|
||||
active
|
||||
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]">
|
||||
<SidebarIcon icon={item.icon} />
|
||||
</span>
|
||||
{showLabel ? (
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{showLabel && hasChildren ? (
|
||||
<div className="space-y-2">
|
||||
{item.children?.map((child) => (
|
||||
<SidebarNavItem
|
||||
key={child.href}
|
||||
item={child}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
currentPath,
|
||||
isSidebarCollapsed,
|
||||
forceExpanded = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentPath: string;
|
||||
isSidebarCollapsed: boolean;
|
||||
forceExpanded?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const showLabel = forceExpanded || !isSidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-5">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]">
|
||||
AF
|
||||
</div>
|
||||
{showLabel ? (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
OpenFlare
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{dashboardNavigation.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
forceExpanded={forceExpanded}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const currentPath = pathname ?? '/';
|
||||
const isSidebarCollapsed = useAppShellStore(
|
||||
(state) => state.isSidebarCollapsed,
|
||||
);
|
||||
const isMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.isMobileSidebarOpen,
|
||||
);
|
||||
const setMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.setMobileSidebarOpen,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, [currentPath, setMobileSidebarOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
|
||||
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
|
||||
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={false}
|
||||
forceExpanded
|
||||
onNavigate={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
|
||||
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
currentPath={currentPath}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
createUpgradeLogsWebSocket,
|
||||
confirmManualServerUpgrade,
|
||||
getLatestRelease,
|
||||
parseUpgradeStreamSnapshot,
|
||||
upgradeServer,
|
||||
uploadServerBinary,
|
||||
} from '@/features/update/api/update';
|
||||
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
|
||||
import type {
|
||||
LatestReleaseInfo,
|
||||
ReleaseChannel,
|
||||
UpgradeStreamSnapshot,
|
||||
UploadedServerBinaryInfo,
|
||||
} from '@/features/update/types';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
import { useAppShellStore } from '@/store/app-shell';
|
||||
|
||||
export function DashboardTopbar() {
|
||||
const router = useRouter();
|
||||
const { logout, user } = useAuth();
|
||||
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
||||
const isMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.isMobileSidebarOpen,
|
||||
);
|
||||
const setMobileSidebarOpen = useAppShellStore(
|
||||
(state) => state.setMobileSidebarOpen,
|
||||
);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||
useState<ReleaseChannel>('stable');
|
||||
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
|
||||
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [manualUpgradeError, setManualUpgradeError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadedBinary, setUploadedBinary] =
|
||||
useState<UploadedServerBinaryInfo | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [upgradeStream, setUpgradeStream] =
|
||||
useState<UpgradeStreamSnapshot | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const upgradeRefreshPendingRef = useRef(false);
|
||||
const upgradeReloadStartedRef = useRef(false);
|
||||
const upgradeReloadTimerRef = useRef<number | null>(null);
|
||||
const isRoot = (user?.role ?? 0) >= 100;
|
||||
const upgradeStatusPollInterval = 3000;
|
||||
|
||||
const publicStatusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const stableReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'stable'],
|
||||
queryFn: () => getLatestRelease('stable'),
|
||||
enabled: isRoot,
|
||||
refetchInterval: (query) => {
|
||||
const release = query.state.data;
|
||||
if (isVersionModalOpen && release?.in_progress) {
|
||||
return upgradeStatusPollInterval;
|
||||
}
|
||||
return 60 * 60 * 1000;
|
||||
},
|
||||
});
|
||||
|
||||
const previewReleaseQuery = useQuery({
|
||||
queryKey: ['update', 'latest-release', 'preview'],
|
||||
queryFn: () => getLatestRelease('preview'),
|
||||
enabled: false,
|
||||
refetchInterval: (query) => {
|
||||
const release = query.state.data;
|
||||
if (isVersionModalOpen && release?.in_progress) {
|
||||
return upgradeStatusPollInterval;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: (channel: ReleaseChannel) => upgradeServer(channel),
|
||||
onSuccess: (release) => {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setVersionFeedback(
|
||||
`服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`,
|
||||
);
|
||||
void stableReleaseQuery.refetch();
|
||||
if (release.channel === 'preview') {
|
||||
void previewReleaseQuery.refetch();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setVersionFeedback(
|
||||
error instanceof Error ? error.message : '升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadBinaryMutation = useMutation({
|
||||
mutationFn: (binary: File) =>
|
||||
uploadServerBinary(binary, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
}),
|
||||
onSuccess: (candidate) => {
|
||||
setUploadProgress(0);
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(candidate.comparison_message);
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadProgress(0);
|
||||
setUploadedBinary(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmManualUpgradeMutation = useMutation({
|
||||
mutationFn: confirmManualServerUpgrade,
|
||||
onSuccess: (candidate) => {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeError(null);
|
||||
setUploadedBinary(candidate);
|
||||
setManualUpgradeStatus(
|
||||
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
|
||||
);
|
||||
void stableReleaseQuery.refetch();
|
||||
void previewReleaseQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '确认手动升级失败,请稍后重试。',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleUpgradePageReload = useCallback(() => {
|
||||
if (upgradeReloadStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
upgradeReloadStartedRef.current = true;
|
||||
setVersionFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。');
|
||||
|
||||
const reloadWhenServerReady = async () => {
|
||||
try {
|
||||
await getPublicStatus();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
upgradeReloadTimerRef.current = window.setTimeout(
|
||||
reloadWhenServerReady,
|
||||
1500,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
upgradeReloadTimerRef.current = window.setTimeout(
|
||||
reloadWhenServerReady,
|
||||
1200,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (upgradeReloadTimerRef.current !== null) {
|
||||
window.clearTimeout(upgradeReloadTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVersionModalOpen || !isRoot) {
|
||||
setUpgradeStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = createUpgradeLogsWebSocket();
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const snapshot = parseUpgradeStreamSnapshot(String(event.data));
|
||||
if (snapshot) {
|
||||
if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') {
|
||||
upgradeRefreshPendingRef.current = true;
|
||||
}
|
||||
if (snapshot.upgrade_status === 'failed') {
|
||||
upgradeRefreshPendingRef.current = false;
|
||||
}
|
||||
setUpgradeStream(snapshot);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (upgradeRefreshPendingRef.current) {
|
||||
scheduleUpgradePageReload();
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(connect, 1500);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
}
|
||||
socket?.close();
|
||||
};
|
||||
}, [isRoot, isVersionModalOpen, scheduleUpgradePageReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
setIsUserMenuOpen(false);
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
if (window.innerWidth < 1000) {
|
||||
setMobileSidebarOpen(!isMobileSidebarOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
toggleSidebar();
|
||||
};
|
||||
|
||||
const handleOpenVersionModal = () => {
|
||||
setSelectedReleaseChannel('stable');
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
setIsVersionModalOpen(true);
|
||||
if (isRoot) {
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
upgradeMutation.mutate(selectedReleaseChannel);
|
||||
};
|
||||
|
||||
const handleCheckRelease = () => {
|
||||
setVersionFeedback(null);
|
||||
if (isRoot) {
|
||||
if (selectedReleaseChannel === 'preview') {
|
||||
void previewReleaseQuery.refetch();
|
||||
} else {
|
||||
void stableReleaseQuery.refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseChannelChange = (channel: ReleaseChannel) => {
|
||||
setSelectedReleaseChannel(channel);
|
||||
setVersionFeedback(null);
|
||||
};
|
||||
|
||||
const handleUploadBinary = (binary: File) => {
|
||||
setUploadProgress(0);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
uploadBinaryMutation.mutate(binary);
|
||||
};
|
||||
|
||||
const handleConfirmManualUpgrade = () => {
|
||||
if (!uploadedBinary?.upload_token) {
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError('请先上传并检查升级包。');
|
||||
return;
|
||||
}
|
||||
setVersionFeedback(null);
|
||||
setManualUpgradeStatus(null);
|
||||
setManualUpgradeError(null);
|
||||
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
|
||||
};
|
||||
|
||||
const selectedRelease =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.data
|
||||
: stableReleaseQuery.data;
|
||||
const releaseWithStream = mergeReleaseWithUpgradeStream(
|
||||
selectedRelease,
|
||||
upgradeStream,
|
||||
);
|
||||
const selectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.error
|
||||
: stableReleaseQuery.error;
|
||||
const isSelectedReleaseError =
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isError
|
||||
: stableReleaseQuery.isError;
|
||||
const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update);
|
||||
const currentVersion = publicStatusQuery.data?.version || 'unknown';
|
||||
const versionLabel = hasUpdate
|
||||
? `版本 ${publicEnv.appVersion} · 可升级`
|
||||
: `版本 ${publicEnv.appVersion}`;
|
||||
const versionButtonClassName = hasUpdate
|
||||
? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]'
|
||||
: 'border-[var(--border-default)]';
|
||||
const versionErrorMessage =
|
||||
versionFeedback ||
|
||||
(isSelectedReleaseError
|
||||
? selectedReleaseError instanceof Error
|
||||
? selectedReleaseError.message
|
||||
: '版本检查失败,请稍后重试。'
|
||||
: undefined);
|
||||
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSidebarToggle}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="切换侧边栏"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenVersionModal}
|
||||
className={[
|
||||
'inline-flex rounded-full border px-3 py-1.5 transition',
|
||||
versionButtonClassName,
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="sm:hidden">版本</span>
|
||||
<span className="hidden sm:inline">{versionLabel}</span>
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsUserMenuOpen((value) => !value)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold">
|
||||
{(user?.display_name || user?.username || 'U')
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{user?.display_name || user?.username || '用户'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div className="absolute top-[calc(100%+0.5rem)] right-0 w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]">
|
||||
<div className="rounded-xl px-3 py-2">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{user?.display_name || user?.username || '用户'}
|
||||
</p>
|
||||
{user?.username ? (
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
@{user.username}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogout()}
|
||||
disabled={isLoggingOut}
|
||||
className="flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoggingOut ? '退出中...' : '退出登录'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<VersionUpgradeModal
|
||||
isOpen={isVersionModalOpen}
|
||||
onClose={() => setIsVersionModalOpen(false)}
|
||||
currentVersion={currentVersion}
|
||||
release={releaseWithStream}
|
||||
selectedChannel={selectedReleaseChannel}
|
||||
uploadedBinary={uploadedBinary}
|
||||
isLoading={
|
||||
(selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isLoading && !previewReleaseQuery.data
|
||||
: stableReleaseQuery.isLoading && !stableReleaseQuery.data) &&
|
||||
isRoot
|
||||
}
|
||||
releaseErrorMessage={versionErrorMessage}
|
||||
manualStatusMessage={manualUpgradeStatus ?? undefined}
|
||||
manualErrorMessage={manualUpgradeErrorMessage}
|
||||
canUpgrade={isRoot}
|
||||
isChecking={
|
||||
selectedReleaseChannel === 'preview'
|
||||
? previewReleaseQuery.isFetching
|
||||
: stableReleaseQuery.isFetching
|
||||
}
|
||||
isUpgrading={upgradeMutation.isPending}
|
||||
isUploadingBinary={uploadBinaryMutation.isPending}
|
||||
uploadProgress={uploadProgress}
|
||||
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
|
||||
onChannelChange={handleReleaseChannelChange}
|
||||
onCheck={handleCheckRelease}
|
||||
onUpgrade={handleUpgrade}
|
||||
onUploadBinary={handleUploadBinary}
|
||||
onConfirmManualUpgrade={handleConfirmManualUpgrade}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeReleaseWithUpgradeStream(
|
||||
release: LatestReleaseInfo | null | undefined,
|
||||
stream: UpgradeStreamSnapshot | null,
|
||||
) {
|
||||
if (!release || !stream) {
|
||||
return release;
|
||||
}
|
||||
|
||||
return {
|
||||
...release,
|
||||
in_progress: stream.in_progress,
|
||||
upgrade_status: stream.upgrade_status,
|
||||
upgrade_logs: stream.upgrade_logs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium tracking-[0.24em] text-[var(--brand-primary)] uppercase">
|
||||
OpenFlare
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
import {publicEnv} from '@/lib/env/public-env';
|
||||
|
||||
interface PublicShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicShell({ children }: PublicShellProps) {
|
||||
return (
|
||||
<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="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>
|
||||
<p className="text-3xl font-medium text-[var(--brand-primary)]">
|
||||
OpenFlare
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
|
||||
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
|
||||
{publicEnv.appVersion}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">{children}</div>
|
||||
|
||||
<div className="mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
关于
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
getCurrentUser,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { clearStoredOpenFlareToken } from '@/lib/api/auth-token';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUserState] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const nextUser = await getCurrentUser();
|
||||
setUserState(nextUser);
|
||||
return nextUser;
|
||||
} catch {
|
||||
clearStoredOpenFlareToken();
|
||||
setUserState(null);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
const setUser = useCallback((nextUser: AuthUser | null) => {
|
||||
setUserState(nextUser);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutRequest();
|
||||
} finally {
|
||||
setUserState(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
refreshUser,
|
||||
setUser,
|
||||
logout,
|
||||
}),
|
||||
[isLoading, logout, refreshUser, setUser, user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
isThemeMode,
|
||||
resolveTheme,
|
||||
THEME_MEDIA_QUERY,
|
||||
THEME_STORAGE_KEY,
|
||||
type ResolvedTheme,
|
||||
type ThemeMode,
|
||||
} from '@/lib/theme/theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
themeMode: ThemeMode;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function getSystemPreference() {
|
||||
return window.matchMedia(THEME_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
function applyTheme(mode: ThemeMode) {
|
||||
const resolvedTheme = resolveTheme(mode, getSystemPreference());
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.themeMode = mode;
|
||||
root.dataset.theme = resolvedTheme;
|
||||
root.style.colorScheme = resolvedTheme;
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
|
||||
return resolvedTheme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const domThemeMode = root.dataset.themeMode;
|
||||
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
|
||||
const nextResolvedTheme = applyTheme(nextThemeMode);
|
||||
|
||||
setThemeModeState(nextThemeMode);
|
||||
setResolvedTheme(nextResolvedTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
|
||||
|
||||
const handleChange = () => {
|
||||
if (themeMode !== 'system') {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedTheme(applyTheme('system'));
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [themeMode]);
|
||||
|
||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
setResolvedTheme(applyTheme(mode));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
themeMode,
|
||||
resolvedTheme,
|
||||
setThemeMode,
|
||||
}),
|
||||
[resolvedTheme, setThemeMode, themeMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AppCardProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{(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">
|
||||
<div className="space-y-1">
|
||||
{title ? (
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</header>
|
||||
)}
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface AppModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClassNameMap = {
|
||||
md: 'max-w-2xl',
|
||||
lg: 'max-w-4xl',
|
||||
xl: 'max-w-5xl',
|
||||
} satisfies Record<NonNullable<AppModalProps['size']>, string>;
|
||||
|
||||
export function AppModal({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
size = 'lg',
|
||||
}: AppModalProps) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹窗"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex max-h-[calc(100vh-3rem)] w-full flex-col overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
sizeClassNameMap[size],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭弹窗"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="border-t border-[var(--border-default)] px-6 py-5">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
type DrawerDirection = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
type DrawerContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
direction: DrawerDirection;
|
||||
};
|
||||
|
||||
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
||||
|
||||
function useDrawerContext() {
|
||||
const context = useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Drawer components must be used within Drawer.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function renderWithOptionalChild(
|
||||
child: ReactNode,
|
||||
props: Record<string, unknown>,
|
||||
fallback: ReactNode,
|
||||
) {
|
||||
if (isValidElement(child)) {
|
||||
return cloneElement(child as ReactElement, props);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
direction = 'bottom',
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
direction?: DrawerDirection;
|
||||
title?: string;
|
||||
description?: string;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = open !== undefined;
|
||||
const resolvedOpen = isControlled ? open : internalOpen;
|
||||
|
||||
const value = useMemo<DrawerContextValue>(
|
||||
() => ({
|
||||
open: resolvedOpen,
|
||||
direction,
|
||||
setOpen: (nextOpen) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
}),
|
||||
[direction, isControlled, onOpenChange, resolvedOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={value}>
|
||||
{title || description || footer ? (
|
||||
<DrawerContent
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'w-full md:w-[50vw] md:max-w-none',
|
||||
direction === 'right' || direction === 'left' ? '' : 'max-h-[85vh]',
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
{title ? <DrawerTitle>{title}</DrawerTitle> : null}
|
||||
{description ? (
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value.setOpen(false)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
aria-label="关闭抽屉"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <DrawerFooter>{footer}</DrawerFooter> : null}
|
||||
</DrawerContent>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTrigger({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerClose({
|
||||
children,
|
||||
asChild = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const { setOpen } = useDrawerContext();
|
||||
|
||||
if (asChild) {
|
||||
return renderWithOptionalChild(
|
||||
children,
|
||||
{
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerContent({
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
const { open, setOpen, direction } = useDrawerContext();
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionClassName =
|
||||
direction === 'right'
|
||||
? 'inset-y-0 right-0 h-full border-l'
|
||||
: direction === 'left'
|
||||
? 'inset-y-0 left-0 h-full border-r'
|
||||
: direction === 'top'
|
||||
? 'inset-x-0 top-0 border-b'
|
||||
: 'inset-x-0 bottom-0 border-t';
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-slate-950/45 backdrop-blur-[2px]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="关闭抽屉"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabel ? undefined : titleId}
|
||||
aria-describedby={descriptionId}
|
||||
className={cn(
|
||||
'absolute flex w-full flex-col overflow-hidden border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||
positionClassName,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DrawerMetaContext.Provider value={{ titleId, descriptionId }}>
|
||||
{children}
|
||||
</DrawerMetaContext.Provider>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type DrawerMetaContextValue = {
|
||||
titleId: string;
|
||||
descriptionId: string;
|
||||
};
|
||||
|
||||
const DrawerMetaContext = createContext<DrawerMetaContextValue | null>(null);
|
||||
|
||||
function useDrawerMetaContext() {
|
||||
const context = useContext(DrawerMetaContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Drawer title and description must be used within DrawerContent.',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-[var(--border-default)] px-6 py-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-[var(--border-default)] px-6 py-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const { titleId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<h2
|
||||
id={titleId}
|
||||
className={cn(
|
||||
'text-xl font-semibold text-[var(--foreground-primary)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const { descriptionId } = useDrawerMetaContext();
|
||||
|
||||
return (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
'mt-2 text-sm leading-6 text-[var(--foreground-secondary)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
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 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,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {cn} from '@/lib/utils/cn';
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.ComponentProps<'label'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm leading-none font-medium text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {Separator as SeparatorPrimitive} from 'radix-ui';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
export function Toaster({ ...props }: ToasterProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
position="bottom-right"
|
||||
theme={resolvedTheme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-[var(--surface-elevated)] group-[.toaster]:text-[var(--foreground-primary)] group-[.toaster]:border-[var(--border-default)] group-[.toaster]:shadow-lg group-[.toaster]:rounded-2xl group-[.toaster]:font-sans',
|
||||
description: 'group-[.toast]:text-[var(--foreground-secondary)]',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-[var(--primary)] group-[.toast]:text-[var(--primary-foreground)]',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-[var(--muted)] group-[.toast]:text-[var(--muted-foreground)]',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const variantClasses = {
|
||||
success:
|
||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||
warning:
|
||||
'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]',
|
||||
danger:
|
||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||
} as const;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
label: string;
|
||||
variant?: keyof typeof variantClasses;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
label,
|
||||
variant = 'info',
|
||||
className,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: StatusBadgeProps) {
|
||||
const badgeClassName = cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium tracking-wide',
|
||||
variantClasses[variant],
|
||||
onClick
|
||||
? 'cursor-pointer transition hover:opacity-85 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
: undefined,
|
||||
className,
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={badgeClassName}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className={badgeClassName}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
disabled?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
checked,
|
||||
defaultChecked = false,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalChecked, setInternalChecked] =
|
||||
React.useState(defaultChecked);
|
||||
const isControlled = checked !== undefined;
|
||||
const isChecked = isControlled ? checked : internalChecked;
|
||||
|
||||
const updateChecked = React.useCallback(
|
||||
(nextChecked: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalChecked(nextChecked);
|
||||
}
|
||||
onCheckedChange?.(nextChecked);
|
||||
},
|
||||
[isControlled, onCheckedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
|
||||
className,
|
||||
)}
|
||||
onClick={() => updateChecked(!isChecked)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
updateChecked(!isChecked);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-state={isChecked ? 'checked' : 'unchecked'}
|
||||
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
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 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,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { themeModes, type ThemeMode } from '@/lib/theme/theme';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
|
||||
const currentIndex = themeModes.indexOf(themeMode);
|
||||
const nextTheme = themeModes[
|
||||
(currentIndex + 1) % themeModes.length
|
||||
] as ThemeMode;
|
||||
|
||||
const icon =
|
||||
themeMode === 'light' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
) : themeMode === 'dark' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="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" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3.5" y="4" width="17" height="12" rx="2.5" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const label =
|
||||
themeMode === 'light'
|
||||
? '浅色模式'
|
||||
: themeMode === 'dark'
|
||||
? '深色模式'
|
||||
: '跟随系统';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThemeMode(nextTheme)}
|
||||
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)]',
|
||||
className,
|
||||
)}
|
||||
aria-label={`当前${label},点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
|
||||
title={`${label}(当前生效:${resolvedTheme === 'dark' ? '深色' : '浅色'}),点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import { getAboutContent } from '@/features/settings/api/settings';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const aboutQuery = useQuery({
|
||||
queryKey: ['about'],
|
||||
queryFn: getAboutContent,
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (aboutQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="关于页加载失败"
|
||||
description={getErrorMessage(aboutQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="系统状态加载失败"
|
||||
description={getErrorMessage(statusQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||
const status = statusQuery.data;
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无系统信息"
|
||||
description="未能获取 OpenFlare 的公开状态信息。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard
|
||||
title="关于 OpenFlare"
|
||||
description="公开展示当前系统简介、版本信息与项目入口。"
|
||||
action={<StatusBadge label={status.version || 'dev'} variant="info" />}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
系统名称
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{status.system_name || 'OpenFlare'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Server 启动时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{formatDateTime(new Date(status.start_time * 1000))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
项目仓库
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Rain-kl/OpenFlare"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
github.com/Rain-kl/OpenFlare
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
AccessLogCleanupPayload,
|
||||
AccessLogCleanupResult,
|
||||
AccessLogFilters,
|
||||
AccessLogIPSummaryFilters,
|
||||
AccessLogIPSummaryList,
|
||||
AccessLogIPTrend,
|
||||
AccessLogIPTrendFilters,
|
||||
AccessLogList,
|
||||
FoldedAccessLogIPFilters,
|
||||
FoldedAccessLogIPList,
|
||||
FoldedAccessLogFilters,
|
||||
FoldedAccessLogList,
|
||||
} from '@/features/access-logs/types';
|
||||
|
||||
function buildSearchParams(filters: object) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(
|
||||
filters as Record<string, string | number | undefined>,
|
||||
).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
export function getAccessLogs(filters: AccessLogFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogList>(`/access-logs/${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<FoldedAccessLogList>(
|
||||
`/access-logs/folds${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<FoldedAccessLogIPList>(
|
||||
`/access-logs/folds/ip-summary${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogIPSummaryList>(
|
||||
`/access-logs/ip-summary${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) {
|
||||
const query = buildSearchParams(filters);
|
||||
return apiRequest<AccessLogIPTrend>(
|
||||
`/access-logs/ip-summary/trend${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function cleanupAccessLogs(payload: AccessLogCleanupPayload) {
|
||||
return apiRequest<AccessLogCleanupResult>('/access-logs/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
export interface AccessLogFilters {
|
||||
node_id?: string;
|
||||
remote_addr?: string;
|
||||
host?: string;
|
||||
path?: string;
|
||||
p?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
node_name: string;
|
||||
logged_at: string;
|
||||
remote_addr: string;
|
||||
region: string;
|
||||
host: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
}
|
||||
|
||||
export interface AccessLogList {
|
||||
items: AccessLogItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_record: number;
|
||||
total_ip: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogFilters extends AccessLogFilters {
|
||||
fold_minutes: 3 | 5;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogItem {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
unique_ip_count: number;
|
||||
unique_host_count: number;
|
||||
success_count: number;
|
||||
client_error_count: number;
|
||||
server_error_count: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogList {
|
||||
items: FoldedAccessLogItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_bucket: number;
|
||||
total_record: number;
|
||||
total_ip: number;
|
||||
fold_minutes: number;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
|
||||
bucket_started_at: string;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPItem {
|
||||
remote_addr: string;
|
||||
request_count: number;
|
||||
success_count: number;
|
||||
client_error_count: number;
|
||||
server_error_count: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface FoldedAccessLogIPList {
|
||||
items: FoldedAccessLogIPItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_ip: number;
|
||||
bucket_started_at: string;
|
||||
fold_minutes: number;
|
||||
sort_by: string;
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryFilters {
|
||||
node_id?: string;
|
||||
remote_addr?: string;
|
||||
host?: string;
|
||||
p?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryItem {
|
||||
remote_addr: string;
|
||||
total_requests: number;
|
||||
recent_requests: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface AccessLogIPSummaryList {
|
||||
items: AccessLogIPSummaryItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_ip: number;
|
||||
sort_by: string;
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrendFilters {
|
||||
node_id?: string;
|
||||
remote_addr: string;
|
||||
host?: string;
|
||||
hours?: number;
|
||||
bucket_minutes?: number;
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
}
|
||||
|
||||
export interface AccessLogIPTrend {
|
||||
remote_addr: string;
|
||||
hours: number;
|
||||
bucket_minutes: number;
|
||||
points: AccessLogIPTrendPoint[];
|
||||
}
|
||||
|
||||
export interface AccessLogCleanupPayload {
|
||||
retention_days: number;
|
||||
}
|
||||
|
||||
export interface AccessLogCleanupResult {
|
||||
retention_days: number;
|
||||
deleted_count: number;
|
||||
cutoff: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type { AcmeAccountItem } from '@/features/acme-accounts/types';
|
||||
|
||||
export function getDefaultAcmeAccount() {
|
||||
return apiRequest<AcmeAccountItem>('/acme-accounts/default');
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface AcmeAccountItem {
|
||||
id: number;
|
||||
email: string;
|
||||
url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogCleanupResult,
|
||||
ApplyLogList,
|
||||
ApplyLogListQuery,
|
||||
} from '@/features/apply-logs/types';
|
||||
|
||||
export function getApplyLogs(query: ApplyLogListQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedNodeId = query.node_id?.trim();
|
||||
if (normalizedNodeId) {
|
||||
params.set('node_id', normalizedNodeId);
|
||||
}
|
||||
if (query.pageNo) {
|
||||
params.set('pageNo', String(query.pageNo));
|
||||
}
|
||||
if (query.pageSize) {
|
||||
params.set('pageSize', String(query.pageSize));
|
||||
}
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return apiRequest<ApplyLogList>(`/apply-logs/${suffix}`);
|
||||
}
|
||||
|
||||
export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) {
|
||||
return apiRequest<ApplyLogCleanupResult>('/apply-logs/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
cleanupApplyLogs,
|
||||
getApplyLogs,
|
||||
} from '@/features/apply-logs/api/apply-logs';
|
||||
import type {
|
||||
ApplyLogCleanupPayload,
|
||||
ApplyLogItem,
|
||||
} from '@/features/apply-logs/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
|
||||
const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
|
||||
['apply-logs', nodeId, pageNo, pageSize] as const;
|
||||
|
||||
const pageSizeOptions = [20, 50, 100];
|
||||
const emptyApplyLogRows: ApplyLogItem[] = [];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function getResultMeta(result: string) {
|
||||
if (result === 'success') {
|
||||
return { label: '成功', variant: 'success' as const };
|
||||
}
|
||||
if (result === 'warning') {
|
||||
return { label: '警告', variant: 'warning' as const };
|
||||
}
|
||||
return { label: '失败', variant: 'danger' as const };
|
||||
}
|
||||
|
||||
function truncateHash(value: string) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function buildSummary(
|
||||
rows: ApplyLogItem[],
|
||||
total: number,
|
||||
current: number,
|
||||
totalPage: number,
|
||||
) {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: '总记录数', value: total },
|
||||
{ label: '当前页', value: current },
|
||||
{ label: '总页数', value: totalPage },
|
||||
{ label: '当前页节点数', value: nodeIds.size },
|
||||
];
|
||||
}
|
||||
|
||||
export function ApplyLogsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState('');
|
||||
const [nodeFilter, setNodeFilter] = useState('');
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
|
||||
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
|
||||
const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
|
||||
const [customRetentionDays, setCustomRetentionDays] = useState('30');
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nodeID = new URLSearchParams(window.location.search)
|
||||
.get('node_id')
|
||||
?.trim();
|
||||
if (!nodeID) {
|
||||
return;
|
||||
}
|
||||
setNodeFilterInput(nodeID);
|
||||
setNodeFilter(nodeID);
|
||||
setPageNo(1);
|
||||
}, []);
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
|
||||
queryFn: () =>
|
||||
getApplyLogs({
|
||||
node_id: nodeFilter || undefined,
|
||||
pageNo,
|
||||
pageSize,
|
||||
}),
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload),
|
||||
onSuccess: async (result) => {
|
||||
setCleanupModalOpen(false);
|
||||
setPageNo(1);
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: result.delete_all
|
||||
? `已删除全部应用日志,共 ${result.deleted_count} 条。`
|
||||
: `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['apply-logs'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const rows = logsQuery.data?.rows ?? emptyApplyLogRows;
|
||||
const current = logsQuery.data?.current ?? pageNo;
|
||||
const total = logsQuery.data?.total ?? 0;
|
||||
const totalPage = logsQuery.data?.totalPage ?? 0;
|
||||
const summary = useMemo(
|
||||
() => buildSummary(rows, total, current, totalPage),
|
||||
[rows, total, current, totalPage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rows.length === 0 && selectedLog) {
|
||||
setSelectedLog(null);
|
||||
return;
|
||||
}
|
||||
if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
|
||||
setSelectedLog(null);
|
||||
}
|
||||
}, [rows, selectedLog]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setFeedback(null);
|
||||
setPageNo(1);
|
||||
setNodeFilter(nodeFilterInput.trim());
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFeedback(null);
|
||||
setNodeFilter('');
|
||||
setNodeFilterInput('');
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setFeedback(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCleanupConfirm = () => {
|
||||
const payload: ApplyLogCleanupPayload =
|
||||
cleanupMode === 'all'
|
||||
? { delete_all: true }
|
||||
: {
|
||||
retention_days: Number.parseInt(customRetentionDays, 10),
|
||||
};
|
||||
cleanupMutation.mutate(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="应用日志"
|
||||
description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton type="button" onClick={handleRefresh}>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFeedback(null);
|
||||
setCleanupModalOpen(true);
|
||||
}}
|
||||
>
|
||||
删除日志
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回节点
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="日志摘要"
|
||||
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="过滤与列表"
|
||||
description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_180px]">
|
||||
<ResourceField label="Node ID">
|
||||
<ResourceInput
|
||||
value={nodeFilterInput}
|
||||
onChange={(event) => setNodeFilterInput(event.target.value)}
|
||||
placeholder="输入 node_id 过滤应用日志"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="每页条数">
|
||||
<ResourceSelect
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => {
|
||||
setPageSize(Number.parseInt(event.target.value, 10));
|
||||
setPageNo(1);
|
||||
}}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option} 条
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrimaryButton type="button" onClick={handleSearch}>
|
||||
筛选
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type="button" onClick={handleReset}>
|
||||
清空
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : logsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="应用日志加载失败"
|
||||
description={getErrorMessage(logsQuery.error)}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无应用日志"
|
||||
description="当前筛选条件下没有可展示的应用记录。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">Node ID</th>
|
||||
<th className="px-3 py-3 font-medium">版本</th>
|
||||
<th className="px-3 py-3 font-medium">结果</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">时间</th>
|
||||
<th className="px-3 py-3 font-medium">消息</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{rows.map((log) => {
|
||||
const resultMeta = getResultMeta(log.result);
|
||||
return (
|
||||
<tr key={log.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{log.node_id}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{log.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={resultMeta.label}
|
||||
variant={resultMeta.variant}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={log.checksum}
|
||||
>
|
||||
{truncateHash(log.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="space-y-1">
|
||||
<p>{formatDateTime(log.created_at)}</p>
|
||||
<p className="text-xs">
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="max-w-72 break-words whitespace-pre-wrap">
|
||||
{log.message || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
className="px-3 py-2 text-xs"
|
||||
onClick={() => setSelectedLog(log)}
|
||||
>
|
||||
查看详情
|
||||
</SecondaryButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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)]">
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
|
||||
条记录。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={current <= 1}
|
||||
onClick={() =>
|
||||
setPageNo((previous) => Math.max(1, previous - 1))
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={totalPage === 0 || current >= totalPage}
|
||||
onClick={() =>
|
||||
setPageNo((previous) =>
|
||||
totalPage > 0
|
||||
? Math.min(totalPage, previous + 1)
|
||||
: previous,
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppModal
|
||||
isOpen={selectedLog !== null}
|
||||
title="应用日志详情"
|
||||
description="查看单条应用日志的完整结果、消息和校验信息。"
|
||||
size="lg"
|
||||
onClose={() => setSelectedLog(null)}
|
||||
>
|
||||
{selectedLog ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
||||
<StatusBadge
|
||||
label={`Node:${selectedLog.node_id}`}
|
||||
variant="info"
|
||||
/>
|
||||
<StatusBadge
|
||||
label={`版本:${selectedLog.version}`}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<MetricCard
|
||||
label="创建时间"
|
||||
value={formatDateTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="相对时间"
|
||||
value={formatRelativeTime(selectedLog.created_at)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="目标 Checksum"
|
||||
value={selectedLog.checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
<MetricCard
|
||||
label="支持文件数"
|
||||
value={String(selectedLog.support_file_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="主配置摘要"
|
||||
value={selectedLog.main_config_checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
<MetricCard
|
||||
label="路由配置摘要"
|
||||
value={selectedLog.route_config_checksum || '—'}
|
||||
breakAll
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
消息
|
||||
</p>
|
||||
<pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{selectedLog.message || '—'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
|
||||
<AppModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
title="删除应用日志"
|
||||
description="可以选择删除全部应用日志,或仅保留最近自定义天数内的记录。"
|
||||
onClose={() => setCleanupModalOpen(false)}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setCleanupModalOpen(false)}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={handleCleanupConfirm}
|
||||
>
|
||||
{cleanupMutation.isPending ? '处理中...' : '确认删除'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleanupMode('all')}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${
|
||||
cleanupMode === 'all'
|
||||
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
|
||||
}`}
|
||||
>
|
||||
全部删除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCleanupMode('custom')}
|
||||
className={`rounded-2xl border px-4 py-3 text-sm transition ${
|
||||
cleanupMode === 'custom'
|
||||
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
||||
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
|
||||
}`}
|
||||
>
|
||||
保留自定义天数
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cleanupMode === 'custom' ? (
|
||||
<ResourceField label="保留天数" hint="当前支持 1 到 3650 天。">
|
||||
<ResourceInput
|
||||
value={customRetentionDays}
|
||||
onChange={(event) => setCustomRetentionDays(event.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
placeholder="输入保留天数"
|
||||
/>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
|
||||
{cleanupMutation.isError ? (
|
||||
<ErrorState
|
||||
title="删除应用日志失败"
|
||||
description={getErrorMessage(cleanupMutation.error)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
breakAll = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
breakAll?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-sm text-[var(--foreground-primary)] ${
|
||||
breakAll ? 'break-all' : ''
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ApplyLogItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
version: string;
|
||||
result: 'success' | 'failed' | string;
|
||||
message: string;
|
||||
checksum: string;
|
||||
main_config_checksum: string;
|
||||
route_config_checksum: string;
|
||||
support_file_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApplyLogList {
|
||||
rows: ApplyLogItem[];
|
||||
current: number;
|
||||
total: number;
|
||||
totalPage: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogListQuery {
|
||||
node_id?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogCleanupPayload {
|
||||
delete_all?: boolean;
|
||||
retention_days?: number;
|
||||
}
|
||||
|
||||
export interface ApplyLogCleanupResult {
|
||||
delete_all: boolean;
|
||||
retention_days: number;
|
||||
deleted_count: number;
|
||||
cutoff?: string;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import {
|
||||
clearStoredOpenFlareToken,
|
||||
setStoredOpenFlareToken,
|
||||
} from '@/lib/api/auth-token';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout').finally(() => {
|
||||
clearStoredOpenFlareToken();
|
||||
});
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload) {
|
||||
return apiRequest<void>('/user/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(
|
||||
`/oauth/github?code=${encodeURIComponent(code)}`,
|
||||
).then((user) => {
|
||||
if (user.token) {
|
||||
setStoredOpenFlareToken(user.token);
|
||||
}
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResult {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'registered' | 'linked' | 'link_required';
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export interface LinkExistingOAuthPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizeUrl(source: number | string) {
|
||||
return apiRequest<OAuthAuthorizeResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/authorize`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeOAuthCode(
|
||||
source: number | string,
|
||||
code: string,
|
||||
state: string,
|
||||
) {
|
||||
const searchParams = new URLSearchParams({ code, state });
|
||||
return apiRequest<OAuthCallbackResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
|
||||
).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
|
||||
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}).then((result) => {
|
||||
if (result.user?.token) {
|
||||
setStoredOpenFlareToken(result.user.token);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
import type {PublicStatus} from '@/types/public-status';
|
||||
|
||||
export function getPublicStatus() {
|
||||
return apiRequest<PublicStatus>('/status');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function AuthFormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? (
|
||||
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<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 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface DashboardAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const redirect =
|
||||
pathname && pathname !== '/'
|
||||
? `?redirect=${encodeURIComponent(pathname)}`
|
||||
: '';
|
||||
router.replace(`/login${redirect}`);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
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}</>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeGitHubCode} from '@/features/auth/api/auth';
|
||||
|
||||
export function GitHubOAuthCallback() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledCodeRef = useRef<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exchangeGitHubCode,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || 'GitHub 授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setPrompt('缺少授权 code');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到 GitHub 授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledCodeRef.current === code) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledCodeRef.current = code;
|
||||
mutation.mutate(code);
|
||||
}, [code, mutation]);
|
||||
|
||||
return (
|
||||
<AppCard title="GitHub OAuth 回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {getOAuthAuthorizeUrl, login} from '@/features/auth/api/auth';
|
||||
import {getPublicStatus} from '@/features/auth/api/public';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const TEXT = {
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||
loginFailed:
|
||||
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
oauthUnavailable: '第三方登录当前不可用。',
|
||||
title: '\u7528\u6237\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
password: '\u5bc6\u7801',
|
||||
loginPending: '\u767b\u5f55\u4e2d...',
|
||||
login: '\u767b\u5f55',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
register: '\u6ce8\u518c',
|
||||
};
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, TEXT.usernameRequired),
|
||||
password: z.string().min(1, TEXT.passwordRequired),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const redirect = searchParams?.get('redirect') || '/';
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
router.replace(redirect);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.loginFailed);
|
||||
},
|
||||
});
|
||||
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: getOAuthAuthorizeUrl,
|
||||
onSuccess: (result) => {
|
||||
window.location.href = result.authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.oauthUnavailable);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
loginMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleOAuthLogin = (sourceName: string) => {
|
||||
setErrorMessage('');
|
||||
oauthMutation.mutate(sourceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.password}>
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
第三方账号登录
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(statusQuery.data?.auth_sources ?? []).map((source) => (
|
||||
<SecondaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(source.name)}
|
||||
className="min-w-36"
|
||||
disabled={oauthMutation.isPending}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/reset"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeOAuthCode} from '@/features/auth/api/auth';
|
||||
|
||||
function parseOAuthSource(pathname: string | null, sourceParam: string) {
|
||||
const normalizedParam = sourceParam.trim();
|
||||
if (normalizedParam) {
|
||||
return normalizedParam;
|
||||
}
|
||||
|
||||
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
|
||||
if (!pathMatch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(pathMatch[1]);
|
||||
}
|
||||
|
||||
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledRef = useRef('');
|
||||
const [prompt, setPrompt] = useState('正在处理授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
const state = searchParams?.get('state')?.trim() ?? '';
|
||||
const oauthError = searchParams?.get('error')?.trim() ?? '';
|
||||
const oauthErrorDescription =
|
||||
searchParams?.get('error_description')?.trim() ?? '';
|
||||
const resolvedSource =
|
||||
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'link_required') {
|
||||
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
|
||||
router.replace('/oauth/link');
|
||||
return;
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthError || oauthErrorDescription) {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: oauthErrorDescription || oauthError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resolvedSource).trim() === '') {
|
||||
setPrompt('缺少认证源参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到认证源参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setPrompt('缺少授权参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到完整授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${resolvedSource}:${code}:${state}`;
|
||||
if (handledRef.current === key) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = key;
|
||||
mutation.mutate();
|
||||
}, [
|
||||
code,
|
||||
mutation,
|
||||
oauthError,
|
||||
oauthErrorDescription,
|
||||
resolvedSource,
|
||||
state,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppCard title="第三方登录回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
{message?.tone === 'danger' ? (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {linkExistingOAuthAccount} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OAuthLinkExistingForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: linkExistingOAuthAccount,
|
||||
onSuccess: (result) => {
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || '绑定失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="绑定已有账号"
|
||||
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="用户名">
|
||||
<AuthInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label="密码">
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : 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="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {resetPassword} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
export function PasswordResetConfirmForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams?.get('email') || '';
|
||||
const token = searchParams?.get('token') || '';
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => resetPassword({ email, token }),
|
||||
onSuccess: async (password) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setMessage({
|
||||
tone: 'success',
|
||||
text: `密码已重置,新密码已复制到剪贴板:${password}`,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '密码重置失败,请重新获取链接。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const missingParams = !email || !token;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="密码重置确认"
|
||||
description="确认后,系统会生成新的随机密码。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput value={email} readOnly />
|
||||
</AuthFormField>
|
||||
|
||||
{missingParams ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message="重置链接缺少必要参数,请重新发起密码重置。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<AuthButton
|
||||
type="button"
|
||||
disabled={missingParams || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||
</AuthButton>
|
||||
{message?.tone === 'success' ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {PasswordResetConfirmForm} from '@/features/auth/components/password-reset-confirm-form';
|
||||
import {PasswordResetRequestForm} from '@/features/auth/components/password-reset-request-form';
|
||||
|
||||
export function PasswordResetFlow() {
|
||||
const searchParams = useSearchParams();
|
||||
const hasConfirmationParams = Boolean(
|
||||
searchParams?.get('email') && searchParams?.get('token'),
|
||||
);
|
||||
|
||||
return hasConfirmationParams ? (
|
||||
<PasswordResetConfirmForm />
|
||||
) : (
|
||||
<PasswordResetRequestForm />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {sendPasswordResetEmail} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().email('请输入有效邮箱地址'),
|
||||
});
|
||||
|
||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const form = useForm<ResetRequestFormValues>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ResetRequestFormValues) =>
|
||||
sendPasswordResetEmail(values.email),
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||
form.reset();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '重置邮件发送失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard
|
||||
title="密码重置"
|
||||
description="提交后,系统会向你的注册邮箱发送重置链接。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
|
||||
interface PublicAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
const redirect = searchParams?.get('redirect');
|
||||
router.replace(redirect || '/');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
|
||||
|
||||
interface PublicRoutePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PublicRoutePlaceholder({
|
||||
title,
|
||||
description,
|
||||
}: PublicRoutePlaceholderProps) {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title={title}
|
||||
description={description}
|
||||
milestones={[
|
||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
} from '@/features/config-versions/types';
|
||||
|
||||
export function getConfigVersions() {
|
||||
return apiRequest<ConfigVersionSummary[]>('/config-versions/');
|
||||
}
|
||||
|
||||
export function getConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
|
||||
}
|
||||
|
||||
export function getActiveConfigVersion() {
|
||||
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
||||
}
|
||||
|
||||
export function getConfigVersionPreview() {
|
||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||
}
|
||||
|
||||
export function getConfigVersionDiff() {
|
||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||
}
|
||||
|
||||
export function publishConfigVersion(force?: boolean) {
|
||||
const query = force ? '?force=true' : '';
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function activateConfigVersion(id: number) {
|
||||
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupConfigVersions(payload: { keep_count: number }) {
|
||||
return apiRequest<{ deleted_count: number }>('/config-versions/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
export function CleanupConfigVersionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (keepCount: number) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [keepCount, setKeepCount] = useState<number>(10);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (keepCount < 3) {
|
||||
setError('最少保留 3 个历史快照');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onConfirm(keepCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="清理历史快照"
|
||||
description="删除旧的历史快照配置,系统将始终自动保护当前已激活的版本不被删除。"
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<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 ? '清理中...' : '确认清理'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="cleanup-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="keepCount"
|
||||
className="mb-1 block text-sm font-medium text-[var(--foreground-primary)]"
|
||||
>
|
||||
保留最近快照个数
|
||||
</label>
|
||||
<input
|
||||
id="keepCount"
|
||||
type="number"
|
||||
min={3}
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(parseInt(e.target.value, 10) || 3)}
|
||||
className="w-full rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm text-[var(--foreground-primary)] focus:border-[var(--brand-primary)] focus:ring-1 focus:ring-[var(--brand-primary)] focus:outline-none"
|
||||
disabled={isPending}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||
默认为 10 个,最少需保留 3 个。
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
|
||||
import type { ConfigVersionSummary } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const versionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'detail', version?.id ?? 0],
|
||||
queryFn: () => {
|
||||
if (!version) {
|
||||
throw new Error('missing config version');
|
||||
}
|
||||
return getConfigVersion(version.id);
|
||||
},
|
||||
enabled: Boolean(version?.id),
|
||||
});
|
||||
const versionDetail = versionDetailQuery.data ?? null;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
onClose={onClose}
|
||||
title={version ? `版本 ${version.version}` : '查看快照'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
|
||||
<LoadingState />
|
||||
) : versionDetailQuery.isError ? (
|
||||
<ErrorState
|
||||
title="配置版本详情加载失败"
|
||||
description={getErrorMessage(versionDetailQuery.error)}
|
||||
/>
|
||||
) : versionDetail ? (
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{versionDetail.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{versionDetail.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(versionDetail.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{versionDetail.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{versionDetail.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
activateConfigVersion,
|
||||
getActiveConfigVersion,
|
||||
getConfigVersionDiff,
|
||||
getConfigVersionPreview,
|
||||
getConfigVersions,
|
||||
publishConfigVersion,
|
||||
cleanupConfigVersions,
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import { CleanupConfigVersionsModal } from '@/features/config-versions/components/cleanup-config-versions-modal';
|
||||
import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionDetail,
|
||||
ConfigVersionSummary,
|
||||
SupportFile,
|
||||
} from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function truncateChecksum(checksum: string) {
|
||||
if (!checksum) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
|
||||
}
|
||||
|
||||
function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
return (
|
||||
diff.added_domains.length > 0 ||
|
||||
diff.removed_domains.length > 0 ||
|
||||
diff.modified_domains.length > 0 ||
|
||||
diff.main_config_changed ||
|
||||
diff.changed_option_keys.length > 0 ||
|
||||
!diff.active_version
|
||||
);
|
||||
}
|
||||
|
||||
function DiffList({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{title}
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${items.length} 项`}
|
||||
variant={items.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无相关变更。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SupportFilesList({ files }: { files: SupportFile[] }) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前发布不需要额外支持文件。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<details
|
||||
key={file.path}
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{file.path}
|
||||
</summary>
|
||||
<CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
|
||||
{file.content}
|
||||
</CodeBlock>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionValue(value: string) {
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无 OpenResty 性能参数变化。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">参数</th>
|
||||
<th className="px-3 py-3 font-medium">当前激活值</th>
|
||||
<th className="px-3 py-3 font-medium">待发布值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{items.map((item) => (
|
||||
<tr key={item.key} className="align-top">
|
||||
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{item.key}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.previous_value)}</code>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.current_value)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishPreviewCard({
|
||||
preview,
|
||||
diff,
|
||||
activeVersionMeta,
|
||||
activeVersionDetail,
|
||||
isActiveVersionDetailLoading,
|
||||
activeVersionDetailError,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
activeVersionMeta: ConfigVersionSummary | null;
|
||||
activeVersionDetail: ConfigVersionDetail | null;
|
||||
isActiveVersionDetailLoading: boolean;
|
||||
activeVersionDetailError: string | null;
|
||||
isPublishing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="发布前预览"
|
||||
description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
|
||||
action={
|
||||
<StatusBadge
|
||||
label={`启用规则 ${preview.route_count} 条`}
|
||||
variant="info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-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] text-[var(--foreground-muted)] uppercase">
|
||||
当前激活版本
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{diff.active_version || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
|
||||
新增域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
|
||||
{diff.added_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
|
||||
删除域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
|
||||
{diff.removed_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
|
||||
修改域名
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
|
||||
{diff.modified_domains.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
主配置变化
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{diff.main_config_changed ? '已变化' : '无变化'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canPublish ? (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前规则与已激活版本一致,已阻止重复发布。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<DiffList title="新增域名" items={diff.added_domains} />
|
||||
<DiffList title="删除域名" items={diff.removed_domains} />
|
||||
<DiffList title="修改域名" items={diff.modified_domains} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
OpenResty 参数变化
|
||||
</p>
|
||||
<StatusBadge
|
||||
label={`${diff.changed_option_keys.length} 项`}
|
||||
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
{diff.main_config_changed && activeVersionMeta ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Current Active Main Config
|
||||
</p>
|
||||
<StatusBadge label={activeVersionMeta.version} variant="info" />
|
||||
</div>
|
||||
{isActiveVersionDetailLoading ? (
|
||||
<LoadingState />
|
||||
) : activeVersionDetailError ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={activeVersionDetailError}
|
||||
/>
|
||||
) : activeVersionDetail ? (
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersionDetail.main_config}
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前激活版本详情暂不可用。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
Pending Main Config
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Legacy duplicated main config preview block kept commented out while
|
||||
we replace it with an explicit active-vs-pending comparison above.
|
||||
{diff.main_config_changed && activeVersion ? (
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
褰撳墠婵€娲讳富閰嶇疆
|
||||
</p>
|
||||
<StatusBadge label={activeVersion.version} variant="info" />
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{activeVersion.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
寰呭彂甯冧富閰嶇疆
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum锛歿preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
Checksum:{preview.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{preview.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
支持文件
|
||||
</p>
|
||||
<SupportFilesList files={preview.support_files} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isPublishing || !canPublish}
|
||||
>
|
||||
{isPublishing ? '发布中...' : '确认发布'}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
取消预览
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigVersionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [publishPreview, setPublishPreview] = useState<{
|
||||
preview: ConfigPreviewResult;
|
||||
diff: ConfigDiffResult;
|
||||
} | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
|
||||
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: versionsQueryKey,
|
||||
queryFn: getConfigVersions,
|
||||
});
|
||||
|
||||
const versions = useMemo(
|
||||
() => versionsQuery.data ?? [],
|
||||
[versionsQuery.data],
|
||||
);
|
||||
const activeVersion = useMemo(
|
||||
() => versions.find((item) => item.is_active) ?? null,
|
||||
[versions],
|
||||
);
|
||||
const selectedVersion = useMemo(
|
||||
() => versions.find((item) => item.id === selectedVersionId) ?? null,
|
||||
[selectedVersionId, versions],
|
||||
);
|
||||
const shouldLoadActiveVersionDetail = Boolean(
|
||||
activeVersion?.id && publishPreview?.diff.main_config_changed,
|
||||
);
|
||||
const activeVersionDetailQuery = useQuery({
|
||||
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
|
||||
queryFn: getActiveConfigVersion,
|
||||
enabled: shouldLoadActiveVersionDetail,
|
||||
});
|
||||
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (force?: boolean) => publishConfigVersion(force),
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `发布成功,版本 ${version.version}`,
|
||||
});
|
||||
setPublishPreview(null);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: activateConfigVersion,
|
||||
onSuccess: async (version) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `已激活版本 ${version.version}`,
|
||||
});
|
||||
setSelectedVersionId(version.id);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: cleanupConfigVersions,
|
||||
onSuccess: async (data) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: `成功清理 ${data.deleted_count} 个历史快照`,
|
||||
});
|
||||
setIsCleanupModalOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenPublishPreview = async () => {
|
||||
setFeedback(null);
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
try {
|
||||
const [preview, diff] = await Promise.all([
|
||||
getConfigVersionPreview(),
|
||||
getConfigVersionDiff(),
|
||||
]);
|
||||
setPublishPreview({ preview, diff });
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = (version: ConfigVersionSummary) => {
|
||||
if (version.is_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
activateMutation.mutate(version.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="配置版本"
|
||||
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
|
||||
)
|
||||
) {
|
||||
publishMutation.mutate(true);
|
||||
}
|
||||
}}
|
||||
disabled={publishMutation.isPending}
|
||||
>
|
||||
重新发布
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleOpenPublishPreview}
|
||||
disabled={isPreviewLoading || publishMutation.isPending}
|
||||
>
|
||||
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{publishPreview ? (
|
||||
<PublishPreviewCard
|
||||
preview={publishPreview.preview}
|
||||
diff={publishPreview.diff}
|
||||
activeVersionMeta={activeVersion}
|
||||
activeVersionDetail={activeVersionDetailQuery.data ?? null}
|
||||
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
|
||||
activeVersionDetailError={
|
||||
activeVersionDetailQuery.isError
|
||||
? getErrorMessage(activeVersionDetailQuery.error)
|
||||
: null
|
||||
}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={() => publishMutation.mutate(false)}
|
||||
onCancel={() => setPublishPreview(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="历史版本"
|
||||
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCleanupModalOpen(true)}
|
||||
>
|
||||
清理旧版本
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: versionsQueryKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新列表
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{versionsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : versionsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="版本列表加载失败"
|
||||
description={getErrorMessage(versionsQuery.error)}
|
||||
/>
|
||||
) : versions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无历史版本"
|
||||
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">版本号</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">创建人</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">创建时间</th>
|
||||
<th className="px-3 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{versions.map((version) => (
|
||||
<tr key={version.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{version.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={version.is_active ? '当前激活' : '历史版本'}
|
||||
variant={version.is_active ? 'success' : 'info'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{version.created_by || '系统'}
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={version.checksum}
|
||||
>
|
||||
{truncateChecksum(version.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setSelectedVersionId(version.id)}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
查看快照
|
||||
</SecondaryButton>
|
||||
{!version.is_active ? (
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => handleActivate(version)}
|
||||
disabled={activateMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
重新激活
|
||||
</PrimaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ConfigVersionSnapshotModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setSelectedVersionId(null)}
|
||||
/>
|
||||
|
||||
<CleanupConfigVersionsModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
onConfirm={(keepCount) =>
|
||||
cleanupMutation.mutate({ keep_count: keepCount })
|
||||
}
|
||||
isPending={cleanupMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface ConfigVersionSummary {
|
||||
id: number;
|
||||
version: string;
|
||||
checksum: string;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigVersionDetail extends ConfigVersionSummary {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
rendered_config: string;
|
||||
support_files_json: string;
|
||||
}
|
||||
|
||||
export interface SupportFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ConfigPreviewResult {
|
||||
snapshot_json: string;
|
||||
main_config: string;
|
||||
route_config: string;
|
||||
rendered_config: string;
|
||||
support_files: SupportFile[];
|
||||
checksum: string;
|
||||
route_count: number;
|
||||
website_count: number;
|
||||
}
|
||||
|
||||
export interface ConfigDiffResult {
|
||||
active_version?: string;
|
||||
added_sites: string[];
|
||||
removed_sites: string[];
|
||||
modified_sites: string[];
|
||||
added_domains: string[];
|
||||
removed_domains: string[];
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
waf_config_changed: boolean;
|
||||
changed_option_keys: string[];
|
||||
changed_option_details: ConfigOptionDiffItem[];
|
||||
current_website_count: number;
|
||||
active_website_count: number;
|
||||
}
|
||||
|
||||
export interface ConfigOptionDiffItem {
|
||||
key: string;
|
||||
previous_value: string;
|
||||
current_value: string;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
CompactCapacityTrendPoint,
|
||||
CompactDashboardNodeHealth,
|
||||
CompactDiskIOTrendPoint,
|
||||
CompactDistributionItem,
|
||||
CompactNetworkTrendPoint,
|
||||
CompactTrafficTrendPoint,
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardOverview,
|
||||
DashboardOverviewCompact,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
|
||||
function arrayOrEmpty<T>(value: T[] | null | undefined) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function isCompactDistributionItem(
|
||||
value: DistributionItem | CompactDistributionItem,
|
||||
): value is CompactDistributionItem {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactTrafficTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint,
|
||||
): value is CompactTrafficTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactCapacityTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint,
|
||||
): value is CompactCapacityTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactNetworkTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint,
|
||||
): value is CompactNetworkTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDiskIOTrendPoint(
|
||||
value:
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint,
|
||||
): value is CompactDiskIOTrendPoint {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isCompactDashboardNode(
|
||||
value: DashboardNodeHealth | CompactDashboardNodeHealth,
|
||||
): value is CompactDashboardNodeHealth {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeDistributionItems(
|
||||
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
|
||||
): DistributionItem[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDistributionItem(item)
|
||||
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTrafficTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['traffic_24h'][number]
|
||||
| CompactTrafficTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactTrafficTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
request_count: Number(item[1] ?? 0),
|
||||
error_count: Number(item[2] ?? 0),
|
||||
unique_visitor_count: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapacityTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['capacity_24h'][number]
|
||||
| CompactCapacityTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactCapacityTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
average_cpu_usage_percent: Number(item[1] ?? 0),
|
||||
average_memory_usage_percent: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeNetworkTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['network_24h'][number]
|
||||
| CompactNetworkTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactNetworkTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
network_rx_bytes: Number(item[1] ?? 0),
|
||||
network_tx_bytes: Number(item[2] ?? 0),
|
||||
openresty_rx_bytes: Number(item[3] ?? 0),
|
||||
openresty_tx_bytes: Number(item[4] ?? 0),
|
||||
reported_nodes: Number(item[5] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiskIOTrendPoints(
|
||||
items:
|
||||
| Array<
|
||||
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||
| CompactDiskIOTrendPoint
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDiskIOTrendPoint(item)
|
||||
? {
|
||||
bucket_started_at: String(item[0] ?? ''),
|
||||
disk_read_bytes: Number(item[1] ?? 0),
|
||||
disk_write_bytes: Number(item[2] ?? 0),
|
||||
reported_nodes: Number(item[3] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardNodes(
|
||||
items:
|
||||
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
|
||||
| null
|
||||
| undefined,
|
||||
): DashboardNodeHealth[] {
|
||||
return arrayOrEmpty(items).map((item) =>
|
||||
isCompactDashboardNode(item)
|
||||
? {
|
||||
id: Number(item[0] ?? 0),
|
||||
node_id: String(item[1] ?? ''),
|
||||
name: String(item[2] ?? ''),
|
||||
geo_name: String(item[3] ?? ''),
|
||||
geo_latitude:
|
||||
item[4] === null || item[4] === undefined ? null : Number(item[4]),
|
||||
geo_longitude:
|
||||
item[5] === null || item[5] === undefined ? null : Number(item[5]),
|
||||
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
|
||||
openresty_status: (item[7] ??
|
||||
'unknown') as DashboardNodeHealth['openresty_status'],
|
||||
current_version: String(item[8] ?? ''),
|
||||
last_seen_at: String(item[9] ?? ''),
|
||||
active_event_count: Number(item[10] ?? 0),
|
||||
cpu_usage_percent: Number(item[11] ?? 0),
|
||||
memory_usage_percent: Number(item[12] ?? 0),
|
||||
storage_usage_percent: Number(item[13] ?? 0),
|
||||
request_count: Number(item[14] ?? 0),
|
||||
error_count: Number(item[15] ?? 0),
|
||||
unique_visitor_count: Number(item[16] ?? 0),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDashboardOverview(
|
||||
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
|
||||
): DashboardOverview | null {
|
||||
if (!overview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = (overview.summary ?? {}) as DashboardSummary;
|
||||
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
|
||||
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
|
||||
|
||||
return {
|
||||
generated_at: String(overview.generated_at ?? ''),
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes: normalizeDashboardNodes(overview.nodes),
|
||||
distributions: {
|
||||
source_countries: normalizeDistributionItems(
|
||||
overview.distributions?.source_countries,
|
||||
),
|
||||
status_codes: normalizeDistributionItems(
|
||||
overview.distributions?.status_codes,
|
||||
),
|
||||
top_domains: normalizeDistributionItems(
|
||||
overview.distributions?.top_domains,
|
||||
),
|
||||
},
|
||||
trends: {
|
||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
|
||||
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
|
||||
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardOverview() {
|
||||
const overview = await apiRequest<
|
||||
DashboardOverview | DashboardOverviewCompact
|
||||
>('/dashboard/overview');
|
||||
return normalizeDashboardOverview(overview);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getDashboardOverview } from '@/features/dashboard/api/overview';
|
||||
import type { DashboardNodeHealth } from '@/features/dashboard/types';
|
||||
import { WorldStage } from '@/features/dashboard/components/world-stage';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getNodeStatusVariant,
|
||||
getOpenrestyStatusLabel,
|
||||
getOpenrestyStatusVariant,
|
||||
isWSConnectedLastSeen,
|
||||
} from '@/features/nodes/utils';
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||
import {
|
||||
formatBytes,
|
||||
formatBytesPerSecond,
|
||||
formatPercent,
|
||||
} from '@/lib/utils/metrics';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function formatTrendHour(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return `${date.getHours().toString().padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function buildNodeRankItems(
|
||||
nodes: DashboardNodeHealth[],
|
||||
selector: (node: DashboardNodeHealth) => number,
|
||||
limit = 5,
|
||||
) {
|
||||
return [...nodes]
|
||||
.sort((left, right) => {
|
||||
const leftValue = selector(left);
|
||||
const rightValue = selector(right);
|
||||
if (leftValue === rightValue) {
|
||||
return left.name.localeCompare(right.name, 'zh-CN');
|
||||
}
|
||||
return rightValue - leftValue;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.filter((node) => selector(node) > 0)
|
||||
.map((node) => ({
|
||||
label: node.name,
|
||||
value: selector(node),
|
||||
}));
|
||||
}
|
||||
|
||||
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
||||
return (
|
||||
<Link
|
||||
href={buildNodeDetailHref(node.id)}
|
||||
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
{node.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||
{node.node_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(node.openresty_status)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
CPU
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatPercent(node.cpu_usage_percent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
内存
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatPercent(node.memory_usage_percent)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
最近窗口请求
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.request_count.toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
活动异常
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{node.active_event_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>当前版本:{node.current_version || '未应用'}</p>
|
||||
<p>
|
||||
最近心跳:
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? ' WS 已连接'
|
||||
: node.last_seen_at
|
||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: ' 暂无'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: getDashboardOverview,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="总览看板加载失败"
|
||||
description={getErrorMessage(overviewQuery.error)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
if (!overview) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无总览数据"
|
||||
description="系统已经启动,但还没有可展示的总览聚合结果。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<WorldStage
|
||||
summary={overview.summary}
|
||||
traffic={overview.traffic}
|
||||
capacity={overview.capacity}
|
||||
nodes={overview.nodes}
|
||||
sourceCountries={overview.distributions.source_countries}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="观察整体请求量和错误量是否出现异常抬升。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.traffic_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.request_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '错误量',
|
||||
color: '#ef4444',
|
||||
values: overview.trends.traffic_24h.map(
|
||||
(point) => point.error_count,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="24 小时容量趋势"
|
||||
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
|
||||
>
|
||||
<TrendChart
|
||||
labels={overview.trends.capacity_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_cpu_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
color: '#2563eb',
|
||||
values: overview.trends.capacity_24h.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
),
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<AppCard title="24 小时网络与磁盘趋势">
|
||||
<div className="space-y-6">
|
||||
<TrendChart
|
||||
labels={overview.trends.network_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
|
||||
series={[
|
||||
{
|
||||
label: 'OpenResty 入站',
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_rx_bytes,
|
||||
),
|
||||
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
|
||||
},
|
||||
{
|
||||
label: 'OpenResty 出站',
|
||||
color: '#38bdf8',
|
||||
values: overview.trends.network_24h.map(
|
||||
(point) => point.openresty_tx_bytes,
|
||||
),
|
||||
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TrendChart
|
||||
labels={overview.trends.disk_io_24h.map((point) =>
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
height={180}
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
label: '磁盘读',
|
||||
color: '#a78bfa',
|
||||
fillColor: 'rgba(167, 139, 250, 0.14)',
|
||||
variant: 'area',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_read_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '磁盘写',
|
||||
color: '#fb7185',
|
||||
values: overview.trends.disk_io_24h.map(
|
||||
(point) => point.disk_write_bytes,
|
||||
),
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Top 节点榜单">
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
流量最高节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(
|
||||
overview.nodes,
|
||||
(node) => node.request_count,
|
||||
)}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无流量榜单"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||
容量压力节点
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(overview.nodes, (node) =>
|
||||
Math.round(
|
||||
Math.max(
|
||||
node.cpu_usage_percent,
|
||||
node.memory_usage_percent,
|
||||
node.storage_usage_percent,
|
||||
),
|
||||
),
|
||||
)}
|
||||
color="#ef4444"
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage="暂无容量压力数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
|
||||
<RankChart
|
||||
items={overview.distributions.source_countries.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#38bdf8"
|
||||
emptyMessage="暂无来源分布数据"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="状态码分布"
|
||||
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
|
||||
>
|
||||
<RankChart
|
||||
items={overview.distributions.status_codes.map((item) => ({
|
||||
label: `HTTP ${item.key}`,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#f59e0b"
|
||||
emptyMessage="暂无状态码分布"
|
||||
/>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
|
||||
<RankChart
|
||||
items={overview.distributions.top_domains.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
color="#34d399"
|
||||
emptyMessage="暂无域名分布"
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="节点健康列表"
|
||||
description="按异常数量和资源压力排序。"
|
||||
action={
|
||||
<Link
|
||||
href="/node"
|
||||
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
进入节点页
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{overview.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无节点"
|
||||
description="节点接入后,这里会展示系统健康与容量摘要。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{overview.nodes.slice(0, 8).map((node) => (
|
||||
<NodeHealthRow key={node.node_id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
'use client';
|
||||
|
||||
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { MapChart, ScatterChart } from 'echarts/charts';
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
|
||||
import type {
|
||||
DashboardNodeHealth,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
import {
|
||||
getNodeStatusLabel,
|
||||
getOpenrestyStatusLabel,
|
||||
} from '@/features/nodes/utils';
|
||||
|
||||
echarts.use([
|
||||
GeoComponent,
|
||||
TooltipComponent,
|
||||
MapChart,
|
||||
ScatterChart,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const fallbackCoordinates = [
|
||||
[-122.4194, 37.7749],
|
||||
[-46.6333, -23.5505],
|
||||
[-0.1276, 51.5072],
|
||||
[2.3522, 48.8566],
|
||||
[77.209, 28.6139],
|
||||
[121.4737, 31.2304],
|
||||
[103.8198, 1.3521],
|
||||
[151.2093, -33.8688],
|
||||
[28.0473, -26.2041],
|
||||
[139.6917, 35.6895],
|
||||
] as const;
|
||||
|
||||
type Tone = 'healthy' | 'warning' | 'danger';
|
||||
|
||||
type WorldGeoJson = {
|
||||
type: string;
|
||||
features?: WorldFeature[];
|
||||
};
|
||||
|
||||
type WorldFeature = {
|
||||
properties?: {
|
||||
name?: string;
|
||||
};
|
||||
geometry?: {
|
||||
type?: 'Polygon' | 'MultiPolygon';
|
||||
coordinates?: number[][][] | number[][][][];
|
||||
};
|
||||
};
|
||||
|
||||
type MapNodeDatum = {
|
||||
id: number;
|
||||
name: string;
|
||||
geoName: string;
|
||||
route: string;
|
||||
derivedFromGeo: boolean;
|
||||
requestCount: number;
|
||||
errorCount: number;
|
||||
activeEventCount: number;
|
||||
status: DashboardNodeHealth['status'];
|
||||
openrestyStatus: DashboardNodeHealth['openresty_status'];
|
||||
value: [number, number, number];
|
||||
itemStyle: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
};
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CountryRegionDatum = {
|
||||
name: string;
|
||||
value: number;
|
||||
itemStyle: {
|
||||
areaColor: string;
|
||||
borderColor: string;
|
||||
};
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor: string;
|
||||
borderColor: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
let worldMapRegistrationAttempted = false;
|
||||
let worldMapRegistrationSucceeded = false;
|
||||
|
||||
const baseWorldMapLayoutSizePercent = 168;
|
||||
const baseWorldMapZoom = 1;
|
||||
|
||||
function buildNodeDetailHref(id?: number | null) {
|
||||
if (!id) {
|
||||
return '/node';
|
||||
}
|
||||
return `/node/detail?id=${id}`;
|
||||
}
|
||||
|
||||
function getNodeTone(node: DashboardNodeHealth): Tone {
|
||||
if (
|
||||
node.status === 'offline' ||
|
||||
node.openresty_status === 'unhealthy' ||
|
||||
node.active_event_count > 0
|
||||
) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
if (
|
||||
node.cpu_usage_percent >= 80 ||
|
||||
node.memory_usage_percent >= 85 ||
|
||||
node.storage_usage_percent >= 85
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
|
||||
if (
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number'
|
||||
) {
|
||||
return {
|
||||
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
|
||||
derivedFromGeo: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
coordinates: [
|
||||
...fallbackCoordinates[index % fallbackCoordinates.length],
|
||||
] as [number, number],
|
||||
derivedFromGeo: false,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureWorldMapRegistered() {
|
||||
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
|
||||
worldMapRegistrationSucceeded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (worldMapRegistrationAttempted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worldMapRegistrationAttempted = true;
|
||||
|
||||
try {
|
||||
const geoJson = worldGeoJson as WorldGeoJson;
|
||||
|
||||
if (
|
||||
geoJson.type !== 'FeatureCollection' ||
|
||||
!Array.isArray(geoJson.features)
|
||||
) {
|
||||
throw new Error('invalid world geojson payload');
|
||||
}
|
||||
|
||||
echarts.registerMap(
|
||||
'world',
|
||||
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
|
||||
);
|
||||
|
||||
if (!echarts.getMap('world')) {
|
||||
throw new Error('world map registration failed');
|
||||
}
|
||||
|
||||
worldMapRegistrationSucceeded = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
const registrationError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('unknown world map registration error');
|
||||
console.error('Failed to register ECharts world map', registrationError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function WorldStageMap({
|
||||
isDark,
|
||||
nodes,
|
||||
sourceCountries,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapFailed, setMapFailed] = useState(false);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const ready = ensureWorldMapRegistered();
|
||||
setMapReady(ready);
|
||||
setMapFailed(!ready);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = chartContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSize = () => {
|
||||
const nextWidth = container.clientWidth;
|
||||
const nextHeight = container.clientHeight;
|
||||
setContainerSize((previous) =>
|
||||
previous.width === nextWidth && previous.height === nextHeight
|
||||
? previous
|
||||
: { width: nextWidth, height: nextHeight },
|
||||
);
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSize);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mapPalette = useMemo(
|
||||
() =>
|
||||
isDark
|
||||
? {
|
||||
areaColor: '#13233b',
|
||||
borderColor: 'rgba(125,211,252,0.14)',
|
||||
labelColor: '#e2e8f0',
|
||||
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
|
||||
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
|
||||
healthyColor: '#34d399',
|
||||
warningColor: '#fbbf24',
|
||||
dangerColor: '#fb7185',
|
||||
healthyBorder: '#bbf7d0',
|
||||
warningBorder: '#fde68a',
|
||||
dangerBorder: '#fecdd3',
|
||||
}
|
||||
: {
|
||||
areaColor: '#eaf2ff',
|
||||
borderColor: 'rgba(71,85,105,0.12)',
|
||||
labelColor: '#0f172a',
|
||||
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
|
||||
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
|
||||
healthyColor: '#10b981',
|
||||
warningColor: '#f59e0b',
|
||||
dangerColor: '#f43f5e',
|
||||
healthyBorder: '#d1fae5',
|
||||
warningBorder: '#fde68a',
|
||||
dangerBorder: '#fecdd3',
|
||||
},
|
||||
[isDark],
|
||||
);
|
||||
|
||||
const mapNodes = useMemo<MapNodeDatum[]>(
|
||||
() =>
|
||||
nodes.map((node, index) => {
|
||||
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
|
||||
const tone = getNodeTone(node);
|
||||
const toneColor =
|
||||
tone === 'healthy'
|
||||
? mapPalette.healthyColor
|
||||
: tone === 'warning'
|
||||
? mapPalette.warningColor
|
||||
: mapPalette.dangerColor;
|
||||
const toneBorder =
|
||||
tone === 'healthy'
|
||||
? mapPalette.healthyBorder
|
||||
: tone === 'warning'
|
||||
? mapPalette.warningBorder
|
||||
: mapPalette.dangerBorder;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
geoName: node.geo_name || node.name,
|
||||
route: buildNodeDetailHref(node.id),
|
||||
derivedFromGeo,
|
||||
requestCount: node.request_count,
|
||||
errorCount: node.error_count,
|
||||
activeEventCount: node.active_event_count,
|
||||
status: node.status,
|
||||
openrestyStatus: node.openresty_status,
|
||||
value: [
|
||||
coordinates[0],
|
||||
coordinates[1],
|
||||
Math.max(node.request_count, 1),
|
||||
],
|
||||
itemStyle: {
|
||||
color: toneColor,
|
||||
borderColor: toneBorder,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: toneBorder,
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
[mapPalette, nodes],
|
||||
);
|
||||
|
||||
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
|
||||
const maxRequests = Math.max(
|
||||
1,
|
||||
...sourceCountries.map((item) => item.value || 0),
|
||||
);
|
||||
|
||||
return sourceCountries
|
||||
.filter((item) => item.key && item.value > 0)
|
||||
.map((item) => {
|
||||
const intensity = Math.max(0.18, item.value / maxRequests);
|
||||
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
|
||||
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
|
||||
const areaColor = isDark
|
||||
? `rgba(56, 189, 248, ${areaOpacity})`
|
||||
: `rgba(14, 165, 233, ${areaOpacity})`;
|
||||
const borderColor = isDark
|
||||
? `rgba(125, 211, 252, ${borderOpacity})`
|
||||
: `rgba(14, 165, 233, ${borderOpacity})`;
|
||||
|
||||
return {
|
||||
name: item.key,
|
||||
value: item.value,
|
||||
itemStyle: {
|
||||
areaColor,
|
||||
borderColor,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
areaColor,
|
||||
borderColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [isDark, sourceCountries]);
|
||||
|
||||
const responsiveMapScale = useMemo(() => {
|
||||
const { width, height } = containerSize;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const widthScale = Math.min(Math.max(width / 960, 0.52), 1.08);
|
||||
const heightScale = Math.min(Math.max(height / 520, 0.72), 1.1);
|
||||
const compactViewportScale = width < 640 ? 0.9 : 1;
|
||||
|
||||
return Number(
|
||||
(Math.min(widthScale, heightScale) * compactViewportScale).toFixed(3),
|
||||
);
|
||||
}, [containerSize]);
|
||||
|
||||
const computedLayoutSize = useMemo(
|
||||
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
|
||||
[responsiveMapScale],
|
||||
);
|
||||
const computedZoom = useMemo(
|
||||
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
|
||||
[responsiveMapScale],
|
||||
);
|
||||
|
||||
const mapOption = useMemo<EChartsCoreOption>(
|
||||
() => ({
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
transitionDuration: 0,
|
||||
backgroundColor: isDark
|
||||
? 'rgba(15,23,42,0.96)'
|
||||
: 'rgba(255,255,255,0.96)',
|
||||
borderColor: isDark
|
||||
? 'rgba(148,163,184,0.2)'
|
||||
: 'rgba(148,163,184,0.24)',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: isDark ? '#e2e8f0' : '#0f172a',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const payload = params as {
|
||||
data?: MapNodeDatum | CountryRegionDatum;
|
||||
seriesType?: string;
|
||||
name?: string;
|
||||
};
|
||||
const data = payload.data;
|
||||
if (
|
||||
payload.seriesType !== 'scatter' &&
|
||||
data &&
|
||||
'value' in data &&
|
||||
typeof data.value === 'number'
|
||||
) {
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
|
||||
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
if (!data || !('requestCount' in data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const locationLine = data.derivedFromGeo
|
||||
? data.geoName
|
||||
: `${data.geoName} · 预设落点`;
|
||||
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
].join('');
|
||||
},
|
||||
},
|
||||
geo: {
|
||||
map: 'world',
|
||||
roam: true,
|
||||
silent: true,
|
||||
layoutCenter: ['50%', '50%'],
|
||||
layoutSize: computedLayoutSize,
|
||||
zoom: computedZoom,
|
||||
scaleLimit: {
|
||||
min: Math.max(Number((computedZoom * 0.78).toFixed(3)), 0.35),
|
||||
max: Math.max(Number((computedZoom * 2.2).toFixed(3)), 1.4),
|
||||
},
|
||||
regions: countryRegions,
|
||||
itemStyle: {
|
||||
areaColor: mapPalette.areaColor,
|
||||
borderColor: mapPalette.borderColor,
|
||||
borderWidth: 0.7,
|
||||
},
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'world',
|
||||
geoIndex: 0,
|
||||
data: countryRegions,
|
||||
silent: true,
|
||||
z: 1,
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'geo',
|
||||
data: mapNodes,
|
||||
z: 3,
|
||||
progressive: 64,
|
||||
large: true,
|
||||
largeThreshold: 24,
|
||||
symbolSize: (value: unknown) => {
|
||||
const size =
|
||||
Array.isArray(value) && typeof value[2] === 'number'
|
||||
? value[2]
|
||||
: 1;
|
||||
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
|
||||
return Math.max(
|
||||
7,
|
||||
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
|
||||
);
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
color: mapPalette.labelColor,
|
||||
fontSize: 11,
|
||||
},
|
||||
emphasis: {
|
||||
scale: 1.12,
|
||||
label: {
|
||||
show: mapNodes.length > 0 && mapNodes.length <= 5,
|
||||
position: 'right',
|
||||
distance: 8,
|
||||
formatter: '{b}',
|
||||
backgroundColor: isDark
|
||||
? 'rgba(8,15,31,0.7)'
|
||||
: 'rgba(255,255,255,0.88)',
|
||||
borderColor: isDark
|
||||
? 'rgba(148,163,184,0.16)'
|
||||
: 'rgba(148,163,184,0.2)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
padding: [4, 8],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
computedLayoutSize,
|
||||
computedZoom,
|
||||
countryRegions,
|
||||
isDark,
|
||||
mapNodes,
|
||||
mapPalette,
|
||||
responsiveMapScale,
|
||||
],
|
||||
);
|
||||
|
||||
if (!mapReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={chartContainerRef} className="h-full w-full">
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={mapOption}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
opts={{ renderer: 'canvas' }}
|
||||
onEvents={{
|
||||
click: (params: { data?: MapNodeDatum }) => {
|
||||
if (params.data?.route) {
|
||||
router.push(params.data.route);
|
||||
}
|
||||
},
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import type {
|
||||
DashboardCapacity,
|
||||
DashboardNodeHealth,
|
||||
DashboardSummary,
|
||||
DashboardTraffic,
|
||||
DistributionItem,
|
||||
} from '@/features/dashboard/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
|
||||
|
||||
const WorldStageMap = dynamic(
|
||||
() =>
|
||||
import('@/features/dashboard/components/world-stage-map').then(
|
||||
(module) => module.WorldStageMap,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function formatPercent(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '0%';
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function HeroMetric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[24px] border px-5 py-5 backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/10 bg-white/6'
|
||||
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] tracking-[0.26em] uppercase',
|
||||
isDark ? 'text-slate-300' : 'text-slate-500',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 text-[30px] leading-none font-semibold',
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-sm',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
)}
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendPill({
|
||||
label,
|
||||
tone,
|
||||
isDark,
|
||||
}: {
|
||||
label: string;
|
||||
tone: Tone;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'healthy'
|
||||
? isDark
|
||||
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: tone === 'warning'
|
||||
? isDark
|
||||
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: tone === 'source'
|
||||
? isDark
|
||||
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
||||
: 'border-sky-200 bg-sky-50 text-sky-700'
|
||||
: isDark
|
||||
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
|
||||
: 'border-rose-200 bg-rose-50 text-rose-700';
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorldStage({
|
||||
summary,
|
||||
traffic,
|
||||
capacity,
|
||||
nodes,
|
||||
sourceCountries,
|
||||
}: {
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRenderMap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapViewport = mapViewportRef.current;
|
||||
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
|
||||
setShouldRenderMap(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setShouldRenderMap(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '180px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(mapViewport);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [shouldRenderMap]);
|
||||
|
||||
const onlineRate =
|
||||
summary.total_nodes > 0
|
||||
? (summary.online_nodes / summary.total_nodes) * 100
|
||||
: 0;
|
||||
const healthyNodes = Math.max(
|
||||
0,
|
||||
summary.online_nodes - summary.unhealthy_nodes,
|
||||
);
|
||||
const healthyRate =
|
||||
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
|
||||
const geoConfiguredNodes = nodes.filter(
|
||||
(node) =>
|
||||
typeof node.geo_latitude === 'number' &&
|
||||
typeof node.geo_longitude === 'number',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[32px] border transition-colors',
|
||||
isDark
|
||||
? 'border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]'
|
||||
: 'border-sky-100/90 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(59,130,246,0.12),transparent_20%),linear-gradient(135deg,#f8fbff,#edf5ff_45%,#ffffff)] shadow-[0_32px_80px_rgba(148,163,184,0.18)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-6 py-6 md:px-7 md:py-7',
|
||||
isDark ? 'border-white/8' : 'border-slate-200/70',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px] tracking-[0.34em] uppercase',
|
||||
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
|
||||
)}
|
||||
>
|
||||
Global Stage
|
||||
</p>
|
||||
<h2
|
||||
className={cn(
|
||||
'text-2xl font-semibold',
|
||||
isDark ? 'text-white' : 'text-slate-950',
|
||||
)}
|
||||
>
|
||||
全球态势板
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.32fr_0.88fr]">
|
||||
<div className="space-y-5">
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[540px] overflow-hidden rounded-[28px] border py-6 lg:min-h-[600px]',
|
||||
isDark
|
||||
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
|
||||
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
|
||||
isDark
|
||||
? 'bg-sky-400/20 text-sky-100'
|
||||
: 'bg-sky-100/90 text-sky-700',
|
||||
)}
|
||||
>
|
||||
{sourceCountries.length > 0
|
||||
? '访客来源热度'
|
||||
: geoConfiguredNodes > 0
|
||||
? '节点地理坐标'
|
||||
: '节点覆盖信号'}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
|
||||
<LegendPill
|
||||
label="国家底色: 来源热度"
|
||||
tone="source"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="绿色: 运行正常"
|
||||
tone="healthy"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="黄色: 资源承压"
|
||||
tone="warning"
|
||||
isDark={isDark}
|
||||
/>
|
||||
<LegendPill
|
||||
label="红色: 异常待处理"
|
||||
tone="danger"
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={mapViewportRef}
|
||||
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
|
||||
>
|
||||
<div className="h-full w-full min-w-0 overflow-hidden">
|
||||
{shouldRenderMap ? (
|
||||
<WorldStageMap
|
||||
isDark={isDark}
|
||||
nodes={nodes}
|
||||
sourceCountries={sourceCountries}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title="全球地图准备中"
|
||||
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
|
||||
isDark
|
||||
? 'border-white/15 bg-white/5 text-slate-300'
|
||||
: 'border-slate-300/70 bg-white/78 text-slate-600',
|
||||
)}
|
||||
>
|
||||
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
|
||||
<HeroMetric
|
||||
label="在线覆盖"
|
||||
value={formatPercent(onlineRate)}
|
||||
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="运行健康"
|
||||
value={formatPercent(healthyRate)}
|
||||
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="最近窗口请求"
|
||||
value={traffic.request_count.toLocaleString('zh-CN')}
|
||||
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="平均 CPU"
|
||||
value={formatPercent(capacity.average_cpu_usage_percent)}
|
||||
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="平均内存"
|
||||
value={formatPercent(capacity.average_memory_usage_percent)}
|
||||
hint={`${capacity.high_memory_nodes} 个高内存节点`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<HeroMetric
|
||||
label="高存储节点"
|
||||
value={capacity.high_storage_nodes.toLocaleString('zh-CN')}
|
||||
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
export interface DashboardSummary {
|
||||
total_nodes: number;
|
||||
online_nodes: number;
|
||||
offline_nodes: number;
|
||||
pending_nodes: number;
|
||||
unhealthy_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardTraffic {
|
||||
request_count: number;
|
||||
unique_visitors: number;
|
||||
error_count: number;
|
||||
estimated_qps: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DashboardCapacity {
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
high_cpu_nodes: number;
|
||||
high_memory_nodes: number;
|
||||
high_storage_nodes: number;
|
||||
}
|
||||
|
||||
export interface DistributionItem {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type CompactDistributionItem = [string, number];
|
||||
|
||||
export interface TrafficTrendPoint {
|
||||
bucket_started_at: string;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface CapacityTrendPoint {
|
||||
bucket_started_at: string;
|
||||
average_cpu_usage_percent: number;
|
||||
average_memory_usage_percent: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface NetworkTrendPoint {
|
||||
bucket_started_at: string;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
openresty_rx_bytes: number;
|
||||
openresty_tx_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface DiskIOTrendPoint {
|
||||
bucket_started_at: string;
|
||||
disk_read_bytes: number;
|
||||
disk_write_bytes: number;
|
||||
reported_nodes: number;
|
||||
}
|
||||
|
||||
export interface TrafficDistributions {
|
||||
status_codes: DistributionItem[];
|
||||
top_domains: DistributionItem[];
|
||||
source_countries: DistributionItem[];
|
||||
}
|
||||
|
||||
export interface DashboardTrends {
|
||||
traffic_24h: TrafficTrendPoint[];
|
||||
capacity_24h: CapacityTrendPoint[];
|
||||
network_24h: NetworkTrendPoint[];
|
||||
disk_io_24h: DiskIOTrendPoint[];
|
||||
}
|
||||
|
||||
export interface DashboardNodeHealth {
|
||||
id: number;
|
||||
node_id: string;
|
||||
name: string;
|
||||
geo_name: string;
|
||||
geo_latitude?: number | null;
|
||||
geo_longitude?: number | null;
|
||||
status: 'online' | 'offline' | 'pending';
|
||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
current_version: string;
|
||||
last_seen_at: string;
|
||||
active_event_count: number;
|
||||
cpu_usage_percent: number;
|
||||
memory_usage_percent: number;
|
||||
storage_usage_percent: number;
|
||||
request_count: number;
|
||||
error_count: number;
|
||||
unique_visitor_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: TrafficDistributions;
|
||||
trends: DashboardTrends;
|
||||
nodes: DashboardNodeHealth[];
|
||||
}
|
||||
|
||||
export type CompactTrafficTrendPoint = [string, number, number, number];
|
||||
export type CompactCapacityTrendPoint = [string, number, number, number];
|
||||
export type CompactNetworkTrendPoint = [
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
export type CompactDiskIOTrendPoint = [string, number, number, number];
|
||||
export type CompactDashboardNodeHealth = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number | null,
|
||||
number | null,
|
||||
DashboardNodeHealth['status'],
|
||||
DashboardNodeHealth['openresty_status'],
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
export interface DashboardOverviewCompact {
|
||||
generated_at: string;
|
||||
summary: DashboardSummary;
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
distributions: {
|
||||
status_codes: CompactDistributionItem[];
|
||||
top_domains: CompactDistributionItem[];
|
||||
source_countries: CompactDistributionItem[];
|
||||
};
|
||||
trends: {
|
||||
traffic_24h: CompactTrafficTrendPoint[];
|
||||
capacity_24h: CompactCapacityTrendPoint[];
|
||||
network_24h: CompactNetworkTrendPoint[];
|
||||
disk_io_24h: CompactDiskIOTrendPoint[];
|
||||
};
|
||||
nodes: CompactDashboardNodeHealth[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
import type {DnsAccountItem, DnsAccountMutationPayload,} from '@/features/dns-accounts/types';
|
||||
|
||||
export function getDnsAccounts() {
|
||||
return apiRequest<DnsAccountItem[]>('/dns-accounts/');
|
||||
}
|
||||
|
||||
export function createDnsAccount(payload: DnsAccountMutationPayload) {
|
||||
return apiRequest<DnsAccountItem>('/dns-accounts/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDnsAccount(
|
||||
id: number,
|
||||
payload: DnsAccountMutationPayload,
|
||||
) {
|
||||
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDnsAccount(id: number) {
|
||||
return apiRequest<void>(`/dns-accounts/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
import {EmptyState} from '@/components/feedback/empty-state';
|
||||
import {ErrorState} from '@/components/feedback/error-state';
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {PageHeader} from '@/components/layout/page-header';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {AppModal} from '@/components/ui/app-modal';
|
||||
import {createDnsAccount, deleteDnsAccount, getDnsAccounts,} from '@/features/dns-accounts/api/dns-accounts';
|
||||
import type {DnsAccountItem} from '@/features/dns-accounts/types';
|
||||
import {getErrorMessage} from '@/features/websites/utils';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import {formatDateTime} from '@/lib/utils/date';
|
||||
|
||||
export function DnsAccountsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const dnsAccountsQuery = useQuery({
|
||||
queryKey: ['dns-accounts'],
|
||||
queryFn: getDnsAccounts,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDnsAccount,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (account: DnsAccountItem) => {
|
||||
if (!window.confirm(`确认删除 DNS 账号 ${account.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(account.id);
|
||||
};
|
||||
|
||||
const accounts = useMemo(
|
||||
() => dnsAccountsQuery.data ?? [],
|
||||
[dnsAccountsQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="DNS 账号"
|
||||
description="统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/website/certificate"
|
||||
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
添加账号
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard title="DNS 账号列表">
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="加载失败"
|
||||
description={getErrorMessage(dnsAccountsQuery.error)}
|
||||
/>
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无 DNS 账号"
|
||||
description="点击右上角“添加账号”开始录入。"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<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="space-y-2">
|
||||
<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>
|
||||
</p>
|
||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||
<p>创建于:{formatDateTime(account.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(account)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isCreateOpen && (
|
||||
<DnsAccountCreateModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||
setIsCreateOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsAccountCreateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState('');
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createDnsAccount,
|
||||
onSuccess: onCreated,
|
||||
onError: (err) => setError(getErrorMessage(err)),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
setError('');
|
||||
// for cloudflare we wrap the token in JSON if it isn't already (the backend expects JSON)
|
||||
let auth = values.authorization;
|
||||
if (!auth.startsWith('{')) {
|
||||
auth = JSON.stringify({ api_token: values.authorization });
|
||||
}
|
||||
createMutation.mutate({ ...values, authorization: auth });
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
{error && <InlineMessage tone="danger" message={error} />}
|
||||
<ResourceField
|
||||
label="账号名称"
|
||||
error={formState.errors.name?.message as string}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="Cloudlfare 邮箱账号"
|
||||
{...register('name', { required: '请输入名称' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="DNS 服务商">
|
||||
<ResourceSelect {...register('type')}>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="API Token" hint="请勿使用 Global API Key">
|
||||
<ResourceInput
|
||||
{...register('authorization', { required: '请输入 Token' })}
|
||||
/>
|
||||
</ResourceField>
|
||||
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '提交中...' : '提交'}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DnsAccountItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DnsAccountMutationPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {apiRequest} from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
ManagedDomainCertificateOption,
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMutationPayload,
|
||||
} from '@/features/managed-domains/types';
|
||||
|
||||
export function getManagedDomains() {
|
||||
return apiRequest<ManagedDomainItem[]>('/managed-domains/');
|
||||
}
|
||||
|
||||
export function createManagedDomain(payload: ManagedDomainMutationPayload) {
|
||||
return apiRequest<ManagedDomainItem>('/managed-domains/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateManagedDomain(
|
||||
id: number,
|
||||
payload: ManagedDomainMutationPayload,
|
||||
) {
|
||||
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteManagedDomain(id: number) {
|
||||
return apiRequest<void>(`/managed-domains/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getManagedDomainCertificates() {
|
||||
return apiRequest<ManagedDomainCertificateOption[]>('/tls-certificates/');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user