[优化] 改名

This commit is contained in:
ryan
2026-03-15 16:04:54 +08:00
parent d68773c554
commit 32d90ba641
304 changed files with 629 additions and 969 deletions
@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { marked } from 'marked';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public';
import { getAboutContent } from '@/features/settings/api/settings';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function AboutPage() {
const aboutQuery = useQuery({
queryKey: ['about'],
queryFn: getAboutContent,
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
if (aboutQuery.isLoading || statusQuery.isLoading) {
return <LoadingState />;
}
if (aboutQuery.isError) {
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />;
}
if (statusQuery.isError) {
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />;
}
const aboutContent = aboutQuery.data?.trim() ?? '';
const status = statusQuery.data;
if (!status) {
return <EmptyState title='暂无系统信息' description='未能获取 OpenFlare 的公开状态信息。' />;
}
return (
<div className='space-y-6'>
<AppCard
title='关于 OpenFlare'
description='公开展示当前系统简介、版本信息与项目入口。'
action={<StatusBadge label={status.version || 'dev'} variant='info' />}
>
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p>
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'OpenFlare'}</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
<p className='text-xs 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))}
</p>
</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)]'>项目仓库</p>
<a
href='https://github.com/Rain-kl/OpenFlare'
target='_blank'
rel='noreferrer'
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80'
>
github.com/Rain-kl/OpenFlare
</a>
</div>
</div>
</AppCard>
{aboutContent ? (
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'>
<div
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]'
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }}
/>
</AppCard>
) : (
<EmptyState
title='尚未配置关于内容'
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。'
/>
)}
<div className='flex flex-wrap gap-3 text-sm'>
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
前往登录
</Link>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { apiRequest } from '@/lib/api/client';
import type { AccessLogList } from '@/features/access-logs/types';
export function getAccessLogs(page: number, nodeId?: string, pageSize = 50) {
const normalizedNodeId = nodeId?.trim();
const searchParams = new URLSearchParams({
p: String(Math.max(page, 0)),
page_size: String(pageSize),
});
if (normalizedNodeId) {
searchParams.set('node_id', normalizedNodeId);
}
return apiRequest<AccessLogList>(`/access-logs/?${searchParams.toString()}`);
}
@@ -0,0 +1,250 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getAccessLogs } from '@/features/access-logs/api/access-logs';
import type { AccessLogItem } from '@/features/access-logs/types';
import {
PrimaryButton,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const accessLogsQueryKey = (nodeId: string) => ['access-logs', nodeId] as const;
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function buildSummary(totalRecord = 0, totalIP = 0) {
return [
{ label: '访问记录', value: totalRecord },
{ label: '来源 IP', value: totalIP },
];
}
function getStatusMeta(statusCode: number) {
if (statusCode >= 500) {
return { label: String(statusCode), variant: 'danger' as const };
}
if (statusCode >= 400) {
return { label: String(statusCode), variant: 'warning' as const };
}
return { label: String(statusCode), variant: 'success' as const };
}
export function AccessLogsPage() {
const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
const [page, setPage] = useState(0);
const logsQuery = useQuery({
queryKey: [...accessLogsQueryKey(nodeFilter), page],
queryFn: () => getAccessLogs(page, nodeFilter),
});
const logs = useMemo(() => logsQuery.data?.items ?? [], [logsQuery.data]);
const hasMore = logsQuery.data?.has_more ?? false;
const pageSize = logsQuery.data?.page_size ?? 50;
const summary = useMemo(
() =>
buildSummary(
logsQuery.data?.total_record ?? 0,
logsQuery.data?.total_ip ?? 0,
),
[logsQuery.data?.total_ip, logsQuery.data?.total_record],
);
return (
<div className="space-y-6">
<PageHeader
title="日志"
description="查看最近时间窗口内的访问记录,包含来源 IP、归属地、访问域名、路径、命中节点与响应状态码。"
action={
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点页
</Link>
}
/>
<AppCard
title="日志摘要"
description="基于当前筛选条件汇总最近时间窗口内的访问记录与来源 IP 规模。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<AppCard
title="访问记录"
description="默认展示全部节点的最近访问日志,可按 node_id 快速过滤。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: accessLogsQueryKey(nodeFilter),
})
}
>
刷新
</SecondaryButton>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤访问日志"
className="lg:max-w-md"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton
type="button"
onClick={() => {
setNodeFilter(nodeFilterInput.trim());
setPage(0);
}}
>
筛选
</PrimaryButton>
<SecondaryButton
type="button"
onClick={() => {
setNodeFilter('');
setNodeFilterInput('');
setPage(0);
}}
>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="访问日志加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : logs.length === 0 ? (
<EmptyState
title="暂无访问日志"
description="当前时间窗口内还没有可展示的访问记录。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">原 IP</th>
<th className="px-3 py-3 font-medium">访问域名</th>
<th className="px-3 py-3 font-medium">路径</th>
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态码</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{logs.map((item) => {
const statusMeta = getStatusMeta(item.status_code);
return (
<tr key={item.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{formatDateTime(item.logged_at)}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.logged_at)}
</div>
</td>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
<div>{item.remote_addr || '—'}</div>
{item.region ? (
<div className="mt-2">
<span className="inline-flex rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-2.5 py-1 text-[11px] font-medium text-[var(--foreground-secondary)]">
{item.region}
</span>
</div>
) : null}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{item.host || '—'}
</td>
<td
className="max-w-[360px] px-3 py-4 text-[var(--foreground-secondary)]"
title={item.path}
>
<span className="break-all">{item.path || '—'}</span>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{item.node_name || item.node_id}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{item.node_id}
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]">
第 {page + 1} 页,每页 {pageSize} 条。
</p>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={page === 0 || logsQuery.isLoading}
onClick={() => setPage((value) => Math.max(value - 1, 0))}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={!hasMore || logsQuery.isLoading}
onClick={() => setPage((value) => value + 1)}
>
下一页
</SecondaryButton>
</div>
</div>
</div>
</AppCard>
</div>
);
}
@@ -0,0 +1,20 @@
export interface AccessLogItem {
id: number;
node_id: string;
node_name: string;
logged_at: string;
remote_addr: string;
region: string;
host: string;
path: string;
status_code: number;
}
export interface AccessLogList {
items: AccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_record: number;
total_ip: number;
}
@@ -0,0 +1,9 @@
import { apiRequest } from '@/lib/api/client';
import type { ApplyLogItem } from '@/features/apply-logs/types';
export function getApplyLogs(nodeId?: string) {
const normalizedNodeId = nodeId?.trim();
const query = normalizedNodeId ? `?node_id=${encodeURIComponent(normalizedNodeId)}` : '';
return apiRequest<ApplyLogItem[]>(`/apply-logs/${query}`);
}
@@ -0,0 +1,345 @@
'use client';
import Link from 'next/link';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import type { ApplyLogItem } from '@/features/apply-logs/types';
import {
PrimaryButton,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const;
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function getResultMeta(result: string) {
if (result === 'success') {
return { label: '成功', variant: 'success' as const };
}
return { label: '失败', variant: 'danger' as const };
}
function buildSummary(logs: ApplyLogItem[]) {
const nodeIds = new Set(logs.map((item) => item.node_id));
return [
{ label: '记录总数', value: logs.length },
{
label: '成功',
value: logs.filter((item) => item.result === 'success').length,
},
{
label: '失败',
value: logs.filter((item) => item.result !== 'success').length,
},
{ label: '节点数', value: nodeIds.size },
];
}
function truncateHash(value: string) {
if (!value) {
return '—';
}
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
export function ApplyLogsPage() {
const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
const [selectedLogId, setSelectedLogId] = useState<number | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const logsQuery = useQuery({
queryKey: applyLogsQueryKey(nodeFilter),
queryFn: () => getApplyLogs(nodeFilter),
});
const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]);
const summary = useMemo(() => buildSummary(logs), [logs]);
useEffect(() => {
if (logs.length === 0) {
setSelectedLogId(null);
return;
}
if (!logs.some((item) => item.id === selectedLogId)) {
setSelectedLogId(logs[0].id);
}
}, [logs, selectedLogId]);
const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null;
const handleSearch = () => {
setFeedback(null);
setNodeFilter(nodeFilterInput.trim());
};
const handleReset = () => {
setFeedback(null);
setNodeFilter('');
setNodeFilterInput('');
};
return (
<div className="space-y-6">
<PageHeader
title="应用记录"
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。"
action={
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点页
</Link>
}
/>
{feedback ? <InlineMessage tone="info" message={feedback} /> : null}
<AppCard
title="记录摘要"
description="帮助快速识别失败趋势和受影响节点范围。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title="过滤与列表"
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter),
})
}
>
刷新
</SecondaryButton>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用记录"
className="lg:max-w-md"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : logs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="当前筛选条件下没有可展示的应用结果。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">Node ID</th>
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">详情</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{logs.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{log.node_id}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={resultMeta.label}
variant={resultMeta.variant}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{truncateHash(log.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<p className="text-xs">
{formatRelativeTime(log.created_at)}
</p>
</div>
</td>
<td className="px-3 py-4">
<SecondaryButton
type="button"
onClick={() => {
setSelectedLogId(log.id);
setFeedback(
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
);
}}
className="px-3 py-2 text-xs"
>
查看详情
</SecondaryButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</AppCard>
<AppCard
title="记录详情"
description="展示所选应用记录的完整结果与错误信息。"
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge
label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
相对时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatRelativeTime(selectedLog.created_at)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
目标 Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
支持文件数
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{selectedLog.support_file_count}
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.main_config_checksum || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
路由配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.route_config_checksum || '无'}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
应用信息
</p>
<p className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '无附加信息'}
</p>
</div>
</div>
) : (
<EmptyState
title="未选择记录"
description="请先从左侧列表中选择一条应用记录查看详情。"
/>
)}
</AppCard>
</div>
</div>
);
}
@@ -0,0 +1,12 @@
export interface ApplyLogItem {
id: number;
node_id: string;
version: string;
result: 'success' | 'failed' | string;
message: string;
checksum: string;
main_config_checksum: string;
route_config_checksum: string;
support_file_count: number;
created_at: string;
}
@@ -0,0 +1,60 @@
import { apiRequest } from '@/lib/api/client';
import type {
AuthUser,
LoginPayload,
PasswordResetRequestPayload,
RegisterPayload,
} from '@/types/auth';
export function getCurrentUser() {
return apiRequest<AuthUser>('/user/self');
}
export function login(payload: LoginPayload) {
return apiRequest<AuthUser>('/user/login', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function logout() {
return apiRequest<void>('/user/logout');
}
export function register(payload: RegisterPayload, turnstileToken?: string) {
const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
return apiRequest<void>(`/user/register${query}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function sendEmailVerification(email: string, turnstileToken?: string) {
const searchParams = new URLSearchParams({ email });
if (turnstileToken) {
searchParams.set('turnstile', turnstileToken);
}
return apiRequest<void>(`/verification?${searchParams.toString()}`);
}
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
const searchParams = new URLSearchParams({ email });
if (turnstileToken) {
searchParams.set('turnstile', turnstileToken);
}
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
}
export function resetPassword(payload: PasswordResetRequestPayload) {
return apiRequest<string>('/user/reset', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function exchangeGitHubCode(code: string) {
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
}
@@ -0,0 +1,6 @@
import { apiRequest } from '@/lib/api/client';
import type { PublicStatus } from '@/types/public-status';
export function getPublicStatus() {
return apiRequest<PublicStatus>('/status');
}
@@ -0,0 +1,63 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils/cn';
export function AuthFormField({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className='block space-y-2'>
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</span>
{children}
{hint ? <span className='block text-xs text-[var(--foreground-secondary)]'>{hint}</span> : null}
</label>
);
}
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
props.className,
)}
/>
);
}
export function AuthButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={cn(
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
/>
);
}
export function SecondaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={cn(
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
/>
);
}
@@ -0,0 +1,35 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
interface DashboardAuthGuardProps {
children: ReactNode;
}
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
const redirect = pathname && pathname !== '/' ? `?redirect=${encodeURIComponent(pathname)}` : '';
router.replace(`/login${redirect}`);
}
}, [isAuthenticated, isLoading, pathname, router]);
if (isLoading || !isAuthenticated) {
return (
<main className='flex min-h-screen items-center justify-center px-4 py-8'>
<LoadingState className='w-full max-w-md' />
</main>
);
}
return <>{children}</>;
}
@@ -0,0 +1,52 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { exchangeGitHubCode } from '@/features/auth/api/auth';
export function GitHubOAuthCallback() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
const mutation = useMutation({
mutationFn: exchangeGitHubCode,
onSuccess: (user) => {
setUser(user);
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
router.replace('/');
},
onError: (error: Error) => {
setPrompt('授权处理失败');
setMessage({ tone: 'danger', text: error.message || 'GitHub 授权失败,请稍后重试。' });
},
});
useEffect(() => {
const code = searchParams?.get('code');
if (!code) {
setPrompt('缺少授权 code');
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
return;
}
mutation.mutate(code);
}, [mutation, searchParams]);
return (
<AppCard title='GitHub OAuth 回调' description={prompt}>
<div className='space-y-4'>
{mutation.isPending ? <LoadingState /> : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
</div>
</AppCard>
);
}
@@ -0,0 +1,128 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const loginSchema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || '登录失败,请稍后重试。');
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleGitHubLogin = () => {
const clientId = statusQuery.data?.github_client_id;
if (!clientId) {
setErrorMessage('GitHub 登录当前不可用。');
return;
}
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('scope', 'user:email');
window.location.href = authorizeUrl.toString();
};
return (
<PublicAuthGuard>
<AppCard title='用户登录'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? <InlineMessage tone='danger' message={errorMessage} /> : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? '登录中...' : '登录'}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton type='button' onClick={handleGitHubLogin} className='w-full sm:w-auto'>
GitHub 登录
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
忘记密码?
</Link>
<span>·</span>
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
注册
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,82 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppCard } from '@/components/ui/app-card';
import { resetPassword } from '@/features/auth/api/auth';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
export function PasswordResetConfirmForm() {
const searchParams = useSearchParams();
const email = searchParams?.get('email') || '';
const token = searchParams?.get('token') || '';
const [message, setMessage] = useState<{ tone: 'success' | 'danger'; text: string } | null>(null);
const mutation = useMutation({
mutationFn: () => resetPassword({ email, token }),
onSuccess: async (password) => {
try {
await navigator.clipboard.writeText(password);
setMessage({ tone: 'success', text: `密码已重置,新密码已复制到剪贴板:${password}` });
} catch {
setMessage({ tone: 'success', text: `密码已重置:${password}` });
}
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '密码重置失败,请重新获取链接。' });
},
});
const missingParams = !email || !token;
return (
<AppCard title='密码重置确认' description='确认后,系统会生成新的随机密码。'>
<div className='space-y-4'>
<AuthFormField label='邮箱地址'>
<AuthInput value={email} readOnly />
</AuthFormField>
{missingParams ? (
<InlineMessage tone='danger' message='重置链接缺少必要参数,请重新发起密码重置。' />
) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='button' disabled={missingParams || mutation.isPending} onClick={() => mutation.mutate()}>
{mutation.isPending ? '处理中...' : '确认重置密码'}
</AuthButton>
{message?.tone === 'success' ? (
<SecondaryButton
type='button'
onClick={async () => {
const password = message.text.split(':').pop() || '';
if (password) {
await navigator.clipboard.writeText(password);
}
}}
>
再次复制密码
</SecondaryButton>
) : null}
</div>
<div className='text-sm text-[var(--foreground-secondary)]'>
处理完成后可返回
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
登录页
</Link>
</div>
</div>
</AppCard>
);
}
@@ -0,0 +1,13 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
export function PasswordResetFlow() {
const searchParams = useSearchParams();
const hasConfirmationParams = Boolean(searchParams?.get('email') && searchParams?.get('token'));
return hasConfirmationParams ? <PasswordResetConfirmForm /> : <PasswordResetRequestForm />;
}
@@ -0,0 +1,101 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const resetRequestSchema = z.object({
email: z.string().email('请输入有效邮箱地址'),
});
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
export function PasswordResetRequestForm() {
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
const form = useForm<ResetRequestFormValues>({
resolver: zodResolver(resetRequestSchema),
defaultValues: { email: '' },
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const mutation = useMutation({
mutationFn: (values: ResetRequestFormValues) =>
sendPasswordResetEmail(values.email, turnstileToken || undefined),
onSuccess: () => {
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
form.reset();
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' });
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (statusQuery.data?.turnstile_check && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
mutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title='密码重置' description='提交后,系统会向你的注册邮箱发送重置链接。'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='邮箱地址'>
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
<AuthButton type='submit' disabled={mutation.isPending}>
{mutation.isPending ? '提交中...' : '发送重置邮件'}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
想起密码了?
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
返回登录
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,35 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
interface PublicAuthGuardProps {
children: ReactNode;
}
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && isAuthenticated) {
const redirect = searchParams?.get('redirect');
router.replace(redirect || '/');
}
}, [isAuthenticated, isLoading, router, searchParams]);
if (isLoading) {
return <LoadingState />;
}
if (isAuthenticated) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,23 @@
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
interface PublicRoutePlaceholderProps {
title: string;
description: string;
}
export function PublicRoutePlaceholder({
title,
description,
}: PublicRoutePlaceholderProps) {
return (
<FeaturePlaceholder
title={title}
description={description}
milestones={[
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
]}
/>
);
}
@@ -0,0 +1,225 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const baseSchemaObject = z.object({
username: z.string().min(1, '请输入用户名').max(12, '用户名最长 12 位'),
password: z.string().min(8, '密码至少 8 位').max(20, '密码最长 20 位'),
password2: z.string().min(8, '请再次输入密码'),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email('请输入有效邮箱地址'),
verification_code: z.string().min(1, '请输入验证码'),
})
.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '注册失败,请稍后重试。' });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: '请输入邮箱地址' });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: '验证码发送成功,请检查邮箱。' });
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '验证码发送失败,请稍后重试。' });
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title='新用户注册' description='兼容现有密码注册链路,后续可继续扩展第三方注册。'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名' hint='最长 12 位'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码' hint='最短 8 位,最长 20 位'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='确认密码'>
<AuthInput type='password' placeholder='请再次输入密码' {...form.register('password2')} />
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label='邮箱地址'>
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='邮箱验证码'>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder='请输入验证码'
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending ? '发送中...' : '获取验证码'}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? '注册中...' : '注册'}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
已有账户?
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
点击登录
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,31 @@
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionItem[]>('/config-versions/');
}
export function getConfigVersionPreview() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
}
export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff');
}
export function publishConfigVersion() {
return apiRequest<ConfigVersionItem>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionItem>(`/config-versions/${id}/activate`, {
method: 'PUT',
});
}
@@ -0,0 +1,90 @@
'use client';
import { AppModal } from '@/components/ui/app-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
onClose: () => void;
}) {
return (
<AppModal
isOpen={Boolean(version)}
onClose={onClose}
title={version ? `版本 ${version.version}` : '查看快照'}
description="在弹窗中查看快照 JSON 与渲染结果,避免把页面主视图撑长。"
size="xl"
footer={
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
}
>
{version ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.checksum}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建人
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.created_by || '系统'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(version.created_at)}
</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
</CodeBlock>
</div>
</div>
) : null}
</AppModal>
);
}
@@ -0,0 +1,604 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activateConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
SupportFile,
} from '@/features/config-versions/types';
import {
CodeBlock,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
const versionsQueryKey = ['config-versions'];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function truncateChecksum(checksum: string) {
if (!checksum) {
return '—';
}
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
}
function hasConfigDiff(diff: ConfigDiffResult) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
function DiffList({ title, items }: { title: string; items: string[] }) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{title}
</p>
<StatusBadge
label={`${items.length} 项`}
variant={items.length > 0 ? 'info' : 'warning'}
/>
</div>
{items.length > 0 ? (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
>
{item}
</span>
))}
</div>
) : (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无相关变更。
</p>
)}
</div>
);
}
function SupportFilesList({ files }: { files: SupportFile[] }) {
if (files.length === 0) {
return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前发布不需要额外支持文件。
</p>
);
}
return (
<div className="space-y-3">
{files.map((file) => (
<details
key={file.path}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
>
<summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
{file.path}
</summary>
<CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
{file.content}
</CodeBlock>
</details>
))}
</div>
);
}
function renderOptionValue(value: string) {
return value === '' ? '空' : value;
}
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
if (items.length === 0) {
return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无 OpenResty 性能参数变化。
</p>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">参数</th>
<th className="px-3 py-3 font-medium">当前激活值</th>
<th className="px-3 py-3 font-medium">待发布值</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr key={item.key} className="align-top">
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
{item.key}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.previous_value)}</code>
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.current_value)}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function PublishPreviewCard({
preview,
diff,
activeVersion,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
activeVersion: ConfigVersionItem | null;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
return (
<AppCard
title="发布前预览"
description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
action={
<StatusBadge
label={`启用规则 ${preview.route_count} 条`}
variant="info"
/>
}
>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前激活版本
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{diff.active_version || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
新增域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
{diff.added_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
删除域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
{diff.removed_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
修改域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
{diff.modified_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置变化
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{diff.main_config_changed ? '已变化' : '无变化'}
</p>
</div>
</div>
{!canPublish ? (
<InlineMessage
tone="info"
message="当前规则与已激活版本一致,已阻止重复发布。"
/>
) : null}
<div className="grid gap-5 xl:grid-cols-3">
<DiffList title="新增域名" items={diff.added_domains} />
<DiffList title="删除域名" items={diff.removed_domains} />
<DiffList title="修改域名" items={diff.modified_domains} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
OpenResty 参数变化
</p>
<StatusBadge
label={`${diff.changed_option_keys.length} 项`}
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
/>
</div>
<OptionDiffTable items={diff.changed_option_details} />
</div>
{diff.main_config_changed && activeVersion ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config
</p>
<StatusBadge label={activeVersion.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Pending Main Config
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{`Checksum: ${preview.checksum}`}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
</div>
) : null}
{/* Legacy duplicated main config preview block kept commented out while
we replace it with an explicit active-vs-pending comparison above.
{diff.main_config_changed && activeVersion ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
褰撳墠婵€娲讳富閰嶇疆
</p>
<StatusBadge label={activeVersion.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
寰呭彂甯冧富閰嶇疆
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Checksum锛歿preview.checksum}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
</div>
) : null}
*/}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Checksum:{preview.checksum}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.rendered_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
支持文件
</p>
<SupportFilesList files={preview.support_files} />
</div>
<div className="flex flex-wrap gap-3">
<PrimaryButton
type="button"
onClick={onConfirm}
disabled={isPublishing || !canPublish}
>
{isPublishing ? '发布中...' : '确认发布'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={onCancel}
disabled={isPublishing}
>
取消预览
</SecondaryButton>
</div>
</div>
</AppCard>
);
}
export function ConfigVersionsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
null,
);
const [publishPreview, setPublishPreview] = useState<{
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
} | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const versionsQuery = useQuery({
queryKey: versionsQueryKey,
queryFn: getConfigVersions,
});
const versions = useMemo(
() => versionsQuery.data ?? [],
[versionsQuery.data],
);
const activeVersion = useMemo(
() => versions.find((item) => item.is_active) ?? null,
[versions],
);
const selectedVersion = useMemo(
() => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions],
);
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
setPublishPreview(null);
setSelectedVersionId(version.id);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const activateMutation = useMutation({
mutationFn: activateConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `已激活版本 ${version.version}`,
});
setSelectedVersionId(version.id);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleOpenPublishPreview = async () => {
setFeedback(null);
setIsPreviewLoading(true);
try {
const [preview, diff] = await Promise.all([
getConfigVersionPreview(),
getConfigVersionDiff(),
]);
setPublishPreview({ preview, diff });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
} finally {
setIsPreviewLoading(false);
}
};
const handleActivate = (version: ConfigVersionItem) => {
if (version.is_active) {
return;
}
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
return;
}
setFeedback(null);
activateMutation.mutate(version.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="配置版本"
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
action={
<PrimaryButton
type="button"
onClick={handleOpenPublishPreview}
disabled={isPreviewLoading}
>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
activeVersion={activeVersion}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
/>
) : null}
<AppCard
title="历史版本"
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: versionsQueryKey,
})
}
>
刷新列表
</SecondaryButton>
}
>
{versionsQuery.isLoading ? (
<LoadingState />
) : versionsQuery.isError ? (
<ErrorState
title="版本列表加载失败"
description={getErrorMessage(versionsQuery.error)}
/>
) : versions.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">版本号</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">创建人</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">创建时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{versions.map((version) => (
<tr key={version.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{version.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={version.is_active ? '当前激活' : '历史版本'}
variant={version.is_active ? 'success' : 'info'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{version.created_by || '系统'}
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(version.created_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setSelectedVersionId(version.id)}
className="px-3 py-2 text-xs"
>
查看快照
</SecondaryButton>
{!version.is_active ? (
<PrimaryButton
type="button"
onClick={() => handleActivate(version)}
disabled={activateMutation.isPending}
className="px-3 py-2 text-xs"
>
重新激活
</PrimaryButton>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<ConfigVersionSnapshotModal
version={selectedVersion}
onClose={() => setSelectedVersionId(null)}
/>
</>
);
}
@@ -0,0 +1,43 @@
export interface ConfigVersionItem {
id: number;
version: string;
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
checksum: string;
is_active: boolean;
created_by: string;
created_at: string;
}
export interface SupportFile {
path: string;
content: string;
}
export interface ConfigPreviewResult {
snapshot_json: string;
main_config: string;
route_config: string;
rendered_config: string;
support_files: SupportFile[];
checksum: string;
route_count: number;
}
export interface ConfigDiffResult {
active_version?: string;
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
}
export interface ConfigOptionDiffItem {
key: string;
previous_value: string;
current_value: string;
}
@@ -0,0 +1,38 @@
import { apiRequest } from '@/lib/api/client';
import type { DashboardOverview } from '@/features/dashboard/types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function normalizeDashboardOverview(
overview: DashboardOverview | null | undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
return {
...overview,
nodes: arrayOrEmpty(overview.nodes),
distributions: {
...overview.distributions,
source_countries: arrayOrEmpty(overview.distributions?.source_countries),
status_codes: arrayOrEmpty(overview.distributions?.status_codes),
top_domains: arrayOrEmpty(overview.distributions?.top_domains),
},
trends: {
...overview.trends,
traffic_24h: arrayOrEmpty(overview.trends?.traffic_24h),
capacity_24h: arrayOrEmpty(overview.trends?.capacity_24h),
network_24h: arrayOrEmpty(overview.trends?.network_24h),
disk_io_24h: arrayOrEmpty(overview.trends?.disk_io_24h),
},
};
}
export async function getDashboardOverview() {
const overview = await apiRequest<DashboardOverview>('/dashboard/overview');
return normalizeDashboardOverview(overview);
}
@@ -0,0 +1,424 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getDashboardOverview } from '@/features/dashboard/api/overview';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import { WorldStage } from '@/features/dashboard/components/world-stage';
import {
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
} from '@/features/nodes/utils';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
formatBytes,
formatBytesPerSecond,
formatPercent,
} from '@/lib/utils/metrics';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function buildNodeRankItems(
nodes: DashboardNodeHealth[],
selector: (node: DashboardNodeHealth) => number,
limit = 5,
) {
return [...nodes]
.sort((left, right) => {
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
}
return rightValue - leftValue;
})
.slice(0, limit)
.filter((node) => selector(node) > 0)
.map((node) => ({
label: node.name,
value: selector(node),
}));
}
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
return (
<Link
href={buildNodeDetailHref(node.id)}
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{node.name}
</p>
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{node.node_id}
</p>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)}
/>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-4">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
CPU
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatPercent(node.cpu_usage_percent)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
内存
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatPercent(node.memory_usage_percent)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
最近窗口请求
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{node.request_count.toLocaleString('zh-CN')}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
活动异常
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{node.active_event_count}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-[var(--foreground-secondary)]">
<p>当前版本:{node.current_version || '未应用'}</p>
<p>
最近心跳:
{node.last_seen_at
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
</div>
</Link>
);
}
export function DashboardOverview() {
const overviewQuery = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: getDashboardOverview,
refetchInterval: 10000,
});
if (overviewQuery.isLoading) {
return <LoadingState />;
}
if (overviewQuery.isError) {
return (
<ErrorState
title="总览看板加载失败"
description={getErrorMessage(overviewQuery.error)}
/>
);
}
const overview = overviewQuery.data;
if (!overview) {
return (
<EmptyState
title="暂无总览数据"
description="系统已经启动,但还没有可展示的总览聚合结果。"
/>
);
}
return (
<div className="space-y-6">
<WorldStage
summary={overview.summary}
traffic={overview.traffic}
capacity={overview.capacity}
nodes={overview.nodes}
sourceCountries={overview.distributions.source_countries}
/>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
description="观察整体请求量和错误量是否出现异常抬升。"
>
<TrendChart
labels={overview.trends.traffic_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: overview.trends.traffic_24h.map(
(point) => point.request_count,
),
},
{
label: '错误量',
color: '#ef4444',
values: overview.trends.traffic_24h.map(
(point) => point.error_count,
),
},
]}
/>
</AppCard>
<AppCard
title="24 小时容量趋势"
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
>
<TrendChart
labels={overview.trends.capacity_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: overview.trends.capacity_24h.map(
(point) => point.average_cpu_usage_percent,
),
valueFormatter: formatPercent,
},
{
label: '平均内存',
color: '#2563eb',
values: overview.trends.capacity_24h.map(
(point) => point.average_memory_usage_percent,
),
valueFormatter: formatPercent,
},
]}
/>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="24 小时网络与磁盘趋势"
description="把 OpenResty 吞吐和磁盘读写放到同一屏,方便判断是流量拉升还是资源抖动。"
>
<div className="space-y-6">
<TrendChart
labels={overview.trends.network_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
series={[
{
label: 'OpenResty 入站',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: overview.trends.network_24h.map(
(point) => point.openresty_rx_bytes,
),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values: overview.trends.network_24h.map(
(point) => point.openresty_tx_bytes,
),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
]}
/>
<TrendChart
labels={overview.trends.disk_io_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
yAxisValueFormatter={formatBytes}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_read_bytes,
),
valueFormatter: formatBytes,
},
{
label: '磁盘写',
color: '#fb7185',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_write_bytes,
),
valueFormatter: formatBytes,
},
]}
/>
</div>
</AppCard>
<AppCard
title="Top 节点榜单"
description="把流量、错误与容量压力节点并排展示,保留快速定位热点与瓶颈的入口。"
>
<div className="grid gap-6 xl:grid-cols-1">
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
流量最高节点
</p>
<RankChart
items={buildNodeRankItems(
overview.nodes,
(node) => node.request_count,
)}
color="#38bdf8"
emptyMessage="暂无流量榜单"
/>
</div>
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
容量压力节点
</p>
<RankChart
items={buildNodeRankItems(overview.nodes, (node) =>
Math.round(
Math.max(
node.cpu_usage_percent,
node.memory_usage_percent,
node.storage_usage_percent,
),
),
)}
color="#ef4444"
valueFormatter={(value) => `${value}%`}
emptyMessage="暂无容量压力数据"
/>
</div>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-3">
<AppCard
title="来源分布"
description="聚合最近 24 小时主要来源国家,优先识别流量重心变化。"
>
<RankChart
items={overview.distributions.source_countries.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#38bdf8"
emptyMessage="暂无来源分布数据"
/>
</AppCard>
<AppCard
title="状态码分布"
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
>
<RankChart
items={overview.distributions.status_codes.map((item) => ({
label: `HTTP ${item.key}`,
value: item.value,
}))}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</AppCard>
<AppCard
title="Top Domain"
description="观察主要流量集中在哪些域名,方便判断业务热度与异常集中点。"
>
<RankChart
items={overview.distributions.top_domains.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#34d399"
emptyMessage="暂无域名分布"
/>
</AppCard>
</div>
<AppCard
title="节点健康列表"
description="按异常数量和资源压力排序,优先显示最需要关注的节点。"
action={
<Link
href="/node"
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
进入节点页
</Link>
}
>
{overview.nodes.length === 0 ? (
<EmptyState
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
/>
) : (
<div className="space-y-4">
{overview.nodes.slice(0, 8).map((node) => (
<NodeHealthRow key={node.node_id} node={node} />
))}
</div>
)}
</AppCard>
</div>
);
}
@@ -0,0 +1,565 @@
'use client';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import { ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type {
DashboardNodeHealth,
DistributionItem,
} from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
} from '@/features/nodes/utils';
echarts.use([GeoComponent, TooltipComponent, ScatterChart, CanvasRenderer]);
const fallbackCoordinates = [
[-122.4194, 37.7749],
[-46.6333, -23.5505],
[-0.1276, 51.5072],
[2.3522, 48.8566],
[77.209, 28.6139],
[121.4737, 31.2304],
[103.8198, 1.3521],
[151.2093, -33.8688],
[28.0473, -26.2041],
[139.6917, 35.6895],
] as const;
type Tone = 'healthy' | 'warning' | 'danger';
type WorldGeoJson = {
type: string;
features?: WorldFeature[];
};
type WorldFeature = {
properties?: {
name?: string;
};
geometry?: {
type?: 'Polygon' | 'MultiPolygon';
coordinates?: number[][][] | number[][][][];
};
};
type MapNodeDatum = {
id: number;
name: string;
geoName: string;
route: string;
derivedFromGeo: boolean;
requestCount: number;
errorCount: number;
activeEventCount: number;
status: DashboardNodeHealth['status'];
openrestyStatus: DashboardNodeHealth['openresty_status'];
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
};
emphasis: {
itemStyle: {
borderColor: string;
borderWidth: number;
};
};
};
type CountryRegionDatum = {
name: string;
value: number;
itemStyle: {
areaColor: string;
borderColor: string;
};
emphasis: {
itemStyle: {
areaColor: string;
borderColor: string;
};
};
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
const baseWorldMapLayoutSizePercent = 168;
const baseWorldMapZoom = 1;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
return 'danger';
}
if (
node.cpu_usage_percent >= 80 ||
node.memory_usage_percent >= 85 ||
node.storage_usage_percent >= 85
) {
return 'warning';
}
return 'healthy';
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
return {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
return {
coordinates: [
...fallbackCoordinates[index % fallbackCoordinates.length],
] as [number, number],
derivedFromGeo: false,
};
}
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (
geoJson.type !== 'FeatureCollection' ||
!Array.isArray(geoJson.features)
) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
return true;
} catch (error) {
const registrationError =
error instanceof Error
? error
: new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', registrationError);
return false;
}
}
export function WorldStageMap({
isDark,
nodes,
sourceCountries,
}: {
isDark: boolean;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
useEffect(() => {
const container = chartContainerRef.current;
if (!container) {
return;
}
const updateSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
setContainerSize((previous) =>
previous.width === nextWidth && previous.height === nextHeight
? previous
: { width: nextWidth, height: nextHeight },
);
};
updateSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
};
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.14)',
labelColor: '#e2e8f0',
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a',
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
healthyBorder: '#d1fae5',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
},
[isDark],
);
const mapNodes = useMemo<MapNodeDatum[]>(
() =>
nodes.map((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
const tone = getNodeTone(node);
const toneColor =
tone === 'healthy'
? mapPalette.healthyColor
: tone === 'warning'
? mapPalette.warningColor
: mapPalette.dangerColor;
const toneBorder =
tone === 'healthy'
? mapPalette.healthyBorder
: tone === 'warning'
? mapPalette.warningBorder
: mapPalette.dangerBorder;
return {
id: node.id,
name: node.name,
geoName: node.geo_name || node.name,
route: buildNodeDetailHref(node.id),
derivedFromGeo,
requestCount: node.request_count,
errorCount: node.error_count,
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
value: [
coordinates[0],
coordinates[1],
Math.max(node.request_count, 1),
],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 1.5,
},
emphasis: {
itemStyle: {
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
const maxRequests = Math.max(
1,
...sourceCountries.map((item) => item.value || 0),
);
return sourceCountries
.filter((item) => item.key && item.value > 0)
.map((item) => {
const intensity = Math.max(0.18, item.value / maxRequests);
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
const areaColor = isDark
? `rgba(56, 189, 248, ${areaOpacity})`
: `rgba(14, 165, 233, ${areaOpacity})`;
const borderColor = isDark
? `rgba(125, 211, 252, ${borderOpacity})`
: `rgba(14, 165, 233, ${borderOpacity})`;
return {
name: item.key,
value: item.value,
itemStyle: {
areaColor,
borderColor,
},
emphasis: {
itemStyle: {
areaColor,
borderColor,
},
},
};
});
}, [isDark, sourceCountries]);
const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize;
if (width <= 0 || height <= 0) {
return 1;
}
const widthScale = Math.min(Math.max(width / 960, 0.52), 1.08);
const heightScale = Math.min(Math.max(height / 520, 0.72), 1.1);
const compactViewportScale = width < 640 ? 0.9 : 1;
return Number(
(Math.min(widthScale, heightScale) * compactViewportScale).toFixed(3),
);
}, [containerSize]);
const computedLayoutSize = useMemo(
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
[responsiveMapScale],
);
const computedZoom = useMemo(
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
[responsiveMapScale],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
transitionDuration: 0,
backgroundColor: isDark
? 'rgba(15,23,42,0.96)'
: 'rgba(255,255,255,0.96)',
borderColor: isDark
? 'rgba(148,163,184,0.2)'
: 'rgba(148,163,184,0.24)',
borderWidth: 1,
textStyle: {
color: isDark ? '#e2e8f0' : '#0f172a',
fontSize: 12,
},
formatter: (params: unknown) => {
const payload = params as {
data?: MapNodeDatum | CountryRegionDatum;
seriesType?: string;
name?: string;
};
const data = payload.data;
if (
payload.seriesType !== 'scatter' &&
data &&
'value' in data &&
typeof data.value === 'number'
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
].join('');
}
if (!data || !('requestCount' in data)) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 预设落点`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
].join('');
},
},
geo: {
map: 'world',
roam: true,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: computedLayoutSize,
zoom: computedZoom,
scaleLimit: {
min: Math.max(Number((computedZoom * 0.78).toFixed(3)), 0.35),
max: Math.max(Number((computedZoom * 2.2).toFixed(3)), 1.4),
},
regions: countryRegions,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.7,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'map',
map: 'world',
geoIndex: 0,
data: countryRegions,
silent: true,
z: 1,
emphasis: {
disabled: true,
},
},
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
z: 3,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size =
Array.isArray(value) && typeof value[2] === 'number'
? value[2]
: 1;
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
return Math.max(
7,
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
);
},
label: {
show: false,
color: mapPalette.labelColor,
fontSize: 11,
},
emphasis: {
scale: 1.12,
label: {
show: mapNodes.length > 0 && mapNodes.length <= 5,
position: 'right',
distance: 8,
formatter: '{b}',
backgroundColor: isDark
? 'rgba(8,15,31,0.7)'
: 'rgba(255,255,255,0.88)',
borderColor: isDark
? 'rgba(148,163,184,0.16)'
: 'rgba(148,163,184,0.2)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
},
},
],
}),
[
computedLayoutSize,
computedZoom,
countryRegions,
isDark,
mapNodes,
mapPalette,
responsiveMapScale,
],
);
if (!mapReady) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/>
</div>
);
}
return (
<div ref={chartContainerRef} className="h-full w-full">
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{ height: '100%', width: '100%' }}
/>
</div>
);
}
@@ -0,0 +1,355 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import type {
DashboardCapacity,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
import { cn } from '@/lib/utils/cn';
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
const WorldStageMap = dynamic(
() =>
import('@/features/dashboard/components/world-stage-map').then(
(module) => module.WorldStageMap,
),
{ ssr: false },
);
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
return '0%';
}
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
}
function HeroMetric({
label,
value,
hint,
isDark,
}: {
label: string;
value: string;
hint: string;
isDark: boolean;
}) {
return (
<div
className={cn(
'rounded-[24px] border px-5 py-5 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
)}
>
<p
className={cn(
'text-[11px] tracking-[0.26em] uppercase',
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
{label}
</p>
<p
className={cn(
'mt-3 text-[30px] leading-none font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{value}
</p>
<p
className={cn(
'mt-2 text-sm',
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
{hint}
</p>
</div>
);
}
function LegendPill({
label,
tone,
isDark,
}: {
label: string;
tone: Tone;
isDark: boolean;
}) {
const toneClass =
tone === 'healthy'
? isDark
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
: tone === 'warning'
? isDark
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
: 'border-amber-200 bg-amber-50 text-amber-700'
: tone === 'source'
? isDark
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
: 'border-sky-200 bg-sky-50 text-sky-700'
: isDark
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
: 'border-rose-200 bg-rose-50 text-rose-700';
return (
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
{label}
</div>
);
}
export function WorldStage({
summary,
traffic,
capacity,
nodes,
sourceCountries,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
useEffect(() => {
if (shouldRenderMap) {
return;
}
const mapViewport = mapViewportRef.current;
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
setShouldRenderMap(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldRenderMap(true);
observer.disconnect();
}
},
{ rootMargin: '180px 0px' },
);
observer.observe(mapViewport);
return () => {
observer.disconnect();
};
}, [shouldRenderMap]);
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
const healthyNodes = Math.max(
0,
summary.online_nodes - summary.unhealthy_nodes,
);
const healthyRate =
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
const geoConfiguredNodes = nodes.filter(
(node) =>
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number',
).length;
return (
<section
className={cn(
'overflow-hidden rounded-[32px] border transition-colors',
isDark
? 'border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]'
: 'border-sky-100/90 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(59,130,246,0.12),transparent_20%),linear-gradient(135deg,#f8fbff,#edf5ff_45%,#ffffff)] shadow-[0_32px_80px_rgba(148,163,184,0.18)]',
)}
>
<div
className={cn(
'border-b px-6 py-6 md:px-7 md:py-7',
isDark ? 'border-white/8' : 'border-slate-200/70',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-2">
<p
className={cn(
'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)}
>
Global Stage
</p>
<h2
className={cn(
'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
全球态势板
</h2>
</div>
</div>
</div>
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.32fr_0.88fr]">
<div className="space-y-5">
<div
className={cn(
'relative min-h-[540px] overflow-hidden rounded-[28px] border py-6 lg:min-h-[600px]',
isDark
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
)}
>
<div
className={cn(
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
isDark
? 'bg-sky-400/20 text-sky-100'
: 'bg-sky-100/90 text-sky-700',
)}
>
{sourceCountries.length > 0
? '访客来源热度'
: geoConfiguredNodes > 0
? '节点地理坐标'
: '节点覆盖信号'}
</div>
<div
className={cn(
'absolute top-16 left-6 z-10 rounded-full px-3 py-1 text-[11px] backdrop-blur',
isDark
? 'bg-slate-900/45 text-slate-200'
: 'bg-white/85 text-slate-600 shadow-[0_10px_24px_rgba(148,163,184,0.16)]',
)}
>
拖动平移,滚轮缩放
</div>
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
<LegendPill
label="国家底色: 来源热度"
tone="source"
isDark={isDark}
/>
<LegendPill
label="绿色: 运行正常"
tone="healthy"
isDark={isDark}
/>
<LegendPill
label="黄色: 资源承压"
tone="warning"
isDark={isDark}
/>
<LegendPill
label="红色: 异常待处理"
tone="danger"
isDark={isDark}
/>
</div>
<div
ref={mapViewportRef}
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
>
<div className="h-full w-full min-w-0 overflow-hidden">
{shouldRenderMap ? (
<WorldStageMap
isDark={isDark}
nodes={nodes}
sourceCountries={sourceCountries}
/>
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title="全球地图准备中"
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
/>
</div>
)}
</div>
</div>
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
isDark
? 'border-white/15 bg-white/5 text-slate-300'
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
</div>
</div>
) : null}
</div>
</div>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
<HeroMetric
label="在线覆盖"
value={formatPercent(onlineRate)}
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
isDark={isDark}
/>
<HeroMetric
label="运行健康"
value={formatPercent(healthyRate)}
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
isDark={isDark}
/>
<HeroMetric
label="最近窗口请求"
value={traffic.request_count.toLocaleString('zh-CN')}
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
isDark={isDark}
/>
<HeroMetric
label="平均 CPU"
value={formatPercent(capacity.average_cpu_usage_percent)}
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
isDark={isDark}
/>
<HeroMetric
label="平均内存"
value={formatPercent(capacity.average_memory_usage_percent)}
hint={`${capacity.high_memory_nodes} 个高内存节点`}
isDark={isDark}
/>
<HeroMetric
label="高存储节点"
value={capacity.high_storage_nodes.toLocaleString('zh-CN')}
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
isDark={isDark}
/>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,101 @@
export interface DashboardSummary {
total_nodes: number;
online_nodes: number;
offline_nodes: number;
pending_nodes: number;
unhealthy_nodes: number;
}
export interface DashboardTraffic {
request_count: number;
unique_visitors: number;
error_count: number;
estimated_qps: number;
reported_nodes: number;
}
export interface DashboardCapacity {
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
high_cpu_nodes: number;
high_memory_nodes: number;
high_storage_nodes: number;
}
export interface DistributionItem {
key: string;
value: number;
}
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface CapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface DiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface TrafficDistributions {
status_codes: DistributionItem[];
top_domains: DistributionItem[];
source_countries: DistributionItem[];
}
export interface DashboardTrends {
traffic_24h: TrafficTrendPoint[];
capacity_24h: CapacityTrendPoint[];
network_24h: NetworkTrendPoint[];
disk_io_24h: DiskIOTrendPoint[];
}
export interface DashboardNodeHealth {
id: number;
node_id: string;
name: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
status: 'online' | 'offline' | 'pending';
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
current_version: string;
last_seen_at: string;
active_event_count: number;
cpu_usage_percent: number;
memory_usage_percent: number;
storage_usage_percent: number;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface DashboardOverview {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: TrafficDistributions;
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
}
@@ -0,0 +1,35 @@
import { apiRequest } from '@/lib/api/client';
import type {
ManagedDomainCertificateOption,
ManagedDomainItem,
ManagedDomainMutationPayload,
} from '@/features/managed-domains/types';
export function getManagedDomains() {
return apiRequest<ManagedDomainItem[]>('/managed-domains/');
}
export function createManagedDomain(payload: ManagedDomainMutationPayload) {
return apiRequest<ManagedDomainItem>('/managed-domains/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateManagedDomain(id: number, payload: ManagedDomainMutationPayload) {
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteManagedDomain(id: number) {
return apiRequest<void>(`/managed-domains/${id}`, {
method: 'DELETE',
});
}
export function getManagedDomainCertificates() {
return apiRequest<ManagedDomainCertificateOption[]>('/tls-certificates/');
}
@@ -0,0 +1,22 @@
export interface ManagedDomainItem {
id: number;
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface ManagedDomainMutationPayload {
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
}
export interface ManagedDomainCertificateOption {
id: number;
name: string;
not_after: string | null;
}
@@ -0,0 +1,87 @@
import { apiRequest } from '@/lib/api/client';
import type {
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeObservability,
} from '@/features/nodes/types';
import type { ReleaseChannel } from '@/features/update/types';
export function getNodes() {
return apiRequest<NodeItem[]>('/nodes/');
}
export function createNode(payload: NodeMutationPayload) {
return apiRequest<NodeItem>('/nodes/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateNode(id: number, payload: NodeMutationPayload) {
return apiRequest<NodeItem>(`/nodes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteNode(id: number) {
return apiRequest<void>(`/nodes/${id}`, {
method: 'DELETE',
});
}
export function getNodeBootstrapToken() {
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
}
export function rotateNodeBootstrapToken() {
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
method: 'POST',
});
}
export function getNodeAgentRelease(
id: number,
channel: ReleaseChannel = 'stable',
) {
return apiRequest<NodeAgentReleaseInfo>(
`/nodes/${id}/agent-release?channel=${channel}`,
);
}
export function requestNodeAgentUpdate(
id: number,
payload?: NodeAgentUpdatePayload,
) {
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
method: 'POST',
body: JSON.stringify(payload ?? {}),
});
}
export function requestNodeOpenrestyRestart(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/openresty-restart`, {
method: 'POST',
});
}
export function getNodeObservability(
id: number,
options?: { hours?: number; limit?: number },
) {
const params = new URLSearchParams();
if (options?.hours) {
params.set('hours', String(options.hours));
}
if (options?.limit) {
params.set('limit', String(options.limit));
}
const query = params.toString();
return apiRequest<NodeObservability>(
`/nodes/${id}/observability${query ? `?${query}` : ''}`,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,438 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMemo, useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { AppModal } from '@/components/ui/app-modal';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry;
properties?: {
name?: string;
};
};
type RegionOption = {
label: string;
latitude: number;
longitude: number;
};
const nodeEditorSchema = z
.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_region: z.string(),
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
geo_latitude: z.string().trim(),
geo_longitude: z.string().trim(),
})
.superRefine((values, ctx) => {
if (!values.geo_manual_override) {
return;
}
const hasLatitude = values.geo_latitude !== '';
const hasLongitude = values.geo_longitude !== '';
if (hasLatitude !== hasLongitude) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度和经度需要同时填写',
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '纬度和经度需要同时填写',
});
return;
}
if (hasLatitude) {
const latitude = Number(values.geo_latitude);
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度必须在 -90 到 90 之间',
});
}
}
if (hasLongitude) {
const longitude = Number(values.geo_longitude);
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '经度必须在 -180 到 180 之间',
});
}
}
});
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
const defaultValues: NodeEditorValues = {
name: '',
ip: '',
auto_update_enabled: false,
geo_manual_override: false,
geo_region: '',
geo_name: '',
geo_latitude: '',
geo_longitude: '',
};
function collectPoints(value: unknown, points: Array<[number, number]>) {
if (!Array.isArray(value)) {
return;
}
if (
value.length >= 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number'
) {
points.push([value[0], value[1]]);
return;
}
for (const item of value) {
collectPoints(item, points);
}
}
function getRegionCenter(feature: GeoJsonFeature) {
const points: Array<[number, number]> = [];
collectPoints(feature.geometry?.coordinates, points);
if (points.length === 0) {
return null;
}
let minLongitude = Number.POSITIVE_INFINITY;
let maxLongitude = Number.NEGATIVE_INFINITY;
let minLatitude = Number.POSITIVE_INFINITY;
let maxLatitude = Number.NEGATIVE_INFINITY;
for (const [longitude, latitude] of points) {
minLongitude = Math.min(minLongitude, longitude);
maxLongitude = Math.max(maxLongitude, longitude);
minLatitude = Math.min(minLatitude, latitude);
maxLatitude = Math.max(maxLatitude, latitude);
}
return {
longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)),
latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)),
};
}
function buildRegionOptions() {
const features = ((worldGeoJson as { features?: GeoJsonFeature[] }).features ??
[]) as GeoJsonFeature[];
const options = new Map<string, RegionOption>();
for (const feature of features) {
const label = feature.properties?.name?.trim();
if (!label || options.has(label)) {
continue;
}
const center = getRegionCenter(feature);
if (!center) {
continue;
}
options.set(label, {
label,
latitude: center.latitude,
longitude: center.longitude,
});
}
return Array.from(options.values()).sort((a, b) =>
a.label.localeCompare(b.label),
);
}
function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
if (!node) {
return defaultValues;
}
return {
name: node.name ?? '',
ip: node.ip ?? '',
auto_update_enabled: node.auto_update_enabled ?? false,
geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? node.geo_name ?? '' : '',
geo_name: node.geo_name ?? '',
geo_latitude:
node.geo_latitude === undefined || node.geo_latitude === null
? ''
: String(node.geo_latitude),
geo_longitude:
node.geo_longitude === undefined || node.geo_longitude === null
? ''
: String(node.geo_longitude),
};
}
function toPayload(values: NodeEditorValues): NodeMutationPayload {
if (!values.geo_manual_override) {
return {
name: values.name.trim(),
ip: values.ip.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_manual_override: false,
geo_name: '',
geo_latitude: null,
geo_longitude: null,
};
}
return {
name: values.name.trim(),
ip: values.ip.trim(),
auto_update_enabled: values.auto_update_enabled,
geo_manual_override: true,
geo_name: values.geo_name.trim(),
geo_latitude:
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
geo_longitude:
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
};
}
export function NodeEditorModal({
isOpen,
node,
isSubmitting,
title,
description,
submitLabel,
onClose,
onSubmit,
}: {
isOpen: boolean;
node?: Partial<NodeItem> | null;
isSubmitting: boolean;
title: string;
description: string;
submitLabel: string;
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => void;
}) {
const form = useForm<NodeEditorValues>({
resolver: zodResolver(nodeEditorSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const watchedGeoManualOverride = useWatch({
control: form.control,
name: 'geo_manual_override',
});
const regionOptions = useMemo(() => buildRegionOptions(), []);
useEffect(() => {
if (!isOpen) {
return;
}
form.reset(buildFormValues(node));
}, [form, isOpen, node]);
const handleSubmit = form.handleSubmit((values) => {
onSubmit(toPayload(values));
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onClose}
disabled={isSubmitting}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-editor-form"
disabled={isSubmitting}
>
{isSubmitting ? '保存中...' : submitLabel}
</PrimaryButton>
</div>
}
>
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ResourceField
label="节点 IP"
hint="可手动维护节点当前对外 IP;留空则等待 Agent 注册或心跳自动回填。"
error={form.formState.errors.ip?.message}
>
<ResourceInput placeholder="203.0.113.10" {...form.register('ip')} />
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<ToggleField
label="手动指定地图地区"
description="关闭时,节点会根据当前 IP 自动解析归属地;开启后使用你手动选择的地区与坐标。"
checked={watchedGeoManualOverride}
onChange={(checked) => {
form.setValue('geo_manual_override', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('geo_region', '', { shouldDirty: true });
form.setValue('geo_name', '', { shouldDirty: true });
form.setValue('geo_latitude', '', { shouldDirty: true });
form.setValue('geo_longitude', '', { shouldDirty: true });
}
}}
/>
{watchedGeoManualOverride ? (
<ResourceField
label="地区选择"
hint="选择后会自动填充位置名与地图坐标,你也可以继续微调。"
>
<ResourceSelect
value={form.watch('geo_region')}
onChange={(event) => {
const regionName = event.target.value;
form.setValue('geo_region', regionName, {
shouldDirty: true,
});
const selectedRegion = regionOptions.find(
(item) => item.label === regionName,
);
if (!selectedRegion) {
return;
}
form.setValue('geo_name', selectedRegion.label, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('geo_latitude', String(selectedRegion.latitude), {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
'geo_longitude',
String(selectedRegion.longitude),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
>
<option value="">请选择地区</option>
{regionOptions.map((option) => (
<option key={option.label} value={option.label}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
) : null}
<ResourceField
label="地图位置名"
hint={
watchedGeoManualOverride
? '可在自动填充后继续修改,例如使用更贴近业务的展示名称。'
: '自动解析模式下,该字段会由系统根据节点 IP 回填。'
}
error={form.formState.errors.geo_name?.message}
>
<ResourceInput
placeholder="Shanghai"
disabled={!watchedGeoManualOverride}
{...form.register('geo_name')}
/>
</ResourceField>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="纬度"
hint="范围 -90 到 90,例如上海约为 31.2304"
error={form.formState.errors.geo_latitude?.message}
>
<ResourceInput
placeholder="31.2304"
disabled={!watchedGeoManualOverride}
{...form.register('geo_latitude')}
/>
</ResourceField>
<ResourceField
label="经度"
hint="范围 -180 到 180,例如上海约为 121.4737"
error={form.formState.errors.geo_longitude?.message}
>
<ResourceInput
placeholder="121.4737"
disabled={!watchedGeoManualOverride}
{...form.register('geo_longitude')}
/>
</ResourceField>
</div>
</form>
</AppModal>
);
}
@@ -0,0 +1,408 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
import {
createNode,
deleteNode,
getNodes,
updateNode,
} from '@/features/nodes/api/nodes';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
import {
DangerButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
isMeaningfulTime,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes'];
const supportedRiskFilters = [
'all',
'offline',
'unhealthy',
'lagging',
] as const;
type NodeRiskFilter = (typeof supportedRiskFilters)[number];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function NodesPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
const configVersionsQuery = useQuery({
queryKey: ['config-versions'],
queryFn: getConfigVersions,
refetchInterval: 5000,
});
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
return editingNode
? updateNode(editingNode.id, payload)
: createNode(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingNode ? '节点已更新。' : '节点已创建。',
});
setEditingNode(null);
setIsEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteNode,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNode(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const activeVersion = useMemo(
() =>
(configVersionsQuery.data ?? []).find((item) => item.is_active)
?.version ?? '',
[configVersionsQuery.data],
);
const riskFilter = useMemo<NodeRiskFilter>(() => {
const current = searchParams.get('risk')?.trim().toLowerCase() ?? 'all';
return supportedRiskFilters.includes(current as NodeRiskFilter)
? (current as NodeRiskFilter)
: 'all';
}, [searchParams]);
const filteredNodes = useMemo(() => {
switch (riskFilter) {
case 'offline':
return nodes.filter((node) => node.status === 'offline');
case 'unhealthy':
return nodes.filter((node) => node.openresty_status === 'unhealthy');
case 'lagging':
return nodes.filter((node) => {
if (!activeVersion) {
return false;
}
return node.current_version !== activeVersion;
});
default:
return nodes;
}
}, [activeVersion, nodes, riskFilter]);
const filterDescription = useMemo(() => {
switch (riskFilter) {
case 'offline':
return '当前仅展示离线节点。';
case 'unhealthy':
return '当前仅展示 OpenResty 异常节点。';
case 'lagging':
return activeVersion
? `当前仅展示未追平激活版本 ${activeVersion} 的节点。`
: '当前没有激活版本,无法筛选配置落后节点。';
default:
return '列表每 5 秒自动刷新一次。';
}
}, [activeVersion, riskFilter]);
const handleReset = () => {
setFeedback(null);
setEditingNode(null);
setIsEditorOpen(false);
};
const handleCreate = () => {
setFeedback(null);
setEditingNode(null);
setIsEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNode(node);
setIsEditorOpen(true);
};
const handleDelete = (nodeId: number, nodeName: string) => {
if (
!window.confirm(
`确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(nodeId);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="节点管理"
description="节点列表只保留状态、版本与最近活动等关键内容。节点部署、Token、更新模式与手动升级入口统一收敛到详情页。"
action={
<>
<SecondaryButton type="button" onClick={handleCreate}>
新增节点
</SecondaryButton>
<Link
href="/apply-log"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
应用记录
</Link>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard
title="节点列表"
description={filterDescription}
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
}
>
立即刷新
</SecondaryButton>
}
>
{nodesQuery.isLoading ? (
<LoadingState />
) : nodesQuery.isError ? (
<ErrorState
title="节点列表加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
) : filteredNodes.length === 0 ? (
<EmptyState
title={riskFilter === 'all' ? '暂无节点' : '当前筛选无结果'}
description={
riskFilter === 'all'
? '请先创建一个节点,然后进入详情页查看专属部署命令。'
: '可以返回总览继续排查,或切换到全部节点查看完整列表。'
}
/>
) : (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Link
href="/node"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
riskFilter === 'all'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
全部节点
</Link>
<Link
href="/node?risk=offline"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
riskFilter === 'offline'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
离线节点
</Link>
<Link
href="/node?risk=unhealthy"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
riskFilter === 'unhealthy'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
OpenResty 异常
</Link>
<Link
href="/node?risk=lagging"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
riskFilter === 'lagging'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
配置落后
</Link>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">
Agent / OpenResty
</th>
<th className="px-3 py-3 font-medium">运行健康</th>
<th className="px-3 py-3 font-medium">当前版本</th>
<th className="px-3 py-3 font-medium">最近应用</th>
<th className="px-3 py-3 font-medium">最近心跳</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredNodes.map((node) => (
<tr key={node.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<p className="font-medium text-[var(--foreground-primary)]">
{node.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
IP:{node.ip || 'null'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
位置:{node.geo_name || '未配置地图点位'}
</p>
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.agent_version || 'unknown'} /{' '}
{node.nginx_version || 'unknown'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getOpenrestyStatusLabel(
node.openresty_status,
)}
variant={getOpenrestyStatusVariant(
node.openresty_status,
)}
/>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(
node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<Link
href={`/node/detail?id=${node.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<SecondaryButton
type="button"
onClick={() => handleEdit(node)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(node.id, node.name)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</AppCard>
</div>
<NodeEditorModal
isOpen={isEditorOpen}
node={editingNode}
isSubmitting={saveMutation.isPending}
title={editingNode ? '编辑节点' : '新增节点'}
onClose={handleReset}
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
submitLabel={editingNode ? '保存修改' : '新增节点'}
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
</>
);
}
@@ -0,0 +1,205 @@
import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
name: string;
ip: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
agent_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
agent_version: string;
nginx_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string;
status: 'online' | 'offline' | 'pending';
current_version: string;
last_seen_at: string;
last_error: string;
latest_apply_result: 'success' | 'failed' | '';
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
name: string;
ip: string;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
channel: ReleaseChannel;
prerelease: boolean;
update_requested: boolean;
requested_channel: ReleaseChannel;
requested_tag: string;
}
export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel;
tag_name?: string;
}
export interface NodeSystemProfile {
hostname: string;
os_name: string;
os_version: string;
kernel_version: string;
architecture: string;
cpu_model: string;
cpu_cores: number;
total_memory_bytes: number;
total_disk_bytes: number;
uptime_seconds: number;
reported_at: string;
}
export interface NodeMetricSnapshot {
captured_at: string;
cpu_usage_percent: number;
memory_used_bytes: number;
memory_total_bytes: number;
storage_used_bytes: number;
storage_total_bytes: number;
disk_read_bytes: number;
disk_write_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
openresty_connections: number;
}
export interface NodeTrafficReport {
window_started_at: string;
window_ended_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
status_codes_json: string;
top_domains_json: string;
source_countries_json: string;
}
export interface NodeTrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface NodeCapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NodeNetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface NodeDiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface NodeDistributionItem {
key: string;
value: number;
}
export interface NodeTrafficDistributions {
status_codes: NodeDistributionItem[];
top_domains: NodeDistributionItem[];
source_countries: NodeDistributionItem[];
}
export interface NodeTrafficSummary {
window_started_at: string;
window_ended_at: string;
request_count: number;
unique_visitor_count: number;
error_count: number;
estimated_qps: number;
error_rate_percent: number;
}
export interface NodeHealthSummary {
active_alerts: number;
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
resolved_alerts: number;
has_capacity_risk: boolean;
has_traffic_risk: boolean;
has_runtime_risk: boolean;
}
export interface NodeObservabilityAnalytics {
traffic: NodeTrafficSummary;
distributions: NodeTrafficDistributions;
health: NodeHealthSummary;
}
export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[];
network_24h: NodeNetworkTrendPoint[];
disk_io_24h: NodeDiskIOTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
}
@@ -0,0 +1,161 @@
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
function parseVersionParts(version: string) {
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;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
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 targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { apiRequest } from '@/lib/api/client';
import type {
ManagedDomainMatchResult,
ProxyRouteItem,
ProxyRouteMutationPayload,
TlsCertificateItem,
} from '@/features/proxy-routes/types';
export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
}
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function deleteProxyRoute(id: number) {
return apiRequest<void>(`/proxy-routes/${id}`, {
method: 'DELETE',
});
}
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
}
export function matchManagedDomainCertificate(domain: string) {
const searchParams = new URLSearchParams({ domain });
return apiRequest<ManagedDomainMatchResult>(`/managed-domains/match?${searchParams.toString()}`);
}
@@ -0,0 +1,820 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
getConfigVersionDiff,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import {
createProxyRoute,
deleteProxyRoute,
getProxyRoutes,
getTlsCertificates,
matchManagedDomainCertificate,
updateProxyRoute,
} from '@/features/proxy-routes/api/proxy-routes';
import type {
ManagedDomainMatchResult,
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
TlsCertificateItem,
} from '@/features/proxy-routes/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ResourceTextarea,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
const customHeaderSchema = z.object({
key: z.string(),
value: z.string(),
});
const proxyRouteSchema = z
.object({
domain: z.string().trim().min(1, '请输入域名'),
origin_url: z
.string()
.trim()
.min(1, '请输入源站地址')
.refine(
(value) => /^https?:\/\//.test(value),
'源站地址必须以 http:// 或 https:// 开头',
)
.refine((value) => {
try {
new URL(value);
return true;
} catch {
return false;
}
}, '请输入合法的源站地址'),
enabled: z.boolean(),
enable_https: z.boolean(),
cert_id: z.string(),
redirect_http: z.boolean(),
custom_headers: z.array(customHeaderSchema).min(1),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
if (value.enable_https && !value.cert_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['cert_id'],
message: '启用 HTTPS 时必须选择证书',
});
}
value.custom_headers.forEach((header, index) => {
const key = header.key.trim();
const headerValue = header.value.trim();
if (!key && !headerValue) {
return;
}
if (!key) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'key'],
message: '请求头名称不能为空',
});
}
if (key && !/^[A-Za-z0-9_-]+$/.test(key)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'key'],
message: '请求头名称仅支持字母、数字、下划线和中划线',
});
}
if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['custom_headers', index, 'value'],
message: '请求头不能包含换行',
});
}
});
});
type ProxyRouteFormValues = z.infer<typeof proxyRouteSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: ProxyRouteFormValues = {
domain: '',
origin_url: '',
enabled: true,
enable_https: false,
cert_id: '',
redirect_http: false,
custom_headers: [{ key: '', value: '' }],
remark: '',
};
const routesQueryKey = ['proxy-routes'];
const certificatesQueryKey = ['tls-certificates'];
const versionsQueryKey = ['config-versions'];
function hasConfigChanges(diff: {
active_version?: string;
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
}) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function parseCustomHeaders(rawValue: string) {
if (!rawValue) {
return [] as ProxyRouteCustomHeader[];
}
try {
const parsed = JSON.parse(rawValue) as ProxyRouteCustomHeader[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
: certificate.name;
}
function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
return {
domain: values.domain.trim(),
origin_url: values.origin_url.trim(),
enabled: values.enabled,
enable_https: values.enable_https,
cert_id:
values.enable_https && values.cert_id ? Number(values.cert_id) : null,
redirect_http: values.enable_https ? values.redirect_http : false,
custom_headers: values.custom_headers
.map((item) => ({ key: item.key.trim(), value: item.value.trim() }))
.filter((item) => item.key || item.value),
remark: values.remark.trim(),
};
}
function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues {
const headers = parseCustomHeaders(route.custom_headers);
return {
domain: route.domain,
origin_url: route.origin_url,
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id ? String(route.cert_id) : '',
redirect_http: route.redirect_http,
custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }],
remark: route.remark || '',
};
}
function getMatchMessage(
matchResult: ManagedDomainMatchResult | null,
isMatching: boolean,
domain: string,
enabled: boolean,
) {
if (!enabled) {
return '启用 HTTPS 后,系统会根据输入域名尝试自动匹配托管证书。';
}
if (isMatching) {
return '正在按域名自动匹配托管证书...';
}
if (!domain.trim()) {
return '输入域名后会自动匹配证书,并优先推荐精确匹配规则。';
}
if (matchResult?.matched && matchResult.candidate) {
return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate.domain},推荐证书:${matchResult.candidate.certificate_name}`;
}
return '未找到匹配证书,可继续手动选择。';
}
export function ProxyRoutesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [matchResult, setMatchResult] =
useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false);
const form = useForm<ProxyRouteFormValues>({
resolver: zodResolver(proxyRouteSchema),
defaultValues,
});
const { fields, append, remove, replace } = useFieldArray({
control: form.control,
name: 'custom_headers',
});
const watchedDomain = useWatch({ control: form.control, name: 'domain' });
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
const watchedEnableHttps = useWatch({
control: form.control,
name: 'enable_https',
});
const watchedRedirectHttp = useWatch({
control: form.control,
name: 'redirect_http',
});
const watchedCertId = useWatch({ control: form.control, name: 'cert_id' });
const routesQuery = useQuery({
queryKey: routesQueryKey,
queryFn: getProxyRoutes,
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: getTlsCertificates,
});
const saveMutation = useMutation({
mutationFn: async (values: ProxyRouteFormValues) => {
const payload = toPayload(values);
return editingRouteId
? updateProxyRoute(editingRouteId, payload)
: createProxyRoute(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingRouteId ? '规则已更新。' : '规则已创建。',
});
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteProxyRoute,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '规则已删除。' });
await queryClient.invalidateQueries({ queryKey: routesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handlePublish = async () => {
setFeedback(null);
try {
const diff = await getConfigVersionDiff();
if (!hasConfigChanges(diff)) {
setFeedback({
tone: 'info',
message: '当前规则没有变更,已阻止重复发布。',
});
return;
}
publishMutation.mutate();
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
useEffect(() => {
if (!watchedEnableHttps) {
setMatchResult(null);
setIsMatching(false);
return;
}
const normalizedDomain = watchedDomain.trim().toLowerCase();
if (!normalizedDomain) {
setMatchResult(null);
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
try {
setIsMatching(true);
const result = await matchManagedDomainCertificate(normalizedDomain);
if (cancelled) {
return;
}
setMatchResult(result);
if (result.candidate?.certificate_id && !form.getValues('cert_id')) {
form.setValue('cert_id', String(result.candidate.certificate_id), {
shouldDirty: true,
shouldValidate: true,
});
}
} catch (error) {
if (!cancelled) {
setMatchResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
} finally {
if (!cancelled) {
setIsMatching(false);
}
}
}, 400);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [watchedDomain, watchedEnableHttps, form]);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
setIsEditorOpen(false);
setMatchResult(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleEdit = (route: ProxyRouteItem) => {
setFeedback(null);
setEditingRouteId(route.id);
setMatchResult(null);
form.reset(toFormValues(route));
setIsEditorOpen(true);
};
const handleDelete = (route: ProxyRouteItem) => {
if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(route.id);
};
const handleRemoveHeader = (index: number) => {
if (fields.length === 1) {
replace([{ key: '', value: '' }]);
return;
}
remove(index);
};
const routes = routesQuery.data || [];
return (
<>
<div className="space-y-6">
<PageHeader
title="反代规则"
description="维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。"
action={
<>
<PrimaryButton
type="button"
onClick={() => void handlePublish()}
disabled={publishMutation.isPending}
>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
<SecondaryButton type="button" onClick={handleCreate}>
新增规则
</SecondaryButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard title="规则列表">
{routesQuery.isLoading ? (
<LoadingState />
) : routesQuery.isError ? (
<ErrorState
title="规则列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
) : routes.length === 0 ? (
<EmptyState
title="暂无反代规则"
description="请先创建至少一条规则,然后再进行发布。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">域名</th>
<th className="px-3 py-3 font-medium">源站地址</th>
<th className="px-3 py-3 font-medium">HTTPS</th>
<th className="px-3 py-3 font-medium">请求头</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">备注</th>
<th className="px-3 py-3 font-medium">更新时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{routes.map((route) => {
const headers = parseCustomHeaders(route.custom_headers);
return (
<tr key={route.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{route.domain}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.origin_url}
</td>
<td className="px-3 py-4">
{route.enable_https ? (
<div className="space-y-2">
<StatusBadge
label={
route.redirect_http
? 'HTTPS + 重定向'
: 'HTTPS'
}
variant="info"
/>
</div>
) : (
<StatusBadge label="HTTP" variant="warning" />
)}
</td>
<td className="px-3 py-4">
<StatusBadge
label={
headers.length > 0 ? `${headers.length} 条` : '无'
}
variant={headers.length > 0 ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4">
<StatusBadge
label={route.enabled ? '启用' : '停用'}
variant={route.enabled ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.remark || '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(route.updated_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleEdit(route)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(route)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingRouteId ? '编辑规则' : '新增规则'}
description="新增或修改反代规则后,可直接回到列表页继续发布。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleReset}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="proxy-route-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingRouteId
? '保存修改'
: '新增规则'}
</PrimaryButton>
</div>
}
>
<form
id="proxy-route-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="域名"
hint="示例:example.com"
error={form.formState.errors.domain?.message}
>
<ResourceInput
placeholder="example.com"
{...form.register('domain')}
/>
</ResourceField>
<ResourceField
label="源站地址"
hint="示例:https://origin.internal"
error={form.formState.errors.origin_url?.message}
>
<ResourceInput
placeholder="https://origin.internal"
{...form.register('origin_url')}
/>
</ResourceField>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<ToggleField
label="启用规则"
description="关闭后该规则不会参与配置渲染与发布。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ToggleField
label="启用 HTTPS"
description="启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。"
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('cert_id', '', {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('redirect_http', false, {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<ResourceField
label="TLS 证书"
hint="启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。"
error={form.formState.errors.cert_id?.message}
>
<ResourceSelect
value={watchedCertId}
disabled={!watchedEnableHttps || certificatesQuery.isLoading}
onChange={(event) =>
form.setValue('cert_id', event.target.value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<option value="">请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
</ResourceField>
<ToggleField
label="HTTP 跳转 HTTPS"
description="仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。"
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
自定义请求头
</p>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
可为空。若填写,请保证 Header 名称合法且不包含换行。
</p>
</div>
<SecondaryButton
type="button"
onClick={() => append({ key: '', value: '' })}
className="px-3 py-2 text-xs"
>
添加请求头
</SecondaryButton>
</div>
<div className="mt-4 space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 md:grid-cols-[1fr_1fr_auto]"
>
<ResourceField
label={
index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`
}
error={
form.formState.errors.custom_headers?.[index]?.key
?.message
}
>
<ResourceInput
placeholder="X-Trace-Id"
{...form.register(`custom_headers.${index}.key`)}
/>
</ResourceField>
<ResourceField
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
error={
form.formState.errors.custom_headers?.[index]?.value
?.message
}
>
<ResourceInput
placeholder="$request_id"
{...form.register(`custom_headers.${index}.value`)}
/>
</ResourceField>
<div className="flex items-end">
<DangerButton
type="button"
onClick={() => handleRemoveHeader(index)}
className="w-full px-3 py-3 text-xs md:w-auto"
>
删除
</DangerButton>
</div>
</div>
))}
</div>
</div>
<AppCard
title="证书匹配提示"
description="根据域名自动匹配托管证书,优先使用精确匹配规则。"
>
<div className="space-y-3">
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
{getMatchMessage(
matchResult,
isMatching,
watchedDomain,
watchedEnableHttps,
)}
</p>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className="flex flex-wrap gap-2">
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={
candidate.match_type === 'exact' ? 'success' : 'info'
}
/>
))}
</div>
) : null}
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]">
当前可选证书:
{certificatesQuery.isLoading
? '加载中...'
: `${certificates.length} 张`}
</div>
</div>
</AppCard>
<ResourceField
label="备注"
hint="可选,便于标记用途、责任人或特殊说明。"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea
placeholder="例如:主站生产流量入口"
{...form.register('remark')}
/>
</ResourceField>
</form>
</AppModal>
</>
);
}
@@ -0,0 +1,50 @@
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRouteItem {
id: number;
domain: string;
origin_url: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
custom_headers: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
domain: string;
origin_url: string;
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
redirect_http: boolean;
custom_headers: ProxyRouteCustomHeader[];
remark: string;
}
export interface TlsCertificateItem {
id: number;
name: string;
not_after?: string | null;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard';
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
@@ -0,0 +1,67 @@
import { apiRequest } from '@/lib/api/client';
import type {
BootstrapTokenPayload,
GeoIPLookupResult,
OptionItem,
SettingsProfile,
UpdateSelfPayload,
} from '@/features/settings/types';
export function getOptions() {
return apiRequest<OptionItem[]>('/option/');
}
export function updateOption(key: string, value: string) {
return apiRequest<void>('/option/', {
method: 'PUT',
body: JSON.stringify({ key, value }),
});
}
export function lookupGeoIP(provider: string, ip: string) {
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
method: 'POST',
body: JSON.stringify({ provider, ip }),
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
export function rotateBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
method: 'POST',
});
}
export function getSettingsProfile() {
return apiRequest<SettingsProfile>('/user/self');
}
export function updateSelf(payload: UpdateSelfPayload) {
return apiRequest<void>('/user/self', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function generateAccessToken() {
return apiRequest<string>('/user/token');
}
export function bindWeChat(code: string) {
return apiRequest<void>(
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
);
}
export function bindEmail(email: string, code: string) {
const searchParams = new URLSearchParams({ email, code });
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
}
export function getAboutContent() {
return apiRequest<string>('/about');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import type { AuthUser } from '@/types/auth';
export interface OptionItem {
key: string;
value: string;
}
export interface BootstrapTokenPayload {
discovery_token: string;
}
export interface GeoIPLookupResult {
provider: string;
ip: string;
iso_code: string;
name: string;
latitude?: number | null;
longitude?: number | null;
}
export interface UpdateSelfPayload {
username: string;
display_name: string;
password: string;
}
export type SettingsProfile = AuthUser;
@@ -0,0 +1,37 @@
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
interface ModulePageSkeletonProps {
title: string;
description: string;
}
export function ModulePageSkeleton({
title,
description,
}: ModulePageSkeletonProps) {
return (
<div className='space-y-6'>
<PageHeader title={title} description={description} />
<FeaturePlaceholder
title={`${title} 页面骨架`}
description='当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。'
milestones={[
'路由入口已创建,可纳入统一导航。',
'页面标题区、说明区与内容容器已统一。',
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
]}
/>
<div className='grid gap-4 xl:grid-cols-3'>
<LoadingState />
<EmptyState title='空态骨架' description='后续接入查询后,可将资源为空时的提示统一沉淀为此组件。' />
<ErrorState title='错误态骨架' description='后续请求失败、鉴权失效与重试提示可在此基础上扩展。' />
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
import { cn } from '@/lib/utils/cn';
interface ResourceFieldProps {
label: string;
hint?: string;
error?: string;
className?: string;
tooltip?: string;
children: ReactNode;
}
function FieldTooltip({ content }: { content: string }) {
return (
<span className="group/tooltip relative inline-flex">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] text-[11px] font-semibold text-[var(--foreground-secondary)] transition group-hover/tooltip:border-[var(--border-strong)] group-hover/tooltip:text-[var(--foreground-primary)]">
?
</span>
<span className="pointer-events-none absolute top-full left-1/2 z-20 mt-2 w-64 -translate-x-1/2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-xs leading-5 text-[var(--foreground-secondary)] opacity-0 shadow-[var(--shadow-soft)] transition-opacity delay-700 duration-200 group-hover/tooltip:opacity-100">
{content}
</span>
</span>
);
}
export function ResourceField({
label,
hint,
error,
className,
tooltip,
children,
}: ResourceFieldProps) {
return (
<label className={cn('block space-y-2', className)}>
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>{label}</span>
{tooltip ? <FieldTooltip content={tooltip} /> : null}
</span>
{children}
{error ? (
<span className="block text-xs text-[var(--status-danger-foreground)]">
{error}
</span>
) : hint ? (
<span className="block text-xs text-[var(--foreground-secondary)]">
{hint}
</span>
) : null}
</label>
);
}
export function ResourceInput(props: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
export function ResourceTextarea(
props: TextareaHTMLAttributes<HTMLTextAreaElement>,
) {
return (
<textarea
{...props}
className={cn(
'min-h-28 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)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
export function ResourceSelect(props: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
function baseButtonClassName(className?: string) {
return cn(
'inline-flex items-center justify-center rounded-2xl px-4 py-3 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60',
className,
);
}
export function PrimaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'bg-[var(--brand-primary)] text-[var(--foreground-inverse)] hover:opacity-90',
className,
),
)}
/>
);
}
export function SecondaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] hover:bg-[var(--control-background-hover)]',
className,
),
)}
/>
);
}
export function DangerButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)] hover:opacity-90',
className,
),
)}
/>
);
}
interface ToggleFieldProps {
label: string;
description?: string;
checked: boolean;
disabled?: boolean;
tooltip?: string;
onChange: (checked: boolean) => void;
}
export function ToggleField({
label,
description,
checked,
disabled,
tooltip,
onChange,
}: ToggleFieldProps) {
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">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-1 h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="flex flex-col">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>{label}</span>
{tooltip ? <FieldTooltip content={tooltip} /> : null}
</span>
{description ? (
<span className="mt-1 block text-xs leading-5 text-[var(--foreground-secondary)]">
{description}
</span>
) : null}
</span>
</label>
);
}
export function CodeBlock({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<pre
className={cn(
'overflow-x-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-xs leading-6 text-[var(--foreground-primary)]',
className,
)}
>
{children}
</pre>
);
}
@@ -0,0 +1,57 @@
import { apiRequest } from '@/lib/api/client';
import type {
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from '@/features/tls-certificates/types';
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
}
export function createTlsCertificate(payload: TlsCertificateMutationPayload) {
return apiRequest<TlsCertificateItem>('/tls-certificates/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function getTlsCertificate(id: number) {
return apiRequest<TlsCertificateDetailItem>(`/tls-certificates/${id}`);
}
export function getTlsCertificateContent(id: number) {
return apiRequest<TlsCertificateContentItem>(`/tls-certificates/${id}/content`);
}
export function updateTlsCertificate(
id: number,
payload: TlsCertificateMutationPayload,
) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('remark', payload.remark);
formData.append('cert_file', payload.certFile);
formData.append('key_file', payload.keyFile);
return apiRequest<TlsCertificateItem>('/tls-certificates/import-file', {
method: 'POST',
body: formData,
});
}
export function deleteTlsCertificate(id: number) {
return apiRequest<void>(`/tls-certificates/${id}`, {
method: 'DELETE',
});
}
@@ -0,0 +1,249 @@
'use client';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
deleteTlsCertificate,
getTlsCertificates,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
import {
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const certificatesQueryKey = ['tls-certificates', 'list'] as const;
export function TlsCertificatesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [selectedCertificateId, setSelectedCertificateId] = useState<
number | null
>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: getTlsCertificates,
});
const deleteCertificateMutation = useMutation({
mutationFn: deleteTlsCertificate,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已删除。' });
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleDeleteCertificate = (certificate: TlsCertificateItem) => {
if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) {
return;
}
setFeedback(null);
deleteCertificateMutation.mutate(certificate.id);
};
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsDetailOpen(true);
};
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsEditorOpen(true);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="证书"
description="统一查看、导入、编辑和删除已添加的 TLS 证书。"
action={
<div className="flex flex-wrap gap-3">
<Link
href="/website"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回网站
</Link>
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: ['tls-certificates'],
})
}
>
刷新证书
</SecondaryButton>
<PrimaryButton type="button" onClick={() => setIsImportOpen(true)}>
添加证书
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard
title="证书列表"
description="展示证书有效期、备注和状态,支持直接查看详情、编辑内容或删除证书。"
>
{certificatesQuery.isLoading ? (
<LoadingState />
) : certificatesQuery.isError ? (
<ErrorState
title="证书列表加载失败"
description={getErrorMessage(certificatesQuery.error)}
/>
) : certificates.length === 0 ? (
<EmptyState
title="暂无证书"
description="点击右上角“添加证书”开始录入。"
/>
) : (
<div className="space-y-3">
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
return (
<div
key={certificate.id}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{certificate.name}
</p>
<StatusBadge
label={status.label}
variant={status.variant}
/>
</div>
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>生效:{formatDateTime(certificate.not_before)}</p>
<p>到期:{formatDateTime(certificate.not_after)}</p>
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleOpenCertificateDetail(certificate)}
className="px-3 py-2 text-xs"
>
查看
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => handleOpenCertificateEditor(certificate)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDeleteCertificate(certificate)}
disabled={deleteCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</div>
</div>
);
})}
</div>
)}
</AppCard>
</div>
{isImportOpen ? (
<CertificateImportModal
isOpen={isImportOpen}
onClose={() => setIsImportOpen(false)}
onImported={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已导入。`,
});
}}
/>
) : null}
{isDetailOpen ? (
<CertificateDetailModal
certificateId={selectedCertificateId}
isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)}
onEdit={() => {
setIsDetailOpen(false);
setIsEditorOpen(true);
}}
onDelete={() => {
const certificate = certificates.find(
(item) => item.id === selectedCertificateId,
);
if (certificate) {
setIsDetailOpen(false);
handleDeleteCertificate(certificate);
}
}}
deleting={deleteCertificateMutation.isPending}
/>
) : null}
{isEditorOpen ? (
<CertificateEditorModal
certificateId={selectedCertificateId}
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
onSaved={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已更新。`,
});
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,35 @@
export interface TlsCertificateItem {
id: number;
name: string;
cert_pem?: string;
key_pem?: string;
not_before: string;
not_after: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface TlsCertificateDetailItem extends TlsCertificateItem {
cert_pem?: never;
key_pem?: never;
}
export interface TlsCertificateContentItem extends TlsCertificateItem {
cert_pem: string;
key_pem: string;
}
export interface TlsCertificateMutationPayload {
name: string;
cert_pem: string;
key_pem: string;
remark: string;
}
export interface TlsCertificateFileImportPayload {
name: string;
remark: string;
certFile: File;
keyFile: File;
}
@@ -0,0 +1,128 @@
import { ApiError, apiRequest, getApiUrl } from '@/lib/api/client';
import type { ApiEnvelope } from '@/types/api';
import type {
LatestReleaseInfo,
ReleaseChannel,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
} from '@/features/update/types';
export function getLatestRelease(channel: ReleaseChannel = 'stable') {
return apiRequest<LatestReleaseInfo>(
`/update/latest-release?channel=${channel}`,
);
}
export function upgradeServer(channel: ReleaseChannel = 'stable') {
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
method: 'POST',
body: JSON.stringify({ channel }),
});
}
export function uploadServerBinary(
binary: File,
onProgress?: (progress: number) => void,
): Promise<UploadedServerBinaryInfo> {
const formData = new FormData();
formData.append('binary', binary);
if (!onProgress) {
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upload', {
method: 'POST',
body: formData,
});
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', getApiUrl('/update/manual-upload'));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
xhr.addEventListener('load', () => {
let payload: ApiEnvelope<UploadedServerBinaryInfo> | null = null;
try {
payload = JSON.parse(
xhr.responseText,
) as ApiEnvelope<UploadedServerBinaryInfo>;
} catch {
payload = null;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(
new ApiError(
payload?.message || `请求失败(${xhr.status})`,
xhr.status,
),
);
return;
}
if (!payload) {
reject(new ApiError('响应格式无效', xhr.status));
return;
}
if (!payload.success) {
reject(new ApiError(payload.message || '请求失败', xhr.status));
return;
}
resolve(payload.data);
});
xhr.addEventListener('error', () => {
reject(new ApiError('上传过程中网络连接中断,请检查网络后重试', 0));
});
xhr.send(formData);
});
}
export function confirmManualServerUpgrade(uploadToken: string) {
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upgrade', {
method: 'POST',
body: JSON.stringify({ upload_token: uploadToken }),
});
}
export function createUpgradeLogsWebSocket() {
if (typeof window === 'undefined') {
return null;
}
const apiUrl = getApiUrl('/update/logs/ws');
const resolvedUrl = apiUrl.startsWith('http://')
? `ws://${apiUrl.slice('http://'.length)}`
: apiUrl.startsWith('https://')
? `wss://${apiUrl.slice('https://'.length)}`
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${apiUrl}`;
return new WebSocket(resolvedUrl);
}
export function parseUpgradeStreamSnapshot(
rawMessage: string,
): UpgradeStreamSnapshot | null {
try {
const parsed = JSON.parse(rawMessage) as Partial<UpgradeStreamSnapshot>;
if (
typeof parsed.in_progress !== 'boolean' ||
typeof parsed.upgrade_status !== 'string' ||
!Array.isArray(parsed.upgrade_logs)
) {
return null;
}
return {
in_progress: parsed.in_progress,
upgrade_status: parsed.upgrade_status,
upgrade_logs: parsed.upgrade_logs,
};
} catch {
return null;
}
}
@@ -0,0 +1,493 @@
'use client';
import {marked} from 'marked';
import {useEffect, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {LoadingState} from '@/components/feedback/loading-state';
import {AppCard} from '@/components/ui/app-card';
import {AppModal} from '@/components/ui/app-modal';
import {StatusBadge} from '@/components/ui/status-badge';
import type {
LatestReleaseInfo,
ReleaseChannel,
UploadedServerBinaryInfo,
} from '@/features/update/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
interface VersionUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
currentVersion: string;
release: LatestReleaseInfo | null | undefined;
selectedChannel: ReleaseChannel;
uploadedBinary: UploadedServerBinaryInfo | null;
isLoading: boolean;
releaseErrorMessage?: string;
manualStatusMessage?: string;
manualErrorMessage?: string;
canUpgrade: boolean;
isChecking: boolean;
isUpgrading: boolean;
isUploadingBinary: boolean;
uploadProgress: number;
isConfirmingManualUpgrade: boolean;
onChannelChange: (channel: ReleaseChannel) => void;
onCheck: () => void;
onUpgrade: () => void;
onUploadBinary: (file: File) => void;
onConfirmManualUpgrade: () => void;
}
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
if (!release) {
return {label: '未检查', variant: 'info' as const};
}
if (release.in_progress) {
return {label: '升级中', variant: 'warning' as const};
}
if (release.has_update) {
return {label: '可升级', variant: 'warning' as const};
}
return {label: '最新', variant: 'success' as const};
}
function formatLogTimestamp(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '0000-00-00 00:00:00.000';
}
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
}
function formatLogLevel(level: string) {
const normalized = (level || 'info').toUpperCase();
return normalized.padEnd(8, ' ');
}
export function VersionUpgradeModal({
isOpen,
onClose,
currentVersion,
release,
selectedChannel,
uploadedBinary,
isLoading,
releaseErrorMessage,
manualStatusMessage,
manualErrorMessage,
canUpgrade,
isChecking,
isUpgrading,
isUploadingBinary,
uploadProgress,
isConfirmingManualUpgrade,
onChannelChange,
onCheck,
onUpgrade,
onUploadBinary,
onConfirmManualUpgrade,
}: VersionUpgradeModalProps) {
const upgradeBadge = getUpgradeBadge(release);
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
const uploadPhaseLabel =
uploadProgress >= 100
? '已上传,正在服务端校验版本...'
: '上传中...';
const selectedChannelLabel =
selectedChannel === 'preview' ? '预览版' : '正式版';
const toggleChannel = () => {
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
};
const canConfirmManualUpgrade = Boolean(
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
);
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
const upgradeLogs = release?.upgrade_logs ?? [];
useEffect(() => {
if (!isOpen) {
setSelectedBinary(null);
}
}, [isOpen]);
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="版本"
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级,或上传 Server 二进制确认升级。升级开始后服务会短暂重启。"
size="lg"
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
<AppCard title="服务端版本">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{currentVersion || 'unknown'}
</p>
<StatusBadge
label={upgradeBadge.label}
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="升级日志"
description="展示当前自动升级任务的下载、校验与替换进度。"
>
{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="暂无升级日志"
description="开始自动升级后,这里会滚动展示后端返回的执行日志。"
/>
)}
</AppCard>
) : null}
{canUpgrade ? (
<AppCard
title="手动升级"
description="上传服务端二进制后,服务端会先检查版本,再由你确认是否执行升级。"
>
<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="尚未上传升级包"
description="上传后会在这里展示识别出的版本信息,并允许你确认升级。"
/>
)}
</div>
</AppCard>
) : null}
</div>
</AppModal>
);
}
@@ -0,0 +1,40 @@
export type ReleaseChannel = 'stable' | 'preview';
export interface UpgradeLogItem {
level: string;
message: string;
created_at: string;
}
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
channel: ReleaseChannel;
prerelease: boolean;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UpgradeStreamSnapshot {
in_progress: boolean;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UploadedServerBinaryInfo {
upload_token: string;
file_name: string;
detected_version: string;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
ready_to_upgrade: boolean;
comparison_message: string;
uploaded_at: string;
}
@@ -0,0 +1,41 @@
import { apiRequest } from '@/lib/api/client';
import type {
ManageUserAction,
ManageUserResult,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
export function getUsers(page: number) {
return apiRequest<UserItem[]>(`/user/?p=${page}`);
}
export function searchUsers(keyword: string) {
return apiRequest<UserItem[]>(`/user/search?keyword=${encodeURIComponent(keyword)}`);
}
export function getUser(id: number) {
return apiRequest<UserItem>(`/user/${id}`);
}
export function createUser(payload: UserMutationPayload) {
return apiRequest<void>('/user/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/', {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function manageUser(username: string, action: ManageUserAction) {
return apiRequest<ManageUserResult>('/user/manage', {
method: 'POST',
body: JSON.stringify({ username, action }),
});
}
@@ -0,0 +1,485 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createUser,
getUser,
getUsers,
manageUser,
searchUsers,
updateUser,
} from '@/features/users/api/users';
import type {
ManageUserAction,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const ITEMS_PER_PAGE = 10;
const usersListQueryKey = ['users', 'list'] as const;
const userSchema = z.object({
username: z.string().trim().min(1, '请输入用户名').max(12, '用户名不能超过 12 个字符'),
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
password: z
.string()
.max(20, '密码不能超过 20 个字符')
.refine((value) => value.length === 0 || value.length >= 8, '密码至少需要 8 个字符'),
});
type UserFormValues = z.infer<typeof userSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: UserFormValues = {
username: '',
display_name: '',
password: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function getRoleMeta(role: number) {
if (role >= 100) {
return { label: '超级管理员', variant: 'warning' as const };
}
if (role >= 10) {
return { label: '管理员', variant: 'info' as const };
}
return { label: '普通用户', variant: 'success' as const };
}
function getStatusMeta(status: number) {
if (status === 1) {
return { label: '已激活', variant: 'success' as const };
}
return { label: '已封禁', variant: 'danger' as const };
}
function toPayload(values: UserFormValues): UserMutationPayload {
return {
username: values.username.trim(),
display_name: values.display_name.trim(),
password: values.password,
};
}
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues,
});
const isAdmin = (currentUser?.role ?? 0) >= 10;
const isRoot = (currentUser?.role ?? 0) >= 100;
const usersQuery = useQuery({
queryKey: [...usersListQueryKey, page],
queryFn: () => getUsers(page),
enabled: isAdmin && searchKeyword.length === 0,
});
const searchQuery = useQuery({
queryKey: [...usersListQueryKey, 'search', searchKeyword],
queryFn: () => searchUsers(searchKeyword),
enabled: isAdmin && searchKeyword.length > 0,
});
const editingUserQuery = useQuery({
queryKey: ['users', 'detail', editingUserId],
queryFn: () => getUser(editingUserId as number),
enabled: isAdmin && editingUserId !== null,
});
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
const saveMutation = useMutation({
mutationFn: async (values: UserFormValues) => {
if (!editingUserId && values.password.length === 0) {
throw new Error('创建用户时必须填写密码。');
}
const payload = toPayload(values);
if (editingUserId) {
await updateUser({ id: editingUserId, ...payload });
return '用户信息已更新。';
}
await createUser(payload);
return '用户已创建。';
},
onSuccess: async (message) => {
setFeedback({ tone: 'success', message });
setEditingUserId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const manageMutation = useMutation({
mutationFn: async ({ username, action }: { username: string; action: ManageUserAction }) => {
await manageUser(username, action);
return action;
},
onSuccess: async (action) => {
const actionLabel = {
promote: '提升权限',
demote: '降级权限',
delete: '删除用户',
disable: '禁用用户',
enable: '启用用户',
}[action];
setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` });
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
const handleSearchSubmit = () => {
setFeedback(null);
setPage(0);
setSearchKeyword(searchInput.trim());
};
const handleResetSearch = () => {
setSearchInput('');
setSearchKeyword('');
setPage(0);
setFeedback(null);
};
const handleResetForm = () => {
setEditingUserId(null);
setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setEditingUserId(null);
setFeedback(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (targetUser: UserItem) => {
setFeedback(null);
setEditingUserId(targetUser.id);
setIsEditorOpen(true);
};
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
const actionText = {
promote: '提升',
demote: '降级',
delete: '删除',
disable: '禁用',
enable: '启用',
}[action];
if (action === 'delete' && !window.confirm(`确认删除用户“${targetUser.username}”吗?`)) {
return;
}
if ((action === 'disable' || action === 'enable') && !window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)) {
return;
}
setFeedback(null);
manageMutation.mutate({ username: targetUser.username, action });
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
if (!isAdmin) {
return (
<div className='space-y-6'>
<PageHeader
title='用户管理'
description='阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。'
/>
<EmptyState
title='权限不足'
description='用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。'
/>
</div>
);
}
return (
<>
<div className='space-y-6'>
<PageHeader
title='用户管理'
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
action={
<PrimaryButton type='button' onClick={handleCreate}>
新建用户
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard
title='用户列表'
action={
<div className='flex flex-wrap gap-2'>
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: usersListQueryKey })}
>
刷新
</SecondaryButton>
</div>
}
>
<div className='space-y-5'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex w-full flex-col gap-3 md:flex-row'>
<ResourceInput
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder='搜索用户 ID、用户名、显示名称或邮箱地址'
className='md:max-w-xl'
/>
<div className='flex flex-wrap gap-2'>
<PrimaryButton type='button' onClick={handleSearchSubmit}>
搜索
</PrimaryButton>
<SecondaryButton type='button' onClick={handleResetSearch}>
清空
</SecondaryButton>
</div>
</div>
{searchKeyword.length === 0 ? (
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={() => setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}>
上一页
</SecondaryButton>
<SecondaryButton
type='button'
onClick={() => setPage((value) => value + 1)}
disabled={activeQuery.isLoading || users.length < ITEMS_PER_PAGE}
>
下一页
</SecondaryButton>
</div>
) : null}
</div>
{activeQuery.isLoading ? (
<LoadingState />
) : activeQuery.isError ? (
<ErrorState title='用户列表加载失败' description={getErrorMessage(activeQuery.error)} />
) : users.length === 0 ? (
<EmptyState title='暂无用户' description='当前条件下没有可展示的用户记录。' />
) : (
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
<thead>
<tr className='text-[var(--foreground-secondary)]'>
<th className='px-3 py-3 font-medium'>用户</th>
<th className='px-3 py-3 font-medium'>邮箱</th>
<th className='px-3 py-3 font-medium'>角色</th>
<th className='px-3 py-3 font-medium'>状态</th>
<th className='px-3 py-3 font-medium'>操作</th>
</tr>
</thead>
<tbody className='divide-y divide-[var(--border-default)]'>
{users.map((targetUser) => {
const roleMeta = getRoleMeta(targetUser.role);
const statusMeta = getStatusMeta(targetUser.status);
const canManage = (currentUser?.role ?? 0) > targetUser.role;
return (
<tr key={targetUser.id} className='align-top'>
<td className='px-3 py-4'>
<div className='space-y-1'>
<p className='font-medium text-[var(--foreground-primary)]'>{targetUser.username}</p>
<p className='text-xs text-[var(--foreground-secondary)]'>
显示名称:{targetUser.display_name || '未设置'}
</p>
<p className='text-xs text-[var(--foreground-secondary)]'>ID:{targetUser.id}</p>
</div>
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
{targetUser.email || '未绑定'}
</td>
<td className='px-3 py-4'>
<StatusBadge label={roleMeta.label} variant={roleMeta.variant} />
</td>
<td className='px-3 py-4'>
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} />
</td>
<td className='px-3 py-4'>
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={() => handleEdit(targetUser)} className='px-3 py-2 text-xs' disabled={!canManage}>
编辑
</SecondaryButton>
<SecondaryButton
type='button'
onClick={() => handleManage(targetUser, targetUser.status === 1 ? 'disable' : 'enable')}
className='px-3 py-2 text-xs'
disabled={!canManage || manageMutation.isPending}
>
{targetUser.status === 1 ? '禁用' : '启用'}
</SecondaryButton>
<SecondaryButton
type='button'
onClick={() => handleManage(targetUser, targetUser.role >= 10 ? 'demote' : 'promote')}
className='px-3 py-2 text-xs'
disabled={!canManage || manageMutation.isPending || (!isRoot && targetUser.role < 10)}
>
{targetUser.role >= 10 ? '降级' : '提升'}
</SecondaryButton>
<DangerButton
type='button'
onClick={() => handleManage(targetUser, 'delete')}
className='px-3 py-2 text-xs'
disabled={!canManage || manageMutation.isPending}
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'}
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='user-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
</PrimaryButton>
</div>
}
>
{editingUserId && editingUserQuery.isLoading ? (
<LoadingState />
) : editingUserId && editingUserQuery.isError ? (
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
) : (
<form id='user-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
</ResourceField>
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
</ResourceField>
<ResourceField
label={editingUserId ? '新密码' : '密码'}
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
error={form.formState.errors.password?.message}
>
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
</ResourceField>
{editingUserId && editingUserQuery.data ? (
<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'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{editingUserQuery.data.email || '未绑定'}
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>第三方账号</p>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p>
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p>
</div>
</div>
) : null}
</form>
)}
</AppModal>
</>
);
}
@@ -0,0 +1,24 @@
export interface UserItem {
id: number;
username: string;
display_name: string;
role: number;
status: number;
email?: string;
github_id?: string;
wechat_id?: string;
}
export interface UserMutationPayload {
id?: number;
username: string;
display_name: string;
password: string;
}
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable';
export interface ManageUserResult {
role: number;
status: number;
}
@@ -0,0 +1,211 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import {
getTlsCertificate,
getTlsCertificateContent,
} from '@/features/tls-certificates/api/tls-certificates';
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
import {
CodeBlock,
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
interface CertificateDetailModalProps {
certificateId: number | null;
isOpen: boolean;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
deleting?: boolean;
}
export function CertificateDetailModal({
certificateId,
isOpen,
onClose,
onEdit,
onDelete,
deleting = false,
}: CertificateDetailModalProps) {
const [copyMessage, setCopyMessage] = useState<string | null>(null);
const certificateQuery = useQuery({
queryKey: ['tls-certificates', 'detail', certificateId],
queryFn: () => getTlsCertificate(certificateId as number),
enabled: isOpen && certificateId !== null,
});
const contentQuery = useQuery({
queryKey: ['tls-certificates', 'content', certificateId],
queryFn: () => getTlsCertificateContent(certificateId as number),
enabled: isOpen && certificateId !== null,
});
const certificate = certificateQuery.data;
const content = contentQuery.data;
const status = certificate ? getCertificateStatus(certificate) : null;
const handleCopy = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
setCopyMessage(message);
} catch (error) {
setCopyMessage(getErrorMessage(error));
}
};
return (
<AppModal
isOpen={isOpen}
onClose={() => {
setCopyMessage(null);
onClose();
}}
title="证书详情"
description="查看证书元信息、备注以及当前保存的 PEM 内容。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
<PrimaryButton
type="button"
onClick={onEdit}
disabled={!certificate}
>
编辑证书
</PrimaryButton>
<DangerButton
type="button"
onClick={onDelete}
disabled={!certificate || deleting}
>
{deleting ? '删除中...' : '删除证书'}
</DangerButton>
</div>
}
>
{copyMessage ? (
<InlineMessage tone="success" message={copyMessage} />
) : null}
{certificateQuery.isLoading || contentQuery.isLoading ? (
<LoadingState />
) : certificateQuery.isError || contentQuery.isError ? (
<ErrorState
title="证书详情加载失败"
description={getErrorMessage(
certificateQuery.error ?? contentQuery.error,
)}
/>
) : !certificate || !content ? (
<EmptyState
title="证书不存在"
description="当前证书可能已被删除。"
/>
) : (
<div className="space-y-6">
<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">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
证书名称
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{certificate.name}
</p>
</div>
<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>
<div className="mt-2">
{status ? (
<StatusBadge label={status.label} variant={status.variant} />
) : null}
</div>
</div>
<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>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(certificate.not_before)}
</p>
</div>
<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>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(certificate.not_after)}
</p>
</div>
</div>
<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>
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
{certificate.remark || '暂无备注'}
</p>
</div>
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
证书 PEM
</p>
<SecondaryButton
type="button"
className="px-3 py-2 text-xs"
onClick={() =>
void handleCopy(content.cert_pem, '证书 PEM 已复制。')
}
>
复制
</SecondaryButton>
</div>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
{content.cert_pem}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
私钥 PEM
</p>
<SecondaryButton
type="button"
className="px-3 py-2 text-xs"
onClick={() =>
void handleCopy(content.key_pem, '私钥 PEM 已复制。')
}
>
复制
</SecondaryButton>
</div>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
{content.key_pem}
</CodeBlock>
</div>
</div>
</div>
)}
</AppModal>
);
}
@@ -0,0 +1,182 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import {
getTlsCertificateContent,
updateTlsCertificate,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
defaultManualImportValues,
manualImportSchema,
type ManualImportFormValues,
} from '@/features/websites/schemas';
import { getErrorMessage, toManualPayload } from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
interface CertificateEditorModalProps {
certificateId: number | null;
isOpen: boolean;
onClose: () => void;
onSaved?: (certificate: TlsCertificateItem) => void;
}
export function CertificateEditorModal({
certificateId,
isOpen,
onClose,
onSaved,
}: CertificateEditorModalProps) {
const queryClient = useQueryClient();
const form = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
defaultValues: defaultManualImportValues,
});
const certificateQuery = useQuery({
queryKey: ['tls-certificates', 'content', certificateId],
queryFn: () => getTlsCertificateContent(certificateId as number),
enabled: isOpen && certificateId !== null,
});
const updateMutation = useMutation({
mutationFn: async (values: ManualImportFormValues) =>
updateTlsCertificate(certificateId as number, toManualPayload(values)),
onSuccess: async (certificate) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }),
queryClient.invalidateQueries({ queryKey: ['managed-domains'] }),
]);
onSaved?.(certificate);
handleClose();
},
});
useEffect(() => {
if (!isOpen || !certificateQuery.data) {
return;
}
form.reset({
name: certificateQuery.data.name,
cert_pem: certificateQuery.data.cert_pem,
key_pem: certificateQuery.data.key_pem,
remark: certificateQuery.data.remark || '',
});
}, [certificateQuery.data, form, isOpen]);
const handleSubmit = form.handleSubmit((values) => {
updateMutation.mutate(values);
});
const handleClose = () => {
updateMutation.reset();
form.reset(defaultManualImportValues);
onClose();
};
return (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title="编辑证书"
description="可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={updateMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="certificate-editor-form"
disabled={updateMutation.isPending || certificateQuery.isLoading}
>
{updateMutation.isPending ? '保存中...' : '保存证书'}
</PrimaryButton>
</div>
}
>
{certificateQuery.isLoading ? (
<LoadingState />
) : certificateQuery.isError ? (
<ErrorState
title="证书内容加载失败"
description={getErrorMessage(certificateQuery.error)}
/>
) : !certificateQuery.data ? (
<EmptyState
title="证书不存在"
description="当前证书可能已被删除。"
/>
) : (
<form
id="certificate-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
{updateMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(updateMutation.error)}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={form.formState.errors.name?.message}
>
<ResourceInput {...form.register('name')} />
</ResourceField>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceInput {...form.register('remark')} />
</ResourceField>
</div>
<ResourceField
label="证书 PEM"
error={form.formState.errors.cert_pem?.message}
>
<ResourceTextarea
className="min-h-40 font-mono text-xs"
{...form.register('cert_pem')}
/>
</ResourceField>
<ResourceField
label="私钥 PEM"
error={form.formState.errors.key_pem?.message}
>
<ResourceTextarea
className="min-h-40 font-mono text-xs"
{...form.register('key_pem')}
/>
</ResourceField>
</form>
)}
</AppModal>
);
}
@@ -0,0 +1,320 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type FormEvent } from 'react';
import { useForm } from 'react-hook-form';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import {
createTlsCertificate,
importTlsCertificateFiles,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
defaultFileImportValues,
defaultManualImportValues,
type FileImportFormValues,
manualImportSchema,
type ManualImportFormValues,
} from '@/features/websites/schemas';
import {
getErrorMessage,
toFilePayload,
toManualPayload,
} from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
type FeedbackState = {
tone: 'success' | 'danger';
message: string;
};
interface CertificateImportModalProps {
isOpen: boolean;
onClose: () => void;
onImported?: (certificate: TlsCertificateItem) => void;
}
export function CertificateImportModal({
isOpen,
onClose,
onImported,
}: CertificateImportModalProps) {
const queryClient = useQueryClient();
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [fileForm, setFileForm] =
useState<FileImportFormValues>(defaultFileImportValues);
const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [fileInputNonce, setFileInputNonce] = useState(0);
const manualForm = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
defaultValues: defaultManualImportValues,
});
useEffect(() => {
if (!isOpen) {
return;
}
setFeedback(null);
}, [isOpen]);
const resetFileForm = () => {
setFileForm(defaultFileImportValues);
setCertFile(null);
setKeyFile(null);
setFileInputNonce((value) => value + 1);
};
const resetAll = () => {
setImportMode('manual');
setFeedback(null);
manualForm.reset(defaultManualImportValues);
resetFileForm();
};
const invalidateCertificateQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }),
queryClient.invalidateQueries({ queryKey: ['managed-domains'] }),
]);
};
const manualImportMutation = useMutation({
mutationFn: async (values: ManualImportFormValues) =>
createTlsCertificate(toManualPayload(values)),
onSuccess: async (certificate) => {
await invalidateCertificateQueries();
onImported?.(certificate);
resetAll();
onClose();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const fileImportMutation = useMutation({
mutationFn: async (values: FileImportFormValues) =>
importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)),
onSuccess: async (certificate) => {
await invalidateCertificateQueries();
onImported?.(certificate);
resetAll();
onClose();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleManualSubmit = manualForm.handleSubmit((values) => {
setFeedback(null);
manualImportMutation.mutate(values);
});
const handleFileSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setFeedback(null);
fileImportMutation.mutate(fileForm);
};
const handleClose = () => {
resetAll();
onClose();
};
return (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title="添加证书"
description="支持手动粘贴 PEM 或上传证书文件。导入成功后可立即在网站表单里选择。"
size="xl"
>
<div className="space-y-6">
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="inline-flex rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-1">
<button
type="button"
onClick={() => setImportMode('manual')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'manual'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
手动导入
</button>
<button
type="button"
onClick={() => setImportMode('file')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'file'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
文件导入
</button>
</div>
{importMode === 'manual' ? (
<AppCard description="直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。">
<form className="space-y-5" onSubmit={handleManualSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={manualForm.formState.errors.name?.message}
>
<ResourceInput
placeholder="example-com"
{...manualForm.register('name')}
/>
</ResourceField>
<ResourceField
label="备注"
hint="可选,用于记录证书用途或来源。"
error={manualForm.formState.errors.remark?.message}
>
<ResourceInput
placeholder="例如:主站生产证书"
{...manualForm.register('remark')}
/>
</ResourceField>
</div>
<ResourceField
label="证书 PEM"
error={manualForm.formState.errors.cert_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN CERTIFICATE-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('cert_pem')}
/>
</ResourceField>
<ResourceField
label="私钥 PEM"
error={manualForm.formState.errors.key_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN PRIVATE KEY-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('key_pem')}
/>
</ResourceField>
<PrimaryButton
type="submit"
disabled={manualImportMutation.isPending}
>
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
</PrimaryButton>
</form>
</AppCard>
) : (
<AppCard description="上传证书文件和私钥文件,适合直接复用现有 PEM 文件。">
<form className="space-y-5" onSubmit={handleFileSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField label="证书名称">
<ResourceInput
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="wildcard-example"
/>
</ResourceField>
<ResourceField label="备注">
<ResourceInput
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({
...current,
remark: event.target.value,
}))
}
placeholder="例如:泛域名生产证书"
/>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书文件"
hint={
certFile
? `已选择:${certFile.name}`
: '请选择 PEM/CRT 文件'
}
>
<ResourceInput
key={`cert-${fileInputNonce}`}
type="file"
accept=".pem,.crt,.cer"
onChange={(event) =>
setCertFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
<ResourceField
label="私钥文件"
hint={
keyFile
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'
}
>
<ResourceInput
key={`key-${fileInputNonce}`}
type="file"
accept=".key,.pem"
onChange={(event) =>
setKeyFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
</div>
<div className="flex flex-wrap gap-3">
<PrimaryButton
type="submit"
disabled={fileImportMutation.isPending}
>
{fileImportMutation.isPending ? '上传中...' : '上传文件'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={resetFileForm}
disabled={fileImportMutation.isPending}
>
清空文件
</SecondaryButton>
</div>
</form>
</AppCard>
)}
</div>
</AppModal>
);
}
@@ -0,0 +1,503 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {deleteManagedDomain, getManagedDomains,} from '@/features/managed-domains/api/managed-domains';
import {getProxyRoutes,} from '@/features/proxy-routes/api/proxy-routes';
import {deleteTlsCertificate, getTlsCertificates,} from '@/features/tls-certificates/api/tls-certificates';
import {CertificateDetailModal} from '@/features/websites/components/certificate-detail-modal';
import {CertificateEditorModal} from '@/features/websites/components/certificate-editor-modal';
import {CertificateImportModal} from '@/features/websites/components/certificate-import-modal';
import {WebsiteEditorModal} from '@/features/websites/components/website-editor-modal';
import {
buildCertificateLabel,
getCertificateStatus,
getErrorMessage,
getMatchTypeMeta,
isRouteRelatedToManagedDomain,
} from '@/features/websites/utils';
import {DangerButton, PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {formatDateTime} from '@/lib/utils/date';
type FeedbackState = {
tone: 'success' | 'danger';
message: string;
};
export function WebsiteDetailPage({websiteId}: { websiteId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false);
const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<
number | null
>(null);
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
});
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
});
const proxyRoutesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const deleteDomainMutation = useMutation({
mutationFn: deleteManagedDomain,
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['managed-domains']});
router.push('/website');
},
onError: (error) => {
setFeedback({tone: 'danger', message: getErrorMessage(error)});
},
});
const deleteCertificateMutation = useMutation({
mutationFn: deleteTlsCertificate,
onSuccess: async () => {
setFeedback({tone: 'success', message: '证书已删除。'});
await Promise.all([
queryClient.invalidateQueries({queryKey: ['tls-certificates']}),
queryClient.invalidateQueries({queryKey: ['managed-domains']}),
]);
},
onError: (error) => {
setFeedback({tone: 'danger', message: getErrorMessage(error)});
},
});
const website = useMemo(() => {
return (
(managedDomainsQuery.data ?? []).find(
(item) => String(item.id) === websiteId,
) ?? null
);
}, [managedDomainsQuery.data, websiteId]);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const relatedRoutes = useMemo(() => {
if (!website) {
return [];
}
return (proxyRoutesQuery.data ?? []).filter((route) =>
isRouteRelatedToManagedDomain(website.domain, route.domain),
);
}, [proxyRoutesQuery.data, website]);
const certificate = website?.cert_id
? certificateMap.get(website.cert_id) ?? null
: null;
const enabledRoutesCount = relatedRoutes.filter((route) => route.enabled).length;
const handleDeleteWebsite = () => {
if (!website) {
return;
}
if (!window.confirm(`确认删除网站 ${website.domain} 吗?`)) {
return;
}
setFeedback(null);
deleteDomainMutation.mutate(website.id);
};
const handleDeleteCertificate = () => {
if (!certificate) {
return;
}
if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) {
return;
}
setFeedback(null);
deleteCertificateMutation.mutate(certificate.id);
};
if (
managedDomainsQuery.isLoading ||
certificatesQuery.isLoading ||
proxyRoutesQuery.isLoading
) {
return <LoadingState/>;
}
if (managedDomainsQuery.isError) {
return (
<ErrorState
title="网站详情加载失败"
description={getErrorMessage(managedDomainsQuery.error)}
/>
);
}
if (certificatesQuery.isError) {
return (
<ErrorState
title="证书信息加载失败"
description={getErrorMessage(certificatesQuery.error)}
/>
);
}
if (proxyRoutesQuery.isError) {
return (
<ErrorState
title="关联规则加载失败"
description={getErrorMessage(proxyRoutesQuery.error)}
/>
);
}
if (!website) {
return (
<EmptyState
title="网站不存在"
description="该网站可能已被删除,或当前 ID 无法匹配到网站记录。"
/>
);
}
const matchType = getMatchTypeMeta(website.domain);
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
return (
<>
<div className="space-y-6">
<PageHeader
title={website.domain}
description="网站详情"
action={
<>
<Link
href="/website"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑网站
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => setIsCertificateImportOpen(true)}
>
添加证书
</PrimaryButton>
<DangerButton
type="button"
onClick={handleDeleteWebsite}
disabled={deleteDomainMutation.isPending}
>
删除网站
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message}/>
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="匹配类型">
<div className="space-y-3">
<StatusBadge label={matchType.label} variant={matchType.variant}/>
<p className="text-sm text-[var(--foreground-secondary)]">
{website.domain.startsWith('*.')
? '当前网站会覆盖该后缀下的子域名规则。'
: '当前网站只匹配同名精确域名。'}
</p>
</div>
</AppCard>
<AppCard title="网站状态">
<div className="space-y-3">
<StatusBadge
label={website.enabled ? '启用' : '停用'}
variant={website.enabled ? 'success' : 'warning'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
更新时间:{formatDateTime(website.updated_at)}
</p>
</div>
</AppCard>
<AppCard title="关联规则">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>规则总数:{relatedRoutes.length}</p>
<p>已启用规则:{enabledRoutesCount}</p>
</div>
</AppCard>
<AppCard title="绑定证书">
<div className="space-y-3">
<StatusBadge
label={certificate ? certificate.name : '未绑定证书'}
variant={certificate ? 'success' : 'warning'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{certificateStatus
? `证书状态:${certificateStatus.label}`
: '当前网站未设置默认证书。'}
</p>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<AppCard title="网站信息" description="当前网站的基础配置与托管信息。">
<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">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
域名
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{website.domain}
</p>
</div>
<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>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(website.created_at)}
</p>
</div>
<div
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 md:col-span-2">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
备注
</p>
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
{website.remark || '暂无备注'}
</p>
</div>
</div>
</AppCard>
<AppCard
title="证书信息"
description="当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。"
>
{certificate ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={certificateStatus?.label ?? '有效'}
variant={certificateStatus?.variant ?? 'success'}
/>
</div>
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>证书名称:{buildCertificateLabel(certificate)}</p>
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={() => setIsCertificateDetailOpen(true)}
>
查看
</SecondaryButton>
<DangerButton
type="button"
onClick={handleDeleteCertificate}
disabled={deleteCertificateMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
) : (
<EmptyState
title="未绑定证书"
description="点击右上角“添加证书”后,可以在编辑网站时直接选择并应用。"
/>
)}
</AppCard>
</div>
<AppCard
title="关联规则"
>
{relatedRoutes.length === 0 ? (
<EmptyState
title="暂无关联规则"
description="当前网站还没有命中任何代理规则。创建或调整规则后,这里会自动展示。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">规则域名</th>
<th className="px-3 py-3 font-medium">源站</th>
<th className="px-3 py-3 font-medium">HTTPS</th>
<th className="px-3 py-3 font-medium">证书</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">备注</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{relatedRoutes.map((route) => {
const routeCertificate = route.cert_id
? certificateMap.get(route.cert_id) ?? null
: null;
return (
<tr key={route.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
<div className="space-y-2">
<p>{route.domain}</p>
<StatusBadge
label={
route.domain === website.domain
? '直接关联'
: '被网站覆盖'
}
variant={
route.domain === website.domain
? 'info'
: 'warning'
}
/>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-72 break-all">{route.origin_url}</p>
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<StatusBadge
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
variant={route.enable_https ? 'success' : 'warning'}
/>
{route.redirect_http ? (
<StatusBadge label="HTTP 跳转" variant="info"/>
) : null}
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{routeCertificate
? routeCertificate.name
: '未绑定证书'}
</td>
<td className="px-3 py-4">
<StatusBadge
label={route.enabled ? '启用' : '停用'}
variant={route.enabled ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.remark || '暂无备注'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
{isEditorOpen ? (
<WebsiteEditorModal
isOpen={isEditorOpen}
onClose={() => {
setIsEditorOpen(false);
setPreferredCertificateId(null);
}}
initialDomain={website}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setIsCertificateImportOpen(true)}
onSaved={() => {
setPreferredCertificateId(null);
setFeedback({tone: 'success', message: '网站已更新。'});
}}
/>
) : null}
{isCertificateImportOpen ? (
<CertificateImportModal
isOpen={isCertificateImportOpen}
onClose={() => setIsCertificateImportOpen(false)}
onImported={(importedCertificate) => {
setPreferredCertificateId(importedCertificate.id);
setFeedback({
tone: 'success',
message: `证书 ${importedCertificate.name} 已导入,可在编辑网站时直接应用。`,
});
setIsEditorOpen(true);
}}
/>
) : null}
{isCertificateDetailOpen ? (
<CertificateDetailModal
certificateId={certificate?.id ?? null}
isOpen={isCertificateDetailOpen}
onClose={() => setIsCertificateDetailOpen(false)}
onEdit={() => {
setIsCertificateDetailOpen(false);
setIsCertificateEditorOpen(true);
}}
onDelete={() => {
setIsCertificateDetailOpen(false);
handleDeleteCertificate();
}}
deleting={deleteCertificateMutation.isPending}
/>
) : null}
{isCertificateEditorOpen ? (
<CertificateEditorModal
certificateId={certificate?.id ?? null}
isOpen={isCertificateEditorOpen}
onClose={() => setIsCertificateEditorOpen(false)}
onSaved={(updatedCertificate) => {
setFeedback({
tone: 'success',
message: `证书 ${updatedCertificate.name} 已更新。`,
});
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,274 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createManagedDomain,
updateManagedDomain,
} from '@/features/managed-domains/api/managed-domains';
import type { ManagedDomainItem } from '@/features/managed-domains/types';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
defaultManagedDomainValues,
managedDomainSchema,
type ManagedDomainFormValues,
} from '@/features/websites/schemas';
import {
buildCertificateLabel,
getErrorMessage,
getMatchTypeMeta,
toManagedDomainFormValues,
toManagedDomainPayload,
} from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
interface WebsiteEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
onRequestImportCertificate: () => void;
certificates: TlsCertificateItem[];
certificatesLoading?: boolean;
initialDomain?: ManagedDomainItem | null;
preferredCertificateId?: number | null;
}
export function WebsiteEditorModal({
isOpen,
onClose,
onSaved,
onRequestImportCertificate,
certificates,
certificatesLoading = false,
initialDomain = null,
preferredCertificateId = null,
}: WebsiteEditorModalProps) {
const queryClient = useQueryClient();
const form = useForm<ManagedDomainFormValues>({
resolver: zodResolver(managedDomainSchema),
defaultValues: defaultManagedDomainValues,
});
const watchedDomain = useWatch({
control: form.control,
name: 'domain',
});
const watchedCertId = useWatch({
control: form.control,
name: 'cert_id',
});
const watchedEnabled = useWatch({
control: form.control,
name: 'enabled',
});
const saveMutation = useMutation({
mutationFn: async (values: ManagedDomainFormValues) => {
const payload = toManagedDomainPayload(values);
return initialDomain
? updateManagedDomain(initialDomain.id, payload)
: createManagedDomain(payload);
},
onSuccess: async (domain) => {
await queryClient.invalidateQueries({ queryKey: ['managed-domains'] });
onSaved?.(domain, initialDomain ? 'update' : 'create');
handleClose();
},
});
const currentCertificate = watchedCertId
? certificates.find((item) => item.id === Number(watchedCertId)) ?? null
: null;
const handleSubmit = form.handleSubmit((values) => {
saveMutation.mutate(values);
});
const handleClose = () => {
saveMutation.reset();
form.reset(defaultManagedDomainValues);
onClose();
};
useEffect(() => {
if (!isOpen) {
return;
}
form.reset(
initialDomain
? toManagedDomainFormValues(initialDomain)
: defaultManagedDomainValues,
);
}, [form, initialDomain, isOpen]);
useEffect(() => {
if (!isOpen || !preferredCertificateId) {
return;
}
form.setValue('cert_id', String(preferredCertificateId), {
shouldDirty: true,
shouldValidate: true,
});
}, [form, isOpen, preferredCertificateId]);
return (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title={initialDomain ? '编辑网站' : '新增网站'}
description="录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="website-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: initialDomain
? '保存网站'
: '创建网站'}
</PrimaryButton>
</div>
}
>
<form
id="website-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
{saveMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(saveMutation.error)}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="域名"
hint="示例:example.com 或 *.example.com"
error={form.formState.errors.domain?.message}
>
<ResourceInput
placeholder="example.com 或 *.example.com"
{...form.register('domain')}
/>
</ResourceField>
<ResourceField
label="绑定证书"
hint="默认证书会用于该网站的自动匹配与规则推荐。"
error={form.formState.errors.cert_id?.message}
>
<div className="space-y-3">
<ResourceSelect
value={watchedCertId}
disabled={certificatesLoading}
onChange={(event) =>
form.setValue('cert_id', event.target.value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<option value="">不绑定证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
<SecondaryButton
type="button"
onClick={onRequestImportCertificate}
className="w-full sm:w-auto"
>
添加证书
</SecondaryButton>
</div>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]">
当前域名
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{watchedDomain?.trim() || '未填写域名'}
</p>
</div>
<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>
<div className="mt-2">
{watchedDomain?.trim() ? (
<StatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
) : (
<StatusBadge label="等待输入" variant="warning" />
)}
</div>
</div>
<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>
<div className="mt-2">
<StatusBadge
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
variant={currentCertificate ? 'success' : 'warning'}
/>
</div>
</div>
</div>
<ToggleField
label="启用网站"
description="停用后该网站不会参与自动匹配,但记录会保留。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceInput
placeholder="例如:主站 / 泛域名 / 生产"
{...form.register('remark')}
/>
</ResourceField>
</form>
</AppModal>
);
}
@@ -0,0 +1,243 @@
'use client';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
deleteManagedDomain,
getManagedDomains,
} from '@/features/managed-domains/api/managed-domains';
import type { ManagedDomainItem } from '@/features/managed-domains/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
import {
buildCertificateLabel,
getErrorMessage,
getMatchTypeMeta,
} from '@/features/websites/utils';
import {
DangerButton,
PrimaryButton,
} from '@/features/shared/components/resource-primitives';
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
export function WebsitesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false);
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<
number | null
>(null);
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
});
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
});
const deleteDomainMutation = useMutation({
mutationFn: deleteManagedDomain,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '网站已删除。' });
await queryClient.invalidateQueries({ queryKey: ['managed-domains'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const domains = useMemo(
() => managedDomainsQuery.data ?? [],
[managedDomainsQuery.data],
);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const handleOpenWebsiteModal = () => {
setPreferredCertificateId(null);
setFeedback(null);
setIsWebsiteModalOpen(true);
};
const handleDeleteDomain = (domain: ManagedDomainItem) => {
if (!window.confirm(`确认删除网站 ${domain.domain} 吗?`)) {
return;
}
setFeedback(null);
deleteDomainMutation.mutate(domain.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="网站"
description="主界面只保留网站列表卡片。新增网站时可直接绑定证书,也可以在弹窗内先添加证书后再应用。"
action={
<div className="flex flex-wrap gap-3">
<Link
href="/website/certificate"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
证书列表
</Link>
<PrimaryButton type="button" onClick={handleOpenWebsiteModal}>
新增网站
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard
title="网站列表"
description="查看网站绑定的证书、启用状态和更新时间。"
>
{managedDomainsQuery.isLoading ? (
<LoadingState />
) : managedDomainsQuery.isError ? (
<ErrorState
title="网站列表加载失败"
description={getErrorMessage(managedDomainsQuery.error)}
/>
) : domains.length === 0 ? (
<EmptyState
title="暂无网站"
description="点击右上角“新增网站”开始录入。录入时如还没有证书,也可以直接在弹窗里添加。"
/>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{domains.map((domain) => {
const certificate = domain.cert_id
? certificateMap.get(domain.cert_id) ?? null
: null;
const matchType = getMatchTypeMeta(domain.domain);
return (
<article
key={domain.id}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{domain.domain}
</h2>
<StatusBadge
label={matchType.label}
variant={matchType.variant}
/>
<StatusBadge
label={domain.enabled ? '启用' : '停用'}
variant={domain.enabled ? 'success' : 'warning'}
/>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{domain.remark || '暂无备注'}
</p>
</div>
<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">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
绑定证书
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{certificate
? buildCertificateLabel(certificate)
: '未绑定证书'}
</p>
</div>
</div>
</div>
<div className="flex flex-row gap-2">
<Link
href={`/website/detail?id=${domain.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<DangerButton
type="button"
onClick={() => handleDeleteDomain(domain)}
disabled={deleteDomainMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
</article>
);
})}
</div>
)}
</AppCard>
</div>
{isWebsiteModalOpen ? (
<WebsiteEditorModal
isOpen={isWebsiteModalOpen}
onClose={() => {
setIsWebsiteModalOpen(false);
setPreferredCertificateId(null);
}}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setIsCertificateImportOpen(true)}
onSaved={(_, mode) => {
setPreferredCertificateId(null);
setFeedback({
tone: 'success',
message: mode === 'create' ? '网站已创建。' : '网站已更新。',
});
}}
/>
) : null}
{isCertificateImportOpen ? (
<CertificateImportModal
isOpen={isCertificateImportOpen}
onClose={() => setIsCertificateImportOpen(false)}
onImported={(certificate) => {
setPreferredCertificateId(certificate.id);
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已导入,可直接用于当前网站。`,
});
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,67 @@
import { z } from 'zod';
export const managedDomainSchema = z.object({
domain: z
.string()
.trim()
.min(1, '请输入域名')
.max(255, '域名不能超过 255 个字符')
.refine(
(value) => !value.includes('://') && !value.includes('/'),
'域名格式不合法',
)
.refine(
(value) =>
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
value,
),
'域名格式不合法',
)
.refine(
(value) =>
!value.includes('*') ||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
'通配符域名仅支持 *.example.com 格式',
),
cert_id: z.string(),
enabled: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export const manualImportSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入证书名称')
.max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
export type FileImportFormValues = {
name: string;
remark: string;
};
export const defaultManagedDomainValues: ManagedDomainFormValues = {
domain: '',
cert_id: '',
enabled: true,
remark: '',
};
export const defaultManualImportValues: ManualImportFormValues = {
name: '',
cert_pem: '',
key_pem: '',
remark: '',
};
export const defaultFileImportValues: FileImportFormValues = {
name: '',
remark: '',
};
@@ -0,0 +1,118 @@
import type {
ManagedDomainItem,
ManagedDomainMutationPayload,
} from '@/features/managed-domains/types';
import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from '@/features/tls-certificates/types';
import type {
FileImportFormValues,
ManagedDomainFormValues,
ManualImportFormValues,
} from '@/features/websites/schemas';
import { formatDateTime } from '@/lib/utils/date';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getMatchTypeMeta(domain: string) {
return domain.startsWith('*.')
? { label: '通配符', variant: 'warning' as const }
: { label: '精确匹配', variant: 'info' as const };
}
export function getCertificateStatus(certificate: TlsCertificateItem) {
const expiresAt = new Date(certificate.not_after).getTime();
const diffMs = expiresAt - Date.now();
const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (Number.isNaN(expiresAt)) {
return { label: '有效期未知', variant: 'warning' as const };
}
if (days < 0) {
return { label: '已过期', variant: 'danger' as const };
}
if (days <= 30) {
return { label: `${days} 天内到期`, variant: 'warning' as const };
}
return { label: '有效', variant: 'success' as const };
}
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
: certificate.name;
}
export function toManagedDomainPayload(
values: ManagedDomainFormValues,
): ManagedDomainMutationPayload {
return {
domain: values.domain.trim().toLowerCase(),
cert_id: values.cert_id ? Number(values.cert_id) : null,
enabled: values.enabled,
remark: values.remark.trim(),
};
}
export function toManagedDomainFormValues(
domain: ManagedDomainItem,
): ManagedDomainFormValues {
return {
domain: domain.domain,
cert_id: domain.cert_id ? String(domain.cert_id) : '',
enabled: domain.enabled,
remark: domain.remark || '',
};
}
export function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
return {
name: values.name.trim(),
cert_pem: values.cert_pem.trim(),
key_pem: values.key_pem.trim(),
remark: values.remark.trim(),
};
}
export function toFilePayload(
values: FileImportFormValues,
certFile: File | null,
keyFile: File | null,
): TlsCertificateFileImportPayload {
if (!certFile || !keyFile) {
throw new Error('请选择证书文件和私钥文件。');
}
return {
name: values.name.trim(),
remark: values.remark.trim(),
certFile,
keyFile,
};
}
export function isRouteRelatedToManagedDomain(
managedDomain: string,
routeDomain: string,
) {
if (managedDomain === routeDomain) {
return true;
}
if (!managedDomain.startsWith('*.')) {
return false;
}
const suffix = managedDomain.slice(2);
return routeDomain.endsWith(`.${suffix}`);
}