diff --git a/atsf_server/docs/docs.go b/atsf_server/docs/docs.go
index b89c9179..3757386a 100644
--- a/atsf_server/docs/docs.go
+++ b/atsf_server/docs/docs.go
@@ -1429,8 +1429,8 @@ var SwaggerInfo = &swag.Spec{
Description: "ATSFlare Server 管理端与 Agent API 文档。",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
- LeftDelim: "{{",
- RightDelim: "}}",
+ //LeftDelim: "{{",
+ //RightDelim: "}}",
}
func init() {
diff --git a/atsf_server/web/.eslintrc.json b/atsf_server/web/.eslintrc.json
index 818cad86..5ffd7078 100644
--- a/atsf_server/web/.eslintrc.json
+++ b/atsf_server/web/.eslintrc.json
@@ -3,6 +3,7 @@
"ignorePatterns": [
".next",
"build",
+ "out",
"coverage",
"playwright-report",
"test-results",
diff --git a/atsf_server/web/app/(dashboard)/config-versions/page.tsx b/atsf_server/web/app/(dashboard)/config-versions/page.tsx
index 0922483d..0ced55fd 100644
--- a/atsf_server/web/app/(dashboard)/config-versions/page.tsx
+++ b/atsf_server/web/app/(dashboard)/config-versions/page.tsx
@@ -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 (
-
- );
+export default function ConfigVersionsRoute() {
+ return ;
}
diff --git a/atsf_server/web/app/(dashboard)/nodes/page.tsx b/atsf_server/web/app/(dashboard)/nodes/page.tsx
index ab824c04..9665570b 100644
--- a/atsf_server/web/app/(dashboard)/nodes/page.tsx
+++ b/atsf_server/web/app/(dashboard)/nodes/page.tsx
@@ -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 (
-
- );
+export default function NodesRoute() {
+ return ;
}
diff --git a/atsf_server/web/app/(dashboard)/page.tsx b/atsf_server/web/app/(dashboard)/page.tsx
index 62f329cc..7e1a0bd6 100644
--- a/atsf_server/web/app/(dashboard)/page.tsx
+++ b/atsf_server/web/app/(dashboard)/page.tsx
@@ -5,8 +5,8 @@ export default function DashboardPage() {
return (
diff --git a/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx b/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx
index 5d08b2dc..86758892 100644
--- a/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx
+++ b/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx
@@ -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 (
-
- );
+export default function ProxyRoutesRoute() {
+ return ;
}
diff --git a/atsf_server/web/features/config-versions/api/config-versions.ts b/atsf_server/web/features/config-versions/api/config-versions.ts
new file mode 100644
index 00000000..88f7eb59
--- /dev/null
+++ b/atsf_server/web/features/config-versions/api/config-versions.ts
@@ -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('/config-versions/');
+}
+
+export function getConfigVersionPreview() {
+ return apiRequest('/config-versions/preview');
+}
+
+export function getConfigVersionDiff() {
+ return apiRequest('/config-versions/diff');
+}
+
+export function publishConfigVersion() {
+ return apiRequest('/config-versions/publish', {
+ method: 'POST',
+ });
+}
+
+export function activateConfigVersion(id: number) {
+ return apiRequest(`/config-versions/${id}/activate`, {
+ method: 'PUT',
+ });
+}
diff --git a/atsf_server/web/features/config-versions/components/config-versions-page.tsx b/atsf_server/web/features/config-versions/components/config-versions-page.tsx
new file mode 100644
index 00000000..ce600d52
--- /dev/null
+++ b/atsf_server/web/features/config-versions/components/config-versions-page.tsx
@@ -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 (
+
+
+
{title}
+
0 ? 'info' : 'warning'} />
+
+ {items.length > 0 ? (
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+ ) : (
+
当前无相关变更。
+ )}
+
+ );
+}
+
+function SupportFilesList({ files }: { files: SupportFile[] }) {
+ if (files.length === 0) {
+ return 当前发布不需要额外支持文件。
;
+ }
+
+ return (
+
+ {files.map((file) => (
+
+
+ {file.path}
+
+ {file.content}
+
+ ))}
+
+ );
+}
+
+function VersionDetailCard({
+ version,
+}: {
+ version: ConfigVersionItem;
+}) {
+ return (
+
+ }
+ >
+
+
+
Checksum
+
{version.checksum}
+
+
+
创建人
+
{version.created_by || '系统'}
+
+
+
创建时间
+
{formatDateTime(version.created_at)}
+
+
+
+
+
+
快照 JSON
+
{version.snapshot_json}
+
+
+
渲染结果
+
{version.rendered_config}
+
+
+
+ );
+}
+
+function PublishPreviewCard({
+ preview,
+ diff,
+ isPublishing,
+ onConfirm,
+ onCancel,
+}: {
+ preview: ConfigPreviewResult;
+ diff: ConfigDiffResult;
+ isPublishing: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+ }
+ >
+
+
+
+
当前激活版本
+
{diff.active_version || '无'}
+
+
+
新增域名
+
{diff.added_domains.length}
+
+
+
删除域名
+
{diff.removed_domains.length}
+
+
+
修改域名
+
{diff.modified_domains.length}
+
+
+
+
+
+
+
+
+
+
+
+
渲染结果
+
Checksum:{preview.checksum}
+
+
{preview.rendered_config}
+
+
+
+
+
+
+ {isPublishing ? '发布中...' : '确认发布'}
+
+
+ 取消预览
+
+
+
+
+ );
+}
+
+export function ConfigVersionsPage() {
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [selectedVersion, setSelectedVersion] = useState(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 (
+
+
+
+ {feedback ?
: null}
+
+
+
+ {isPreviewLoading ? '加载预览中...' : '预览并发布'}
+
+ }
+ >
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
+
+ {publishPreview ? (
+
publishMutation.mutate()}
+ onCancel={() => setPublishPreview(null)}
+ />
+ ) : (
+
+
+
1. 使用“预览并发布”查看新增、删除和修改的域名清单。
+
2. 确认渲染出的 Nginx 配置与支持文件内容符合预期。
+
3. 若需要回滚,可在下方历史版本列表中重新激活旧版本。
+
+
+ )}
+
+
+
+ {versionsQuery.isLoading ? (
+
+ ) : versionsQuery.isError ? (
+
+ ) : versions.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 版本号 |
+ 状态 |
+ 创建人 |
+ Checksum |
+ 创建时间 |
+ 操作 |
+
+
+
+ {versions.map((version) => (
+
+ | {version.version} |
+
+
+ |
+ {version.created_by || '系统'} |
+
+ {truncateChecksum(version.checksum)}
+ |
+
+ {formatDateTime(version.created_at)}
+ |
+
+
+ setSelectedVersion(version)}
+ className='px-3 py-2 text-xs'
+ >
+ 查看快照
+
+ {!version.is_active ? (
+ handleActivate(version)}
+ disabled={activateMutation.isPending}
+ className='px-3 py-2 text-xs'
+ >
+ 重新激活
+
+ ) : null}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {selectedVersion ?
: null}
+
+ );
+}
diff --git a/atsf_server/web/features/config-versions/types.ts b/atsf_server/web/features/config-versions/types.ts
new file mode 100644
index 00000000..bac83205
--- /dev/null
+++ b/atsf_server/web/features/config-versions/types.ts
@@ -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[];
+}
diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
index 619e048e..c1c39cdd 100644
--- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
+++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx
@@ -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 (
}
+ title='阶段 3 进行中'
+ description='当前已完成新版管理端基础工程与认证骨架,可继续推进核心业务模块迁移。'
+ action={
}
>
{readinessItems.map((item) => (
@@ -43,7 +43,7 @@ export function DashboardOverview() {
-
+
{dashboardNavigation.slice(1).map((item) => (
-
+
- - 1. 继续完善认证页细节与第三方登录体验。
- - 2. 在业务模块中逐步接入 Query 与 API 资源层。
- - 3. 为主链路页面补齐真实列表、表单与动作反馈。
+ - 1. 继续迁移节点、域名、证书与应用记录页面。
+ - 2. 为核心动作补齐确认、反馈与错误提示的一致体验。
+ - 3. 补充模块测试与静态构建回归验证。
diff --git a/atsf_server/web/features/nodes/api/nodes.ts b/atsf_server/web/features/nodes/api/nodes.ts
new file mode 100644
index 00000000..4fd2090d
--- /dev/null
+++ b/atsf_server/web/features/nodes/api/nodes.ts
@@ -0,0 +1,47 @@
+import { apiRequest } from '@/lib/api/client';
+
+import type {
+ NodeBootstrapToken,
+ NodeItem,
+ NodeMutationPayload,
+} from '@/features/nodes/types';
+
+export function getNodes() {
+ return apiRequest('/nodes/');
+}
+
+export function createNode(payload: NodeMutationPayload) {
+ return apiRequest('/nodes/', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function updateNode(id: number, payload: NodeMutationPayload) {
+ return apiRequest(`/nodes/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function deleteNode(id: number) {
+ return apiRequest(`/nodes/${id}`, {
+ method: 'DELETE',
+ });
+}
+
+export function getNodeBootstrapToken() {
+ return apiRequest('/nodes/bootstrap-token');
+}
+
+export function rotateNodeBootstrapToken() {
+ return apiRequest('/nodes/bootstrap-token/rotate', {
+ method: 'POST',
+ });
+}
+
+export function requestNodeAgentUpdate(id: number) {
+ return apiRequest(`/nodes/${id}/agent-update`, {
+ method: 'POST',
+ });
+}
diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx
new file mode 100644
index 00000000..deca4cfa
--- /dev/null
+++ b/atsf_server/web/features/nodes/components/nodes-page.tsx
@@ -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;
+
+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(null);
+ const [editingNodeId, setEditingNodeId] = useState(null);
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [serverUrl, setServerUrl] = useState('');
+ const [, setRefreshTick] = useState(0);
+
+ const form = useForm({
+ 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 (
+
+
+
+ {feedback ?
: null}
+
+
+
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
+ >
+ 刷新列表
+
+ }
+ >
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {rotateMutation.isPending ? '生成中...' : '重新生成 Token'}
+
+ {discoveryCommand ? (
+ void handleCopy(discoveryCommand, 'Discovery 部署命令已复制。')}
+ >
+ 复制命令
+
+ ) : null}
+
+ }
+ >
+ {bootstrapQuery.isLoading ? (
+
+ ) : bootstrapQuery.isError ? (
+
+ ) : (
+
+
+ setServerUrl(event.target.value)} />
+
+
+
+
+
Discovery Token
+
+ {bootstrapToken || '暂无'}
+
+
+
+ 创建节点后也可以使用节点专属 Token 安装,这种方式适合预分配节点名与更新策略。
+
+
+
+ {discoveryCommand ?
{discoveryCommand} : null}
+
+ )}
+
+
+
void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')}
+ >
+ 复制命令
+
+ ) : null
+ }
+ >
+ {selectedNodeFromList ? (
+
+
+
+
+
{selectedNodeFromList.name}
+
+
+
Node ID:{selectedNodeFromList.node_id}
+
+
+
Agent Token
+
+ {selectedNodeFromList.agent_token || '暂无'}
+
+
+
+
+ {nodeInstallCommand ?
{nodeInstallCommand} : null}
+
+ ) : (
+
+ )}
+
+
+
+
+ {nodesQuery.isLoading ? (
+
+ ) : nodesQuery.isError ? (
+
+ ) : nodes.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 节点 |
+ 状态 |
+ 更新模式 |
+ Agent / Nginx |
+ 当前版本 |
+ 最近应用 |
+ 最近心跳 |
+ 错误 |
+ 操作 |
+
+
+
+ {nodes.map((node) => {
+ const updateMode = getUpdateMode(node);
+
+ return (
+
+
+
+ {node.name}
+ Node ID:{node.node_id}
+
+ Token:{node.agent_token || '暂无'}
+
+ IP:{node.ip || '暂无'}
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+ {node.agent_version || 'unknown'} / {node.nginx_version || 'unknown'}
+ |
+
+ {node.current_version || '未应用'}
+ |
+
+
+
+
+ {node.latest_apply_message || '暂无记录'}
+
+
+ {isMeaningfulTime(node.latest_apply_at)
+ ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
+ : '暂无'}
+
+
+ |
+
+ {isMeaningfulTime(node.last_seen_at)
+ ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
+ : '暂无'}
+ |
+
+
+ {node.last_error || '无'}
+
+ |
+
+
+ updateAgentMutation.mutate(node.id)}
+ disabled={updateAgentMutation.isPending || node.update_requested}
+ className='px-3 py-2 text-xs'
+ >
+ 立即更新
+
+ setSelectedNode(node)}
+ className='px-3 py-2 text-xs'
+ >
+ 部署命令
+
+ handleEdit(node)}
+ className='px-3 py-2 text-xs'
+ >
+ 编辑
+
+ handleDelete(node)}
+ disabled={deleteMutation.isPending}
+ className='px-3 py-2 text-xs'
+ >
+ 删除
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts
new file mode 100644
index 00000000..b6acc69e
--- /dev/null
+++ b/atsf_server/web/features/nodes/types.ts
@@ -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;
+}
diff --git a/atsf_server/web/features/proxy-routes/api/proxy-routes.ts b/atsf_server/web/features/proxy-routes/api/proxy-routes.ts
new file mode 100644
index 00000000..99f9413c
--- /dev/null
+++ b/atsf_server/web/features/proxy-routes/api/proxy-routes.ts
@@ -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
('/proxy-routes/');
+}
+
+export function createProxyRoute(payload: ProxyRouteMutationPayload) {
+ return apiRequest('/proxy-routes/', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) {
+ return apiRequest(`/proxy-routes/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ });
+}
+
+export function deleteProxyRoute(id: number) {
+ return apiRequest(`/proxy-routes/${id}`, {
+ method: 'DELETE',
+ });
+}
+
+export function getTlsCertificates() {
+ return apiRequest('/tls-certificates/');
+}
+
+export function matchManagedDomainCertificate(domain: string) {
+ const searchParams = new URLSearchParams({ domain });
+ return apiRequest(`/managed-domains/match?${searchParams.toString()}`);
+}
diff --git a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
new file mode 100644
index 00000000..e7fea2d0
--- /dev/null
+++ b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
@@ -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;
+
+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(null);
+ const [editingRouteId, setEditingRouteId] = useState(null);
+ const [matchResult, setMatchResult] = useState(null);
+ const [isMatching, setIsMatching] = useState(false);
+
+ const form = useForm({
+ 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 (
+
+
+
+ {feedback ?
: null}
+
+
+
+
+
+
+
+
publishMutation.mutate()} disabled={publishMutation.isPending}>
+ {publishMutation.isPending ? '发布中...' : '发布当前规则'}
+
+ }
+ >
+
+ {summary.map((item) => (
+
+
+ {item.label}
+
+
{item.value}
+
+ ))}
+
+
+
+
+
+
+ {getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
+
+ {matchResult?.matched && matchResult.candidates.length > 1 ? (
+
+ {matchResult.candidates.map((candidate) => (
+
+ ))}
+
+ ) : null}
+
+ 当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
+
+
+
+
+
+
+
+ {routesQuery.isLoading ? (
+
+ ) : routesQuery.isError ? (
+
+ ) : routes.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 域名 |
+ 源站地址 |
+ HTTPS |
+ 请求头 |
+ 状态 |
+ 备注 |
+ 更新时间 |
+ 操作 |
+
+
+
+ {routes.map((route) => {
+ const headers = parseCustomHeaders(route.custom_headers);
+ const certificate = route.cert_id ? certificateMap.get(route.cert_id) : null;
+
+ return (
+
+ | {route.domain} |
+ {route.origin_url} |
+
+ {route.enable_https ? (
+
+
+
+ {certificate ? certificate.name : '证书未加载'}
+
+
+ ) : (
+
+ )}
+ |
+
+ 0 ? `${headers.length} 条` : '无'}
+ variant={headers.length > 0 ? 'success' : 'warning'}
+ />
+ |
+
+
+ |
+ {route.remark || '—'} |
+
+ {formatDateTime(route.updated_at)}
+ |
+
+
+ handleEdit(route)}
+ className='px-3 py-2 text-xs'
+ >
+ 编辑
+
+ handleDelete(route)}
+ disabled={deleteMutation.isPending}
+ className='px-3 py-2 text-xs'
+ >
+ 删除
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/atsf_server/web/features/proxy-routes/types.ts b/atsf_server/web/features/proxy-routes/types.ts
new file mode 100644
index 00000000..c08d4a14
--- /dev/null
+++ b/atsf_server/web/features/proxy-routes/types.ts
@@ -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[];
+}
diff --git a/atsf_server/web/features/shared/components/resource-primitives.tsx b/atsf_server/web/features/shared/components/resource-primitives.tsx
new file mode 100644
index 00000000..777f766c
--- /dev/null
+++ b/atsf_server/web/features/shared/components/resource-primitives.tsx
@@ -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 (
+
+ );
+}
+
+export function ResourceInput(props: InputHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function ResourceTextarea(props: TextareaHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function ResourceSelect(props: SelectHTMLAttributes) {
+ return (
+
+ );
+}
+
+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) {
+ return (
+
+ );
+}
+
+export function SecondaryButton({
+ className,
+ ...props
+}: ButtonHTMLAttributes) {
+ return (
+
+ );
+}
+
+export function DangerButton({
+ className,
+ ...props
+}: ButtonHTMLAttributes) {
+ return (
+
+ );
+}
+
+interface ToggleFieldProps {
+ label: string;
+ description: string;
+ checked: boolean;
+ disabled?: boolean;
+ onChange: (checked: boolean) => void;
+}
+
+export function ToggleField({
+ label,
+ description,
+ checked,
+ disabled,
+ onChange,
+}: ToggleFieldProps) {
+ return (
+
+ );
+}
+
+export function CodeBlock({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts
index 8ba79b7e..7e280fd0 100644
--- a/atsf_server/web/lib/constants/navigation.ts
+++ b/atsf_server/web/lib/constants/navigation.ts
@@ -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',
diff --git a/atsf_server/web/lib/utils/date.ts b/atsf_server/web/lib/utils/date.ts
new file mode 100644
index 00000000..78b1c148
--- /dev/null
+++ b/atsf_server/web/lib/utils/date.ts
@@ -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');
+}