mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
[重构] 前端改造v3
This commit is contained in:
@@ -1429,8 +1429,8 @@ var SwaggerInfo = &swag.Spec{
|
||||
Description: "ATSFlare Server 管理端与 Agent API 文档。",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
//LeftDelim: "{{",
|
||||
//RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user