From 9ec3d5b42db5f0d5d9f9bb2fc39e4d20dc0ef3cf Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 30 May 2026 13:15:20 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openflare_server/web/.eslintrc.json | 24 +- openflare_server/web/README.md | 70 +- .../app/(dashboard)/config-version/page.tsx | 10 +- .../web/app/(dashboard)/layout.tsx | 32 +- .../web/app/(dashboard)/node/page.tsx | 10 +- openflare_server/web/app/(dashboard)/page.tsx | 2 +- .../web/app/(dashboard)/proxy-route/page.tsx | 10 +- .../web/app/(dashboard)/setting/page.tsx | 10 +- .../web/app/(dashboard)/user/page.tsx | 10 +- .../web/app/(public)/about/page.tsx | 10 +- openflare_server/web/app/(public)/layout.tsx | 22 +- .../web/app/(public)/login/page.tsx | 24 +- .../web/app/(public)/oauth/github/page.tsx | 24 +- .../web/app/(public)/reset/page.tsx | 24 +- .../web/app/(public)/user/reset/page.tsx | 24 +- openflare_server/web/app/globals.css | 305 +- openflare_server/web/app/layout.tsx | 80 +- openflare_server/web/app/not-found.tsx | 42 +- .../web/components/data/rank-chart.tsx | 165 +- .../web/components/data/trend-chart.tsx | 108 +- .../web/components/feedback/empty-state.tsx | 36 +- .../web/components/feedback/error-state.tsx | 30 +- .../feedback/feature-placeholder.tsx | 62 +- .../components/feedback/inline-message.tsx | 70 +- .../web/components/feedback/loading-state.tsx | 30 +- .../web/components/forms/turnstile-widget.tsx | 197 +- .../web/components/layout/dashboard-shell.tsx | 40 +- .../web/components/layout/page-header.tsx | 44 +- .../web/components/layout/public-shell.tsx | 80 +- .../components/providers/app-providers.tsx | 74 +- .../components/providers/auth-provider.tsx | 181 +- .../components/providers/theme-provider.tsx | 216 +- .../web/components/ui/app-card.tsx | 88 +- .../web/components/ui/app-modal.tsx | 42 +- openflare_server/web/components/ui/drawer.tsx | 34 +- openflare_server/web/components/ui/input.tsx | 2 +- openflare_server/web/components/ui/label.tsx | 25 +- .../web/components/ui/separator.tsx | 18 +- openflare_server/web/components/ui/switch.tsx | 3 +- .../web/components/ui/textarea.tsx | 2 +- .../web/components/ui/theme-toggle.tsx | 64 +- .../features/about/components/about-page.tsx | 237 +- .../apply-logs/components/apply-logs-page.tsx | 69 +- .../web/features/auth/api/public.ts | 12 +- .../auth/components/auth-form-primitives.tsx | 136 +- .../auth/components/dashboard-auth-guard.tsx | 73 +- .../auth/components/github-oauth-callback.tsx | 103 +- .../password-reset-confirm-form.tsx | 188 +- .../auth/components/password-reset-flow.tsx | 32 +- .../password-reset-request-form.tsx | 221 +- .../auth/components/public-auth-guard.tsx | 70 +- .../components/public-route-placeholder.tsx | 46 +- .../config-versions/api/config-versions.ts | 18 +- .../cleanup-config-versions-modal.tsx | 7 +- .../components/config-versions-page.tsx | 15 +- .../web/features/dashboard/api/overview.ts | 40 +- .../components/dashboard-overview.tsx | 22 +- .../features/dashboard/data/world-geo.json | 17809 +++++++++++++++- .../features/dns-accounts/api/dns-accounts.ts | 5 +- .../components/dns-accounts-page.tsx | 79 +- .../managed-domains/api/managed-domains.ts | 5 +- .../web/features/nodes/api/nodes.ts | 6 +- .../nodes/components/node-detail-page.tsx | 28 +- .../nodes/components/node-editor-modal.tsx | 6 +- .../features/nodes/components/nodes-page.tsx | 8 +- openflare_server/web/features/nodes/types.ts | 390 +- openflare_server/web/features/nodes/utils.ts | 383 +- .../components/performance-page.tsx | 2564 ++- .../features/proxy-routes/api/proxy-routes.ts | 53 +- .../components/domain-list-input.tsx | 8 +- .../components/proxy-route-config-page.tsx | 53 +- .../components/proxy-route-create-drawer.tsx | 15 +- .../components/proxy-routes-page.tsx | 33 +- .../settings/components/auth-source-modal.tsx | 2 +- .../settings/components/settings-page.tsx | 5 +- .../components/module-page-skeleton.tsx | 80 +- .../shared/components/resource-primitives.tsx | 2 +- .../components/version-upgrade-modal.tsx | 912 +- .../web/features/users/api/users.ts | 72 +- .../features/users/components/users-page.tsx | 1094 +- openflare_server/web/features/users/types.ts | 53 +- .../web/features/waf/components/waf-page.tsx | 2288 +- .../components/certificate-detail-modal.tsx | 30 +- .../components/certificate-import-modal.tsx | 9 +- .../components/website-detail-page.tsx | 7 +- .../components/website-editor-modal.tsx | 12 +- .../websites/components/websites-page.tsx | 4 +- .../web/features/websites/schemas.ts | 1 - openflare_server/web/lib/env/public-env.ts | 26 +- openflare_server/web/lib/theme/theme.ts | 82 +- openflare_server/web/lib/utils.ts | 6 +- openflare_server/web/lib/utils/cn.ts | 12 +- openflare_server/web/lib/utils/date.ts | 148 +- openflare_server/web/next-env.d.ts | 10 +- openflare_server/web/next.config.mjs | 14 +- openflare_server/web/playwright.config.ts | 32 +- openflare_server/web/pnpm-lock.yaml | 6387 ++++-- openflare_server/web/postcss.config.mjs | 10 +- openflare_server/web/prettier.config.mjs | 16 +- openflare_server/web/scripts/dev-server.mjs | 34 +- .../web/scripts/prepare-static-export.mjs | 8 +- openflare_server/web/store/app-shell.ts | 4 +- openflare_server/web/styles/tokens.css | 160 +- .../web/tests/setup/vitest.setup.ts | 2 +- .../web/tests/unit/proxy-routes-page.test.tsx | 13 +- .../web/tests/unit/theme-utils.test.ts | 38 +- openflare_server/web/tsconfig.json | 54 +- openflare_server/web/types/api.ts | 10 +- openflare_server/web/types/auth.ts | 54 +- openflare_server/web/vitest.config.ts | 28 +- 110 files changed, 29155 insertions(+), 7642 deletions(-) diff --git a/openflare_server/web/.eslintrc.json b/openflare_server/web/.eslintrc.json index 5ffd7078..5cce9572 100644 --- a/openflare_server/web/.eslintrc.json +++ b/openflare_server/web/.eslintrc.json @@ -1,12 +1,12 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"], - "ignorePatterns": [ - ".next", - "build", - "out", - "coverage", - "playwright-report", - "test-results", - "src" - ] -} +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "ignorePatterns": [ + ".next", + "build", + "out", + "coverage", + "playwright-report", + "test-results", + "src" + ] +} diff --git a/openflare_server/web/README.md b/openflare_server/web/README.md index c205be77..f29f9f1a 100644 --- a/openflare_server/web/README.md +++ b/openflare_server/web/README.md @@ -1,49 +1,49 @@ -# OpenFlare Web - -OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。 - +# OpenFlare Web + +OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。 + ## 常用命令 - -```shell -corepack enable -pnpm install - + +```shell +corepack enable +pnpm install + # 本地开发(3001 前端同源代理后端 HTTP/WS) pnpm dev # 生产模式启动 pnpm start - -# 类型检查 -pnpm typecheck - -# 代码检查 -pnpm lint - -# 单元测试 -pnpm test - -# 生成静态构建产物到 build/ -pnpm build -``` - + +# 类型检查 +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 生产模式。 +- 构建采用 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/`:测试代码 + +- `app/`:路由与布局 +- `features/`:业务模块 +- `components/`:复用组件 +- `lib/`:请求、环境变量、工具与常量 +- `tests/`:测试代码 diff --git a/openflare_server/web/app/(dashboard)/config-version/page.tsx b/openflare_server/web/app/(dashboard)/config-version/page.tsx index 0ced55fd..ded737bc 100644 --- a/openflare_server/web/app/(dashboard)/config-version/page.tsx +++ b/openflare_server/web/app/(dashboard)/config-version/page.tsx @@ -1,5 +1,5 @@ -import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page'; - -export default function ConfigVersionsRoute() { - return ; -} +import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page'; + +export default function ConfigVersionsRoute() { + return ; +} diff --git a/openflare_server/web/app/(dashboard)/layout.tsx b/openflare_server/web/app/(dashboard)/layout.tsx index 8896d9f7..6e7e960a 100644 --- a/openflare_server/web/app/(dashboard)/layout.tsx +++ b/openflare_server/web/app/(dashboard)/layout.tsx @@ -1,16 +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 ( - - {children} - - ); -} +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 ( + + {children} + + ); +} diff --git a/openflare_server/web/app/(dashboard)/node/page.tsx b/openflare_server/web/app/(dashboard)/node/page.tsx index 9665570b..a4310aa0 100644 --- a/openflare_server/web/app/(dashboard)/node/page.tsx +++ b/openflare_server/web/app/(dashboard)/node/page.tsx @@ -1,5 +1,5 @@ -import { NodesPage } from '@/features/nodes/components/nodes-page'; - -export default function NodesRoute() { - return ; -} +import { NodesPage } from '@/features/nodes/components/nodes-page'; + +export default function NodesRoute() { + return ; +} diff --git a/openflare_server/web/app/(dashboard)/page.tsx b/openflare_server/web/app/(dashboard)/page.tsx index 24412991..18442b00 100644 --- a/openflare_server/web/app/(dashboard)/page.tsx +++ b/openflare_server/web/app/(dashboard)/page.tsx @@ -2,7 +2,7 @@ import { DashboardOverview } from '@/features/dashboard/components/dashboard-ove export default function DashboardPage() { return ( -
+
); diff --git a/openflare_server/web/app/(dashboard)/proxy-route/page.tsx b/openflare_server/web/app/(dashboard)/proxy-route/page.tsx index 86758892..7b5b52ea 100644 --- a/openflare_server/web/app/(dashboard)/proxy-route/page.tsx +++ b/openflare_server/web/app/(dashboard)/proxy-route/page.tsx @@ -1,5 +1,5 @@ -import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page'; - -export default function ProxyRoutesRoute() { - return ; -} +import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page'; + +export default function ProxyRoutesRoute() { + return ; +} diff --git a/openflare_server/web/app/(dashboard)/setting/page.tsx b/openflare_server/web/app/(dashboard)/setting/page.tsx index d39a5433..5945a5a0 100644 --- a/openflare_server/web/app/(dashboard)/setting/page.tsx +++ b/openflare_server/web/app/(dashboard)/setting/page.tsx @@ -1,5 +1,5 @@ -import { SettingsPage } from '@/features/settings/components/settings-page'; - -export default function SettingsRoute() { - return ; -} +import { SettingsPage } from '@/features/settings/components/settings-page'; + +export default function SettingsRoute() { + return ; +} diff --git a/openflare_server/web/app/(dashboard)/user/page.tsx b/openflare_server/web/app/(dashboard)/user/page.tsx index ac90dac5..d6015af7 100644 --- a/openflare_server/web/app/(dashboard)/user/page.tsx +++ b/openflare_server/web/app/(dashboard)/user/page.tsx @@ -1,5 +1,5 @@ -import { UsersPage } from '@/features/users/components/users-page'; - -export default function UsersRoute() { - return ; -} +import { UsersPage } from '@/features/users/components/users-page'; + +export default function UsersRoute() { + return ; +} diff --git a/openflare_server/web/app/(public)/about/page.tsx b/openflare_server/web/app/(public)/about/page.tsx index 5240390b..f193fb8c 100644 --- a/openflare_server/web/app/(public)/about/page.tsx +++ b/openflare_server/web/app/(public)/about/page.tsx @@ -1,5 +1,5 @@ -import { AboutPage } from '@/features/about/components/about-page'; - -export default function AboutRoute() { - return ; -} +import { AboutPage } from '@/features/about/components/about-page'; + +export default function AboutRoute() { + return ; +} diff --git a/openflare_server/web/app/(public)/layout.tsx b/openflare_server/web/app/(public)/layout.tsx index 43b9b9ae..3417f291 100644 --- a/openflare_server/web/app/(public)/layout.tsx +++ b/openflare_server/web/app/(public)/layout.tsx @@ -1,11 +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 {children}; -} +import type { ReactNode } from 'react'; + +import { PublicShell } from '@/components/layout/public-shell'; + +interface PublicLayoutProps { + children: ReactNode; +} + +export default function PublicLayout({ children }: PublicLayoutProps) { + return {children}; +} diff --git a/openflare_server/web/app/(public)/login/page.tsx b/openflare_server/web/app/(public)/login/page.tsx index 62260c76..61d6f7df 100644 --- a/openflare_server/web/app/(public)/login/page.tsx +++ b/openflare_server/web/app/(public)/login/page.tsx @@ -1,12 +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 ( - }> - - - ); -} +import { Suspense } from 'react'; + +import { LoadingState } from '@/components/feedback/loading-state'; +import { LoginForm } from '@/features/auth/components/login-form'; + +export default function LoginPage() { + return ( + }> + + + ); +} diff --git a/openflare_server/web/app/(public)/oauth/github/page.tsx b/openflare_server/web/app/(public)/oauth/github/page.tsx index 0cdc4425..c15f7d31 100644 --- a/openflare_server/web/app/(public)/oauth/github/page.tsx +++ b/openflare_server/web/app/(public)/oauth/github/page.tsx @@ -1,12 +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 ( - }> - - - ); -} +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 ( + }> + + + ); +} diff --git a/openflare_server/web/app/(public)/reset/page.tsx b/openflare_server/web/app/(public)/reset/page.tsx index 936dd236..633cb7d8 100644 --- a/openflare_server/web/app/(public)/reset/page.tsx +++ b/openflare_server/web/app/(public)/reset/page.tsx @@ -1,12 +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 ( - }> - - - ); -} +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 ( + }> + + + ); +} diff --git a/openflare_server/web/app/(public)/user/reset/page.tsx b/openflare_server/web/app/(public)/user/reset/page.tsx index e02019a0..837f928e 100644 --- a/openflare_server/web/app/(public)/user/reset/page.tsx +++ b/openflare_server/web/app/(public)/user/reset/page.tsx @@ -1,12 +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 ( - }> - - - ); -} +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 ( + }> + + + ); +} diff --git a/openflare_server/web/app/globals.css b/openflare_server/web/app/globals.css index 62fdda49..eedae85f 100644 --- a/openflare_server/web/app/globals.css +++ b/openflare_server/web/app/globals.css @@ -1,173 +1,178 @@ @import '../styles/tokens.css'; @import 'tailwindcss'; @import '@heroui/styles'; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:is(.dark *)); -* { - box-sizing: border-box; -} +* { + box-sizing: border-box; +} -html { - background: var(--surface-canvas); -} +html { + background: var(--surface-canvas); +} -html[data-theme='light'] { - color-scheme: light; -} +html[data-theme='light'] { + color-scheme: light; +} -html[data-theme='dark'] { - color-scheme: dark; -} +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; -} +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; -} +a { + color: inherit; + text-decoration: none; +} -button { - cursor: pointer; -} +button { + cursor: pointer; +} -::selection { - background: var(--selection-background); - color: var(--selection-foreground); -} +::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); -} +@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); -} +: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); -} +.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; + @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground; } html { - @apply font-sans; - } + @apply font-sans; + } } diff --git a/openflare_server/web/app/layout.tsx b/openflare_server/web/app/layout.tsx index ee78dcf9..74538292 100644 --- a/openflare_server/web/app/layout.tsx +++ b/openflare_server/web/app/layout.tsx @@ -1,38 +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 ( - - - - {children} - - - ); -} +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 ( + + + + {children} + + + ); +} diff --git a/openflare_server/web/app/not-found.tsx b/openflare_server/web/app/not-found.tsx index d5c740ab..66294afc 100644 --- a/openflare_server/web/app/not-found.tsx +++ b/openflare_server/web/app/not-found.tsx @@ -1,18 +1,24 @@ -import Link from 'next/link'; - -import { AppCard } from '@/components/ui/app-card'; - -export default function NotFound() { - return ( -
- -
-

请返回总览页,或继续通过侧边导航访问已初始化的模块入口。

- - 返回总览 - -
-
-
- ); -} +import Link from 'next/link'; + +import { AppCard } from '@/components/ui/app-card'; + +export default function NotFound() { + return ( +
+ +
+

请返回总览页,或继续通过侧边导航访问已初始化的模块入口。

+ + 返回总览 + +
+
+
+ ); +} diff --git a/openflare_server/web/components/data/rank-chart.tsx b/openflare_server/web/components/data/rank-chart.tsx index 267c4416..995b8ff3 100644 --- a/openflare_server/web/components/data/rank-chart.tsx +++ b/openflare_server/web/components/data/rank-chart.tsx @@ -31,101 +31,102 @@ function getChartValue(params: unknown) { } export function RankChart({ - items, - color, - valueFormatter = defaultFormatter, - emptyMessage = '暂无分布数据', - }: RankChartProps) { + items, + color, + valueFormatter = defaultFormatter, + emptyMessage = '暂无分布数据', +}: RankChartProps) { const option = useMemo( - () => ({ - animationDuration: 400, - grid: { - left: 16, - right: 24, - top: 12, - bottom: 12, - containLabel: true, + () => ({ + animationDuration: 400, + grid: { + left: 16, + right: 24, + top: 12, + bottom: 12, + containLabel: true, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', }, - 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 ?? ''}
${valueFormatter(data.value ?? 0)}`; + }, + }, + xAxis: { + type: 'value', + axisLabel: { + color: '#94a3b8', + }, + splitLine: { + lineStyle: { + color: 'rgba(148, 163, 184, 0.16)', + type: 'dashed', }, - backgroundColor: 'rgba(15, 23, 42, 0.92)', - borderWidth: 0, - textStyle: { + }, + }, + yAxis: { + type: 'category', + data: items.map((item) => item.label), + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { + color: '#cbd5e1', + width: 120, + overflow: 'truncate', + }, + }, + series: [ + { + type: 'bar', + data: items.map((item) => item.value), + barWidth: 12, + showBackground: true, + backgroundStyle: { + color: 'rgba(148, 163, 184, 0.12)', + borderRadius: 999, + }, + itemStyle: { + color, + borderRadius: 999, + }, + label: { + show: true, + position: 'right', color: '#e2e8f0', - fontSize: 12, - }, - formatter: (params: unknown) => { - const item = Array.isArray(params) ? params[0] : params; - const data = item as { name?: string; value?: number }; - return `${data.name ?? ''}
${valueFormatter(data.value ?? 0)}`; + formatter: (params: unknown) => + valueFormatter(getChartValue(params)), }, }, - 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], + ], + }), + [color, items, valueFormatter], ); if (items.length === 0) { return ( -
- {emptyMessage} -
+
+ {emptyMessage} +
); } return ( - + ); } diff --git a/openflare_server/web/components/data/trend-chart.tsx b/openflare_server/web/components/data/trend-chart.tsx index dc14c60c..4c4345cd 100644 --- a/openflare_server/web/components/data/trend-chart.tsx +++ b/openflare_server/web/components/data/trend-chart.tsx @@ -32,15 +32,15 @@ type TooltipParam = { const defaultFormatter = (value: number) => formatCompactNumber(value); export function TrendChart({ - labels, - series, - height = 220, - yAxisValueFormatter, - }: TrendChartProps) { + labels, + series, + height = 220, + yAxisValueFormatter, +}: TrendChartProps) { const option = useMemo(() => { const axisFormatter = yAxisValueFormatter ?? defaultFormatter; const maxValue = calculateNiceAxisMax( - series.flatMap((item) => item.values), + series.flatMap((item) => item.values), ); return { @@ -70,17 +70,17 @@ export function TrendChart({ const header = items[0]?.axisValueLabel ?? ''; const rows = items.map((item) => { const matchedSeries = series.find( - (seriesItem) => seriesItem.label === item.seriesName, + (seriesItem) => seriesItem.label === item.seriesName, ); const formatter = - matchedSeries?.valueFormatter ?? - yAxisValueFormatter ?? - defaultFormatter; + matchedSeries?.valueFormatter ?? + yAxisValueFormatter ?? + defaultFormatter; const rawValue = Array.isArray(item.value) - ? item.value[1] - : item.value; + ? item.value[1] + : item.value; const numericValue = - typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0); + typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0); return [ '', @@ -145,11 +145,11 @@ export function TrendChart({ color: item.color, }, areaStyle: - item.variant === 'area' - ? { - color: item.fillColor ?? `${item.color}33`, - } - : undefined, + item.variant === 'area' + ? { + color: item.fillColor ?? `${item.color}33`, + } + : undefined, emphasis: { focus: 'series', scale: true, @@ -161,48 +161,48 @@ export function TrendChart({ if (labels.length === 0 || series.length === 0) { return ( -
- 暂无趋势数据 -
+
+ 暂无趋势数据 +
); } return ( -
-
- {series.map((item) => { - const latestValue = item.values[item.values.length - 1] ?? 0; - const formatter = item.valueFormatter ?? defaultFormatter; - return ( -
-
+
+
+ {series.map((item) => { + const latestValue = item.values[item.values.length - 1] ?? 0; + const formatter = item.valueFormatter ?? defaultFormatter; + return ( +
+
-

- {item.label} -

-
-

- {formatter(latestValue)} -

-
- ); - })} -
- -
- -
+

+ {item.label} +

+
+

+ {formatter(latestValue)} +

+
+ ); + })}
+ +
+ +
+
); } diff --git a/openflare_server/web/components/feedback/empty-state.tsx b/openflare_server/web/components/feedback/empty-state.tsx index 073d3f99..05fe98d6 100644 --- a/openflare_server/web/components/feedback/empty-state.tsx +++ b/openflare_server/web/components/feedback/empty-state.tsx @@ -1,17 +1,19 @@ -interface EmptyStateProps { - title: string; - description?: string; -} - -export function EmptyState({title, description}: EmptyStateProps) { - return ( -
-

{title}

- {description ? -

{description}

: null - } - -
- ); -} +interface EmptyStateProps { + title: string; + description?: string; +} + +export function EmptyState({ title, description }: EmptyStateProps) { + return ( +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} diff --git a/openflare_server/web/components/feedback/error-state.tsx b/openflare_server/web/components/feedback/error-state.tsx index e1b8b317..196aad49 100644 --- a/openflare_server/web/components/feedback/error-state.tsx +++ b/openflare_server/web/components/feedback/error-state.tsx @@ -1,13 +1,17 @@ -interface ErrorStateProps { - title: string; - description: string; -} - -export function ErrorState({ title, description }: ErrorStateProps) { - return ( -
-

{title}

-

{description}

-
- ); -} +interface ErrorStateProps { + title: string; + description: string; +} + +export function ErrorState({ title, description }: ErrorStateProps) { + return ( +
+

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/openflare_server/web/components/feedback/feature-placeholder.tsx b/openflare_server/web/components/feedback/feature-placeholder.tsx index cda1bb65..4f4ab1eb 100644 --- a/openflare_server/web/components/feedback/feature-placeholder.tsx +++ b/openflare_server/web/components/feedback/feature-placeholder.tsx @@ -1,31 +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 ( - } - > -
    - {milestones.map((item) => ( -
  • - - {item} -
  • - ))} -
-
- ); -} +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 ( + } + > +
    + {milestones.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ); +} diff --git a/openflare_server/web/components/feedback/inline-message.tsx b/openflare_server/web/components/feedback/inline-message.tsx index 7830c097..9770bcf7 100644 --- a/openflare_server/web/components/feedback/inline-message.tsx +++ b/openflare_server/web/components/feedback/inline-message.tsx @@ -1,35 +1,35 @@ -import { cn } from '@/lib/utils/cn'; - -type InlineMessageTone = 'info' | 'success' | 'danger'; - -const toneClasses: Record = { - 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; -} - -export function InlineMessage({ - tone = 'info', - message, - className, -}: InlineMessageProps) { - return ( -
- {message} -
- ); -} +import { cn } from '@/lib/utils/cn'; + +type InlineMessageTone = 'info' | 'success' | 'danger'; + +const toneClasses: Record = { + 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; +} + +export function InlineMessage({ + tone = 'info', + message, + className, +}: InlineMessageProps) { + return ( +
+ {message} +
+ ); +} diff --git a/openflare_server/web/components/feedback/loading-state.tsx b/openflare_server/web/components/feedback/loading-state.tsx index 4305074f..1d72bcfd 100644 --- a/openflare_server/web/components/feedback/loading-state.tsx +++ b/openflare_server/web/components/feedback/loading-state.tsx @@ -1,15 +1,15 @@ -import { cn } from '@/lib/utils/cn'; - -interface LoadingStateProps { - className?: string; -} - -export function LoadingState({ className }: LoadingStateProps) { - return ( -
-
-
-
-
- ); -} +import { cn } from '@/lib/utils/cn'; + +interface LoadingStateProps { + className?: string; +} + +export function LoadingState({ className }: LoadingStateProps) { + return ( +
+
+
+
+
+ ); +} diff --git a/openflare_server/web/components/forms/turnstile-widget.tsx b/openflare_server/web/components/forms/turnstile-widget.tsx index 79287c0c..2da15fcd 100644 --- a/openflare_server/web/components/forms/turnstile-widget.tsx +++ b/openflare_server/web/components/forms/turnstile-widget.tsx @@ -1,96 +1,101 @@ -'use client'; - -import { useEffect, useId, useRef } from 'react'; - -declare global { - interface Window { - turnstile?: { - render: ( - container: string | HTMLElement, - options: { - sitekey: string; - callback: (token: string) => void; - 'expired-callback'?: () => void; - 'error-callback'?: () => void; - theme?: 'auto' | 'light' | 'dark'; - }, - ) => string; - remove: (widgetId: string) => void; - reset: (widgetId?: string) => void; - }; - } -} - -const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script'; -const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; - -interface TurnstileWidgetProps { - siteKey: string; - onVerify: (token: string) => void; - onExpire?: () => void; - onError?: () => void; -} - -export function TurnstileWidget({ - siteKey, - onVerify, - onExpire, - onError, -}: TurnstileWidgetProps) { - const containerRef = useRef(null); - const widgetIdRef = useRef(null); - const elementId = useId().replace(/:/g, '-'); - - useEffect(() => { - let cancelled = false; - - const mountWidget = () => { - if (cancelled || !containerRef.current || !window.turnstile) { - return; - } - - if (widgetIdRef.current) { - window.turnstile.remove(widgetIdRef.current); - widgetIdRef.current = null; - } - - widgetIdRef.current = window.turnstile.render(containerRef.current, { - sitekey: siteKey, - callback: onVerify, - 'expired-callback': onExpire, - 'error-callback': onError, - theme: 'auto', - }); - }; - - const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null; - - if (window.turnstile) { - mountWidget(); - } else if (existingScript) { - existingScript.addEventListener('load', mountWidget); - } else { - const script = document.createElement('script'); - script.id = TURNSTILE_SCRIPT_ID; - script.src = TURNSTILE_SCRIPT_SRC; - script.async = true; - script.defer = true; - script.addEventListener('load', mountWidget); - document.head.appendChild(script); - } - - return () => { - cancelled = true; - const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null; - if (script) { - script.removeEventListener('load', mountWidget); - } - if (widgetIdRef.current && window.turnstile) { - window.turnstile.remove(widgetIdRef.current); - widgetIdRef.current = null; - } - }; - }, [onError, onExpire, onVerify, siteKey]); - - return
; -} +'use client'; + +import { useEffect, useId, useRef } from 'react'; + +declare global { + interface Window { + turnstile?: { + render: ( + container: string | HTMLElement, + options: { + sitekey: string; + callback: (token: string) => void; + 'expired-callback'?: () => void; + 'error-callback'?: () => void; + theme?: 'auto' | 'light' | 'dark'; + }, + ) => string; + remove: (widgetId: string) => void; + reset: (widgetId?: string) => void; + }; + } +} + +const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script'; +const TURNSTILE_SCRIPT_SRC = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +interface TurnstileWidgetProps { + siteKey: string; + onVerify: (token: string) => void; + onExpire?: () => void; + onError?: () => void; +} + +export function TurnstileWidget({ + siteKey, + onVerify, + onExpire, + onError, +}: TurnstileWidgetProps) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const elementId = useId().replace(/:/g, '-'); + + useEffect(() => { + let cancelled = false; + + const mountWidget = () => { + if (cancelled || !containerRef.current || !window.turnstile) { + return; + } + + if (widgetIdRef.current) { + window.turnstile.remove(widgetIdRef.current); + widgetIdRef.current = null; + } + + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + callback: onVerify, + 'expired-callback': onExpire, + 'error-callback': onError, + theme: 'auto', + }); + }; + + const existingScript = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (window.turnstile) { + mountWidget(); + } else if (existingScript) { + existingScript.addEventListener('load', mountWidget); + } else { + const script = document.createElement('script'); + script.id = TURNSTILE_SCRIPT_ID; + script.src = TURNSTILE_SCRIPT_SRC; + script.async = true; + script.defer = true; + script.addEventListener('load', mountWidget); + document.head.appendChild(script); + } + + return () => { + cancelled = true; + const script = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + if (script) { + script.removeEventListener('load', mountWidget); + } + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current); + widgetIdRef.current = null; + } + }; + }, [onError, onExpire, onVerify, siteKey]); + + return
; +} diff --git a/openflare_server/web/components/layout/dashboard-shell.tsx b/openflare_server/web/components/layout/dashboard-shell.tsx index 4c733d40..bdb63530 100644 --- a/openflare_server/web/components/layout/dashboard-shell.tsx +++ b/openflare_server/web/components/layout/dashboard-shell.tsx @@ -1,20 +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 ( -
- -
- -
{children}
-
-
- ); -} +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 ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/openflare_server/web/components/layout/page-header.tsx b/openflare_server/web/components/layout/page-header.tsx index 688d6723..5518af01 100644 --- a/openflare_server/web/components/layout/page-header.tsx +++ b/openflare_server/web/components/layout/page-header.tsx @@ -1,26 +1,30 @@ -import type {ReactNode} from 'react'; +import type { ReactNode } from 'react'; interface PageHeaderProps { - title: string; - description?: string; - action?: ReactNode; + title: string; + description?: string; + action?: ReactNode; } -export function PageHeader({title, description, action}: PageHeaderProps) { - return ( -
-
-

OpenFlare

-
-

{title}

- {description ? ( -

- {description} -

- ) : null} -
-
- {action ?
{action}
: null} +export function PageHeader({ title, description, action }: PageHeaderProps) { + return ( +
+
+

+ OpenFlare +

+
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null}
- ); +
+ {action ?
{action}
: null} +
+ ); } diff --git a/openflare_server/web/components/layout/public-shell.tsx b/openflare_server/web/components/layout/public-shell.tsx index a731dd95..493a68cf 100644 --- a/openflare_server/web/components/layout/public-shell.tsx +++ b/openflare_server/web/components/layout/public-shell.tsx @@ -2,39 +2,47 @@ 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 ( -
-
-
-
-

OpenFlare

-
-
- - {publicEnv.appVersion} - -
-
- -
{children}
- -
-
- - 返回 - - - 关于 - -
-
-
-
- ); -} + +interface PublicShellProps { + children: ReactNode; +} + +export function PublicShell({ children }: PublicShellProps) { + return ( +
+
+
+
+

+ OpenFlare +

+
+
+ + {publicEnv.appVersion} + +
+
+ +
{children}
+ +
+
+ + 返回 + + + 关于 + +
+
+
+
+ ); +} diff --git a/openflare_server/web/components/providers/app-providers.tsx b/openflare_server/web/components/providers/app-providers.tsx index 99fd9a7d..1efac407 100644 --- a/openflare_server/web/components/providers/app-providers.tsx +++ b/openflare_server/web/components/providers/app-providers.tsx @@ -1,37 +1,37 @@ -'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'; - -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 ( - - - {children} - - - ); -} +'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'; + +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 ( + + + {children} + + + ); +} diff --git a/openflare_server/web/components/providers/auth-provider.tsx b/openflare_server/web/components/providers/auth-provider.tsx index f0ff50c9..80ebcfe4 100644 --- a/openflare_server/web/components/providers/auth-provider.tsx +++ b/openflare_server/web/components/providers/auth-provider.tsx @@ -1,89 +1,92 @@ -'use client'; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; - -import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth'; -import type { AuthUser } from '@/types/auth'; - -interface AuthContextValue { - user: AuthUser | null; - isLoading: boolean; - isAuthenticated: boolean; - refreshUser: () => Promise; - setUser: (user: AuthUser | null) => void; - logout: () => Promise; -} - -const AuthContext = createContext(null); - -interface AuthProviderProps { - children: ReactNode; -} - -export function AuthProvider({ children }: AuthProviderProps) { - const [user, setUserState] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const refreshUser = useCallback(async () => { - try { - const nextUser = await getCurrentUser(); - setUserState(nextUser); - return nextUser; - } catch { - 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 {children}; -} - -export function useAuth() { - const context = useContext(AuthContext); - - if (!context) { - throw new Error('useAuth must be used within AuthProvider'); - } - - return context; -} +'use client'; + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import { + logout as logoutRequest, + getCurrentUser, +} from '@/features/auth/api/auth'; +import type { AuthUser } from '@/types/auth'; + +interface AuthContextValue { + user: AuthUser | null; + isLoading: boolean; + isAuthenticated: boolean; + refreshUser: () => Promise; + setUser: (user: AuthUser | null) => void; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUserState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshUser = useCallback(async () => { + try { + const nextUser = await getCurrentUser(); + setUserState(nextUser); + return nextUser; + } catch { + 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 {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + + return context; +} diff --git a/openflare_server/web/components/providers/theme-provider.tsx b/openflare_server/web/components/providers/theme-provider.tsx index 594b30e4..4a694e3a 100644 --- a/openflare_server/web/components/providers/theme-provider.tsx +++ b/openflare_server/web/components/providers/theme-provider.tsx @@ -1,107 +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(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('system'); - const [resolvedTheme, setResolvedTheme] = useState('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 {children}; -} - -export function useTheme() { - const context = useContext(ThemeContext); - - if (!context) { - throw new Error('useTheme must be used within ThemeProvider'); - } - - return context; -} +'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(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('system'); + const [resolvedTheme, setResolvedTheme] = useState('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 ( + {children} + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + + if (!context) { + throw new Error('useTheme must be used within ThemeProvider'); + } + + return context; +} diff --git a/openflare_server/web/components/ui/app-card.tsx b/openflare_server/web/components/ui/app-card.tsx index 879a7dc5..6cfa257b 100644 --- a/openflare_server/web/components/ui/app-card.tsx +++ b/openflare_server/web/components/ui/app-card.tsx @@ -1,41 +1,47 @@ -import type { HTMLAttributes, ReactNode } from 'react'; - -import { cn } from '@/lib/utils/cn'; - -interface AppCardProps extends HTMLAttributes { - title?: string; - description?: string; - action?: ReactNode; -} - -export function AppCard({ - title, - description, - action, - className, - children, - ...props -}: AppCardProps) { - return ( -
- {(title || description || action) && ( -
-
- {title ?

{title}

: null} - {description ? ( -

{description}

- ) : null} -
- {action ?
{action}
: null} -
- )} -
{children}
-
- ); -} +import type { HTMLAttributes, ReactNode } from 'react'; + +import { cn } from '@/lib/utils/cn'; + +interface AppCardProps extends HTMLAttributes { + title?: string; + description?: string; + action?: ReactNode; +} + +export function AppCard({ + title, + description, + action, + className, + children, + ...props +}: AppCardProps) { + return ( +
+ {(title || description || action) && ( +
+
+ {title ? ( +

+ {title} +

+ ) : null} + {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+ )} +
{children}
+
+ ); +} diff --git a/openflare_server/web/components/ui/app-modal.tsx b/openflare_server/web/components/ui/app-modal.tsx index 3dea741b..d72b197c 100644 --- a/openflare_server/web/components/ui/app-modal.tsx +++ b/openflare_server/web/components/ui/app-modal.tsx @@ -55,32 +55,52 @@ export function AppModal({ } return ( -
-
-
{children}
- {footer ?
{footer}
: null} +
+ {children} +
+ {footer ? ( +
+ {footer} +
+ ) : null}
); diff --git a/openflare_server/web/components/ui/drawer.tsx b/openflare_server/web/components/ui/drawer.tsx index 9d2d7379..980e462e 100644 --- a/openflare_server/web/components/ui/drawer.tsx +++ b/openflare_server/web/components/ui/drawer.tsx @@ -261,7 +261,9 @@ function useDrawerMetaContext() { const context = useContext(DrawerMetaContext); if (!context) { - throw new Error('Drawer title and description must be used within DrawerContent.'); + throw new Error( + 'Drawer title and description must be used within DrawerContent.', + ); } return context; @@ -271,14 +273,30 @@ export function DrawerHeader({ className, ...props }: HTMLAttributes) { - return
; + return ( +
+ ); } export function DrawerFooter({ className, ...props }: HTMLAttributes) { - return
; + return ( +
+ ); } export function DrawerTitle({ @@ -290,7 +308,10 @@ export function DrawerTitle({ return (

); @@ -305,7 +326,10 @@ export function DrawerDescription({ return (

); diff --git a/openflare_server/web/components/ui/input.tsx b/openflare_server/web/components/ui/input.tsx index c0a82df5..8ada0f87 100644 --- a/openflare_server/web/components/ui/input.tsx +++ b/openflare_server/web/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>( ->(({ className, ...props }, ref) => ( -