mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
[优化] 格式化
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
".next",
|
".next",
|
||||||
"build",
|
"build",
|
||||||
"out",
|
"out",
|
||||||
"coverage",
|
"coverage",
|
||||||
"playwright-report",
|
"playwright-report",
|
||||||
"test-results",
|
"test-results",
|
||||||
"src"
|
"src"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,49 @@
|
|||||||
# OpenFlare Web
|
# OpenFlare Web
|
||||||
|
|
||||||
OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
|
OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
corepack enable
|
corepack enable
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 本地开发(3001 前端同源代理后端 HTTP/WS)
|
# 本地开发(3001 前端同源代理后端 HTTP/WS)
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
# 生产模式启动
|
# 生产模式启动
|
||||||
pnpm start
|
pnpm start
|
||||||
|
|
||||||
# 类型检查
|
# 类型检查
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
|
||||||
# 代码检查
|
# 代码检查
|
||||||
pnpm lint
|
pnpm lint
|
||||||
|
|
||||||
# 单元测试
|
# 单元测试
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|
||||||
# 生成静态构建产物到 build/
|
# 生成静态构建产物到 build/
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 构建说明
|
## 构建说明
|
||||||
|
|
||||||
* 构建采用 Next.js 静态导出模式。
|
- 构建采用 Next.js 静态导出模式。
|
||||||
* `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
|
- `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
|
||||||
* 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
|
- 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
|
||||||
* 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
|
- 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
|
||||||
* `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。
|
- `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。
|
||||||
* `pnpm start` 用于启动已构建产物,对应 Next 生产模式。
|
- `pnpm start` 用于启动已构建产物,对应 Next 生产模式。
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
`openflare_server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。
|
`openflare_server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。
|
||||||
|
|
||||||
## 目录约定
|
## 目录约定
|
||||||
|
|
||||||
* `app/`:路由与布局
|
- `app/`:路由与布局
|
||||||
* `features/`:业务模块
|
- `features/`:业务模块
|
||||||
* `components/`:复用组件
|
- `components/`:复用组件
|
||||||
* `lib/`:请求、环境变量、工具与常量
|
- `lib/`:请求、环境变量、工具与常量
|
||||||
* `tests/`:测试代码
|
- `tests/`:测试代码
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page';
|
import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page';
|
||||||
|
|
||||||
export default function ConfigVersionsRoute() {
|
export default function ConfigVersionsRoute() {
|
||||||
return <ConfigVersionsPage />;
|
return <ConfigVersionsPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||||
import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard';
|
import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<DashboardAuthGuard>
|
<DashboardAuthGuard>
|
||||||
<DashboardShell>{children}</DashboardShell>
|
<DashboardShell>{children}</DashboardShell>
|
||||||
</DashboardAuthGuard>
|
</DashboardAuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NodesPage } from '@/features/nodes/components/nodes-page';
|
import { NodesPage } from '@/features/nodes/components/nodes-page';
|
||||||
|
|
||||||
export default function NodesRoute() {
|
export default function NodesRoute() {
|
||||||
return <NodesPage />;
|
return <NodesPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DashboardOverview } from '@/features/dashboard/components/dashboard-ove
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className="space-y-6">
|
||||||
<DashboardOverview />
|
<DashboardOverview />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
|
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
|
||||||
|
|
||||||
export default function ProxyRoutesRoute() {
|
export default function ProxyRoutesRoute() {
|
||||||
return <ProxyRoutesPage />;
|
return <ProxyRoutesPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SettingsPage } from '@/features/settings/components/settings-page';
|
import { SettingsPage } from '@/features/settings/components/settings-page';
|
||||||
|
|
||||||
export default function SettingsRoute() {
|
export default function SettingsRoute() {
|
||||||
return <SettingsPage />;
|
return <SettingsPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { UsersPage } from '@/features/users/components/users-page';
|
import { UsersPage } from '@/features/users/components/users-page';
|
||||||
|
|
||||||
export default function UsersRoute() {
|
export default function UsersRoute() {
|
||||||
return <UsersPage />;
|
return <UsersPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AboutPage } from '@/features/about/components/about-page';
|
import { AboutPage } from '@/features/about/components/about-page';
|
||||||
|
|
||||||
export default function AboutRoute() {
|
export default function AboutRoute() {
|
||||||
return <AboutPage />;
|
return <AboutPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { PublicShell } from '@/components/layout/public-shell';
|
import { PublicShell } from '@/components/layout/public-shell';
|
||||||
|
|
||||||
interface PublicLayoutProps {
|
interface PublicLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublicLayout({ children }: PublicLayoutProps) {
|
export default function PublicLayout({ children }: PublicLayoutProps) {
|
||||||
return <PublicShell>{children}</PublicShell>;
|
return <PublicShell>{children}</PublicShell>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { LoginForm } from '@/features/auth/components/login-form';
|
import { LoginForm } from '@/features/auth/components/login-form';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback';
|
import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback';
|
||||||
|
|
||||||
export default function GithubOAuthPage() {
|
export default function GithubOAuthPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<GitHubOAuthCallback />
|
<GitHubOAuthCallback />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
|
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<PasswordResetFlow />
|
<PasswordResetFlow />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
|
import { PasswordResetFlow } from '@/features/auth/components/password-reset-flow';
|
||||||
|
|
||||||
export default function LegacyResetPasswordPage() {
|
export default function LegacyResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<PasswordResetFlow />
|
<PasswordResetFlow />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +1,178 @@
|
|||||||
@import '../styles/tokens.css';
|
@import '../styles/tokens.css';
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import '@heroui/styles';
|
@import '@heroui/styles';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
@import "shadcn/tailwind.css";
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background: var(--surface-canvas);
|
background: var(--surface-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='light'] {
|
html[data-theme='light'] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='dark'] {
|
html[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--body-background);
|
background: var(--body-background);
|
||||||
color: var(--foreground-primary);
|
color: var(--foreground-primary);
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
Inter,
|
||||||
sans-serif;
|
ui-sans-serif,
|
||||||
transition:
|
system-ui,
|
||||||
background 0.2s ease,
|
-apple-system,
|
||||||
color 0.2s ease;
|
BlinkMacSystemFont,
|
||||||
}
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--selection-background);
|
background: var(--selection-background);
|
||||||
color: var(--selection-foreground);
|
color: var(--selection-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,42 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { AppProviders } from '@/components/providers/app-providers';
|
import { AppProviders } from '@/components/providers/app-providers';
|
||||||
import { getThemeInitScript } from '@/lib/theme/theme';
|
import { getThemeInitScript } from '@/lib/theme/theme';
|
||||||
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from 'next/font/google';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
const geist = Geist({ subsets: ['latin'], variable: '--font-sans' });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: 'OpenFlare 控制台',
|
default: 'OpenFlare 控制台',
|
||||||
template: '%s | OpenFlare',
|
template: '%s | OpenFlare',
|
||||||
},
|
},
|
||||||
description: 'OpenFlare 管理端',
|
description: 'OpenFlare 管理端',
|
||||||
applicationName: 'OpenFlare',
|
applicationName: 'OpenFlare',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RootLayoutProps {
|
interface RootLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: RootLayoutProps) {
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
<html
|
||||||
<body>
|
lang="zh-CN"
|
||||||
<Script id='theme-init' strategy='beforeInteractive'>
|
suppressHydrationWarning
|
||||||
{getThemeInitScript()}
|
className={cn('font-sans', geist.variable)}
|
||||||
</Script>
|
>
|
||||||
<AppProviders>{children}</AppProviders>
|
<body>
|
||||||
</body>
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
</html>
|
{getThemeInitScript()}
|
||||||
);
|
</Script>
|
||||||
}
|
<AppProviders>{children}</AppProviders>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<main className='flex min-h-screen items-center justify-center px-4'>
|
<main className="flex min-h-screen items-center justify-center px-4">
|
||||||
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'>
|
<AppCard
|
||||||
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
title="页面不存在"
|
||||||
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
|
description="当前请求的页面尚未接入新版管理端路由。"
|
||||||
<Link href='/' className='inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90'>
|
>
|
||||||
返回总览
|
<div className="space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
</Link>
|
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
|
||||||
</div>
|
<Link
|
||||||
</AppCard>
|
href="/"
|
||||||
</main>
|
className="inline-flex rounded-full bg-[var(--brand-primary)] px-4 py-2 font-medium text-[var(--foreground-inverse)] transition hover:opacity-90"
|
||||||
);
|
>
|
||||||
}
|
返回总览
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,101 +31,102 @@ function getChartValue(params: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RankChart({
|
export function RankChart({
|
||||||
items,
|
items,
|
||||||
color,
|
color,
|
||||||
valueFormatter = defaultFormatter,
|
valueFormatter = defaultFormatter,
|
||||||
emptyMessage = '暂无分布数据',
|
emptyMessage = '暂无分布数据',
|
||||||
}: RankChartProps) {
|
}: RankChartProps) {
|
||||||
const option = useMemo<EChartsOption>(
|
const option = useMemo<EChartsOption>(
|
||||||
() => ({
|
() => ({
|
||||||
animationDuration: 400,
|
animationDuration: 400,
|
||||||
grid: {
|
grid: {
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 24,
|
right: 24,
|
||||||
top: 12,
|
top: 12,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow',
|
||||||
},
|
},
|
||||||
tooltip: {
|
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||||
trigger: 'axis',
|
borderWidth: 0,
|
||||||
axisPointer: {
|
textStyle: {
|
||||||
type: 'shadow',
|
color: '#e2e8f0',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const item = Array.isArray(params) ? params[0] : params;
|
||||||
|
const data = item as { name?: string; value?: number };
|
||||||
|
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
axisLabel: {
|
||||||
|
color: '#94a3b8',
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.16)',
|
||||||
|
type: 'dashed',
|
||||||
},
|
},
|
||||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
},
|
||||||
borderWidth: 0,
|
},
|
||||||
textStyle: {
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: items.map((item) => item.label),
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLine: { show: false },
|
||||||
|
axisLabel: {
|
||||||
|
color: '#cbd5e1',
|
||||||
|
width: 120,
|
||||||
|
overflow: 'truncate',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
data: items.map((item) => item.value),
|
||||||
|
barWidth: 12,
|
||||||
|
showBackground: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.12)',
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color,
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
color: '#e2e8f0',
|
color: '#e2e8f0',
|
||||||
fontSize: 12,
|
formatter: (params: unknown) =>
|
||||||
},
|
valueFormatter(getChartValue(params)),
|
||||||
formatter: (params: unknown) => {
|
|
||||||
const item = Array.isArray(params) ? params[0] : params;
|
|
||||||
const data = item as { name?: string; value?: number };
|
|
||||||
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
xAxis: {
|
],
|
||||||
type: 'value',
|
}),
|
||||||
axisLabel: {
|
[color, items, valueFormatter],
|
||||||
color: '#94a3b8',
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: 'rgba(148, 163, 184, 0.16)',
|
|
||||||
type: 'dashed',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: items.map((item) => item.label),
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
color: '#cbd5e1',
|
|
||||||
width: 120,
|
|
||||||
overflow: 'truncate',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'bar',
|
|
||||||
data: items.map((item) => item.value),
|
|
||||||
barWidth: 12,
|
|
||||||
showBackground: true,
|
|
||||||
backgroundStyle: {
|
|
||||||
color: 'rgba(148, 163, 184, 0.12)',
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color,
|
|
||||||
borderRadius: 999,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
position: 'right',
|
|
||||||
color: '#e2e8f0',
|
|
||||||
formatter: (params: unknown) => valueFormatter(getChartValue(params)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[color, items, valueFormatter],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={option}
|
option={option}
|
||||||
notMerge
|
notMerge
|
||||||
lazyUpdate
|
lazyUpdate
|
||||||
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
|
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ type TooltipParam = {
|
|||||||
const defaultFormatter = (value: number) => formatCompactNumber(value);
|
const defaultFormatter = (value: number) => formatCompactNumber(value);
|
||||||
|
|
||||||
export function TrendChart({
|
export function TrendChart({
|
||||||
labels,
|
labels,
|
||||||
series,
|
series,
|
||||||
height = 220,
|
height = 220,
|
||||||
yAxisValueFormatter,
|
yAxisValueFormatter,
|
||||||
}: TrendChartProps) {
|
}: TrendChartProps) {
|
||||||
const option = useMemo<EChartsOption>(() => {
|
const option = useMemo<EChartsOption>(() => {
|
||||||
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
|
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
|
||||||
const maxValue = calculateNiceAxisMax(
|
const maxValue = calculateNiceAxisMax(
|
||||||
series.flatMap((item) => item.values),
|
series.flatMap((item) => item.values),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -70,17 +70,17 @@ export function TrendChart({
|
|||||||
const header = items[0]?.axisValueLabel ?? '';
|
const header = items[0]?.axisValueLabel ?? '';
|
||||||
const rows = items.map((item) => {
|
const rows = items.map((item) => {
|
||||||
const matchedSeries = series.find(
|
const matchedSeries = series.find(
|
||||||
(seriesItem) => seriesItem.label === item.seriesName,
|
(seriesItem) => seriesItem.label === item.seriesName,
|
||||||
);
|
);
|
||||||
const formatter =
|
const formatter =
|
||||||
matchedSeries?.valueFormatter ??
|
matchedSeries?.valueFormatter ??
|
||||||
yAxisValueFormatter ??
|
yAxisValueFormatter ??
|
||||||
defaultFormatter;
|
defaultFormatter;
|
||||||
const rawValue = Array.isArray(item.value)
|
const rawValue = Array.isArray(item.value)
|
||||||
? item.value[1]
|
? item.value[1]
|
||||||
: item.value;
|
: item.value;
|
||||||
const numericValue =
|
const numericValue =
|
||||||
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
|
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||||
@@ -145,11 +145,11 @@ export function TrendChart({
|
|||||||
color: item.color,
|
color: item.color,
|
||||||
},
|
},
|
||||||
areaStyle:
|
areaStyle:
|
||||||
item.variant === 'area'
|
item.variant === 'area'
|
||||||
? {
|
? {
|
||||||
color: item.fillColor ?? `${item.color}33`,
|
color: item.fillColor ?? `${item.color}33`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
emphasis: {
|
emphasis: {
|
||||||
focus: 'series',
|
focus: 'series',
|
||||||
scale: true,
|
scale: true,
|
||||||
@@ -161,48 +161,48 @@ export function TrendChart({
|
|||||||
|
|
||||||
if (labels.length === 0 || series.length === 0) {
|
if (labels.length === 0 || series.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
|
||||||
暂无趋势数据
|
暂无趋势数据
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{series.map((item) => {
|
{series.map((item) => {
|
||||||
const latestValue = item.values[item.values.length - 1] ?? 0;
|
const latestValue = item.values[item.values.length - 1] ?? 0;
|
||||||
const formatter = item.valueFormatter ?? defaultFormatter;
|
const formatter = item.valueFormatter ?? defaultFormatter;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
style={{ backgroundColor: item.color }}
|
style={{ backgroundColor: item.color }}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
{formatter(latestValue)}
|
{formatter(latestValue)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
|
|
||||||
<ReactECharts
|
|
||||||
option={option}
|
|
||||||
notMerge
|
|
||||||
lazyUpdate
|
|
||||||
style={{ height, width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
style={{ height, width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({title, description}: EmptyStateProps) {
|
export function EmptyState({ title, description }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm">
|
||||||
className='rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm'>
|
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||||
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{title}</p>
|
{title}
|
||||||
{description ?
|
</p>
|
||||||
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p> : null
|
{description ? (
|
||||||
}
|
<p className="mt-2 leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
{description}
|
||||||
</div>
|
</p>
|
||||||
);
|
) : null}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
interface ErrorStateProps {
|
interface ErrorStateProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({ title, description }: ErrorStateProps) {
|
export function ErrorState({ title, description }: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm'>
|
<div className="rounded-2xl border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] px-5 py-6 text-sm">
|
||||||
<p className='text-base font-semibold text-[var(--status-danger-foreground)]'>{title}</p>
|
<p className="text-base font-semibold text-[var(--status-danger-foreground)]">
|
||||||
<p className='mt-2 leading-6 text-[var(--status-danger-foreground)]/80'>{description}</p>
|
{title}
|
||||||
</div>
|
</p>
|
||||||
);
|
<p className="mt-2 leading-6 text-[var(--status-danger-foreground)]/80">
|
||||||
}
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
|
||||||
interface FeaturePlaceholderProps {
|
interface FeaturePlaceholderProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
milestones: string[];
|
milestones: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeaturePlaceholder({
|
export function FeaturePlaceholder({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
milestones,
|
milestones,
|
||||||
}: FeaturePlaceholderProps) {
|
}: FeaturePlaceholderProps) {
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
action={<StatusBadge label='阶段 1 骨架完成' variant='success' />}
|
action={<StatusBadge label="阶段 1 骨架完成" variant="success" />}
|
||||||
>
|
>
|
||||||
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<ul className="space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
{milestones.map((item) => (
|
{milestones.map((item) => (
|
||||||
<li key={item} className='flex gap-3'>
|
<li key={item} className="flex gap-3">
|
||||||
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]' />
|
<span className="mt-2 h-2 w-2 shrink-0 rounded-full bg-[var(--brand-primary)]" />
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
type InlineMessageTone = 'info' | 'success' | 'danger';
|
type InlineMessageTone = 'info' | 'success' | 'danger';
|
||||||
|
|
||||||
const toneClasses: Record<InlineMessageTone, string> = {
|
const toneClasses: Record<InlineMessageTone, string> = {
|
||||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||||
success:
|
success:
|
||||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||||
danger:
|
danger:
|
||||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface InlineMessageProps {
|
interface InlineMessageProps {
|
||||||
tone?: InlineMessageTone;
|
tone?: InlineMessageTone;
|
||||||
message: string;
|
message: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlineMessage({
|
export function InlineMessage({
|
||||||
tone = 'info',
|
tone = 'info',
|
||||||
message,
|
message,
|
||||||
className,
|
className,
|
||||||
}: InlineMessageProps) {
|
}: InlineMessageProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
||||||
toneClasses[tone],
|
toneClasses[tone],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
interface LoadingStateProps {
|
interface LoadingStateProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingState({ className }: LoadingStateProps) {
|
export function LoadingState({ className }: LoadingStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)}>
|
<div className={cn('space-y-3', className)}>
|
||||||
<div className='h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]' />
|
<div className="h-4 w-32 animate-pulse rounded bg-[var(--control-background-hover)]" />
|
||||||
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
|
<div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
|
||||||
<div className='h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]' />
|
<div className="h-24 animate-pulse rounded-2xl bg-[var(--surface-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,101 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useId, useRef } from 'react';
|
import { useEffect, useId, useRef } from 'react';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
turnstile?: {
|
turnstile?: {
|
||||||
render: (
|
render: (
|
||||||
container: string | HTMLElement,
|
container: string | HTMLElement,
|
||||||
options: {
|
options: {
|
||||||
sitekey: string;
|
sitekey: string;
|
||||||
callback: (token: string) => void;
|
callback: (token: string) => void;
|
||||||
'expired-callback'?: () => void;
|
'expired-callback'?: () => void;
|
||||||
'error-callback'?: () => void;
|
'error-callback'?: () => void;
|
||||||
theme?: 'auto' | 'light' | 'dark';
|
theme?: 'auto' | 'light' | 'dark';
|
||||||
},
|
},
|
||||||
) => string;
|
) => string;
|
||||||
remove: (widgetId: string) => void;
|
remove: (widgetId: string) => void;
|
||||||
reset: (widgetId?: string) => void;
|
reset: (widgetId?: string) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
|
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
|
||||||
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
const TURNSTILE_SCRIPT_SRC =
|
||||||
|
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
interface TurnstileWidgetProps {
|
|
||||||
siteKey: string;
|
interface TurnstileWidgetProps {
|
||||||
onVerify: (token: string) => void;
|
siteKey: string;
|
||||||
onExpire?: () => void;
|
onVerify: (token: string) => void;
|
||||||
onError?: () => void;
|
onExpire?: () => void;
|
||||||
}
|
onError?: () => void;
|
||||||
|
}
|
||||||
export function TurnstileWidget({
|
|
||||||
siteKey,
|
export function TurnstileWidget({
|
||||||
onVerify,
|
siteKey,
|
||||||
onExpire,
|
onVerify,
|
||||||
onError,
|
onExpire,
|
||||||
}: TurnstileWidgetProps) {
|
onError,
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
}: TurnstileWidgetProps) {
|
||||||
const widgetIdRef = useRef<string | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const elementId = useId().replace(/:/g, '-');
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const elementId = useId().replace(/:/g, '-');
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const mountWidget = () => {
|
|
||||||
if (cancelled || !containerRef.current || !window.turnstile) {
|
const mountWidget = () => {
|
||||||
return;
|
if (cancelled || !containerRef.current || !window.turnstile) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (widgetIdRef.current) {
|
|
||||||
window.turnstile.remove(widgetIdRef.current);
|
if (widgetIdRef.current) {
|
||||||
widgetIdRef.current = null;
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
}
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
|
||||||
sitekey: siteKey,
|
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||||
callback: onVerify,
|
sitekey: siteKey,
|
||||||
'expired-callback': onExpire,
|
callback: onVerify,
|
||||||
'error-callback': onError,
|
'expired-callback': onExpire,
|
||||||
theme: 'auto',
|
'error-callback': onError,
|
||||||
});
|
theme: 'auto',
|
||||||
};
|
});
|
||||||
|
};
|
||||||
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
|
||||||
|
const existingScript = document.getElementById(
|
||||||
if (window.turnstile) {
|
TURNSTILE_SCRIPT_ID,
|
||||||
mountWidget();
|
) as HTMLScriptElement | null;
|
||||||
} else if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', mountWidget);
|
if (window.turnstile) {
|
||||||
} else {
|
mountWidget();
|
||||||
const script = document.createElement('script');
|
} else if (existingScript) {
|
||||||
script.id = TURNSTILE_SCRIPT_ID;
|
existingScript.addEventListener('load', mountWidget);
|
||||||
script.src = TURNSTILE_SCRIPT_SRC;
|
} else {
|
||||||
script.async = true;
|
const script = document.createElement('script');
|
||||||
script.defer = true;
|
script.id = TURNSTILE_SCRIPT_ID;
|
||||||
script.addEventListener('load', mountWidget);
|
script.src = TURNSTILE_SCRIPT_SRC;
|
||||||
document.head.appendChild(script);
|
script.async = true;
|
||||||
}
|
script.defer = true;
|
||||||
|
script.addEventListener('load', mountWidget);
|
||||||
return () => {
|
document.head.appendChild(script);
|
||||||
cancelled = true;
|
}
|
||||||
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
|
||||||
if (script) {
|
return () => {
|
||||||
script.removeEventListener('load', mountWidget);
|
cancelled = true;
|
||||||
}
|
const script = document.getElementById(
|
||||||
if (widgetIdRef.current && window.turnstile) {
|
TURNSTILE_SCRIPT_ID,
|
||||||
window.turnstile.remove(widgetIdRef.current);
|
) as HTMLScriptElement | null;
|
||||||
widgetIdRef.current = null;
|
if (script) {
|
||||||
}
|
script.removeEventListener('load', mountWidget);
|
||||||
};
|
}
|
||||||
}, [onError, onExpire, onVerify, siteKey]);
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
return <div id={elementId} ref={containerRef} className='min-h-16' />;
|
widgetIdRef.current = null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, [onError, onExpire, onVerify, siteKey]);
|
||||||
|
|
||||||
|
return <div id={elementId} ref={containerRef} className="min-h-16" />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
|
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
|
||||||
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
|
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
|
||||||
|
|
||||||
interface DashboardShellProps {
|
interface DashboardShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ children }: DashboardShellProps) {
|
export function DashboardShell({ children }: DashboardShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-screen bg-transparent text-[var(--foreground-primary)]'>
|
<div className="flex min-h-screen bg-transparent text-[var(--foreground-primary)]">
|
||||||
<DashboardSidebar />
|
<DashboardSidebar />
|
||||||
<div className='flex min-w-0 flex-1 flex-col'>
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<DashboardTopbar />
|
<DashboardTopbar />
|
||||||
<main className='flex-1 px-4 py-6 md:px-8 md:py-8'>{children}</main>
|
<main className="flex-1 px-4 py-6 md:px-8 md:py-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import type {ReactNode} from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({title, description, action}: PageHeaderProps) {
|
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div className='space-y-3'>
|
<div className="space-y-3">
|
||||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>OpenFlare</p>
|
<p className="text-sm font-medium tracking-[0.24em] text-[var(--brand-primary)] uppercase">
|
||||||
<div className='space-y-2'>
|
OpenFlare
|
||||||
<h1 className='text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]'>{title}</h1>
|
</p>
|
||||||
{description ? (
|
<div className="space-y-2">
|
||||||
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
|
<h1 className="text-3xl font-semibold tracking-tight text-[var(--foreground-primary)]">
|
||||||
{description}
|
{title}
|
||||||
</p>
|
</h1>
|
||||||
) : null}
|
{description ? (
|
||||||
</div>
|
<p className="max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]">
|
||||||
</div>
|
{description}
|
||||||
{action ? <div className='flex flex-wrap gap-3'>{action}</div> : null}
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
{action ? <div className="flex flex-wrap gap-3">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,47 @@ import Link from 'next/link';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { publicEnv } from '@/lib/env/public-env';
|
import { publicEnv } from '@/lib/env/public-env';
|
||||||
|
|
||||||
interface PublicShellProps {
|
interface PublicShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicShell({ children }: PublicShellProps) {
|
export function PublicShell({ children }: PublicShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-screen items-center justify-center px-4 py-12'>
|
<div className="flex min-h-screen items-center justify-center px-4 py-12">
|
||||||
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
|
<div className="w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur">
|
||||||
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
|
<div className="mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className='text-3xl font-medium text-[var(--brand-primary)]'>OpenFlare</p>
|
<p className="text-3xl font-medium text-[var(--brand-primary)]">
|
||||||
</div>
|
OpenFlare
|
||||||
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
</p>
|
||||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
</div>
|
||||||
{publicEnv.appVersion}
|
<div className="flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end">
|
||||||
</span>
|
<span className="rounded-full border border-[var(--border-default)] px-3 py-1.5">
|
||||||
</div>
|
{publicEnv.appVersion}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
<div className='space-y-6'>{children}</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
|
<div className="space-y-6">{children}</div>
|
||||||
<div className='flex flex-wrap gap-4'>
|
|
||||||
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
<div className="mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]">
|
||||||
返回
|
<div className="flex flex-wrap gap-4">
|
||||||
</Link>
|
<Link
|
||||||
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
href="/"
|
||||||
关于
|
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
</Link>
|
>
|
||||||
</div>
|
返回
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
<Link
|
||||||
</div>
|
href="/about"
|
||||||
);
|
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
}
|
>
|
||||||
|
关于
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
interface AppProvidersProps {
|
interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppProviders({ children }: AppProvidersProps) {
|
export function AppProviders({ children }: AppProvidersProps) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
retry: 0,
|
retry: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,92 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth';
|
import {
|
||||||
import type { AuthUser } from '@/types/auth';
|
logout as logoutRequest,
|
||||||
|
getCurrentUser,
|
||||||
interface AuthContextValue {
|
} from '@/features/auth/api/auth';
|
||||||
user: AuthUser | null;
|
import type { AuthUser } from '@/types/auth';
|
||||||
isLoading: boolean;
|
|
||||||
isAuthenticated: boolean;
|
interface AuthContextValue {
|
||||||
refreshUser: () => Promise<AuthUser | null>;
|
user: AuthUser | null;
|
||||||
setUser: (user: AuthUser | null) => void;
|
isLoading: boolean;
|
||||||
logout: () => Promise<void>;
|
isAuthenticated: boolean;
|
||||||
}
|
refreshUser: () => Promise<AuthUser | null>;
|
||||||
|
setUser: (user: AuthUser | null) => void;
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
interface AuthProviderProps {
|
|
||||||
children: ReactNode;
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
}
|
|
||||||
|
interface AuthProviderProps {
|
||||||
export function AuthProvider({ children }: AuthProviderProps) {
|
children: ReactNode;
|
||||||
const [user, setUserState] = useState<AuthUser | null>(null);
|
}
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
const refreshUser = useCallback(async () => {
|
const [user, setUserState] = useState<AuthUser | null>(null);
|
||||||
try {
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const nextUser = await getCurrentUser();
|
|
||||||
setUserState(nextUser);
|
const refreshUser = useCallback(async () => {
|
||||||
return nextUser;
|
try {
|
||||||
} catch {
|
const nextUser = await getCurrentUser();
|
||||||
setUserState(null);
|
setUserState(nextUser);
|
||||||
return null;
|
return nextUser;
|
||||||
} finally {
|
} catch {
|
||||||
setIsLoading(false);
|
setUserState(null);
|
||||||
}
|
return null;
|
||||||
}, []);
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
useEffect(() => {
|
}
|
||||||
void refreshUser();
|
}, []);
|
||||||
}, [refreshUser]);
|
|
||||||
|
useEffect(() => {
|
||||||
const setUser = useCallback((nextUser: AuthUser | null) => {
|
void refreshUser();
|
||||||
setUserState(nextUser);
|
}, [refreshUser]);
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
const setUser = useCallback((nextUser: AuthUser | null) => {
|
||||||
|
setUserState(nextUser);
|
||||||
const logout = useCallback(async () => {
|
setIsLoading(false);
|
||||||
try {
|
}, []);
|
||||||
await logoutRequest();
|
|
||||||
} finally {
|
const logout = useCallback(async () => {
|
||||||
setUserState(null);
|
try {
|
||||||
setIsLoading(false);
|
await logoutRequest();
|
||||||
}
|
} finally {
|
||||||
}, []);
|
setUserState(null);
|
||||||
|
setIsLoading(false);
|
||||||
const value = useMemo(
|
}
|
||||||
() => ({
|
}, []);
|
||||||
user,
|
|
||||||
isLoading,
|
const value = useMemo(
|
||||||
isAuthenticated: user !== null,
|
() => ({
|
||||||
refreshUser,
|
user,
|
||||||
setUser,
|
isLoading,
|
||||||
logout,
|
isAuthenticated: user !== null,
|
||||||
}),
|
refreshUser,
|
||||||
[isLoading, logout, refreshUser, setUser, user],
|
setUser,
|
||||||
);
|
logout,
|
||||||
|
}),
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
[isLoading, logout, refreshUser, setUser, user],
|
||||||
}
|
);
|
||||||
|
|
||||||
export function useAuth() {
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
const context = useContext(AuthContext);
|
}
|
||||||
|
|
||||||
if (!context) {
|
export function useAuth() {
|
||||||
throw new Error('useAuth must be used within AuthProvider');
|
const context = useContext(AuthContext);
|
||||||
}
|
|
||||||
|
if (!context) {
|
||||||
return context;
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,107 +1,109 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isThemeMode,
|
isThemeMode,
|
||||||
resolveTheme,
|
resolveTheme,
|
||||||
THEME_MEDIA_QUERY,
|
THEME_MEDIA_QUERY,
|
||||||
THEME_STORAGE_KEY,
|
THEME_STORAGE_KEY,
|
||||||
type ResolvedTheme,
|
type ResolvedTheme,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '@/lib/theme/theme';
|
} from '@/lib/theme/theme';
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
resolvedTheme: ResolvedTheme;
|
resolvedTheme: ResolvedTheme;
|
||||||
setThemeMode: (mode: ThemeMode) => void;
|
setThemeMode: (mode: ThemeMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemPreference() {
|
function getSystemPreference() {
|
||||||
return window.matchMedia(THEME_MEDIA_QUERY).matches;
|
return window.matchMedia(THEME_MEDIA_QUERY).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(mode: ThemeMode) {
|
function applyTheme(mode: ThemeMode) {
|
||||||
const resolvedTheme = resolveTheme(mode, getSystemPreference());
|
const resolvedTheme = resolveTheme(mode, getSystemPreference());
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
root.dataset.themeMode = mode;
|
root.dataset.themeMode = mode;
|
||||||
root.dataset.theme = resolvedTheme;
|
root.dataset.theme = resolvedTheme;
|
||||||
root.style.colorScheme = resolvedTheme;
|
root.style.colorScheme = resolvedTheme;
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
|
||||||
return resolvedTheme;
|
return resolvedTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
|
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const domThemeMode = root.dataset.themeMode;
|
const domThemeMode = root.dataset.themeMode;
|
||||||
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
|
const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system';
|
||||||
const nextResolvedTheme = applyTheme(nextThemeMode);
|
const nextResolvedTheme = applyTheme(nextThemeMode);
|
||||||
|
|
||||||
setThemeModeState(nextThemeMode);
|
setThemeModeState(nextThemeMode);
|
||||||
setResolvedTheme(nextResolvedTheme);
|
setResolvedTheme(nextResolvedTheme);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
|
const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY);
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (themeMode !== 'system') {
|
if (themeMode !== 'system') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setResolvedTheme(applyTheme('system'));
|
setResolvedTheme(applyTheme('system'));
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener('change', handleChange);
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
};
|
};
|
||||||
}, [themeMode]);
|
}, [themeMode]);
|
||||||
|
|
||||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setResolvedTheme(applyTheme(mode));
|
setResolvedTheme(applyTheme(mode));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
themeMode,
|
themeMode,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
}),
|
}),
|
||||||
[resolvedTheme, setThemeMode, themeMode],
|
[resolvedTheme, setThemeMode, themeMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return (
|
||||||
}
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
);
|
||||||
export function useTheme() {
|
}
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
|
export function useTheme() {
|
||||||
if (!context) {
|
const context = useContext(ThemeContext);
|
||||||
throw new Error('useTheme must be used within ThemeProvider');
|
|
||||||
}
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider');
|
||||||
return context;
|
}
|
||||||
}
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +1,47 @@
|
|||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
|
interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppCard({
|
export function AppCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
action,
|
action,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: AppCardProps) {
|
}: AppCardProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
|
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{(title || description || action) && (
|
{(title || description || action) && (
|
||||||
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'>
|
<header className="flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between">
|
||||||
<div className='space-y-1'>
|
<div className="space-y-1">
|
||||||
{title ? <h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>{title}</h2> : null}
|
{title ? (
|
||||||
{description ? (
|
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
{title}
|
||||||
) : null}
|
</h2>
|
||||||
</div>
|
) : null}
|
||||||
{action ? <div className='shrink-0'>{action}</div> : null}
|
{description ? (
|
||||||
</header>
|
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
)}
|
{description}
|
||||||
<div className='px-6 py-5'>{children}</div>
|
</p>
|
||||||
</section>
|
) : null}
|
||||||
);
|
</div>
|
||||||
}
|
{action ? <div className="shrink-0">{action}</div> : null}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="px-6 py-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,32 +55,52 @@ export function AppModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6' role='dialog' aria-modal='true' aria-label={title}>
|
<div
|
||||||
<button type='button' className='absolute inset-0 cursor-default' onClick={onClose} aria-label='关闭弹窗' />
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 cursor-default"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭弹窗"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex max-h-[calc(100vh-3rem)] w-full flex-col overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
'relative flex max-h-[calc(100vh-3rem)] w-full flex-col overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] shadow-2xl',
|
||||||
sizeClassNameMap[size],
|
sizeClassNameMap[size],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5'>
|
<div className="flex items-start justify-between gap-4 border-b border-[var(--border-default)] px-6 py-5">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<h2 className='text-xl font-semibold text-[var(--foreground-primary)]'>{title}</h2>
|
<h2 className="text-xl font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className='max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
|
<p className="max-w-2xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className='inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
|
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
aria-label='关闭弹窗'
|
aria-label="关闭弹窗"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 py-6'>{children}</div>
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||||
{footer ? <div className='border-t border-[var(--border-default)] px-6 py-5'>{footer}</div> : null}
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="border-t border-[var(--border-default)] px-6 py-5">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -261,7 +261,9 @@ function useDrawerMetaContext() {
|
|||||||
const context = useContext(DrawerMetaContext);
|
const context = useContext(DrawerMetaContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('Drawer title and description must be used within DrawerContent.');
|
throw new Error(
|
||||||
|
'Drawer title and description must be used within DrawerContent.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
@@ -271,14 +273,30 @@ export function DrawerHeader({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement>) {
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn('border-b border-[var(--border-default)] px-6 py-5', className)} {...props} />;
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-b border-[var(--border-default)] px-6 py-5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerFooter({
|
export function DrawerFooter({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement>) {
|
}: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn('border-t border-[var(--border-default)] px-6 py-4', className)} {...props} />;
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-t border-[var(--border-default)] px-6 py-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerTitle({
|
export function DrawerTitle({
|
||||||
@@ -290,7 +308,10 @@ export function DrawerTitle({
|
|||||||
return (
|
return (
|
||||||
<h2
|
<h2
|
||||||
id={titleId}
|
id={titleId}
|
||||||
className={cn('text-xl font-semibold text-[var(--foreground-primary)]', className)}
|
className={cn(
|
||||||
|
'text-xl font-semibold text-[var(--foreground-primary)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -305,7 +326,10 @@ export function DrawerDescription({
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
id={descriptionId}
|
id={descriptionId}
|
||||||
className={cn('mt-2 text-sm leading-6 text-[var(--foreground-secondary)]', className)}
|
className={cn(
|
||||||
|
'mt-2 text-sm leading-6 text-[var(--foreground-secondary)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<HTMLLabelElement, React.ComponentProps<'label'>>(
|
||||||
HTMLLabelElement,
|
({ className, ...props }, ref) => (
|
||||||
React.ComponentProps<'label'>
|
<label
|
||||||
>(({ className, ...props }, ref) => (
|
ref={ref}
|
||||||
<label
|
className={cn(
|
||||||
ref={ref}
|
'text-sm leading-none font-medium text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
className={cn(
|
className,
|
||||||
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
)}
|
||||||
className,
|
{...props}
|
||||||
)}
|
/>
|
||||||
{...props}
|
),
|
||||||
/>
|
);
|
||||||
));
|
|
||||||
|
|
||||||
Label.displayName = 'Label';
|
Label.displayName = 'Label';
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
orientation = "horizontal",
|
orientation = 'horizontal',
|
||||||
decorative = true,
|
decorative = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
@@ -17,12 +17,12 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
'bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
|
const [internalChecked, setInternalChecked] =
|
||||||
|
React.useState(defaultChecked);
|
||||||
const isControlled = checked !== undefined;
|
const isControlled = checked !== undefined;
|
||||||
const isChecked = isControlled ? checked : internalChecked;
|
const isChecked = isControlled ? checked : internalChecked;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none transition outline-none placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -11,55 +11,61 @@ interface ThemeToggleProps {
|
|||||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||||
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
|
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
|
||||||
const currentIndex = themeModes.indexOf(themeMode);
|
const currentIndex = themeModes.indexOf(themeMode);
|
||||||
const nextTheme = themeModes[(currentIndex + 1) % themeModes.length] as ThemeMode;
|
const nextTheme = themeModes[
|
||||||
|
(currentIndex + 1) % themeModes.length
|
||||||
|
] as ThemeMode;
|
||||||
|
|
||||||
const icon =
|
const icon =
|
||||||
themeMode === 'light' ? (
|
themeMode === 'light' ? (
|
||||||
<svg
|
<svg
|
||||||
className='h-5 w-5'
|
className="h-5 w-5"
|
||||||
viewBox='0 0 24 24'
|
viewBox="0 0 24 24"
|
||||||
fill='none'
|
fill="none"
|
||||||
stroke='currentColor'
|
stroke="currentColor"
|
||||||
strokeWidth='1.8'
|
strokeWidth="1.8"
|
||||||
strokeLinecap='round'
|
strokeLinecap="round"
|
||||||
strokeLinejoin='round'
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<circle cx='12' cy='12' r='4' />
|
<circle cx="12" cy="12" r="4" />
|
||||||
<path d='M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3' />
|
<path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3" />
|
||||||
</svg>
|
</svg>
|
||||||
) : themeMode === 'dark' ? (
|
) : themeMode === 'dark' ? (
|
||||||
<svg
|
<svg
|
||||||
className='h-5 w-5'
|
className="h-5 w-5"
|
||||||
viewBox='0 0 24 24'
|
viewBox="0 0 24 24"
|
||||||
fill='none'
|
fill="none"
|
||||||
stroke='currentColor'
|
stroke="currentColor"
|
||||||
strokeWidth='1.8'
|
strokeWidth="1.8"
|
||||||
strokeLinecap='round'
|
strokeLinecap="round"
|
||||||
strokeLinejoin='round'
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<path d='M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z' />
|
<path d="M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<svg
|
||||||
className='h-5 w-5'
|
className="h-5 w-5"
|
||||||
viewBox='0 0 24 24'
|
viewBox="0 0 24 24"
|
||||||
fill='none'
|
fill="none"
|
||||||
stroke='currentColor'
|
stroke="currentColor"
|
||||||
strokeWidth='1.8'
|
strokeWidth="1.8"
|
||||||
strokeLinecap='round'
|
strokeLinecap="round"
|
||||||
strokeLinejoin='round'
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<rect x='3.5' y='4' width='17' height='12' rx='2.5' />
|
<rect x="3.5" y="4" width="17" height="12" rx="2.5" />
|
||||||
<path d='M8 20h8M12 16v4' />
|
<path d="M8 20h8M12 16v4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
themeMode === 'light' ? '浅色模式' : themeMode === 'dark' ? '深色模式' : '跟随系统';
|
themeMode === 'light'
|
||||||
|
? '浅色模式'
|
||||||
|
: themeMode === 'dark'
|
||||||
|
? '深色模式'
|
||||||
|
: '跟随系统';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => setThemeMode(nextTheme)}
|
onClick={() => setThemeMode(nextTheme)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]',
|
'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]',
|
||||||
|
|||||||
@@ -1,103 +1,134 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import { getPublicStatus } from '@/features/auth/api/public';
|
import { getPublicStatus } from '@/features/auth/api/public';
|
||||||
import { getAboutContent } from '@/features/settings/api/settings';
|
import { getAboutContent } from '@/features/settings/api/settings';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
import { formatDateTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
const aboutQuery = useQuery({
|
const aboutQuery = useQuery({
|
||||||
queryKey: ['about'],
|
queryKey: ['about'],
|
||||||
queryFn: getAboutContent,
|
queryFn: getAboutContent,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusQuery = useQuery({
|
const statusQuery = useQuery({
|
||||||
queryKey: ['public-status'],
|
queryKey: ['public-status'],
|
||||||
queryFn: getPublicStatus,
|
queryFn: getPublicStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aboutQuery.isError) {
|
if (aboutQuery.isError) {
|
||||||
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />;
|
return (
|
||||||
}
|
<ErrorState
|
||||||
|
title="关于页加载失败"
|
||||||
if (statusQuery.isError) {
|
description={getErrorMessage(aboutQuery.error)}
|
||||||
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />;
|
/>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
const aboutContent = aboutQuery.data?.trim() ?? '';
|
|
||||||
const status = statusQuery.data;
|
if (statusQuery.isError) {
|
||||||
|
return (
|
||||||
if (!status) {
|
<ErrorState
|
||||||
return <EmptyState title='暂无系统信息' description='未能获取 OpenFlare 的公开状态信息。' />;
|
title="系统状态加载失败"
|
||||||
}
|
description={getErrorMessage(statusQuery.error)}
|
||||||
|
/>
|
||||||
return (
|
);
|
||||||
<div className='space-y-6'>
|
}
|
||||||
<AppCard
|
|
||||||
title='关于 OpenFlare'
|
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||||
description='公开展示当前系统简介、版本信息与项目入口。'
|
const status = statusQuery.data;
|
||||||
action={<StatusBadge label={status.version || 'dev'} variant='info' />}
|
|
||||||
>
|
if (!status) {
|
||||||
<div className='grid gap-4 md:grid-cols-3'>
|
return (
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
<EmptyState
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p>
|
title="暂无系统信息"
|
||||||
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'OpenFlare'}</p>
|
description="未能获取 OpenFlare 的公开状态信息。"
|
||||||
</div>
|
/>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
);
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p>
|
}
|
||||||
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>
|
|
||||||
{formatDateTime(new Date(status.start_time * 1000))}
|
return (
|
||||||
</p>
|
<div className="space-y-6">
|
||||||
</div>
|
<AppCard
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
title="关于 OpenFlare"
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>项目仓库</p>
|
description="公开展示当前系统简介、版本信息与项目入口。"
|
||||||
<a
|
action={<StatusBadge label={status.version || 'dev'} variant="info" />}
|
||||||
href='https://github.com/Rain-kl/OpenFlare'
|
>
|
||||||
target='_blank'
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
rel='noreferrer'
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||||
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
>
|
系统名称
|
||||||
github.com/Rain-kl/OpenFlare
|
</p>
|
||||||
</a>
|
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
</div>
|
{status.system_name || 'OpenFlare'}
|
||||||
</div>
|
</p>
|
||||||
</AppCard>
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||||
{aboutContent ? (
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'>
|
Server 启动时间
|
||||||
<div
|
</p>
|
||||||
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]'
|
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }}
|
{formatDateTime(new Date(status.start_time * 1000))}
|
||||||
/>
|
</p>
|
||||||
</AppCard>
|
</div>
|
||||||
) : (
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
|
||||||
<EmptyState
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
title='尚未配置关于内容'
|
项目仓库
|
||||||
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。'
|
</p>
|
||||||
/>
|
<a
|
||||||
)}
|
href="https://github.com/Rain-kl/OpenFlare"
|
||||||
|
target="_blank"
|
||||||
<div className='flex flex-wrap gap-3 text-sm'>
|
rel="noreferrer"
|
||||||
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
前往登录
|
>
|
||||||
</Link>
|
github.com/Rain-kl/OpenFlare
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
</AppCard>
|
||||||
|
|
||||||
|
{aboutContent ? (
|
||||||
|
<AppCard
|
||||||
|
title="项目介绍"
|
||||||
|
description="以下内容由系统设置中的“关于内容”维护。"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: marked.parse(aboutContent) as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AppCard>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="尚未配置关于内容"
|
||||||
|
description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
前往登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,8 @@ import {
|
|||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
const applyLogsQueryKey = (
|
const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
|
||||||
nodeId: string,
|
['apply-logs', nodeId, pageNo, pageSize] as const;
|
||||||
pageNo: number,
|
|
||||||
pageSize: number,
|
|
||||||
) => ['apply-logs', nodeId, pageNo, pageSize] as const;
|
|
||||||
|
|
||||||
const pageSizeOptions = [20, 50, 100];
|
const pageSizeOptions = [20, 50, 100];
|
||||||
const emptyApplyLogRows: ApplyLogItem[] = [];
|
const emptyApplyLogRows: ApplyLogItem[] = [];
|
||||||
@@ -64,7 +61,12 @@ function truncateHash(value: string) {
|
|||||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSummary(rows: ApplyLogItem[], total: number, current: number, totalPage: number) {
|
function buildSummary(
|
||||||
|
rows: ApplyLogItem[],
|
||||||
|
total: number,
|
||||||
|
current: number,
|
||||||
|
totalPage: number,
|
||||||
|
) {
|
||||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||||
return [
|
return [
|
||||||
{ label: '总记录数', value: total },
|
{ label: '总记录数', value: total },
|
||||||
@@ -209,14 +211,17 @@ export function ApplyLogsPage() {
|
|||||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AppCard title="日志摘要" description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。">
|
<AppCard
|
||||||
|
title="日志摘要"
|
||||||
|
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
|
||||||
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{summary.map((item) => (
|
{summary.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||||
>
|
>
|
||||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
@@ -348,13 +353,16 @@ export function ApplyLogsPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
|
||||||
|
条记录。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
disabled={current <= 1}
|
disabled={current <= 1}
|
||||||
onClick={() => setPageNo((previous) => Math.max(1, previous - 1))}
|
onClick={() =>
|
||||||
|
setPageNo((previous) => Math.max(1, previous - 1))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
@@ -363,7 +371,9 @@ export function ApplyLogsPage() {
|
|||||||
disabled={totalPage === 0 || current >= totalPage}
|
disabled={totalPage === 0 || current >= totalPage}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPageNo((previous) =>
|
setPageNo((previous) =>
|
||||||
totalPage > 0 ? Math.min(totalPage, previous + 1) : previous,
|
totalPage > 0
|
||||||
|
? Math.min(totalPage, previous + 1)
|
||||||
|
: previous,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -387,14 +397,30 @@ export function ApplyLogsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
||||||
<StatusBadge label={`Node:${selectedLog.node_id}`} variant="info" />
|
<StatusBadge
|
||||||
<StatusBadge label={`版本:${selectedLog.version}`} variant="warning" />
|
label={`Node:${selectedLog.node_id}`}
|
||||||
|
variant="info"
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
label={`版本:${selectedLog.version}`}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<MetricCard label="创建时间" value={formatDateTime(selectedLog.created_at)} />
|
<MetricCard
|
||||||
<MetricCard label="相对时间" value={formatRelativeTime(selectedLog.created_at)} />
|
label="创建时间"
|
||||||
<MetricCard label="目标 Checksum" value={selectedLog.checksum || '—'} breakAll />
|
value={formatDateTime(selectedLog.created_at)}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="相对时间"
|
||||||
|
value={formatRelativeTime(selectedLog.created_at)}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="目标 Checksum"
|
||||||
|
value={selectedLog.checksum || '—'}
|
||||||
|
breakAll
|
||||||
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="支持文件数"
|
label="支持文件数"
|
||||||
value={String(selectedLog.support_file_count)}
|
value={String(selectedLog.support_file_count)}
|
||||||
@@ -412,10 +438,10 @@ export function ApplyLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
消息
|
消息
|
||||||
</p>
|
</p>
|
||||||
<pre className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]">
|
<pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||||
{selectedLog.message || '—'}
|
{selectedLog.message || '—'}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +456,10 @@ export function ApplyLogsPage() {
|
|||||||
onClose={() => setCleanupModalOpen(false)}
|
onClose={() => setCleanupModalOpen(false)}
|
||||||
footer={
|
footer={
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCleanupModalOpen(false)}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -505,7 +534,7 @@ function MetricCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import { apiRequest } from '@/lib/api/client';
|
||||||
import type { PublicStatus } from '@/types/public-status';
|
import type { PublicStatus } from '@/types/public-status';
|
||||||
|
|
||||||
export function getPublicStatus() {
|
export function getPublicStatus() {
|
||||||
return apiRequest<PublicStatus>('/status');
|
return apiRequest<PublicStatus>('/status');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,73 @@
|
|||||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
import type {
|
||||||
|
ButtonHTMLAttributes,
|
||||||
import { cn } from '@/lib/utils/cn';
|
InputHTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
export function AuthFormField({
|
} from 'react';
|
||||||
label,
|
|
||||||
hint,
|
import { cn } from '@/lib/utils/cn';
|
||||||
children,
|
|
||||||
}: {
|
export function AuthFormField({
|
||||||
label: string;
|
label,
|
||||||
hint?: string;
|
hint,
|
||||||
children: ReactNode;
|
children,
|
||||||
}) {
|
}: {
|
||||||
return (
|
label: string;
|
||||||
<label className='block space-y-2'>
|
hint?: string;
|
||||||
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</span>
|
children: ReactNode;
|
||||||
{children}
|
}) {
|
||||||
{hint ? <span className='block text-xs text-[var(--foreground-secondary)]'>{hint}</span> : null}
|
return (
|
||||||
</label>
|
<label className="block space-y-2">
|
||||||
);
|
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
}
|
{label}
|
||||||
|
</span>
|
||||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
{children}
|
||||||
return (
|
{hint ? (
|
||||||
<input
|
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||||
{...props}
|
{hint}
|
||||||
className={cn(
|
</span>
|
||||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
) : null}
|
||||||
props.className,
|
</label>
|
||||||
)}
|
);
|
||||||
/>
|
}
|
||||||
);
|
|
||||||
}
|
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
export function AuthButton({
|
<input
|
||||||
className,
|
{...props}
|
||||||
...props
|
className={cn(
|
||||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||||
return (
|
props.className,
|
||||||
<button
|
)}
|
||||||
{...props}
|
/>
|
||||||
className={cn(
|
);
|
||||||
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
}
|
||||||
className,
|
|
||||||
)}
|
export function AuthButton({
|
||||||
/>
|
className,
|
||||||
);
|
...props
|
||||||
}
|
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
export function SecondaryButton({
|
<button
|
||||||
className,
|
{...props}
|
||||||
...props
|
className={cn(
|
||||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
return (
|
className,
|
||||||
<button
|
)}
|
||||||
{...props}
|
/>
|
||||||
className={cn(
|
);
|
||||||
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
|
}
|
||||||
className,
|
|
||||||
)}
|
export function SecondaryButton({
|
||||||
/>
|
className,
|
||||||
);
|
...props
|
||||||
}
|
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
|
|
||||||
interface DashboardAuthGuardProps {
|
interface DashboardAuthGuardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
const redirect = pathname && pathname !== '/' ? `?redirect=${encodeURIComponent(pathname)}` : '';
|
const redirect =
|
||||||
router.replace(`/login${redirect}`);
|
pathname && pathname !== '/'
|
||||||
}
|
? `?redirect=${encodeURIComponent(pathname)}`
|
||||||
}, [isAuthenticated, isLoading, pathname, router]);
|
: '';
|
||||||
|
router.replace(`/login${redirect}`);
|
||||||
if (isLoading || !isAuthenticated) {
|
}
|
||||||
return (
|
}, [isAuthenticated, isLoading, pathname, router]);
|
||||||
<main className='flex min-h-screen items-center justify-center px-4 py-8'>
|
|
||||||
<LoadingState className='w-full max-w-md' />
|
if (isLoading || !isAuthenticated) {
|
||||||
</main>
|
return (
|
||||||
);
|
<main className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
}
|
<LoadingState className="w-full max-w-md" />
|
||||||
|
</main>
|
||||||
return <>{children}</>;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { exchangeGitHubCode } from '@/features/auth/api/auth';
|
import { exchangeGitHubCode } from '@/features/auth/api/auth';
|
||||||
|
|
||||||
export function GitHubOAuthCallback() {
|
export function GitHubOAuthCallback() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { setUser } = useAuth();
|
const { setUser } = useAuth();
|
||||||
const handledCodeRef = useRef<string | null>(null);
|
const handledCodeRef = useRef<string | null>(null);
|
||||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||||
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
|
const [message, setMessage] = useState<{
|
||||||
|
tone: 'danger' | 'success';
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
const code = searchParams?.get('code')?.trim() ?? '';
|
const code = searchParams?.get('code')?.trim() ?? '';
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: exchangeGitHubCode,
|
mutationFn: exchangeGitHubCode,
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
setPrompt('授权处理失败');
|
setPrompt('授权处理失败');
|
||||||
setMessage({ tone: 'danger', text: error.message || 'GitHub 授权失败,请稍后重试。' });
|
setMessage({
|
||||||
},
|
tone: 'danger',
|
||||||
});
|
text: error.message || 'GitHub 授权失败,请稍后重试。',
|
||||||
|
});
|
||||||
useEffect(() => {
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setPrompt('缺少授权 code');
|
setPrompt('缺少授权 code');
|
||||||
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
|
setMessage({
|
||||||
return;
|
tone: 'danger',
|
||||||
}
|
text: '未收到 GitHub 授权参数,请返回登录页重试。',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (handledCodeRef.current === code) {
|
if (handledCodeRef.current === code) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -46,13 +55,15 @@ export function GitHubOAuthCallback() {
|
|||||||
handledCodeRef.current = code;
|
handledCodeRef.current = code;
|
||||||
mutation.mutate(code);
|
mutation.mutate(code);
|
||||||
}, [code, mutation]);
|
}, [code, mutation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard title='GitHub OAuth 回调' description={prompt}>
|
<AppCard title="GitHub OAuth 回调" description={prompt}>
|
||||||
<div className='space-y-4'>
|
<div className="space-y-4">
|
||||||
{mutation.isPending ? <LoadingState /> : null}
|
{mutation.isPending ? <LoadingState /> : null}
|
||||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
{message ? (
|
||||||
</div>
|
<InlineMessage tone={message.tone} message={message.text} />
|
||||||
</AppCard>
|
) : null}
|
||||||
);
|
</div>
|
||||||
}
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,82 +1,106 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { resetPassword } from '@/features/auth/api/auth';
|
import { resetPassword } from '@/features/auth/api/auth';
|
||||||
import {
|
import {
|
||||||
AuthButton,
|
AuthButton,
|
||||||
AuthFormField,
|
AuthFormField,
|
||||||
AuthInput,
|
AuthInput,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from '@/features/auth/components/auth-form-primitives';
|
} from '@/features/auth/components/auth-form-primitives';
|
||||||
|
|
||||||
export function PasswordResetConfirmForm() {
|
export function PasswordResetConfirmForm() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const email = searchParams?.get('email') || '';
|
const email = searchParams?.get('email') || '';
|
||||||
const token = searchParams?.get('token') || '';
|
const token = searchParams?.get('token') || '';
|
||||||
const [message, setMessage] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null);
|
const [message, setMessage] = useState<{
|
||||||
|
tone: 'success' | 'danger';
|
||||||
const mutation = useMutation({
|
text: string;
|
||||||
mutationFn: () => resetPassword({ email, token }),
|
} | null>(null);
|
||||||
onSuccess: async (password) => {
|
|
||||||
try {
|
const mutation = useMutation({
|
||||||
await navigator.clipboard.writeText(password);
|
mutationFn: () => resetPassword({ email, token }),
|
||||||
setMessage({ tone: 'success', text: `密码已重置,新密码已复制到剪贴板:${password}` });
|
onSuccess: async (password) => {
|
||||||
} catch {
|
try {
|
||||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
await navigator.clipboard.writeText(password);
|
||||||
}
|
setMessage({
|
||||||
},
|
tone: 'success',
|
||||||
onError: (error: Error) => {
|
text: `密码已重置,新密码已复制到剪贴板:${password}`,
|
||||||
setMessage({ tone: 'danger', text: error.message || '密码重置失败,请重新获取链接。' });
|
});
|
||||||
},
|
} catch {
|
||||||
});
|
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||||
|
}
|
||||||
const missingParams = !email || !token;
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
return (
|
setMessage({
|
||||||
<AppCard title='密码重置确认' description='确认后,系统会生成新的随机密码。'>
|
tone: 'danger',
|
||||||
<div className='space-y-4'>
|
text: error.message || '密码重置失败,请重新获取链接。',
|
||||||
<AuthFormField label='邮箱地址'>
|
});
|
||||||
<AuthInput value={email} readOnly />
|
},
|
||||||
</AuthFormField>
|
});
|
||||||
|
|
||||||
{missingParams ? (
|
const missingParams = !email || !token;
|
||||||
<InlineMessage tone='danger' message='重置链接缺少必要参数,请重新发起密码重置。' />
|
|
||||||
) : null}
|
return (
|
||||||
|
<AppCard
|
||||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
title="密码重置确认"
|
||||||
|
description="确认后,系统会生成新的随机密码。"
|
||||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
>
|
||||||
<AuthButton type='button' disabled={missingParams || mutation.isPending} onClick={() => mutation.mutate()}>
|
<div className="space-y-4">
|
||||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
<AuthFormField label="邮箱地址">
|
||||||
</AuthButton>
|
<AuthInput value={email} readOnly />
|
||||||
{message?.tone === 'success' ? (
|
</AuthFormField>
|
||||||
<SecondaryButton
|
|
||||||
type='button'
|
{missingParams ? (
|
||||||
onClick={async () => {
|
<InlineMessage
|
||||||
const password = message.text.split(':').pop() || '';
|
tone="danger"
|
||||||
if (password) {
|
message="重置链接缺少必要参数,请重新发起密码重置。"
|
||||||
await navigator.clipboard.writeText(password);
|
/>
|
||||||
}
|
) : null}
|
||||||
}}
|
|
||||||
>
|
{message ? (
|
||||||
再次复制密码
|
<InlineMessage tone={message.tone} message={message.text} />
|
||||||
</SecondaryButton>
|
) : null}
|
||||||
) : null}
|
|
||||||
</div>
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<AuthButton
|
||||||
<div className='text-sm text-[var(--foreground-secondary)]'>
|
type="button"
|
||||||
处理完成后可返回
|
disabled={missingParams || mutation.isPending}
|
||||||
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
|
onClick={() => mutation.mutate()}
|
||||||
登录页
|
>
|
||||||
</Link>
|
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||||
</div>
|
</AuthButton>
|
||||||
</div>
|
{message?.tone === 'success' ? (
|
||||||
</AppCard>
|
<SecondaryButton
|
||||||
);
|
type="button"
|
||||||
}
|
onClick={async () => {
|
||||||
|
const password = message.text.split(':').pop() || '';
|
||||||
|
if (password) {
|
||||||
|
await navigator.clipboard.writeText(password);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
再次复制密码
|
||||||
|
</SecondaryButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
处理完成后可返回
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
登录页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
|
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
|
||||||
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
|
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
|
||||||
|
|
||||||
export function PasswordResetFlow() {
|
export function PasswordResetFlow() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const hasConfirmationParams = Boolean(searchParams?.get('email') && searchParams?.get('token'));
|
const hasConfirmationParams = Boolean(
|
||||||
|
searchParams?.get('email') && searchParams?.get('token'),
|
||||||
return hasConfirmationParams ? <PasswordResetConfirmForm /> : <PasswordResetRequestForm />;
|
);
|
||||||
}
|
|
||||||
|
return hasConfirmationParams ? (
|
||||||
|
<PasswordResetConfirmForm />
|
||||||
|
) : (
|
||||||
|
<PasswordResetRequestForm />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,101 +1,120 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
|
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
|
||||||
import { getPublicStatus } from '@/features/auth/api/public';
|
import { getPublicStatus } from '@/features/auth/api/public';
|
||||||
import {
|
import {
|
||||||
AuthButton,
|
AuthButton,
|
||||||
AuthFormField,
|
AuthFormField,
|
||||||
AuthInput,
|
AuthInput,
|
||||||
} from '@/features/auth/components/auth-form-primitives';
|
} from '@/features/auth/components/auth-form-primitives';
|
||||||
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||||
|
|
||||||
const resetRequestSchema = z.object({
|
const resetRequestSchema = z.object({
|
||||||
email: z.string().email('请输入有效邮箱地址'),
|
email: z.string().email('请输入有效邮箱地址'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||||
|
|
||||||
export function PasswordResetRequestForm() {
|
export function PasswordResetRequestForm() {
|
||||||
const [turnstileToken, setTurnstileToken] = useState('');
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
|
const [message, setMessage] = useState<{
|
||||||
|
tone: 'success' | 'danger' | 'info';
|
||||||
const form = useForm<ResetRequestFormValues>({
|
text: string;
|
||||||
resolver: zodResolver(resetRequestSchema),
|
} | null>(null);
|
||||||
defaultValues: { email: '' },
|
|
||||||
});
|
const form = useForm<ResetRequestFormValues>({
|
||||||
|
resolver: zodResolver(resetRequestSchema),
|
||||||
const statusQuery = useQuery({
|
defaultValues: { email: '' },
|
||||||
queryKey: ['public-status'],
|
});
|
||||||
queryFn: getPublicStatus,
|
|
||||||
});
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['public-status'],
|
||||||
const mutation = useMutation({
|
queryFn: getPublicStatus,
|
||||||
mutationFn: (values: ResetRequestFormValues) =>
|
});
|
||||||
sendPasswordResetEmail(values.email, turnstileToken || undefined),
|
|
||||||
onSuccess: () => {
|
const mutation = useMutation({
|
||||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
mutationFn: (values: ResetRequestFormValues) =>
|
||||||
form.reset();
|
sendPasswordResetEmail(values.email, turnstileToken || undefined),
|
||||||
},
|
onSuccess: () => {
|
||||||
onError: (error: Error) => {
|
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||||
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' });
|
form.reset();
|
||||||
},
|
},
|
||||||
});
|
onError: (error: Error) => {
|
||||||
|
setMessage({
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
tone: 'danger',
|
||||||
setMessage(null);
|
text: error.message || '重置邮件发送失败,请稍后重试。',
|
||||||
if (statusQuery.data?.turnstile_check && !turnstileToken) {
|
});
|
||||||
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
},
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
mutation.mutate(values);
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
});
|
setMessage(null);
|
||||||
|
if (statusQuery.data?.turnstile_check && !turnstileToken) {
|
||||||
return (
|
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||||
<PublicAuthGuard>
|
return;
|
||||||
<AppCard title='密码重置' description='提交后,系统会向你的注册邮箱发送重置链接。'>
|
}
|
||||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
mutation.mutate(values);
|
||||||
<AuthFormField label='邮箱地址'>
|
});
|
||||||
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
|
|
||||||
{form.formState.errors.email ? (
|
return (
|
||||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
<PublicAuthGuard>
|
||||||
{form.formState.errors.email.message}
|
<AppCard
|
||||||
</span>
|
title="密码重置"
|
||||||
) : null}
|
description="提交后,系统会向你的注册邮箱发送重置链接。"
|
||||||
</AuthFormField>
|
>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? (
|
<AuthFormField label="邮箱地址">
|
||||||
<TurnstileWidget
|
<AuthInput
|
||||||
siteKey={statusQuery.data.turnstile_site_key}
|
type="email"
|
||||||
onVerify={(token) => setTurnstileToken(token)}
|
placeholder="请输入邮箱地址"
|
||||||
onExpire={() => setTurnstileToken('')}
|
{...form.register('email')}
|
||||||
onError={() => setTurnstileToken('')}
|
/>
|
||||||
/>
|
{form.formState.errors.email ? (
|
||||||
) : null}
|
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||||
|
{form.formState.errors.email.message}
|
||||||
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
</span>
|
||||||
|
) : null}
|
||||||
<AuthButton type='submit' disabled={mutation.isPending}>
|
</AuthFormField>
|
||||||
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
|
||||||
</AuthButton>
|
{statusQuery.data?.turnstile_check &&
|
||||||
</form>
|
statusQuery.data.turnstile_site_key ? (
|
||||||
|
<TurnstileWidget
|
||||||
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
|
siteKey={statusQuery.data.turnstile_site_key}
|
||||||
想起密码了?
|
onVerify={(token) => setTurnstileToken(token)}
|
||||||
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
|
onExpire={() => setTurnstileToken('')}
|
||||||
返回登录
|
onError={() => setTurnstileToken('')}
|
||||||
</Link>
|
/>
|
||||||
</div>
|
) : null}
|
||||||
</AppCard>
|
|
||||||
</PublicAuthGuard>
|
{message ? (
|
||||||
);
|
<InlineMessage tone={message.tone} message={message.text} />
|
||||||
}
|
) : null}
|
||||||
|
|
||||||
|
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
||||||
|
</AuthButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||||
|
想起密码了?
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</PublicAuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
|
|
||||||
interface PublicAuthGuardProps {
|
interface PublicAuthGuardProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && isAuthenticated) {
|
if (!isLoading && isAuthenticated) {
|
||||||
const redirect = searchParams?.get('redirect');
|
const redirect = searchParams?.get('redirect');
|
||||||
router.replace(redirect || '/');
|
router.replace(redirect || '/');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||||
|
|
||||||
interface PublicRoutePlaceholderProps {
|
interface PublicRoutePlaceholderProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicRoutePlaceholder({
|
export function PublicRoutePlaceholder({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: PublicRoutePlaceholderProps) {
|
}: PublicRoutePlaceholderProps) {
|
||||||
return (
|
return (
|
||||||
<FeaturePlaceholder
|
<FeaturePlaceholder
|
||||||
title={title}
|
title={title}
|
||||||
description={description}
|
description={description}
|
||||||
milestones={[
|
milestones={[
|
||||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ export function getConfigVersion(id: number) {
|
|||||||
export function getActiveConfigVersion() {
|
export function getActiveConfigVersion() {
|
||||||
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
return apiRequest<ConfigVersionDetail>('/config-versions/active');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigVersionPreview() {
|
export function getConfigVersionPreview() {
|
||||||
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigVersionDiff() {
|
export function getConfigVersionDiff() {
|
||||||
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
return apiRequest<ConfigDiffResult>('/config-versions/diff');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publishConfigVersion(force?: boolean) {
|
export function publishConfigVersion(force?: boolean) {
|
||||||
const query = force ? '?force=true' : '';
|
const query = force ? '?force=true' : '';
|
||||||
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
|
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
|
||||||
|
|||||||
+6
-1
@@ -45,7 +45,12 @@ export function CleanupConfigVersionsModal({
|
|||||||
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
|
||||||
取消
|
取消
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton type="submit" form="cleanup-form" disabled={isPending} className="bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90 border-[var(--status-danger-foreground)]">
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
form="cleanup-form"
|
||||||
|
disabled={isPending}
|
||||||
|
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
|
||||||
|
>
|
||||||
{isPending ? '清理中...' : '确认清理'}
|
{isPending ? '清理中...' : '确认清理'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,7 +285,10 @@ function PublishPreviewCard({
|
|||||||
{isActiveVersionDetailLoading ? (
|
{isActiveVersionDetailLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : activeVersionDetailError ? (
|
) : activeVersionDetailError ? (
|
||||||
<InlineMessage tone="danger" message={activeVersionDetailError} />
|
<InlineMessage
|
||||||
|
tone="danger"
|
||||||
|
message={activeVersionDetailError}
|
||||||
|
/>
|
||||||
) : activeVersionDetail ? (
|
) : activeVersionDetail ? (
|
||||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||||
{activeVersionDetail.main_config}
|
{activeVersionDetail.main_config}
|
||||||
@@ -520,7 +523,11 @@ export function ConfigVersionsPage() {
|
|||||||
<SecondaryButton
|
<SecondaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm('确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。')) {
|
if (
|
||||||
|
window.confirm(
|
||||||
|
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
|
||||||
|
)
|
||||||
|
) {
|
||||||
publishMutation.mutate(true);
|
publishMutation.mutate(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -672,7 +679,9 @@ export function ConfigVersionsPage() {
|
|||||||
<CleanupConfigVersionsModal
|
<CleanupConfigVersionsModal
|
||||||
isOpen={isCleanupModalOpen}
|
isOpen={isCleanupModalOpen}
|
||||||
onClose={() => setIsCleanupModalOpen(false)}
|
onClose={() => setIsCleanupModalOpen(false)}
|
||||||
onConfirm={(keepCount) => cleanupMutation.mutate({ keep_count: keepCount })}
|
onConfirm={(keepCount) =>
|
||||||
|
cleanupMutation.mutate({ keep_count: keepCount })
|
||||||
|
}
|
||||||
isPending={cleanupMutation.isPending}
|
isPending={cleanupMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ function isCompactDistributionItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCompactTrafficTrendPoint(
|
function isCompactTrafficTrendPoint(
|
||||||
value: DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint,
|
value:
|
||||||
|
| DashboardOverview['trends']['traffic_24h'][number]
|
||||||
|
| CompactTrafficTrendPoint,
|
||||||
): value is CompactTrafficTrendPoint {
|
): value is CompactTrafficTrendPoint {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,10 @@ function normalizeDistributionItems(
|
|||||||
|
|
||||||
function normalizeTrafficTrendPoints(
|
function normalizeTrafficTrendPoints(
|
||||||
items:
|
items:
|
||||||
| Array<DashboardOverview['trends']['traffic_24h'][number] | CompactTrafficTrendPoint>
|
| Array<
|
||||||
|
| DashboardOverview['trends']['traffic_24h'][number]
|
||||||
|
| CompactTrafficTrendPoint
|
||||||
|
>
|
||||||
| null
|
| null
|
||||||
| undefined,
|
| undefined,
|
||||||
) {
|
) {
|
||||||
@@ -114,7 +119,8 @@ function normalizeCapacityTrendPoints(
|
|||||||
function normalizeNetworkTrendPoints(
|
function normalizeNetworkTrendPoints(
|
||||||
items:
|
items:
|
||||||
| Array<
|
| Array<
|
||||||
DashboardOverview['trends']['network_24h'][number] | CompactNetworkTrendPoint
|
| DashboardOverview['trends']['network_24h'][number]
|
||||||
|
| CompactNetworkTrendPoint
|
||||||
>
|
>
|
||||||
| null
|
| null
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -136,7 +142,8 @@ function normalizeNetworkTrendPoints(
|
|||||||
function normalizeDiskIOTrendPoints(
|
function normalizeDiskIOTrendPoints(
|
||||||
items:
|
items:
|
||||||
| Array<
|
| Array<
|
||||||
DashboardOverview['trends']['disk_io_24h'][number] | CompactDiskIOTrendPoint
|
| DashboardOverview['trends']['disk_io_24h'][number]
|
||||||
|
| CompactDiskIOTrendPoint
|
||||||
>
|
>
|
||||||
| null
|
| null
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -154,7 +161,10 @@ function normalizeDiskIOTrendPoints(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDashboardNodes(
|
function normalizeDashboardNodes(
|
||||||
items: Array<DashboardNodeHealth | CompactDashboardNodeHealth> | null | undefined,
|
items:
|
||||||
|
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
): DashboardNodeHealth[] {
|
): DashboardNodeHealth[] {
|
||||||
return arrayOrEmpty(items).map((item) =>
|
return arrayOrEmpty(items).map((item) =>
|
||||||
isCompactDashboardNode(item)
|
isCompactDashboardNode(item)
|
||||||
@@ -185,11 +195,7 @@ function normalizeDashboardNodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDashboardOverview(
|
function normalizeDashboardOverview(
|
||||||
overview:
|
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
|
||||||
| DashboardOverview
|
|
||||||
| DashboardOverviewCompact
|
|
||||||
| null
|
|
||||||
| undefined,
|
|
||||||
): DashboardOverview | null {
|
): DashboardOverview | null {
|
||||||
if (!overview) {
|
if (!overview) {
|
||||||
return null;
|
return null;
|
||||||
@@ -209,8 +215,12 @@ function normalizeDashboardOverview(
|
|||||||
source_countries: normalizeDistributionItems(
|
source_countries: normalizeDistributionItems(
|
||||||
overview.distributions?.source_countries,
|
overview.distributions?.source_countries,
|
||||||
),
|
),
|
||||||
status_codes: normalizeDistributionItems(overview.distributions?.status_codes),
|
status_codes: normalizeDistributionItems(
|
||||||
top_domains: normalizeDistributionItems(overview.distributions?.top_domains),
|
overview.distributions?.status_codes,
|
||||||
|
),
|
||||||
|
top_domains: normalizeDistributionItems(
|
||||||
|
overview.distributions?.top_domains,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
trends: {
|
trends: {
|
||||||
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
|
||||||
@@ -222,8 +232,8 @@ function normalizeDashboardOverview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardOverview() {
|
export async function getDashboardOverview() {
|
||||||
const overview = await apiRequest<DashboardOverview | DashboardOverviewCompact>(
|
const overview = await apiRequest<
|
||||||
'/dashboard/overview',
|
DashboardOverview | DashboardOverviewCompact
|
||||||
);
|
>('/dashboard/overview');
|
||||||
return normalizeDashboardOverview(overview);
|
return normalizeDashboardOverview(overview);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
|
|||||||
{isWSConnectedLastSeen(node.last_seen_at)
|
{isWSConnectedLastSeen(node.last_seen_at)
|
||||||
? ' WS 已连接'
|
? ' WS 已连接'
|
||||||
: node.last_seen_at
|
: node.last_seen_at
|
||||||
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||||
: ' 暂无'}
|
: ' 暂无'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -249,9 +249,7 @@ export function DashboardOverview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
<AppCard
|
<AppCard title="24 小时网络与磁盘趋势">
|
||||||
title="24 小时网络与磁盘趋势"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<TrendChart
|
<TrendChart
|
||||||
labels={overview.trends.network_24h.map((point) =>
|
labels={overview.trends.network_24h.map((point) =>
|
||||||
@@ -311,9 +309,7 @@ export function DashboardOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard
|
<AppCard title="Top 节点榜单">
|
||||||
title="Top 节点榜单"
|
|
||||||
>
|
|
||||||
<div className="grid gap-6 xl:grid-cols-1">
|
<div className="grid gap-6 xl:grid-cols-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
|
||||||
@@ -352,10 +348,7 @@ export function DashboardOverview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-3">
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
<AppCard
|
<AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
|
||||||
title="来源分布"
|
|
||||||
description="聚合最近 24 小时主要来源国家。"
|
|
||||||
>
|
|
||||||
<RankChart
|
<RankChart
|
||||||
items={overview.distributions.source_countries.map((item) => ({
|
items={overview.distributions.source_countries.map((item) => ({
|
||||||
label: item.key,
|
label: item.key,
|
||||||
@@ -380,10 +373,7 @@ export function DashboardOverview() {
|
|||||||
/>
|
/>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard
|
<AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
|
||||||
title="Top Domain"
|
|
||||||
description="观察主要流量集中在哪些域名。"
|
|
||||||
>
|
|
||||||
<RankChart
|
<RankChart
|
||||||
items={overview.distributions.top_domains.map((item) => ({
|
items={overview.distributions.top_domains.map((item) => ({
|
||||||
label: item.key,
|
label: item.key,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,10 @@ export function createDnsAccount(payload: DnsAccountMutationPayload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateDnsAccount(id: number, payload: DnsAccountMutationPayload) {
|
export function updateDnsAccount(
|
||||||
|
id: number,
|
||||||
|
payload: DnsAccountMutationPayload,
|
||||||
|
) {
|
||||||
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ import { formatDateTime } from '@/lib/utils/date';
|
|||||||
|
|
||||||
export function DnsAccountsPage() {
|
export function DnsAccountsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [feedback, setFeedback] = useState<{ tone: 'info' | 'success' | 'danger'; message: string } | null>(null);
|
const [feedback, setFeedback] = useState<{
|
||||||
|
tone: 'info' | 'success' | 'danger';
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
|
||||||
const dnsAccountsQuery = useQuery({
|
const dnsAccountsQuery = useQuery({
|
||||||
@@ -57,7 +60,10 @@ export function DnsAccountsPage() {
|
|||||||
deleteMutation.mutate(account.id);
|
deleteMutation.mutate(account.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const accounts = useMemo(() => dnsAccountsQuery.data ?? [], [dnsAccountsQuery.data]);
|
const accounts = useMemo(
|
||||||
|
() => dnsAccountsQuery.data ?? [],
|
||||||
|
[dnsAccountsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -73,30 +79,47 @@ export function DnsAccountsPage() {
|
|||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
</Link>
|
</Link>
|
||||||
<PrimaryButton type="button" onClick={() => setIsCreateOpen(true)}>
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
>
|
||||||
添加账号
|
添加账号
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
{feedback ? (
|
||||||
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<AppCard title="DNS 账号列表">
|
<AppCard title="DNS 账号列表">
|
||||||
{dnsAccountsQuery.isLoading ? (
|
{dnsAccountsQuery.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : dnsAccountsQuery.isError ? (
|
) : dnsAccountsQuery.isError ? (
|
||||||
<ErrorState title="加载失败" description={getErrorMessage(dnsAccountsQuery.error)} />
|
<ErrorState
|
||||||
|
title="加载失败"
|
||||||
|
description={getErrorMessage(dnsAccountsQuery.error)}
|
||||||
|
/>
|
||||||
) : accounts.length === 0 ? (
|
) : accounts.length === 0 ? (
|
||||||
<EmptyState title="暂无 DNS 账号" description="点击右上角“添加账号”开始录入。" />
|
<EmptyState
|
||||||
|
title="暂无 DNS 账号"
|
||||||
|
description="点击右上角“添加账号”开始录入。"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div key={account.id} className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
{account.name} <span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">({account.type})</span>
|
{account.name}{' '}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">
|
||||||
|
({account.type})
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
|
||||||
<p>创建于:{formatDateTime(account.created_at)}</p>
|
<p>创建于:{formatDateTime(account.created_at)}</p>
|
||||||
@@ -119,19 +142,31 @@ export function DnsAccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreateOpen && (
|
{isCreateOpen && (
|
||||||
<DnsAccountCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreated={() => {
|
<DnsAccountCreateModal
|
||||||
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
isOpen={isCreateOpen}
|
||||||
setIsCreateOpen(false);
|
onClose={() => setIsCreateOpen(false)}
|
||||||
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
onCreated={() => {
|
||||||
}} />
|
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean; onClose: () => void; onCreated: () => void }) {
|
function DnsAccountCreateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void;
|
||||||
|
}) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { register, handleSubmit, formState } = useForm({
|
const { register, handleSubmit, formState } = useForm({
|
||||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||||
@@ -157,8 +192,14 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
|
|||||||
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
|
||||||
<form onSubmit={onSubmit} className="space-y-5">
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
{error && <InlineMessage tone="danger" message={error} />}
|
{error && <InlineMessage tone="danger" message={error} />}
|
||||||
<ResourceField label="账号名称" error={formState.errors.name?.message as string}>
|
<ResourceField
|
||||||
<ResourceInput placeholder="Cloudlfare 邮箱账号" {...register('name', { required: '请输入名称' })} />
|
label="账号名称"
|
||||||
|
error={formState.errors.name?.message as string}
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
placeholder="Cloudlfare 邮箱账号"
|
||||||
|
{...register('name', { required: '请输入名称' })}
|
||||||
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField label="DNS 服务商">
|
<ResourceField label="DNS 服务商">
|
||||||
<ResourceSelect {...register('type')}>
|
<ResourceSelect {...register('type')}>
|
||||||
@@ -166,7 +207,9 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
|
|||||||
</ResourceSelect>
|
</ResourceSelect>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField label="API Token" hint="请勿使用 Global API Key">
|
<ResourceField label="API Token" hint="请勿使用 Global API Key">
|
||||||
<ResourceInput {...register('authorization', { required: '请输入 Token' })} />
|
<ResourceInput
|
||||||
|
{...register('authorization', { required: '请输入 Token' })}
|
||||||
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
<PrimaryButton type="submit" disabled={createMutation.isPending}>
|
||||||
{createMutation.isPending ? '提交中...' : '提交'}
|
{createMutation.isPending ? '提交中...' : '提交'}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export function createManagedDomain(payload: ManagedDomainMutationPayload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) {
|
export function updateManagedDomain(
|
||||||
|
id: number,
|
||||||
|
payload: ManagedDomainMutationPayload,
|
||||||
|
) {
|
||||||
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
|
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export function requestNodeAgentUpdate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requestNodeForceSync(id: number) {
|
export function requestNodeForceSync(id: number) {
|
||||||
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
|
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestNodeOpenrestyRestart(id: number) {
|
export function requestNodeOpenrestyRestart(id: number) {
|
||||||
|
|||||||
@@ -404,7 +404,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
});
|
});
|
||||||
setHealthEventCleanupModalOpen(false);
|
setHealthEventCleanupModalOpen(false);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['node-observability', nodeId],
|
||||||
|
}),
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
|
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
@@ -869,9 +871,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard
|
<AppCard title="实时资源">
|
||||||
title="实时资源"
|
|
||||||
>
|
|
||||||
{observabilityQuery.isLoading ? (
|
{observabilityQuery.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : observabilityQuery.isError ? (
|
) : observabilityQuery.isError ? (
|
||||||
@@ -930,9 +930,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard
|
<AppCard title="网络流量">
|
||||||
title="网络流量"
|
|
||||||
>
|
|
||||||
{observabilityQuery.isLoading ? (
|
{observabilityQuery.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : observabilityQuery.isError ? (
|
) : observabilityQuery.isError ? (
|
||||||
@@ -1628,7 +1626,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
|
|
||||||
{nodeInstallCommand ? (
|
{nodeInstallCommand ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">脚本部署 (Linux / macOS)</p>
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
脚本部署 (Linux / macOS)
|
||||||
|
</p>
|
||||||
<CodeBlock className="whitespace-pre-wrap">
|
<CodeBlock className="whitespace-pre-wrap">
|
||||||
{nodeInstallCommand}
|
{nodeInstallCommand}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
@@ -1637,7 +1637,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
|
|
||||||
{nodeDockerInstallCommand ? (
|
{nodeDockerInstallCommand ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">Docker 部署</p>
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
Docker 部署
|
||||||
|
</p>
|
||||||
<CodeBlock className="whitespace-pre-wrap">
|
<CodeBlock className="whitespace-pre-wrap">
|
||||||
{nodeDockerInstallCommand}
|
{nodeDockerInstallCommand}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
@@ -1835,8 +1837,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
|
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
|
||||||
<p>该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。</p>
|
<p>
|
||||||
<p>这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。</p>
|
该操作会删除当前节点已记录的全部健康事件,包括活动中与已恢复事件。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
这不会影响节点后续继续上报新的健康事件,但现有时间线与相关摘要会立即刷新。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ function getRegionCenter(feature: GeoJsonFeature) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRegionOptions() {
|
function buildRegionOptions() {
|
||||||
const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ??
|
const features = ((worldGeoJson as { features?: GeoJsonFeature[] })
|
||||||
[]) as GeoJsonFeature[];
|
.features ?? []) as GeoJsonFeature[];
|
||||||
const options = new Map<string, RegionOption>();
|
const options = new Map<string, RegionOption>();
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
@@ -186,7 +186,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
|||||||
ip: node.ip ?? '',
|
ip: node.ip ?? '',
|
||||||
auto_update_enabled: node.auto_update_enabled ?? false,
|
auto_update_enabled: node.auto_update_enabled ?? false,
|
||||||
geo_manual_override: node.geo_manual_override ?? false,
|
geo_manual_override: node.geo_manual_override ?? false,
|
||||||
geo_region: node.geo_manual_override ? node.geo_name ?? '' : '',
|
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||||
geo_name: node.geo_name ?? '',
|
geo_name: node.geo_name ?? '',
|
||||||
geo_latitude:
|
geo_latitude:
|
||||||
node.geo_latitude === undefined || node.geo_latitude === null
|
node.geo_latitude === undefined || node.geo_latitude === null
|
||||||
|
|||||||
@@ -354,10 +354,10 @@ export function NodesPage() {
|
|||||||
{isWSConnectedLastSeen(node.last_seen_at)
|
{isWSConnectedLastSeen(node.last_seen_at)
|
||||||
? 'WS 已连接'
|
? 'WS 已连接'
|
||||||
: isMeaningfulTime(node.last_seen_at)
|
: isMeaningfulTime(node.last_seen_at)
|
||||||
? `${formatRelativeTime(
|
? `${formatRelativeTime(
|
||||||
node.last_seen_at,
|
node.last_seen_at,
|
||||||
)} · ${formatDateTime(node.last_seen_at)}`
|
)} · ${formatDateTime(node.last_seen_at)}`
|
||||||
: '暂无'}
|
: '暂无'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-3 py-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,188 +1,188 @@
|
|||||||
import type { ReleaseChannel } from '@/features/update/types';
|
import type { ReleaseChannel } from '@/features/update/types';
|
||||||
|
|
||||||
export interface NodeItem {
|
export interface NodeItem {
|
||||||
id: number;
|
id: number;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
geo_longitude?: number | null;
|
geo_longitude?: number | null;
|
||||||
geo_manual_override: boolean;
|
geo_manual_override: boolean;
|
||||||
agent_token: string;
|
agent_token: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
update_requested: boolean;
|
update_requested: boolean;
|
||||||
update_channel: ReleaseChannel;
|
update_channel: ReleaseChannel;
|
||||||
update_tag: string;
|
update_tag: string;
|
||||||
restart_openresty_requested: boolean;
|
restart_openresty_requested: boolean;
|
||||||
agent_version: string;
|
agent_version: string;
|
||||||
nginx_version: string;
|
nginx_version: string;
|
||||||
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
openresty_message: string;
|
openresty_message: string;
|
||||||
status: 'online' | 'offline' | 'pending';
|
status: 'online' | 'offline' | 'pending';
|
||||||
current_version: string;
|
current_version: string;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
last_error: string;
|
last_error: string;
|
||||||
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
latest_apply_result: 'success' | 'warning' | 'failed' | '';
|
||||||
latest_apply_message: string;
|
latest_apply_message: string;
|
||||||
latest_apply_checksum: string;
|
latest_apply_checksum: string;
|
||||||
latest_main_config_checksum: string;
|
latest_main_config_checksum: string;
|
||||||
latest_route_config_checksum: string;
|
latest_route_config_checksum: string;
|
||||||
latest_support_file_count: number;
|
latest_support_file_count: number;
|
||||||
latest_apply_at?: string | null;
|
latest_apply_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeBootstrapToken {
|
export interface NodeBootstrapToken {
|
||||||
discovery_token: string;
|
discovery_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeMutationPayload {
|
export interface NodeMutationPayload {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
geo_longitude?: number | null;
|
geo_longitude?: number | null;
|
||||||
geo_manual_override: boolean;
|
geo_manual_override: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeAgentReleaseInfo {
|
export interface NodeAgentReleaseInfo {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
body: string;
|
body: string;
|
||||||
html_url: string;
|
html_url: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
current_version: string;
|
current_version: string;
|
||||||
has_update: boolean;
|
has_update: boolean;
|
||||||
channel: ReleaseChannel;
|
channel: ReleaseChannel;
|
||||||
prerelease: boolean;
|
prerelease: boolean;
|
||||||
update_requested: boolean;
|
update_requested: boolean;
|
||||||
requested_channel: ReleaseChannel;
|
requested_channel: ReleaseChannel;
|
||||||
requested_tag: string;
|
requested_tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeAgentUpdatePayload {
|
export interface NodeAgentUpdatePayload {
|
||||||
channel?: ReleaseChannel;
|
channel?: ReleaseChannel;
|
||||||
tag_name?: string;
|
tag_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeSystemProfile {
|
export interface NodeSystemProfile {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
os_name: string;
|
os_name: string;
|
||||||
os_version: string;
|
os_version: string;
|
||||||
kernel_version: string;
|
kernel_version: string;
|
||||||
architecture: string;
|
architecture: string;
|
||||||
cpu_model: string;
|
cpu_model: string;
|
||||||
cpu_cores: number;
|
cpu_cores: number;
|
||||||
total_memory_bytes: number;
|
total_memory_bytes: number;
|
||||||
total_disk_bytes: number;
|
total_disk_bytes: number;
|
||||||
uptime_seconds: number;
|
uptime_seconds: number;
|
||||||
reported_at: string;
|
reported_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeMetricSnapshot {
|
export interface NodeMetricSnapshot {
|
||||||
captured_at: string;
|
captured_at: string;
|
||||||
cpu_usage_percent: number;
|
cpu_usage_percent: number;
|
||||||
memory_used_bytes: number;
|
memory_used_bytes: number;
|
||||||
memory_total_bytes: number;
|
memory_total_bytes: number;
|
||||||
storage_used_bytes: number;
|
storage_used_bytes: number;
|
||||||
storage_total_bytes: number;
|
storage_total_bytes: number;
|
||||||
disk_read_bytes: number;
|
disk_read_bytes: number;
|
||||||
disk_write_bytes: number;
|
disk_write_bytes: number;
|
||||||
network_rx_bytes: number;
|
network_rx_bytes: number;
|
||||||
network_tx_bytes: number;
|
network_tx_bytes: number;
|
||||||
openresty_rx_bytes: number;
|
openresty_rx_bytes: number;
|
||||||
openresty_tx_bytes: number;
|
openresty_tx_bytes: number;
|
||||||
openresty_connections: number;
|
openresty_connections: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTrafficReport {
|
export interface NodeTrafficReport {
|
||||||
window_started_at: string;
|
window_started_at: string;
|
||||||
window_ended_at: string;
|
window_ended_at: string;
|
||||||
request_count: number;
|
request_count: number;
|
||||||
error_count: number;
|
error_count: number;
|
||||||
unique_visitor_count: number;
|
unique_visitor_count: number;
|
||||||
status_codes_json: string;
|
status_codes_json: string;
|
||||||
top_domains_json: string;
|
top_domains_json: string;
|
||||||
source_countries_json: string;
|
source_countries_json: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTrafficTrendPoint {
|
export interface NodeTrafficTrendPoint {
|
||||||
bucket_started_at: string;
|
bucket_started_at: string;
|
||||||
request_count: number;
|
request_count: number;
|
||||||
error_count: number;
|
error_count: number;
|
||||||
unique_visitor_count: number;
|
unique_visitor_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeCapacityTrendPoint {
|
export interface NodeCapacityTrendPoint {
|
||||||
bucket_started_at: string;
|
bucket_started_at: string;
|
||||||
average_cpu_usage_percent: number;
|
average_cpu_usage_percent: number;
|
||||||
average_memory_usage_percent: number;
|
average_memory_usage_percent: number;
|
||||||
reported_nodes: number;
|
reported_nodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeNetworkTrendPoint {
|
export interface NodeNetworkTrendPoint {
|
||||||
bucket_started_at: string;
|
bucket_started_at: string;
|
||||||
network_rx_bytes: number;
|
network_rx_bytes: number;
|
||||||
network_tx_bytes: number;
|
network_tx_bytes: number;
|
||||||
openresty_rx_bytes: number;
|
openresty_rx_bytes: number;
|
||||||
openresty_tx_bytes: number;
|
openresty_tx_bytes: number;
|
||||||
reported_nodes: number;
|
reported_nodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeDiskIOTrendPoint {
|
export interface NodeDiskIOTrendPoint {
|
||||||
bucket_started_at: string;
|
bucket_started_at: string;
|
||||||
disk_read_bytes: number;
|
disk_read_bytes: number;
|
||||||
disk_write_bytes: number;
|
disk_write_bytes: number;
|
||||||
reported_nodes: number;
|
reported_nodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeDistributionItem {
|
export interface NodeDistributionItem {
|
||||||
key: string;
|
key: string;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTrafficDistributions {
|
export interface NodeTrafficDistributions {
|
||||||
status_codes: NodeDistributionItem[];
|
status_codes: NodeDistributionItem[];
|
||||||
top_domains: NodeDistributionItem[];
|
top_domains: NodeDistributionItem[];
|
||||||
source_countries: NodeDistributionItem[];
|
source_countries: NodeDistributionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTrafficSummary {
|
export interface NodeTrafficSummary {
|
||||||
window_started_at: string;
|
window_started_at: string;
|
||||||
window_ended_at: string;
|
window_ended_at: string;
|
||||||
request_count: number;
|
request_count: number;
|
||||||
unique_visitor_count: number;
|
unique_visitor_count: number;
|
||||||
error_count: number;
|
error_count: number;
|
||||||
estimated_qps: number;
|
estimated_qps: number;
|
||||||
error_rate_percent: number;
|
error_rate_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeHealthSummary {
|
export interface NodeHealthSummary {
|
||||||
active_alerts: number;
|
active_alerts: number;
|
||||||
critical_alerts: number;
|
critical_alerts: number;
|
||||||
warning_alerts: number;
|
warning_alerts: number;
|
||||||
info_alerts: number;
|
info_alerts: number;
|
||||||
resolved_alerts: number;
|
resolved_alerts: number;
|
||||||
has_capacity_risk: boolean;
|
has_capacity_risk: boolean;
|
||||||
has_traffic_risk: boolean;
|
has_traffic_risk: boolean;
|
||||||
has_runtime_risk: boolean;
|
has_runtime_risk: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeObservabilityAnalytics {
|
export interface NodeObservabilityAnalytics {
|
||||||
traffic: NodeTrafficSummary;
|
traffic: NodeTrafficSummary;
|
||||||
distributions: NodeTrafficDistributions;
|
distributions: NodeTrafficDistributions;
|
||||||
health: NodeHealthSummary;
|
health: NodeHealthSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeObservabilityTrends {
|
export interface NodeObservabilityTrends {
|
||||||
traffic_24h: NodeTrafficTrendPoint[];
|
traffic_24h: NodeTrafficTrendPoint[];
|
||||||
capacity_24h: NodeCapacityTrendPoint[];
|
capacity_24h: NodeCapacityTrendPoint[];
|
||||||
network_24h: NodeNetworkTrendPoint[];
|
network_24h: NodeNetworkTrendPoint[];
|
||||||
disk_io_24h: NodeDiskIOTrendPoint[];
|
disk_io_24h: NodeDiskIOTrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeHealthEvent {
|
export interface NodeHealthEvent {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
@@ -194,13 +194,13 @@ export interface NodeHealthEvent {
|
|||||||
reported_at: string;
|
reported_at: string;
|
||||||
resolved_at?: string | null;
|
resolved_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeObservability {
|
export interface NodeObservability {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
profile: NodeSystemProfile | null;
|
profile: NodeSystemProfile | null;
|
||||||
metric_snapshots: NodeMetricSnapshot[];
|
metric_snapshots: NodeMetricSnapshot[];
|
||||||
traffic_reports: NodeTrafficReport[];
|
traffic_reports: NodeTrafficReport[];
|
||||||
health_events: NodeHealthEvent[];
|
health_events: NodeHealthEvent[];
|
||||||
analytics: NodeObservabilityAnalytics;
|
analytics: NodeObservabilityAnalytics;
|
||||||
trends: NodeObservabilityTrends;
|
trends: NodeObservabilityTrends;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +1,194 @@
|
|||||||
import type { NodeItem } from '@/features/nodes/types';
|
import type { NodeItem } from '@/features/nodes/types';
|
||||||
|
|
||||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||||
|
|
||||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||||
return value === WS_CONNECTED_LAST_SEEN;
|
return value === WS_CONNECTED_LAST_SEEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMeaningfulTime(value: string | null | undefined) {
|
export function isMeaningfulTime(value: string | null | undefined) {
|
||||||
return (
|
return (
|
||||||
Boolean(value) &&
|
Boolean(value) &&
|
||||||
!isWSConnectedLastSeen(value) &&
|
!isWSConnectedLastSeen(value) &&
|
||||||
!String(value).startsWith('0001-01-01')
|
!String(value).startsWith('0001-01-01')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeStatusVariant(status: NodeItem['status']) {
|
export function getNodeStatusVariant(status: NodeItem['status']) {
|
||||||
if (status === 'online') {
|
if (status === 'online') {
|
||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeStatusLabel(status: NodeItem['status']) {
|
export function getNodeStatusLabel(status: NodeItem['status']) {
|
||||||
if (status === 'online') {
|
if (status === 'online') {
|
||||||
return '在线';
|
return '在线';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return '待接入';
|
return '待接入';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '离线';
|
return '离线';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
|
||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
return 'success';
|
return 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === 'warning') {
|
if (result === 'warning') {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === 'failed') {
|
if (result === 'failed') {
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
|
||||||
if (result === 'success') {
|
if (result === 'success') {
|
||||||
return '成功';
|
return '成功';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === 'warning') {
|
if (result === 'warning') {
|
||||||
return '警告';
|
return '警告';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === 'failed') {
|
if (result === 'failed') {
|
||||||
return '失败';
|
return '失败';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '暂无';
|
return '暂无';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpdateMode(node: NodeItem) {
|
export function getUpdateMode(node: NodeItem) {
|
||||||
if (node.update_requested) {
|
if (node.update_requested) {
|
||||||
if (node.update_channel === 'preview') {
|
if (node.update_channel === 'preview') {
|
||||||
return { label: '等待预览更新', variant: 'warning' as const };
|
return { label: '等待预览更新', variant: 'warning' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { label: '等待更新', variant: 'warning' as const };
|
return { label: '等待更新', variant: 'warning' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.auto_update_enabled) {
|
if (node.auto_update_enabled) {
|
||||||
return { label: '自动', variant: 'success' as const };
|
return { label: '自动', variant: 'success' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { label: '手动', variant: 'info' as const };
|
return { label: '手动', variant: 'info' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
|
export function getOpenrestyStatusVariant(
|
||||||
if (status === 'healthy') {
|
status: NodeItem['openresty_status'],
|
||||||
return 'success';
|
) {
|
||||||
}
|
if (status === 'healthy') {
|
||||||
|
return 'success';
|
||||||
if (status === 'unhealthy') {
|
}
|
||||||
return 'danger';
|
|
||||||
}
|
if (status === 'unhealthy') {
|
||||||
|
return 'danger';
|
||||||
return 'warning';
|
}
|
||||||
}
|
|
||||||
|
return 'warning';
|
||||||
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
}
|
||||||
if (status === 'healthy') {
|
|
||||||
return '健康';
|
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
|
||||||
}
|
if (status === 'healthy') {
|
||||||
|
return '健康';
|
||||||
if (status === 'unhealthy') {
|
}
|
||||||
return '异常';
|
|
||||||
}
|
if (status === 'unhealthy') {
|
||||||
|
return '异常';
|
||||||
return '未知';
|
}
|
||||||
}
|
|
||||||
|
return '未知';
|
||||||
function parseVersionParts(version: string) {
|
}
|
||||||
const normalized = version.trim().replace(/^v/i, '');
|
|
||||||
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
function parseVersionParts(version: string) {
|
||||||
return null;
|
const normalized = version.trim().replace(/^v/i, '');
|
||||||
}
|
if (!normalized || normalized.toLowerCase() === 'unknown') {
|
||||||
|
return null;
|
||||||
return normalized.split('.').map((segment) => {
|
}
|
||||||
const matched = segment.trim().match(/^\d+/);
|
|
||||||
return matched ? Number.parseInt(matched[0], 10) : 0;
|
return normalized.split('.').map((segment) => {
|
||||||
});
|
const matched = segment.trim().match(/^\d+/);
|
||||||
}
|
return matched ? Number.parseInt(matched[0], 10) : 0;
|
||||||
|
});
|
||||||
function isOlderVersion(current: string, target: string) {
|
}
|
||||||
const currentParts = parseVersionParts(current);
|
|
||||||
const targetParts = parseVersionParts(target);
|
function isOlderVersion(current: string, target: string) {
|
||||||
if (!currentParts || !targetParts) {
|
const currentParts = parseVersionParts(current);
|
||||||
return false;
|
const targetParts = parseVersionParts(target);
|
||||||
}
|
if (!currentParts || !targetParts) {
|
||||||
|
return false;
|
||||||
const maxLength = Math.max(currentParts.length, targetParts.length);
|
}
|
||||||
for (let index = 0; index < maxLength; index += 1) {
|
|
||||||
const currentPart = currentParts[index] ?? 0;
|
const maxLength = Math.max(currentParts.length, targetParts.length);
|
||||||
const targetPart = targetParts[index] ?? 0;
|
for (let index = 0; index < maxLength; index += 1) {
|
||||||
if (currentPart < targetPart) {
|
const currentPart = currentParts[index] ?? 0;
|
||||||
return true;
|
const targetPart = targetParts[index] ?? 0;
|
||||||
}
|
if (currentPart < targetPart) {
|
||||||
if (currentPart > targetPart) {
|
return true;
|
||||||
return false;
|
}
|
||||||
}
|
if (currentPart > targetPart) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
return false;
|
||||||
export function shouldShowManualUpdate(
|
}
|
||||||
agentVersion: string,
|
|
||||||
serverVersion: string,
|
export function shouldShowManualUpdate(
|
||||||
) {
|
agentVersion: string,
|
||||||
const normalizedServerVersion = serverVersion.trim();
|
serverVersion: string,
|
||||||
const normalizedAgentVersion = agentVersion.trim();
|
) {
|
||||||
|
const normalizedServerVersion = serverVersion.trim();
|
||||||
if (
|
const normalizedAgentVersion = agentVersion.trim();
|
||||||
!normalizedServerVersion ||
|
|
||||||
normalizedServerVersion.toLowerCase() === 'dev' ||
|
if (
|
||||||
!normalizedAgentVersion ||
|
!normalizedServerVersion ||
|
||||||
normalizedAgentVersion.toLowerCase() === 'unknown'
|
normalizedServerVersion.toLowerCase() === 'dev' ||
|
||||||
) {
|
!normalizedAgentVersion ||
|
||||||
return false;
|
normalizedAgentVersion.toLowerCase() === 'unknown'
|
||||||
}
|
) {
|
||||||
|
return false;
|
||||||
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
}
|
||||||
}
|
|
||||||
|
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
|
||||||
export function getServerUrl(value: string) {
|
}
|
||||||
return value.trim().replace(/\/+$/, '');
|
|
||||||
}
|
export function getServerUrl(value: string) {
|
||||||
|
return value.trim().replace(/\/+$/, '');
|
||||||
const installerScriptUrl =
|
}
|
||||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
|
||||||
|
const installerScriptUrl =
|
||||||
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||||
return [
|
|
||||||
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
|
||||||
` --server-url ${serverUrl} \\`,
|
return [
|
||||||
` --agent-token ${agentToken}`,
|
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
||||||
].join('\n');
|
` --server-url ${serverUrl} \\`,
|
||||||
}
|
` --agent-token ${agentToken}`,
|
||||||
|
].join('\n');
|
||||||
export function buildNodeDockerInstallCommand(serverUrl: string, agentToken: string) {
|
}
|
||||||
return [
|
|
||||||
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
export function buildNodeDockerInstallCommand(
|
||||||
` -p 80:80 -p 443:443 \\`,
|
serverUrl: string,
|
||||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
agentToken: string,
|
||||||
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
) {
|
||||||
` ghcr.io/rain-kl/openflare-agent:latest`
|
return [
|
||||||
].join('\n');
|
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
||||||
}
|
` -p 80:80 -p 443:443 \\`,
|
||||||
|
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||||
|
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
||||||
|
` ghcr.io/rain-kl/openflare-agent:latest`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ManagedDomainMatchResult,
|
ManagedDomainMatchResult,
|
||||||
ProxyRouteItem,
|
ProxyRouteItem,
|
||||||
ProxyRouteMutationPayload,
|
ProxyRouteMutationPayload,
|
||||||
TlsCertificateItem,
|
TlsCertificateItem,
|
||||||
} from '@/features/proxy-routes/types';
|
} from '@/features/proxy-routes/types';
|
||||||
|
|
||||||
export function getProxyRoutes() {
|
export function getProxyRoutes() {
|
||||||
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
|
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
|
||||||
}
|
}
|
||||||
@@ -17,12 +17,15 @@ export function getProxyRoute(id: number) {
|
|||||||
|
|
||||||
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
|
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
|
||||||
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
|
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
|
export function updateProxyRoute(
|
||||||
|
id: number,
|
||||||
|
payload: ProxyRouteMutationPayload,
|
||||||
|
) {
|
||||||
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
|
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -34,12 +37,14 @@ export function deleteProxyRoute(id: number) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTlsCertificates() {
|
export function getTlsCertificates() {
|
||||||
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchManagedDomainCertificate(domain: string) {
|
export function matchManagedDomainCertificate(domain: string) {
|
||||||
const searchParams = new URLSearchParams({ domain });
|
const searchParams = new URLSearchParams({ domain });
|
||||||
return apiRequest<ManagedDomainMatchResult>(`/managed-domains/match?${searchParams.toString()}`);
|
return apiRequest<ManagedDomainMatchResult>(
|
||||||
}
|
`/managed-domains/match?${searchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ export function buildDomainRowsFromRoute(
|
|||||||
if (domainCertIDs.length === domains.length) {
|
if (domainCertIDs.length === domains.length) {
|
||||||
return domains.map((domain, index) => ({
|
return domains.map((domain, index) => ({
|
||||||
domain,
|
domain,
|
||||||
certificateId: domainCertIDs[index]
|
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
|
||||||
? String(domainCertIDs[index])
|
|
||||||
: '',
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +183,9 @@ export function DomainListInput({
|
|||||||
value={row.domain}
|
value={row.domain}
|
||||||
list={`${listId}-${index}`}
|
list={`${listId}-${index}`}
|
||||||
aria-label={`域名 ${index + 1}`}
|
aria-label={`域名 ${index + 1}`}
|
||||||
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
|
placeholder={
|
||||||
|
index === 0 ? domainPlaceholder : 'www.example.com'
|
||||||
|
}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextRows = safeRows.slice();
|
const nextRows = safeRows.slice();
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ const rateLimitSchema = z
|
|||||||
limit_rate: z.string(),
|
limit_rate: z.string(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
for (const field of ['limit_conn_per_server', 'limit_conn_per_ip'] as const) {
|
for (const field of [
|
||||||
|
'limit_conn_per_server',
|
||||||
|
'limit_conn_per_ip',
|
||||||
|
] as const) {
|
||||||
const rawValue = value[field].trim();
|
const rawValue = value[field].trim();
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
continue;
|
continue;
|
||||||
@@ -369,7 +372,9 @@ function DomainSettingsSection({
|
|||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
|
hint="每行配置一个域名。可为不同域名选择不同证书,相同证书也可以重复选择。"
|
||||||
error={form.formState.errors.domain_rows?.message as string | undefined}
|
error={
|
||||||
|
form.formState.errors.domain_rows?.message as string | undefined
|
||||||
|
}
|
||||||
container="div"
|
container="div"
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -490,7 +495,10 @@ function RateLimitSection({
|
|||||||
error={form.formState.errors.limit_rate?.message}
|
error={form.formState.errors.limit_rate?.message}
|
||||||
className="md:col-span-2"
|
className="md:col-span-2"
|
||||||
>
|
>
|
||||||
<ResourceInput placeholder="512k/1m" {...form.register('limit_rate')} />
|
<ResourceInput
|
||||||
|
placeholder="512k/1m"
|
||||||
|
{...form.register('limit_rate')}
|
||||||
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
</form>
|
</form>
|
||||||
</ConfigSectionShell>
|
</ConfigSectionShell>
|
||||||
@@ -538,7 +546,9 @@ function ReverseProxySection({
|
|||||||
onSubmit={form.handleSubmit((values) => {
|
onSubmit={form.handleSubmit((values) => {
|
||||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||||
const { headers } = parseCustomHeadersText(values.custom_headers_text);
|
const { headers } = parseCustomHeadersText(
|
||||||
|
values.custom_headers_text,
|
||||||
|
);
|
||||||
|
|
||||||
onSave(
|
onSave(
|
||||||
buildPayloadFromRoute(route, {
|
buildPayloadFromRoute(route, {
|
||||||
@@ -565,7 +575,9 @@ function ReverseProxySection({
|
|||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
aria-label="上游地址"
|
aria-label="上游地址"
|
||||||
className="min-h-40"
|
className="min-h-40"
|
||||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
placeholder={
|
||||||
|
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||||
|
}
|
||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -593,7 +605,10 @@ function ReverseProxySection({
|
|||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField label="备注" error={form.formState.errors.remark?.message}>
|
<ResourceField
|
||||||
|
label="备注"
|
||||||
|
error={form.formState.errors.remark?.message}
|
||||||
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
placeholder="例如:多活回源,优先使用上海入口"
|
placeholder="例如:多活回源,优先使用上海入口"
|
||||||
{...form.register('remark')}
|
{...form.register('remark')}
|
||||||
@@ -617,7 +632,8 @@ function CacheSection({
|
|||||||
resolver: zodResolver(cacheSchema),
|
resolver: zodResolver(cacheSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
cache_enabled: route.cache_enabled,
|
cache_enabled: route.cache_enabled,
|
||||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
cache_policy: (route.cache_policy ||
|
||||||
|
'url') as CacheValues['cache_policy'],
|
||||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -625,7 +641,8 @@ function CacheSection({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
cache_enabled: route.cache_enabled,
|
cache_enabled: route.cache_enabled,
|
||||||
cache_policy: (route.cache_policy || 'url') as CacheValues['cache_policy'],
|
cache_policy: (route.cache_policy ||
|
||||||
|
'url') as CacheValues['cache_policy'],
|
||||||
cache_rules_text: route.cache_rule_list.join('\n'),
|
cache_rules_text: route.cache_rule_list.join('\n'),
|
||||||
});
|
});
|
||||||
}, [form, route]);
|
}, [form, route]);
|
||||||
@@ -650,7 +667,9 @@ function CacheSection({
|
|||||||
cache_enabled: values.cache_enabled,
|
cache_enabled: values.cache_enabled,
|
||||||
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
|
cache_policy: values.cache_enabled ? values.cache_policy : 'url',
|
||||||
cache_rules:
|
cache_rules:
|
||||||
values.cache_enabled && values.cache_policy !== 'url' ? rules : [],
|
values.cache_enabled && values.cache_policy !== 'url'
|
||||||
|
? rules
|
||||||
|
: [],
|
||||||
}),
|
}),
|
||||||
{ message: '缓存设置已保存。' },
|
{ message: '缓存设置已保存。' },
|
||||||
);
|
);
|
||||||
@@ -770,7 +789,13 @@ type PowValues = z.infer<typeof powSchema>;
|
|||||||
|
|
||||||
function buildPowListFromConfig(
|
function buildPowListFromConfig(
|
||||||
list:
|
list:
|
||||||
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
|
| {
|
||||||
|
ips?: string[];
|
||||||
|
ip_cidrs?: string[];
|
||||||
|
paths?: string[];
|
||||||
|
path_regexes?: string[];
|
||||||
|
user_agents?: string[];
|
||||||
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
): PowListValues {
|
): PowListValues {
|
||||||
return {
|
return {
|
||||||
@@ -923,7 +948,7 @@ function PowSection({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
白名单(匹配的请求跳过 PoW)
|
白名单(匹配的请求跳过 PoW)
|
||||||
</legend>
|
</legend>
|
||||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||||
@@ -964,7 +989,7 @@ function PowSection({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
<fieldset disabled={!watchedEnabled} className="space-y-4">
|
||||||
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
|
<legend className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
黑名单(匹配的请求必须 PoW)
|
黑名单(匹配的请求必须 PoW)
|
||||||
</legend>
|
</legend>
|
||||||
<ResourceField label="IP" hint="每行一个 IP 地址">
|
<ResourceField label="IP" hint="每行一个 IP 地址">
|
||||||
@@ -1178,7 +1203,9 @@ export function ProxyRouteConfigPage({
|
|||||||
setFeedback({ tone: 'success', message: context.message });
|
setFeedback({ tone: 'success', message: context.message });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['config-versions', 'diff'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
+11
-4
@@ -156,7 +156,9 @@ export function ProxyRouteCreateDrawer({
|
|||||||
.map((item) => item.domain.trim().toLowerCase())
|
.map((item) => item.domain.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
|
||||||
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows);
|
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||||
|
values.domain_rows,
|
||||||
|
);
|
||||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||||
|
|
||||||
@@ -182,7 +184,8 @@ export function ProxyRouteCreateDrawer({
|
|||||||
cert_id: selectedCertIDs[0] ?? null,
|
cert_id: selectedCertIDs[0] ?? null,
|
||||||
cert_ids: selectedCertIDs,
|
cert_ids: selectedCertIDs,
|
||||||
domain_cert_ids: domainCertIDs,
|
domain_cert_ids: domainCertIDs,
|
||||||
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false,
|
redirect_http:
|
||||||
|
selectedCertIDs.length > 0 ? values.redirect_http : false,
|
||||||
limit_conn_per_server: 0,
|
limit_conn_per_server: 0,
|
||||||
limit_conn_per_ip: 0,
|
limit_conn_per_ip: 0,
|
||||||
limit_rate: '',
|
limit_rate: '',
|
||||||
@@ -248,7 +251,9 @@ export function ProxyRouteCreateDrawer({
|
|||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
|
||||||
error={form.formState.errors.domain_rows?.message as string | undefined}
|
error={
|
||||||
|
form.formState.errors.domain_rows?.message as string | undefined
|
||||||
|
}
|
||||||
container="div"
|
container="div"
|
||||||
>
|
>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -287,7 +292,9 @@ export function ProxyRouteCreateDrawer({
|
|||||||
>
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
aria-label="上游地址"
|
aria-label="上游地址"
|
||||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
placeholder={
|
||||||
|
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
||||||
|
}
|
||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export function ProxyRoutesPage() {
|
|||||||
setFeedback({ tone: 'success', message: '网站已删除。' });
|
setFeedback({ tone: 'success', message: '网站已删除。' });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['config-versions', 'diff'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -104,7 +106,9 @@ export function ProxyRoutesPage() {
|
|||||||
});
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
|
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['config-versions', 'diff'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -135,7 +139,10 @@ export function ProxyRoutesPage() {
|
|||||||
}, [keyword, routes]);
|
}, [keyword, routes]);
|
||||||
|
|
||||||
const diff = diffQuery.data;
|
const diff = diffQuery.data;
|
||||||
const totalDomains = routes.reduce((sum, route) => sum + route.domain_count, 0);
|
const totalDomains = routes.reduce(
|
||||||
|
(sum, route) => sum + route.domain_count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const enabledCount = routes.filter((route) => route.enabled).length;
|
const enabledCount = routes.filter((route) => route.enabled).length;
|
||||||
|
|
||||||
const handleDelete = (route: ProxyRouteItem) => {
|
const handleDelete = (route: ProxyRouteItem) => {
|
||||||
@@ -243,11 +250,11 @@ export function ProxyRoutesPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={
|
label={
|
||||||
diff && hasConfigChanges(diff) ? '有待发布变更' : '已与线上一致'
|
diff && hasConfigChanges(diff)
|
||||||
}
|
? '有待发布变更'
|
||||||
variant={
|
: '已与线上一致'
|
||||||
diff && hasConfigChanges(diff) ? 'warning' : 'success'
|
|
||||||
}
|
}
|
||||||
|
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
{diff
|
{diff
|
||||||
@@ -266,9 +273,7 @@ export function ProxyRoutesPage() {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard
|
<AppCard title="规则列表">
|
||||||
title="规则列表"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
@@ -356,7 +361,7 @@ export function ProxyRoutesPage() {
|
|||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
域名列表
|
域名列表
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -365,7 +370,7 @@ export function ProxyRoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
上游摘要
|
上游摘要
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -393,7 +398,9 @@ export function ProxyRoutesPage() {
|
|||||||
onCreated={async (route) => {
|
onCreated={async (route) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['config-versions', 'diff'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
|
router.push(`/proxy-route/detail?id=${route.id}§ion=domains`);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export function AuthSourceModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<div className="md:col-span-2 rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)]">
|
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
|
||||||
第三方平台的 Redirect URI / Callback URL 请填写:
|
第三方平台的 Redirect URI / Callback URL 请填写:
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -927,7 +927,9 @@ export function SettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleBindAuthSource(source.name)}
|
onClick={() =>
|
||||||
|
handleBindAuthSource(source.name)
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
busyKey === `auth-source-bind-${source.name}`
|
busyKey === `auth-source-bind-${source.name}`
|
||||||
}
|
}
|
||||||
@@ -1591,7 +1593,6 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
||||||
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
|
||||||
interface ModulePageSkeletonProps {
|
interface ModulePageSkeletonProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModulePageSkeleton({
|
export function ModulePageSkeleton({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: ModulePageSkeletonProps) {
|
}: ModulePageSkeletonProps) {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className="space-y-6">
|
||||||
<PageHeader title={title} description={description} />
|
<PageHeader title={title} description={description} />
|
||||||
|
|
||||||
<FeaturePlaceholder
|
<FeaturePlaceholder
|
||||||
title={`${title} 页面骨架`}
|
title={`${title} 页面骨架`}
|
||||||
description='当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。'
|
description="当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。"
|
||||||
milestones={[
|
milestones={[
|
||||||
'路由入口已创建,可纳入统一导航。',
|
'路由入口已创建,可纳入统一导航。',
|
||||||
'页面标题区、说明区与内容容器已统一。',
|
'页面标题区、说明区与内容容器已统一。',
|
||||||
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
|
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='grid gap-4 xl:grid-cols-3'>
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
<EmptyState title='空态骨架' description='后续接入查询后,可将资源为空时的提示统一沉淀为此组件。' />
|
<EmptyState
|
||||||
<ErrorState title='错误态骨架' description='后续请求失败、鉴权失效与重试提示可在此基础上扩展。' />
|
title="空态骨架"
|
||||||
</div>
|
description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
|
||||||
</div>
|
/>
|
||||||
);
|
<ErrorState
|
||||||
}
|
title="错误态骨架"
|
||||||
|
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function ToggleField({
|
|||||||
onChange,
|
onChange,
|
||||||
}: ToggleFieldProps) {
|
}: ToggleFieldProps) {
|
||||||
return (
|
return (
|
||||||
<label className="flex self-start cursor-pointer items-start gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
<label className="flex cursor-pointer items-start gap-3 self-start rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
|
|||||||
@@ -1,489 +1,477 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {marked} from 'marked';
|
import { marked } from 'marked';
|
||||||
import {useEffect, useState} from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {EmptyState} from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
import {ErrorState} from '@/components/feedback/error-state';
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
import {LoadingState} from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import {AppCard} from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import {AppModal} from '@/components/ui/app-modal';
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
import {StatusBadge} from '@/components/ui/status-badge';
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
import type {
|
import type {
|
||||||
LatestReleaseInfo,
|
LatestReleaseInfo,
|
||||||
ReleaseChannel,
|
ReleaseChannel,
|
||||||
UploadedServerBinaryInfo,
|
UploadedServerBinaryInfo,
|
||||||
} from '@/features/update/types';
|
} from '@/features/update/types';
|
||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
ResourceInput,
|
ResourceInput,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
interface VersionUpgradeModalProps {
|
interface VersionUpgradeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
release: LatestReleaseInfo | null | undefined;
|
release: LatestReleaseInfo | null | undefined;
|
||||||
selectedChannel: ReleaseChannel;
|
selectedChannel: ReleaseChannel;
|
||||||
uploadedBinary: UploadedServerBinaryInfo | null;
|
uploadedBinary: UploadedServerBinaryInfo | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
releaseErrorMessage?: string;
|
releaseErrorMessage?: string;
|
||||||
manualStatusMessage?: string;
|
manualStatusMessage?: string;
|
||||||
manualErrorMessage?: string;
|
manualErrorMessage?: string;
|
||||||
canUpgrade: boolean;
|
canUpgrade: boolean;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
isUpgrading: boolean;
|
isUpgrading: boolean;
|
||||||
isUploadingBinary: boolean;
|
isUploadingBinary: boolean;
|
||||||
uploadProgress: number;
|
uploadProgress: number;
|
||||||
isConfirmingManualUpgrade: boolean;
|
isConfirmingManualUpgrade: boolean;
|
||||||
onChannelChange: (channel: ReleaseChannel) => void;
|
onChannelChange: (channel: ReleaseChannel) => void;
|
||||||
onCheck: () => void;
|
onCheck: () => void;
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
onUploadBinary: (file: File) => void;
|
onUploadBinary: (file: File) => void;
|
||||||
onConfirmManualUpgrade: () => void;
|
onConfirmManualUpgrade: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
|
||||||
if (!release) {
|
if (!release) {
|
||||||
return {label: '未检查', variant: 'info' as const};
|
return { label: '未检查', variant: 'info' as const };
|
||||||
}
|
}
|
||||||
if (release.in_progress) {
|
if (release.in_progress) {
|
||||||
return {label: '升级中', variant: 'warning' as const};
|
return { label: '升级中', variant: 'warning' as const };
|
||||||
}
|
}
|
||||||
if (release.has_update) {
|
if (release.has_update) {
|
||||||
return {label: '可升级', variant: 'warning' as const};
|
return { label: '可升级', variant: 'warning' as const };
|
||||||
}
|
}
|
||||||
return {label: '最新', variant: 'success' as const};
|
return { label: '最新', variant: 'success' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogTimestamp(value: string) {
|
function formatLogTimestamp(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
return '0000-00-00 00:00:00.000';
|
return '0000-00-00 00:00:00.000';
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||||
const day = `${date.getDate()}`.padStart(2, '0');
|
const day = `${date.getDate()}`.padStart(2, '0');
|
||||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||||
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
const minute = `${date.getMinutes()}`.padStart(2, '0');
|
||||||
const second = `${date.getSeconds()}`.padStart(2, '0');
|
const second = `${date.getSeconds()}`.padStart(2, '0');
|
||||||
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogLevel(level: string) {
|
function formatLogLevel(level: string) {
|
||||||
const normalized = (level || 'info').toUpperCase();
|
const normalized = (level || 'info').toUpperCase();
|
||||||
return normalized.padEnd(8, ' ');
|
return normalized.padEnd(8, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionUpgradeModal({
|
export function VersionUpgradeModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
release,
|
release,
|
||||||
selectedChannel,
|
selectedChannel,
|
||||||
uploadedBinary,
|
uploadedBinary,
|
||||||
isLoading,
|
isLoading,
|
||||||
releaseErrorMessage,
|
releaseErrorMessage,
|
||||||
manualStatusMessage,
|
manualStatusMessage,
|
||||||
manualErrorMessage,
|
manualErrorMessage,
|
||||||
canUpgrade,
|
canUpgrade,
|
||||||
isChecking,
|
isChecking,
|
||||||
isUpgrading,
|
isUpgrading,
|
||||||
isUploadingBinary,
|
isUploadingBinary,
|
||||||
uploadProgress,
|
uploadProgress,
|
||||||
isConfirmingManualUpgrade,
|
isConfirmingManualUpgrade,
|
||||||
onChannelChange,
|
onChannelChange,
|
||||||
onCheck,
|
onCheck,
|
||||||
onUpgrade,
|
onUpgrade,
|
||||||
onUploadBinary,
|
onUploadBinary,
|
||||||
onConfirmManualUpgrade,
|
onConfirmManualUpgrade,
|
||||||
}: VersionUpgradeModalProps) {
|
}: VersionUpgradeModalProps) {
|
||||||
const upgradeBadge = getUpgradeBadge(release);
|
const upgradeBadge = getUpgradeBadge(release);
|
||||||
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
|
||||||
const uploadPhaseLabel =
|
const uploadPhaseLabel =
|
||||||
uploadProgress >= 100
|
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
|
||||||
? '已上传,正在服务端校验版本...'
|
const selectedChannelLabel =
|
||||||
: '上传中...';
|
selectedChannel === 'preview' ? '预览版' : '正式版';
|
||||||
const selectedChannelLabel =
|
const toggleChannel = () => {
|
||||||
selectedChannel === 'preview' ? '预览版' : '正式版';
|
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
|
||||||
const toggleChannel = () => {
|
};
|
||||||
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
|
const canConfirmManualUpgrade = Boolean(
|
||||||
};
|
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
||||||
const canConfirmManualUpgrade = Boolean(
|
);
|
||||||
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
|
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
|
||||||
);
|
const upgradeLogs = release?.upgrade_logs ?? [];
|
||||||
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
|
|
||||||
const upgradeLogs = release?.upgrade_logs ?? [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setSelectedBinary(null);
|
setSelectedBinary(null);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppModal
|
<AppModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="版本"
|
title="版本"
|
||||||
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||||
|
<AppCard title="服务端版本">
|
||||||
<AppCard title="服务端版本">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
{currentVersion || 'unknown'}
|
||||||
{currentVersion || 'unknown'}
|
</p>
|
||||||
</p>
|
<StatusBadge
|
||||||
<StatusBadge
|
label={upgradeBadge.label}
|
||||||
label={upgradeBadge.label}
|
variant={upgradeBadge.variant}
|
||||||
variant={upgradeBadge.variant}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<AppCard title="最新版本">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{release?.tag_name || '未检查'}
|
|
||||||
</p>
|
|
||||||
{canUpgrade ? (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<StatusBadge
|
|
||||||
label={selectedChannelLabel}
|
|
||||||
variant={
|
|
||||||
selectedChannel === 'preview' ? 'warning' : 'info'
|
|
||||||
}
|
|
||||||
onClick={toggleChannel}
|
|
||||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<SecondaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={onCheck}
|
|
||||||
disabled={isChecking || isUpgrading || isUploadingBinary}
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
>
|
|
||||||
{isChecking ? '检查中...' : '检查'}
|
|
||||||
</SecondaryButton>
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? <LoadingState/> : null}
|
|
||||||
{!isLoading && releaseErrorMessage ? (
|
|
||||||
<ErrorState title="版本检查失败" description={releaseErrorMessage}/>
|
|
||||||
) : null}
|
|
||||||
{!isLoading && !releaseErrorMessage && !release ? (
|
|
||||||
<EmptyState
|
|
||||||
title={`尚未检查${selectedChannelLabel}`}
|
|
||||||
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{!isLoading && !releaseErrorMessage && release ? (
|
|
||||||
<AppCard
|
|
||||||
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
|
||||||
description={
|
|
||||||
release.published_at
|
|
||||||
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
|
||||||
: '未提供发布时间'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<StatusBadge
|
|
||||||
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
|
||||||
variant={release.has_update ? 'warning' : 'success'}
|
|
||||||
/>
|
|
||||||
{release.prerelease ? (
|
|
||||||
<StatusBadge label="Preview 发布" variant="warning"/>
|
|
||||||
) : (
|
|
||||||
<StatusBadge label="正式发布" variant="info"/>
|
|
||||||
)}
|
|
||||||
{!release.upgrade_supported ? (
|
|
||||||
<StatusBadge
|
|
||||||
label="当前平台不支持自动升级"
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{release.in_progress ? (
|
|
||||||
<StatusBadge label="升级任务执行中" variant="warning"/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: marked.parse(
|
|
||||||
release.body || '暂无更新说明',
|
|
||||||
) as string,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={release.html_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
|
||||||
>
|
|
||||||
查看发布详情
|
|
||||||
</a>
|
|
||||||
{canUpgrade ? (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={onUpgrade}
|
|
||||||
disabled={
|
|
||||||
!release.has_update ||
|
|
||||||
release.in_progress ||
|
|
||||||
isUpgrading ||
|
|
||||||
!release.upgrade_supported ||
|
|
||||||
isUploadingBinary ||
|
|
||||||
isConfirmingManualUpgrade
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isUpgrading
|
|
||||||
? '升级中...'
|
|
||||||
: release.in_progress
|
|
||||||
? '升级中...'
|
|
||||||
: selectedChannel === 'preview'
|
|
||||||
? '升级预览版'
|
|
||||||
: '升级正式版'}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{release ? (
|
|
||||||
<AppCard
|
|
||||||
title="升级日志"
|
|
||||||
>
|
|
||||||
{upgradeLogs.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<StatusBadge
|
|
||||||
label={
|
|
||||||
release.upgrade_status === 'failed'
|
|
||||||
? '升级失败'
|
|
||||||
: release.upgrade_status === 'succeeded'
|
|
||||||
? '准备重启'
|
|
||||||
: release.in_progress
|
|
||||||
? '升级中'
|
|
||||||
: '空闲'
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
release.upgrade_status === 'failed'
|
|
||||||
? 'danger'
|
|
||||||
: release.in_progress
|
|
||||||
? 'warning'
|
|
||||||
: release.upgrade_status === 'succeeded'
|
|
||||||
? 'success'
|
|
||||||
: 'info'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
|
|
||||||
{upgradeLogs.map((log, index) => (
|
|
||||||
<pre
|
|
||||||
key={`${log.created_at}-${index}`}
|
|
||||||
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="text-slate-400">
|
|
||||||
{formatLogTimestamp(log.created_at)}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500"> | </span>
|
|
||||||
<span className="text-cyan-300">
|
|
||||||
{formatLogLevel(log.level)}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500"> | </span>
|
|
||||||
<span className="whitespace-pre-wrap break-all text-slate-100">
|
|
||||||
{log.message}
|
|
||||||
</span>
|
|
||||||
</pre>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
title="暂无升级日志"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AppCard>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canUpgrade ? (
|
|
||||||
<AppCard
|
|
||||||
title="手动升级"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ResourceField
|
|
||||||
label="服务端二进制"
|
|
||||||
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
|
||||||
>
|
|
||||||
<ResourceInput
|
|
||||||
type="file"
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files?.[0] ?? null;
|
|
||||||
setSelectedBinary(file);
|
|
||||||
}}
|
|
||||||
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
{showConfirmManualUpgradeAction ? (
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirmManualUpgrade}
|
|
||||||
disabled={
|
|
||||||
!canConfirmManualUpgrade ||
|
|
||||||
isConfirmingManualUpgrade ||
|
|
||||||
isUploadingBinary ||
|
|
||||||
isUpgrading
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
|
|
||||||
</PrimaryButton>
|
|
||||||
) : (
|
|
||||||
<PrimaryButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedBinary) {
|
|
||||||
onUploadBinary(selectedBinary);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={
|
|
||||||
!selectedBinary ||
|
|
||||||
isUploadingBinary ||
|
|
||||||
isConfirmingManualUpgrade
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isUploadingBinary
|
|
||||||
? uploadProgress >= 100
|
|
||||||
? '服务端校验中...'
|
|
||||||
: '上传中...'
|
|
||||||
: '上传并检查'}
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isUploadingBinary ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
|
|
||||||
<span>{uploadPhaseLabel}</span>
|
|
||||||
<span>{uploadProgress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
|
|
||||||
style={{width: `${uploadProgress}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{manualErrorMessage ? (
|
|
||||||
<ErrorState
|
|
||||||
title="手动升级检查失败"
|
|
||||||
description={manualErrorMessage}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!manualErrorMessage && manualStatusMessage ? (
|
|
||||||
<div
|
|
||||||
className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
|
||||||
{manualStatusMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{uploadedBinary ? (
|
|
||||||
<div
|
|
||||||
className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<StatusBadge
|
|
||||||
label={
|
|
||||||
uploadedBinary.ready_to_upgrade
|
|
||||||
? '可确认升级'
|
|
||||||
: uploadedBinary.has_update
|
|
||||||
? '待确认'
|
|
||||||
: '不可升级'
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!uploadedBinary.upgrade_supported ? (
|
|
||||||
<StatusBadge
|
|
||||||
label="当前版本不支持手动升级"
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
|
||||||
文件名
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{uploadedBinary.file_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
|
||||||
上传时间
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{uploadedBinary.uploaded_at
|
|
||||||
? formatDateTime(uploadedBinary.uploaded_at)
|
|
||||||
: '未知'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
|
||||||
当前版本
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{uploadedBinary.current_version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
|
||||||
上传版本
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
|
||||||
{uploadedBinary.detected_version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
|
||||||
{uploadedBinary.comparison_message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
title="尚未上传升级包"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AppCard>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</AppModal>
|
</AppCard>
|
||||||
);
|
|
||||||
|
<AppCard title="最新版本">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{release?.tag_name || '未检查'}
|
||||||
|
</p>
|
||||||
|
{canUpgrade ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge
|
||||||
|
label={selectedChannelLabel}
|
||||||
|
variant={
|
||||||
|
selectedChannel === 'preview' ? 'warning' : 'info'
|
||||||
|
}
|
||||||
|
onClick={toggleChannel}
|
||||||
|
disabled={
|
||||||
|
isChecking || isUpgrading || isUploadingBinary
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onCheck}
|
||||||
|
disabled={isChecking || isUpgrading || isUploadingBinary}
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
{isChecking ? '检查中...' : '检查'}
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <LoadingState /> : null}
|
||||||
|
{!isLoading && releaseErrorMessage ? (
|
||||||
|
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
|
||||||
|
) : null}
|
||||||
|
{!isLoading && !releaseErrorMessage && !release ? (
|
||||||
|
<EmptyState
|
||||||
|
title={`尚未检查${selectedChannelLabel}`}
|
||||||
|
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isLoading && !releaseErrorMessage && release ? (
|
||||||
|
<AppCard
|
||||||
|
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
|
||||||
|
description={
|
||||||
|
release.published_at
|
||||||
|
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||||
|
: '未提供发布时间'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
label={release.has_update ? '发现新版本' : '已经是最新版本'}
|
||||||
|
variant={release.has_update ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
{release.prerelease ? (
|
||||||
|
<StatusBadge label="Preview 发布" variant="warning" />
|
||||||
|
) : (
|
||||||
|
<StatusBadge label="正式发布" variant="info" />
|
||||||
|
)}
|
||||||
|
{!release.upgrade_supported ? (
|
||||||
|
<StatusBadge
|
||||||
|
label="当前平台不支持自动升级"
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{release.in_progress ? (
|
||||||
|
<StatusBadge label="升级任务执行中" variant="warning" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: marked.parse(
|
||||||
|
release.body || '暂无更新说明',
|
||||||
|
) as string,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={release.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
|
||||||
|
>
|
||||||
|
查看发布详情
|
||||||
|
</a>
|
||||||
|
{canUpgrade ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onUpgrade}
|
||||||
|
disabled={
|
||||||
|
!release.has_update ||
|
||||||
|
release.in_progress ||
|
||||||
|
isUpgrading ||
|
||||||
|
!release.upgrade_supported ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isConfirmingManualUpgrade
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUpgrading
|
||||||
|
? '升级中...'
|
||||||
|
: release.in_progress
|
||||||
|
? '升级中...'
|
||||||
|
: selectedChannel === 'preview'
|
||||||
|
? '升级预览版'
|
||||||
|
: '升级正式版'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{release ? (
|
||||||
|
<AppCard title="升级日志">
|
||||||
|
{upgradeLogs.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
release.upgrade_status === 'failed'
|
||||||
|
? '升级失败'
|
||||||
|
: release.upgrade_status === 'succeeded'
|
||||||
|
? '准备重启'
|
||||||
|
: release.in_progress
|
||||||
|
? '升级中'
|
||||||
|
: '空闲'
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
release.upgrade_status === 'failed'
|
||||||
|
? 'danger'
|
||||||
|
: release.in_progress
|
||||||
|
? 'warning'
|
||||||
|
: release.upgrade_status === 'succeeded'
|
||||||
|
? 'success'
|
||||||
|
: 'info'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
|
||||||
|
{upgradeLogs.map((log, index) => (
|
||||||
|
<pre
|
||||||
|
key={`${log.created_at}-${index}`}
|
||||||
|
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{formatLogTimestamp(log.created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500"> | </span>
|
||||||
|
<span className="text-cyan-300">
|
||||||
|
{formatLogLevel(log.level)}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500"> | </span>
|
||||||
|
<span className="break-all whitespace-pre-wrap text-slate-100">
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</pre>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="暂无升级日志" />
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canUpgrade ? (
|
||||||
|
<AppCard title="手动升级">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ResourceField
|
||||||
|
label="服务端二进制"
|
||||||
|
hint="支持上传已编译好的当前平台 Server 可执行文件。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0] ?? null;
|
||||||
|
setSelectedBinary(file);
|
||||||
|
}}
|
||||||
|
disabled={isUploadingBinary || isConfirmingManualUpgrade}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{showConfirmManualUpgradeAction ? (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirmManualUpgrade}
|
||||||
|
disabled={
|
||||||
|
!canConfirmManualUpgrade ||
|
||||||
|
isConfirmingManualUpgrade ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isUpgrading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
|
||||||
|
</PrimaryButton>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedBinary) {
|
||||||
|
onUploadBinary(selectedBinary);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
!selectedBinary ||
|
||||||
|
isUploadingBinary ||
|
||||||
|
isConfirmingManualUpgrade
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUploadingBinary
|
||||||
|
? uploadProgress >= 100
|
||||||
|
? '服务端校验中...'
|
||||||
|
: '上传中...'
|
||||||
|
: '上传并检查'}
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploadingBinary ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
|
||||||
|
<span>{uploadPhaseLabel}</span>
|
||||||
|
<span>{uploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{manualErrorMessage ? (
|
||||||
|
<ErrorState
|
||||||
|
title="手动升级检查失败"
|
||||||
|
description={manualErrorMessage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!manualErrorMessage && manualStatusMessage ? (
|
||||||
|
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
|
||||||
|
{manualStatusMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{uploadedBinary ? (
|
||||||
|
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge
|
||||||
|
label={
|
||||||
|
uploadedBinary.ready_to_upgrade
|
||||||
|
? '可确认升级'
|
||||||
|
: uploadedBinary.has_update
|
||||||
|
? '待确认'
|
||||||
|
: '不可升级'
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!uploadedBinary.upgrade_supported ? (
|
||||||
|
<StatusBadge
|
||||||
|
label="当前版本不支持手动升级"
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
文件名
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{uploadedBinary.file_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
上传时间
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{uploadedBinary.uploaded_at
|
||||||
|
? formatDateTime(uploadedBinary.uploaded_at)
|
||||||
|
: '未知'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
当前版本
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{uploadedBinary.current_version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
上传版本
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{uploadedBinary.detected_version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
{uploadedBinary.comparison_message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="尚未上传升级包" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,43 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ManageUserAction,
|
ManageUserAction,
|
||||||
ManageUserResult,
|
ManageUserResult,
|
||||||
UserItem,
|
UserItem,
|
||||||
UserMutationPayload,
|
UserMutationPayload,
|
||||||
} from '@/features/users/types';
|
} from '@/features/users/types';
|
||||||
|
|
||||||
export function getUsers(page: number) {
|
export function getUsers(page: number) {
|
||||||
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchUsers(keyword: string) {
|
export function searchUsers(keyword: string) {
|
||||||
return apiRequest<UserItem[]>(`/user/search?keyword=${encodeURIComponent(keyword)}`);
|
return apiRequest<UserItem[]>(
|
||||||
}
|
`/user/search?keyword=${encodeURIComponent(keyword)}`,
|
||||||
|
);
|
||||||
export function getUser(id: number) {
|
}
|
||||||
return apiRequest<UserItem>(`/user/${id}`);
|
|
||||||
}
|
export function getUser(id: number) {
|
||||||
|
return apiRequest<UserItem>(`/user/${id}`);
|
||||||
export function createUser(payload: UserMutationPayload) {
|
}
|
||||||
return apiRequest<void>('/user/', {
|
|
||||||
method: 'POST',
|
export function createUser(payload: UserMutationPayload) {
|
||||||
body: JSON.stringify(payload),
|
return apiRequest<void>('/user/', {
|
||||||
});
|
method: 'POST',
|
||||||
}
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updateUser(payload: UserMutationPayload & { id: number }) {
|
export function updateUser(payload: UserMutationPayload & { id: number }) {
|
||||||
return apiRequest<void>('/user/update', {
|
return apiRequest<void>('/user/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function manageUser(username: string, action: ManageUserAction) {
|
export function manageUser(username: string, action: ManageUserAction) {
|
||||||
return apiRequest<ManageUserResult>('/user/manage', {
|
return apiRequest<ManageUserResult>('/user/manage', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, action }),
|
body: JSON.stringify({ username, action }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,29 @@
|
|||||||
export interface UserItem {
|
export interface UserItem {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
role: number;
|
role: number;
|
||||||
status: number;
|
status: number;
|
||||||
email?: string;
|
email?: string;
|
||||||
github_id?: string;
|
github_id?: string;
|
||||||
wechat_id?: string;
|
wechat_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMutationPayload {
|
export interface UserMutationPayload {
|
||||||
id?: number;
|
id?: number;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable';
|
export type ManageUserAction =
|
||||||
|
| 'promote'
|
||||||
export interface ManageUserResult {
|
| 'demote'
|
||||||
role: number;
|
| 'delete'
|
||||||
status: number;
|
| 'disable'
|
||||||
}
|
| 'enable';
|
||||||
|
|
||||||
|
export interface ManageUserResult {
|
||||||
|
role: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,10 @@ import {
|
|||||||
getTlsCertificate,
|
getTlsCertificate,
|
||||||
getTlsCertificateContent,
|
getTlsCertificateContent,
|
||||||
} from '@/features/tls-certificates/api/tls-certificates';
|
} from '@/features/tls-certificates/api/tls-certificates';
|
||||||
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
|
import {
|
||||||
|
getCertificateStatus,
|
||||||
|
getErrorMessage,
|
||||||
|
} from '@/features/websites/utils';
|
||||||
import {
|
import {
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
DangerButton,
|
DangerButton,
|
||||||
@@ -81,11 +84,7 @@ export function CertificateDetailModal({
|
|||||||
<SecondaryButton type="button" onClick={onClose}>
|
<SecondaryButton type="button" onClick={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton type="button" onClick={onEdit} disabled={!certificate}>
|
||||||
type="button"
|
|
||||||
onClick={onEdit}
|
|
||||||
disabled={!certificate}
|
|
||||||
>
|
|
||||||
编辑证书
|
编辑证书
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
<DangerButton
|
<DangerButton
|
||||||
@@ -112,15 +111,12 @@ export function CertificateDetailModal({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : !certificate || !content ? (
|
) : !certificate || !content ? (
|
||||||
<EmptyState
|
<EmptyState title="证书不存在" description="当前证书可能已被删除。" />
|
||||||
title="证书不存在"
|
|
||||||
description="当前证书可能已被删除。"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
证书名称
|
证书名称
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -128,7 +124,7 @@ export function CertificateDetailModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
状态
|
状态
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -138,7 +134,7 @@ export function CertificateDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
生效时间
|
生效时间
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -146,7 +142,7 @@ export function CertificateDetailModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
到期时间
|
到期时间
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -156,7 +152,7 @@ export function CertificateDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
备注
|
备注
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
|
||||||
@@ -180,7 +176,7 @@ export function CertificateDetailModal({
|
|||||||
复制
|
复制
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
|
<CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
|
||||||
{content.cert_pem}
|
{content.cert_pem}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,7 +195,7 @@ export function CertificateDetailModal({
|
|||||||
复制
|
复制
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
|
<CodeBlock className="max-h-56 overflow-y-auto break-all whitespace-pre-wrap">
|
||||||
{content.key_pem}
|
{content.key_pem}
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ export function CertificateImportModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
|
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
|
||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
const [fileForm, setFileForm] =
|
const [fileForm, setFileForm] = useState<FileImportFormValues>(
|
||||||
useState<FileImportFormValues>(defaultFileImportValues);
|
defaultFileImportValues,
|
||||||
|
);
|
||||||
const [certFile, setCertFile] = useState<File | null>(null);
|
const [certFile, setCertFile] = useState<File | null>(null);
|
||||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||||
const [fileInputNonce, setFileInputNonce] = useState(0);
|
const [fileInputNonce, setFileInputNonce] = useState(0);
|
||||||
@@ -280,9 +281,7 @@ export function CertificateImportModal({
|
|||||||
<ResourceField
|
<ResourceField
|
||||||
label="私钥文件"
|
label="私钥文件"
|
||||||
hint={
|
hint={
|
||||||
keyFile
|
keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'
|
||||||
? `已选择:${keyFile.name}`
|
|
||||||
: '请选择 KEY/PEM 文件'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
|
|||||||
@@ -143,13 +143,16 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
|||||||
enabled: Boolean(wafRouteID),
|
enabled: Boolean(wafRouteID),
|
||||||
});
|
});
|
||||||
const wafMutation = useMutation({
|
const wafMutation = useMutation({
|
||||||
mutationFn: (ids: number[]) => replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
|
mutationFn: (ids: number[]) =>
|
||||||
|
replaceWAFSiteRuleGroups(wafRouteID ?? 0, ids),
|
||||||
onSuccess: async (view) => {
|
onSuccess: async (view) => {
|
||||||
setWafSelectedIDs(view.applied_ids);
|
setWafSelectedIDs(view.applied_ids);
|
||||||
setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' });
|
setFeedback({ tone: 'success', message: '网站 WAF 规则组已更新。' });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['waf'] }),
|
queryClient.invalidateQueries({ queryKey: ['waf'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['config-versions', 'diff'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function WebsiteEditorModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentCertificate = watchedCertId
|
const currentCertificate = watchedCertId
|
||||||
? certificates.find((item) => item.id === Number(watchedCertId)) ?? null
|
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
@@ -215,7 +215,7 @@ export function WebsiteEditorModal({
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
当前域名
|
当前域名
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
@@ -223,7 +223,7 @@ export function WebsiteEditorModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
匹配类型
|
匹配类型
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -235,12 +235,14 @@ export function WebsiteEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||||
当前证书
|
当前证书
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
|
label={
|
||||||
|
currentCertificate ? currentCertificate.name : '未绑定证书'
|
||||||
|
}
|
||||||
variant={currentCertificate ? 'success' : 'warning'}
|
variant={currentCertificate ? 'success' : 'warning'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function WebsitesPage() {
|
|||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{domains.map((domain) => {
|
{domains.map((domain) => {
|
||||||
const certificate = domain.cert_id
|
const certificate = domain.cert_id
|
||||||
? certificateMap.get(domain.cert_id) ?? null
|
? (certificateMap.get(domain.cert_id) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const matchType = getMatchTypeMeta(domain.domain);
|
const matchType = getMatchTypeMeta(domain.domain);
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export function WebsitesPage() {
|
|||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-1">
|
<div className="grid gap-3 md:grid-cols-1">
|
||||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
绑定证书
|
绑定证书
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||||
|
|||||||
@@ -97,4 +97,3 @@ export const defaultAcmeApplyValues: AcmeApplyFormValues = {
|
|||||||
dns2: '',
|
dns2: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const publicEnvSchema = z.object({
|
const publicEnvSchema = z.object({
|
||||||
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
|
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
|
||||||
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
|
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
|
||||||
@@ -9,17 +9,17 @@ const rawEnv = {
|
|||||||
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsedEnv = publicEnvSchema.parse(rawEnv);
|
const parsedEnv = publicEnvSchema.parse(rawEnv);
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string) {
|
function normalizeBaseUrl(value: string) {
|
||||||
if (value === '/') {
|
if (value === '/') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.replace(/\/+$/, '');
|
return value.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publicEnv = {
|
export const publicEnv = {
|
||||||
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
|
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
|
||||||
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
|
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
export const THEME_STORAGE_KEY = 'openflare-theme-mode';
|
export const THEME_STORAGE_KEY = 'openflare-theme-mode';
|
||||||
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
export const THEME_MEDIA_QUERY = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
export const themeModes = ['light', 'dark', 'system'] as const;
|
export const themeModes = ['light', 'dark', 'system'] as const;
|
||||||
|
|
||||||
export type ThemeMode = (typeof themeModes)[number];
|
export type ThemeMode = (typeof themeModes)[number];
|
||||||
export type ResolvedTheme = Exclude<ThemeMode, 'system'>;
|
export type ResolvedTheme = Exclude<ThemeMode, 'system'>;
|
||||||
|
|
||||||
export function isThemeMode(value: string | null | undefined): value is ThemeMode {
|
export function isThemeMode(
|
||||||
return themeModes.includes(value as ThemeMode);
|
value: string | null | undefined,
|
||||||
}
|
): value is ThemeMode {
|
||||||
|
return themeModes.includes(value as ThemeMode);
|
||||||
export function resolveTheme(
|
}
|
||||||
mode: ThemeMode,
|
|
||||||
systemPrefersDark: boolean,
|
export function resolveTheme(
|
||||||
): ResolvedTheme {
|
mode: ThemeMode,
|
||||||
if (mode === 'system') {
|
systemPrefersDark: boolean,
|
||||||
return systemPrefersDark ? 'dark' : 'light';
|
): ResolvedTheme {
|
||||||
}
|
if (mode === 'system') {
|
||||||
|
return systemPrefersDark ? 'dark' : 'light';
|
||||||
return mode;
|
}
|
||||||
}
|
|
||||||
|
return mode;
|
||||||
export function getThemeInitScript() {
|
}
|
||||||
return `(() => {
|
|
||||||
const storageKey = '${THEME_STORAGE_KEY}';
|
export function getThemeInitScript() {
|
||||||
const mediaQuery = '${THEME_MEDIA_QUERY}';
|
return `(() => {
|
||||||
const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system';
|
const storageKey = '${THEME_STORAGE_KEY}';
|
||||||
const storedValue = window.localStorage.getItem(storageKey);
|
const mediaQuery = '${THEME_MEDIA_QUERY}';
|
||||||
const themeMode = isThemeMode(storedValue) ? storedValue : 'system';
|
const isThemeMode = (value) => value === 'light' || value === 'dark' || value === 'system';
|
||||||
const resolvedTheme = themeMode === 'system'
|
const storedValue = window.localStorage.getItem(storageKey);
|
||||||
? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light')
|
const themeMode = isThemeMode(storedValue) ? storedValue : 'system';
|
||||||
: themeMode;
|
const resolvedTheme = themeMode === 'system'
|
||||||
const root = document.documentElement;
|
? (window.matchMedia(mediaQuery).matches ? 'dark' : 'light')
|
||||||
|
: themeMode;
|
||||||
root.dataset.themeMode = themeMode;
|
const root = document.documentElement;
|
||||||
root.dataset.theme = resolvedTheme;
|
|
||||||
root.style.colorScheme = resolvedTheme;
|
root.dataset.themeMode = themeMode;
|
||||||
})();`;
|
root.dataset.theme = resolvedTheme;
|
||||||
}
|
root.style.colorScheme = resolvedTheme;
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from 'clsx';
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,76 @@
|
|||||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
hour12: false,
|
hour12: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', {
|
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', {
|
||||||
numeric: 'auto',
|
numeric: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
function toDate(value: string | Date | null | undefined) {
|
function toDate(value: string | Date | null | undefined) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedValue = typeof value === 'string' ? value.replace(' ', 'T') : value;
|
const normalizedValue =
|
||||||
const date = normalizedValue instanceof Date ? normalizedValue : new Date(normalizedValue);
|
typeof value === 'string' ? value.replace(' ', 'T') : value;
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
const date =
|
||||||
}
|
normalizedValue instanceof Date
|
||||||
|
? normalizedValue
|
||||||
export function formatDateTime(value: string | Date | null | undefined) {
|
: new Date(normalizedValue);
|
||||||
const date = toDate(value);
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
if (!date) {
|
|
||||||
return '—';
|
export function formatDateTime(value: string | Date | null | undefined) {
|
||||||
}
|
const date = toDate(value);
|
||||||
|
|
||||||
return dateTimeFormatter.format(date).replace(/\//g, '-');
|
if (!date) {
|
||||||
}
|
return '—';
|
||||||
|
}
|
||||||
export function formatRelativeTime(value: string | Date | null | undefined) {
|
|
||||||
const date = toDate(value);
|
return dateTimeFormatter.format(date).replace(/\//g, '-');
|
||||||
|
}
|
||||||
if (!date) {
|
|
||||||
return '—';
|
export function formatRelativeTime(value: string | Date | null | undefined) {
|
||||||
}
|
const date = toDate(value);
|
||||||
|
|
||||||
const diffMs = date.getTime() - Date.now();
|
if (!date) {
|
||||||
const diffSeconds = Math.round(diffMs / 1000);
|
return '—';
|
||||||
const absSeconds = Math.abs(diffSeconds);
|
}
|
||||||
|
|
||||||
if (absSeconds < 60) {
|
const diffMs = date.getTime() - Date.now();
|
||||||
return relativeTimeFormatter.format(diffSeconds, 'second');
|
const diffSeconds = Math.round(diffMs / 1000);
|
||||||
}
|
const absSeconds = Math.abs(diffSeconds);
|
||||||
|
|
||||||
const diffMinutes = Math.round(diffSeconds / 60);
|
if (absSeconds < 60) {
|
||||||
if (Math.abs(diffMinutes) < 60) {
|
return relativeTimeFormatter.format(diffSeconds, 'second');
|
||||||
return relativeTimeFormatter.format(diffMinutes, 'minute');
|
}
|
||||||
}
|
|
||||||
|
const diffMinutes = Math.round(diffSeconds / 60);
|
||||||
const diffHours = Math.round(diffMinutes / 60);
|
if (Math.abs(diffMinutes) < 60) {
|
||||||
if (Math.abs(diffHours) < 24) {
|
return relativeTimeFormatter.format(diffMinutes, 'minute');
|
||||||
return relativeTimeFormatter.format(diffHours, 'hour');
|
}
|
||||||
}
|
|
||||||
|
const diffHours = Math.round(diffMinutes / 60);
|
||||||
const diffDays = Math.round(diffHours / 24);
|
if (Math.abs(diffHours) < 24) {
|
||||||
if (Math.abs(diffDays) < 30) {
|
return relativeTimeFormatter.format(diffHours, 'hour');
|
||||||
return relativeTimeFormatter.format(diffDays, 'day');
|
}
|
||||||
}
|
|
||||||
|
const diffDays = Math.round(diffHours / 24);
|
||||||
const diffMonths = Math.round(diffDays / 30);
|
if (Math.abs(diffDays) < 30) {
|
||||||
if (Math.abs(diffMonths) < 12) {
|
return relativeTimeFormatter.format(diffDays, 'day');
|
||||||
return relativeTimeFormatter.format(diffMonths, 'month');
|
}
|
||||||
}
|
|
||||||
|
const diffMonths = Math.round(diffDays / 30);
|
||||||
const diffYears = Math.round(diffMonths / 12);
|
if (Math.abs(diffMonths) < 12) {
|
||||||
return relativeTimeFormatter.format(diffYears, 'year');
|
return relativeTimeFormatter.format(diffMonths, 'month');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diffYears = Math.round(diffMonths / 12);
|
||||||
|
return relativeTimeFormatter.format(diffYears, 'year');
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const devBackendUrl =
|
const devBackendUrl =
|
||||||
process.env.NEXT_DEV_BACKEND_URL?.replace(/\/+$/, '') || 'http://127.0.0.1:3000';
|
process.env.NEXT_DEV_BACKEND_URL?.replace(/\/+$/, '') ||
|
||||||
|
'http://127.0.0.1:3000';
|
||||||
const enableDevProxy = process.env.NEXT_DEV_PROXY === 'true';
|
const enableDevProxy = process.env.NEXT_DEV_PROXY === 'true';
|
||||||
|
|
||||||
export default function createNextConfig() {
|
export default function createNextConfig() {
|
||||||
@@ -10,7 +11,16 @@ export default function createNextConfig() {
|
|||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
eslint: {
|
eslint: {
|
||||||
dirs: ['app', 'components', 'features', 'hooks', 'lib', 'store', 'tests', 'types'],
|
dirs: [
|
||||||
|
'app',
|
||||||
|
'components',
|
||||||
|
'features',
|
||||||
|
'hooks',
|
||||||
|
'lib',
|
||||||
|
'store',
|
||||||
|
'tests',
|
||||||
|
'types',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:3000',
|
baseURL: 'http://127.0.0.1:3000',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+4761
-1626
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const config = {
|
const config = {
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
semi: true,
|
semi: true,
|
||||||
trailingComma: 'all',
|
trailingComma: 'all',
|
||||||
plugins: ['prettier-plugin-tailwindcss'],
|
plugins: ['prettier-plugin-tailwindcss'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import next from 'next';
|
|||||||
const port = Number.parseInt(process.env.PORT ?? '3001', 10);
|
const port = Number.parseInt(process.env.PORT ?? '3001', 10);
|
||||||
const hostname = process.env.HOSTNAME || '127.0.0.1';
|
const hostname = process.env.HOSTNAME || '127.0.0.1';
|
||||||
const backendBaseUrl = new URL(
|
const backendBaseUrl = new URL(
|
||||||
(process.env.NEXT_DEV_BACKEND_URL || 'http://127.0.0.1:3000').replace(/\/+$/, ''),
|
(process.env.NEXT_DEV_BACKEND_URL || 'http://127.0.0.1:3000').replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const app = next({
|
const app = next({
|
||||||
@@ -55,11 +58,19 @@ function writeProxyError(res, error) {
|
|||||||
|
|
||||||
function proxyHttpRequest(req, res) {
|
function proxyHttpRequest(req, res) {
|
||||||
const targetUrl = new URL(req.url || '/', backendBaseUrl);
|
const targetUrl = new URL(req.url || '/', backendBaseUrl);
|
||||||
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request;
|
const requestImpl =
|
||||||
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl), (proxyRes) => {
|
targetUrl.protocol === 'https:' ? https.request : http.request;
|
||||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.statusMessage, proxyRes.headers);
|
const proxyReq = requestImpl(
|
||||||
proxyRes.pipe(res);
|
createProxyRequestOptions(req, targetUrl),
|
||||||
});
|
(proxyRes) => {
|
||||||
|
res.writeHead(
|
||||||
|
proxyRes.statusCode || 502,
|
||||||
|
proxyRes.statusMessage,
|
||||||
|
proxyRes.headers,
|
||||||
|
);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
proxyReq.on('error', (error) => {
|
proxyReq.on('error', (error) => {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -92,7 +103,8 @@ function formatUpgradeResponseHeaders(statusCode, statusMessage, headers) {
|
|||||||
|
|
||||||
function proxyWebSocketUpgrade(req, socket, head) {
|
function proxyWebSocketUpgrade(req, socket, head) {
|
||||||
const targetUrl = new URL(req.url || '/', backendBaseUrl);
|
const targetUrl = new URL(req.url || '/', backendBaseUrl);
|
||||||
const requestImpl = targetUrl.protocol === 'https:' ? https.request : http.request;
|
const requestImpl =
|
||||||
|
targetUrl.protocol === 'https:' ? https.request : http.request;
|
||||||
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl));
|
const proxyReq = requestImpl(createProxyRequestOptions(req, targetUrl));
|
||||||
|
|
||||||
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
||||||
@@ -156,6 +168,10 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, hostname, () => {
|
server.listen(port, hostname, () => {
|
||||||
console.log(`OpenFlare web dev server listening on http://${hostname}:${port}`);
|
console.log(
|
||||||
console.log(`Proxying /api/* and websocket upgrades to ${backendBaseUrl.href}`);
|
`OpenFlare web dev server listening on http://${hostname}:${port}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Proxying /api/* and websocket upgrades to ${backendBaseUrl.href}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user