[重构] 前端改造v3

This commit is contained in:
ryan
2026-03-11 14:53:45 +08:00
parent 5e963dc472
commit 13cc880f67
19 changed files with 2253 additions and 44 deletions
+2 -2
View File
@@ -1429,8 +1429,8 @@ var SwaggerInfo = &swag.Spec{
Description: "ATSFlare Server 管理端与 Agent API 文档。",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
//LeftDelim: "{{",
//RightDelim: "}}",
}
func init() {
+1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [
".next",
"build",
"out",
"coverage",
"playwright-report",
"test-results",
@@ -1,10 +1,5 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page';
export default function ConfigVersionsPage() {
return (
<ModulePageSkeleton
title='配置版本'
description='阶段 3 将在此接入版本列表、diff 预览、激活与回滚流程。'
/>
);
export default function ConfigVersionsRoute() {
return <ConfigVersionsPage />;
}
@@ -1,10 +1,5 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
import { NodesPage } from '@/features/nodes/components/nodes-page';
export default function NodesPage() {
return (
<ModulePageSkeleton
title='节点管理'
description='阶段 3 将在此接入节点状态标签、部署命令、心跳与更新动作。'
/>
);
export default function NodesRoute() {
return <NodesPage />;
}
+2 -2
View File
@@ -5,8 +5,8 @@ export default function DashboardPage() {
return (
<div className='space-y-6'>
<PageHeader
title='ATSFlare 管理端工程初始化'
description='新版前端已切换到 Next.js 工程骨架,当前页面用于展示阶段 1 交付结果与模块入口。'
title='ATSFlare 管理端迁移进度'
description='新版前端已完成认证与框架层迁移,当前进入阶段 3,优先接入核心业务模块。'
/>
<DashboardOverview />
</div>
@@ -1,10 +1,5 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
export default function ProxyRoutesPage() {
return (
<ModulePageSkeleton
title='反代规则'
description='阶段 3 将在此接入规则列表、创建编辑抽屉与发布动作。'
/>
);
export default function ProxyRoutesRoute() {
return <ProxyRoutesPage />;
}
@@ -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,417 @@
'use client';
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 {
activateConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import type {
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 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 VersionDetailCard({
version,
}: {
version: ConfigVersionItem;
}) {
return (
<AppCard
title={`版本 ${version.version}`}
description='查看当前版本保存的快照 JSON 与最终渲染配置,便于比对发布结果。'
action={
<StatusBadge label={version.is_active ? '当前激活' : '历史版本'} variant={version.is_active ? 'success' : 'info'} />
}
>
<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)]'>Checksum</p>
<p className='mt-2 break-all text-sm 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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建人</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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建时间</p>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{formatDateTime(version.created_at)}</p>
</div>
</div>
<div className='mt-5 space-y-4'>
<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-[32rem] whitespace-pre-wrap'>{version.rendered_config}</CodeBlock>
</div>
</div>
</AppCard>
);
}
function PublishPreviewCard({
preview,
diff,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>当前激活版本</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 uppercase tracking-[0.2em] text-[var(--status-success-foreground)]'>新增域名</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 uppercase tracking-[0.2em] text-[var(--status-warning-foreground)]'>删除域名</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 uppercase tracking-[0.2em] text-[var(--status-info-foreground)]'>修改域名</p>
<p className='mt-2 text-lg font-semibold text-[var(--status-info-foreground)]'>{diff.modified_domains.length}</p>
</div>
</div>
<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>
<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.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}>
{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 [selectedVersion, setSelectedVersion] = useState<ConfigVersionItem | 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 publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` });
setPublishPreview(null);
setSelectedVersion(version);
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}` });
setSelectedVersion(version);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const summary = useMemo(() => {
const versions = versionsQuery.data || [];
const activeCount = versions.filter((item) => item.is_active).length;
return [
{ label: '版本总数', value: versions.length },
{ label: '激活版本', value: activeCount },
{
label: '最近创建时间',
value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—',
},
];
}, [versionsQuery.data]);
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);
};
const versions = versionsQuery.data || [];
return (
<div className='space-y-6'>
<PageHeader
title='配置版本'
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。'
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[0.8fr_1.2fr]'>
<AppCard
title='版本摘要'
description='阶段 3 已接入发布前预览、历史版本明细与激活动作。'
action={
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
>
<div className='grid gap-4 md:grid-cols-3 xl:grid-cols-1'>
{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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-sm font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
/>
) : (
<AppCard title='发布建议' description='建议先查看变更摘要,再决定是否发布为新的激活版本。'>
<div className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<p>1. 使用“预览并发布”查看新增、删除和修改的域名清单。</p>
<p>2. 确认渲染出的 Nginx 配置与支持文件内容符合预期。</p>
<p>3. 若需要回滚,可在下方历史版本列表中重新激活旧版本。</p>
</div>
</AppCard>
)}
</div>
<AppCard title='历史版本' description='支持查看快照详情,并在必要时将旧版本重新设为当前激活版本。'>
{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={() => setSelectedVersion(version)}
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>
{selectedVersion ? <VersionDetailCard version={selectedVersion} /> : null}
</div>
);
}
@@ -0,0 +1,31 @@
export interface ConfigVersionItem {
id: number;
version: string;
snapshot_json: 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;
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[];
}
@@ -7,15 +7,15 @@ import { dashboardNavigation } from '@/lib/constants/navigation';
const readinessItems = [
{
title: '工程底座',
description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已建立。',
description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已稳定运行。',
},
{
title: '质量工具',
description: 'ESLint、Prettier、Vitest、Playwright 配置已就位,可继续补充模块测试。',
title: '认证骨架',
description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。',
},
{
title: '目录分层',
description: '已拆分 app、components、features、lib、store、tests 等基础结构。',
title: '核心模块',
description: '阶段 3 已接入反代规则、配置版本与节点页面,其余核心模块可继续按优先级接入。',
},
];
@@ -23,9 +23,9 @@ export function DashboardOverview() {
return (
<div className='space-y-6'>
<AppCard
title='阶段 1 已启动'
description='当前已完成新版管理端基础工程初始化,可继续推进认证迁移与框架层骨架。'
action={<StatusBadge label='可继续阶段 2' variant='success' />}
title='阶段 3 进行中'
description='当前已完成新版管理端基础工程与认证骨架,可继续推进核心业务模块迁移。'
action={<StatusBadge label='继续迁移主链路' variant='success' />}
>
<div className='grid gap-4 lg:grid-cols-3'>
{readinessItems.map((item) => (
@@ -43,7 +43,7 @@ export function DashboardOverview() {
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
<AppCard title='模块入口' description='各业务页面已建立路由占位,后续按阶段逐步接入真实数据与交互。'>
<AppCard title='模块入口' description='核心页面已开始接入真实数据与交互,其余模块按阶段逐步替换占位页面。'>
<div className='grid gap-3 md:grid-cols-2'>
{dashboardNavigation.slice(1).map((item) => (
<Link
@@ -60,11 +60,11 @@ export function DashboardOverview() {
</div>
</AppCard>
<AppCard title='下一步建议' description='按前端改造计划,后续优先进入认证与框架层迁移。'>
<AppCard title='下一步建议' description='按前端改造计划,阶段 3 后续优先补齐其余核心主链路页面。'>
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<li>1. 继续完善认证页细节与第三方登录体验。</li>
<li>2. 在业务模块中逐步接入 Query 与 API 资源层。</li>
<li>3. 为主链路页面补齐真实列表、表单与动作反馈。</li>
<li>1. 继续迁移节点、域名、证书与应用记录页面。</li>
<li>2. 为核心动作补齐确认、反馈与错误提示的一致体验。</li>
<li>3. 补充模块测试与静态构建回归验证。</li>
</ol>
</AppCard>
</div>
@@ -0,0 +1,47 @@
import { apiRequest } from '@/lib/api/client';
import type {
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
} from '@/features/nodes/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 requestNodeAgentUpdate(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
method: 'POST',
});
}
@@ -0,0 +1,633 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { 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 { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createNode,
deleteNode,
getNodeBootstrapToken,
getNodes,
requestNodeAgentUpdate,
rotateNodeBootstrapToken,
updateNode,
} from '@/features/nodes/api/nodes';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
import {
CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const nodesQueryKey = ['nodes'];
const bootstrapQueryKey = ['nodes', 'bootstrap-token'];
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
const nodeSchema = z.object({
name: z.string().trim().min(1, '请输入节点名').max(128, '节点名不能超过 128 个字符'),
auto_update_enabled: z.boolean(),
});
type NodeFormValues = z.infer<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(values: NodeFormValues): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
};
}
function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
function getStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
function getStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动更新', variant: 'success' as const };
}
return { label: '手动更新', variant: 'info' as const };
}
function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildDiscoveryInstallCommand(serverUrl: string, discoveryToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --discovery-token ${discoveryToken}`,
].join('\n');
}
function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function NodesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [, setRefreshTick] = useState(0);
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
});
const bootstrapQuery = useQuery({
queryKey: bootstrapQueryKey,
queryFn: getNodeBootstrapToken,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
useEffect(() => {
const timer = window.setInterval(() => {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey });
setRefreshTick((value) => value + 1);
}, 30000);
return () => {
window.clearInterval(timer);
};
}, [queryClient]);
const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) => {
const payload = toPayload(values);
return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload);
},
onSuccess: async (node) => {
setFeedback({
tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。',
});
setEditingNodeId(null);
setSelectedNode(node);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const rotateMutation = useMutation({
mutationFn: rotateNodeBootstrapToken,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '全局 Discovery Token 已重新生成。' });
await queryClient.invalidateQueries({ queryKey: bootstrapQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const updateAgentMutation = useMutation({
mutationFn: requestNodeAgentUpdate,
onSuccess: async (node) => {
setFeedback({ tone: 'success', message: `已向节点 ${node.name} 下发更新指令。` });
setSelectedNode(node);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteNode,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNodeId(null);
setSelectedNode(null);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const bootstrapToken = bootstrapQuery.data?.discovery_token ?? '';
const normalizedServerUrl = getServerUrl(serverUrl);
const selectedNodeFromList = useMemo(() => {
if (!selectedNode) {
return null;
}
return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode;
}, [nodes, selectedNode]);
const summary = useMemo(() => {
return [
{ label: '节点总数', value: nodes.length },
{ label: '在线节点', value: nodes.filter((item) => item.status === 'online').length },
{ label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length },
{ label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length },
];
}, [nodes]);
const handleReset = () => {
setFeedback(null);
setEditingNodeId(null);
form.reset(defaultValues);
};
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNodeId(node.id);
setSelectedNode(node);
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
};
const handleDelete = (node: NodeItem) => {
if (
!window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(node.id);
};
const handleRotateToken = () => {
if (!window.confirm('确认重新生成全局 Discovery Token 吗?现有批量部署命令需要同步更新。')) {
return;
}
setFeedback(null);
rotateMutation.mutate();
};
const handleCopy = async (value: string, successMessage: string) => {
try {
await copyToClipboard(value);
setFeedback({ tone: 'success', message: successMessage });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const discoveryCommand =
normalizedServerUrl && bootstrapToken
? buildDiscoveryInstallCommand(normalizedServerUrl, bootstrapToken)
: '';
const nodeInstallCommand =
normalizedServerUrl && selectedNodeFromList?.agent_token
? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token)
: '';
return (
<div className='space-y-6'>
<PageHeader
title='节点管理'
description='查看节点在线状态、最近心跳、部署命令与 Agent 更新动作,并支持预创建节点。'
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
<AppCard
title='节点摘要'
description='阶段 3 已接入状态标签、部署命令、自动更新开关与手动更新动作。'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
>
刷新列表
</SecondaryButton>
}
>
<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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<AppCard
title='创建或编辑节点'
description='预创建节点后会立即生成节点专属 Token,可直接复制专属安装命令。'
>
<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>
<ToggleField
label='启用自动更新'
description='开启后 Agent 心跳返回会提示节点自动执行自更新。'
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
<div className='flex flex-wrap gap-3'>
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'}
</PrimaryButton>
{editingNodeId ? (
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消编辑
</SecondaryButton>
) : null}
</div>
</form>
</AppCard>
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<AppCard
title='Discovery Token 部署'
description='适用于新节点首次接入,Agent 会自动注册并换取节点专属 Token。'
action={
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={handleRotateToken} disabled={rotateMutation.isPending}>
{rotateMutation.isPending ? '生成中...' : '重新生成 Token'}
</SecondaryButton>
{discoveryCommand ? (
<PrimaryButton
type='button'
onClick={() => void handleCopy(discoveryCommand, 'Discovery 部署命令已复制。')}
>
复制命令
</PrimaryButton>
) : null}
</div>
}
>
{bootstrapQuery.isLoading ? (
<LoadingState />
) : bootstrapQuery.isError ? (
<ErrorState title='Discovery Token 加载失败' description={getErrorMessage(bootstrapQuery.error)} />
) : (
<div className='space-y-4'>
<ResourceField label='Server URL' hint='默认使用当前管理端来源,可按需改为对外访问地址。'>
<ResourceInput value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
</ResourceField>
<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)]'>Discovery Token</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{bootstrapToken || '暂无'}
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
创建节点后也可以使用节点专属 Token 安装,这种方式适合预分配节点名与更新策略。
</div>
</div>
{discoveryCommand ? <CodeBlock className='whitespace-pre-wrap'>{discoveryCommand}</CodeBlock> : null}
</div>
)}
</AppCard>
<AppCard
title='节点专属部署命令'
description='适用于已在管理端预创建节点的场景,可避免首次接入时匿名自动命名。'
action={
nodeInstallCommand ? (
<PrimaryButton
type='button'
onClick={() => void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')}
>
复制命令
</PrimaryButton>
) : null
}
>
{selectedNodeFromList ? (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<div className='flex items-center justify-between gap-3'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{selectedNodeFromList.name}</p>
<StatusBadge
label={selectedNodeFromList.pending ? '未占用' : '已绑定'}
variant={selectedNodeFromList.pending ? 'warning' : 'success'}
/>
</div>
<p className='mt-2 text-xs text-[var(--foreground-secondary)]'>Node ID:{selectedNodeFromList.node_id}</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)]'>Agent Token</p>
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
{selectedNodeFromList.agent_token || '暂无'}
</p>
</div>
</div>
{nodeInstallCommand ? <CodeBlock className='whitespace-pre-wrap'>{nodeInstallCommand}</CodeBlock> : null}
</div>
) : (
<EmptyState title='尚未选择节点' description='可在下方节点列表中点击“部署命令”或创建节点后自动查看专属命令。' />
)}
</AppCard>
</div>
<AppCard title='节点列表' description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'>
{nodesQuery.isLoading ? (
<LoadingState />
) : nodesQuery.isError ? (
<ErrorState title='节点列表加载失败' description={getErrorMessage(nodesQuery.error)} />
) : nodes.length === 0 ? (
<EmptyState title='暂无节点' description='请先创建一个节点,或使用 Discovery Token 在目标机器上接入新节点。' />
) : (
<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'>Agent / Nginx</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)]'>
{nodes.map((node) => {
const updateMode = getUpdateMode(node);
return (
<tr key={node.id} className='align-top'>
<td className='px-3 py-4'>
<div className='space-y-2'>
<p className='font-medium text-[var(--foreground-primary)]'>{node.name}</p>
<p className='text-xs text-[var(--foreground-secondary)]'>Node ID:{node.node_id}</p>
<p className='break-all text-xs text-[var(--foreground-secondary)]'>
Token:{node.agent_token || '暂无'}
</p>
<p className='text-xs text-[var(--foreground-secondary)]'>IP:{node.ip || '暂无'}</p>
</div>
</td>
<td className='px-3 py-4'>
<div className='space-y-2'>
<StatusBadge label={getStatusLabel(node.status)} variant={getStatusVariant(node.status)} />
<StatusBadge
label={node.pending ? '未占用' : '已绑定'}
variant={node.pending ? 'warning' : 'success'}
/>
</div>
</td>
<td className='px-3 py-4'>
<StatusBadge label={updateMode.label} variant={updateMode.variant} />
</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 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)}
/>
<p className='max-w-56 text-xs leading-5 text-[var(--foreground-secondary)]'>
{node.latest_apply_message || '暂无记录'}
</p>
<p className='text-xs text-[var(--foreground-secondary)]'>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</p>
</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 text-[var(--foreground-secondary)]'>
<p className='max-w-56 whitespace-pre-wrap break-words'>
{node.last_error || '无'}
</p>
</td>
<td className='px-3 py-4'>
<div className='flex flex-wrap gap-2'>
<PrimaryButton
type='button'
onClick={() => updateAgentMutation.mutate(node.id)}
disabled={updateAgentMutation.isPending || node.update_requested}
className='px-3 py-2 text-xs'
>
立即更新
</PrimaryButton>
<SecondaryButton
type='button'
onClick={() => setSelectedNode(node)}
className='px-3 py-2 text-xs'
>
部署命令
</SecondaryButton>
<SecondaryButton
type='button'
onClick={() => handleEdit(node)}
className='px-3 py-2 text-xs'
>
编辑
</SecondaryButton>
<DangerButton
type='button'
onClick={() => handleDelete(node)}
disabled={deleteMutation.isPending}
className='px-3 py-2 text-xs'
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
export interface NodeItem {
id: number;
node_id: string;
name: string;
ip: string;
agent_token: string;
pending: boolean;
auto_update_enabled: boolean;
update_requested: boolean;
agent_version: string;
nginx_version: 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_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
name: string;
auto_update_enabled: boolean;
}
@@ -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,693 @@
'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 { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { 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'];
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 [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);
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: (version) => {
setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` });
},
onError: (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 certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const summary = useMemo(() => {
const routes = routesQuery.data || [];
return [
{ label: '规则总数', value: routes.length },
{ label: '已启用', value: routes.filter((item) => item.enabled).length },
{ label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length },
{
label: '自定义请求头',
value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0),
},
];
}, [routesQuery.data]);
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
setMatchResult(null);
form.reset(defaultValues);
};
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));
};
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 证书绑定与自定义请求头,并可直接触发配置发布。'
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.7fr]'>
<AppCard
title={editingRouteId ? '编辑规则' : '新增规则'}
description='新增或修改反代规则后,可直接在当前页面完成发布。'
>
<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>
<ResourceField
label='备注'
hint='可选,便于标记用途、责任人或特殊说明。'
error={form.formState.errors.remark?.message}
>
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} />
</ResourceField>
<div className='flex flex-wrap gap-3'>
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
</PrimaryButton>
{editingRouteId ? (
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消编辑
</SecondaryButton>
) : null}
</div>
</form>
</AppCard>
<div className='space-y-6'>
<AppCard
title='发布与摘要'
description='规则变更完成后,可直接发布为新的配置版本。'
action={
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
}
>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-1'>
{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 uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<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>
</div>
</div>
<AppCard title='规则列表' description='支持查看启用状态、HTTPS 配置、自定义请求头数量及最近更新时间。'>
{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);
const certificate = route.cert_id ? certificateMap.get(route.cert_id) : null;
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'
/>
<p className='text-xs text-[var(--foreground-secondary)]'>
{certificate ? certificate.name : '证书未加载'}
</p>
</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>
);
}
@@ -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,179 @@
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;
children: ReactNode;
}
export function ResourceField({
label,
hint,
error,
className,
children,
}: ResourceFieldProps) {
return (
<label className={cn('block space-y-2', className)}>
<span className='text-sm font-medium text-[var(--foreground-primary)]'>{label}</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)] outline-none transition 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)] outline-none transition 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)] outline-none transition 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;
onChange: (checked: boolean) => void;
}
export function ToggleField({
label,
description,
checked,
disabled,
onChange,
}: ToggleFieldProps) {
return (
<label className='flex 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='space-y-1'>
<span className='block text-sm font-medium text-[var(--foreground-primary)]'>{label}</span>
<span className='block text-xs leading-5 text-[var(--foreground-secondary)]'>{description}</span>
</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>
);
}
+3 -3
View File
@@ -11,19 +11,19 @@ export const dashboardNavigation: NavigationItem[] = [
href: '/proxy-routes',
label: '反代规则',
shortLabel: '规则',
description: '阶段 3 接入列表、表单与发布动作。',
description: '已接入列表、表单、证书匹配与发布动作。',
},
{
href: '/config-versions',
label: '配置版本',
shortLabel: '版本',
description: '阶段 3 接入版本预览、激活与回滚体验。',
description: '已接入版本预览、发布前 diff 与重新激活。',
},
{
href: '/nodes',
label: '节点管理',
shortLabel: '节点',
description: '阶段 3 接入状态标签、部署命令与更新动作。',
description: '已接入状态标签、部署命令、心跳与更新动作。',
},
{
href: '/apply-logs',
+71
View File
@@ -0,0 +1,71 @@
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', {
numeric: 'auto',
});
function toDate(value: string | Date | null | undefined) {
if (!value) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateTime(value: string | Date | null | undefined) {
const date = toDate(value);
if (!date) {
return '—';
}
return dateTimeFormatter.format(date).replace(/\//g, '-');
}
export function formatRelativeTime(value: string | Date | null | undefined) {
const date = toDate(value);
if (!date) {
return '—';
}
const diffMs = date.getTime() - Date.now();
const diffSeconds = Math.round(diffMs / 1000);
const absSeconds = Math.abs(diffSeconds);
if (absSeconds < 60) {
return relativeTimeFormatter.format(diffSeconds, 'second');
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, 'minute');
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, 'hour');
}
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 30) {
return relativeTimeFormatter.format(diffDays, 'day');
}
const diffMonths = Math.round(diffDays / 30);
if (Math.abs(diffMonths) < 12) {
return relativeTimeFormatter.format(diffMonths, 'month');
}
const diffYears = Math.round(diffMonths / 12);
return relativeTimeFormatter.format(diffYears, 'year');
}