diff --git a/atsf_server/router/api_phase1_test.go b/atsf_server/router/api_phase1_test.go
index 72432171..1d7ed6cb 100644
--- a/atsf_server/router/api_phase1_test.go
+++ b/atsf_server/router/api_phase1_test.go
@@ -73,6 +73,24 @@ func TestPhase1PublishLifecycle(t *testing.T) {
t.Fatal("expected published version to contain snapshot, rendered config and checksum")
}
+ repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil)
+ repeatPublishReq.Header.Set("Authorization", "Bearer "+token)
+ repeatPublishRecorder := httptest.NewRecorder()
+ engine.ServeHTTP(repeatPublishRecorder, repeatPublishReq)
+ if repeatPublishRecorder.Code != http.StatusOK {
+ t.Fatalf("unexpected status %d for repeated publish: %s", repeatPublishRecorder.Code, repeatPublishRecorder.Body.String())
+ }
+ var repeatPublishResp apiResponse
+ if err := json.Unmarshal(repeatPublishRecorder.Body.Bytes(), &repeatPublishResp); err != nil {
+ t.Fatalf("failed to unmarshal repeated publish response: %v", err)
+ }
+ if repeatPublishResp.Success {
+ t.Fatal("expected repeated publish without route changes to be rejected")
+ }
+ if !strings.Contains(repeatPublishResp.Message, "当前规则没有变更") {
+ t.Fatalf("unexpected repeated publish message: %s", repeatPublishResp.Message)
+ }
+
initialSnapshot := version1.SnapshotJSON
initialRendered := version1.RenderedConfig
diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go
index fc3e722f..298d4323 100644
--- a/atsf_server/router/api_phase2_test.go
+++ b/atsf_server/router/api_phase2_test.go
@@ -7,14 +7,15 @@ import (
"atsflare/service"
"bytes"
"encoding/json"
- "github.com/gin-contrib/sessions"
- "github.com/gin-contrib/sessions/cookie"
- "github.com/gin-gonic/gin"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-contrib/sessions/cookie"
+ "github.com/gin-gonic/gin"
)
func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
@@ -180,8 +181,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
})
var createdNode service.NodeView
decodeResponseData(t, createdNodeResp, &createdNode)
- if createdNode.AgentToken == "" || !createdNode.Pending {
- t.Fatal("expected created node to expose agent token while pending")
+ if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending {
+ t.Fatal("expected created node to expose agent token with pending status")
}
heartbeatPayload := map[string]any{
@@ -237,8 +238,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
- if nodes[0].Pending {
- t.Fatal("expected registered node to clear pending state")
+ if nodes[0].Status != service.NodeStatusOnline {
+ t.Fatal("expected registered node to become online")
}
if nodes[0].AgentToken != createdNode.AgentToken {
t.Fatal("expected node auth token to remain stable after occupancy")
@@ -394,7 +395,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
if len(nodes) != 1 {
t.Fatalf("expected 1 discovered node, got %d", len(nodes))
}
- if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Pending {
+ if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
t.Fatal("expected discovered node to be created online with issued agent token")
}
}
diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go
index f2e12fe2..2ea8562a 100644
--- a/atsf_server/service/agent.go
+++ b/atsf_server/service/agent.go
@@ -63,7 +63,6 @@ type NodeView struct {
Name string `json:"name"`
IP string `json:"ip"`
AgentToken string `json:"agent_token"`
- Pending bool `json:"pending"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
AgentVersion string `json:"agent_version"`
diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go
index f0601a9d..6402bdf8 100644
--- a/atsf_server/service/config_version.go
+++ b/atsf_server/service/config_version.go
@@ -137,6 +137,21 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
return result, nil
}
+func HasConfigChanges() (bool, error) {
+ bundle, err := buildCurrentConfigBundle(false)
+ if err != nil {
+ return false, err
+ }
+ activeVersion, err := model.GetActiveConfigVersion()
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return len(bundle.Routes) > 0, nil
+ }
+ return false, err
+ }
+ return activeVersion.Checksum != bundle.Checksum, nil
+}
+
func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
bundle, err := buildCurrentConfigBundle(true)
if err != nil {
@@ -145,6 +160,13 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
if len(bundle.Routes) == 0 {
return nil, errors.New("没有可发布的启用规则")
}
+ activeVersion, err := model.GetActiveConfigVersion()
+ if err == nil && activeVersion.Checksum == bundle.Checksum {
+ return nil, errors.New("当前规则没有变更,不能重复发布")
+ }
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, err
+ }
supportFilesJSON, err := json.Marshal(bundle.SupportFiles)
if err != nil {
return nil, err
diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go
index 77b59fac..a80a9619 100644
--- a/atsf_server/service/node.go
+++ b/atsf_server/service/node.go
@@ -178,7 +178,6 @@ func buildNodeView(node *model.Node) *NodeView {
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
- Pending: status == NodeStatusPending,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
}
diff --git a/atsf_server/web/app/(dashboard)/node/detail/page.tsx b/atsf_server/web/app/(dashboard)/node/detail/page.tsx
new file mode 100644
index 00000000..05dc2aff
--- /dev/null
+++ b/atsf_server/web/app/(dashboard)/node/detail/page.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+
+import { NodeDetailPage } from '@/features/nodes/components/node-detail-page';
+
+export default function NodeDetailRoute() {
+ const searchParams = useSearchParams();
+
+ return ;
+}
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
index 2fef4983..95c63915 100644
--- a/atsf_server/web/features/config-versions/components/config-versions-page.tsx
+++ b/atsf_server/web/features/config-versions/components/config-versions-page.tsx
@@ -1,376 +1,531 @@
-'use client';
-
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useState } from 'react';
-
-import { EmptyState } from '@/components/feedback/empty-state';
-import { ErrorState } from '@/components/feedback/error-state';
-import { InlineMessage } from '@/components/feedback/inline-message';
-import { LoadingState } from '@/components/feedback/loading-state';
-import { 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 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 (
-
-
- {isPreviewLoading ? '加载预览中...' : '预览并发布'}
-
- }
- />
-
- {feedback ? : null}
-
-
- {publishPreview ? (
-
publishMutation.mutate()}
- onCancel={() => setPublishPreview(null)}
- />
- ) : null}
-
-
-
- {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}
-
- );
-}
+'use client';
+
+import { useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { EmptyState } from '@/components/feedback/empty-state';
+import { ErrorState } from '@/components/feedback/error-state';
+import { InlineMessage } from '@/components/feedback/inline-message';
+import { LoadingState } from '@/components/feedback/loading-state';
+import { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
+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 hasConfigDiff(diff: ConfigDiffResult) {
+ return (
+ diff.added_domains.length > 0 ||
+ diff.removed_domains.length > 0 ||
+ diff.modified_domains.length > 0 ||
+ !diff.active_version
+ );
+}
+
+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 SnapshotModal({
+ version,
+ onClose,
+}: {
+ version: ConfigVersionItem | null;
+ onClose: () => void;
+}) {
+ return (
+
+
+ 关闭
+
+
+ }
+ >
+ {version ? (
+
+
+
+
+ Checksum
+
+
+ {version.checksum}
+
+
+
+
+ 创建人
+
+
+ {version.created_by || '系统'}
+
+
+
+
+ 创建时间
+
+
+ {formatDateTime(version.created_at)}
+
+
+
+
+
+
+ 快照 JSON
+
+
+ {version.snapshot_json}
+
+
+
+
+ 渲染结果
+
+
+ {version.rendered_config}
+
+
+
+ ) : null}
+
+ );
+}
+
+function PublishPreviewCard({
+ preview,
+ diff,
+ isPublishing,
+ onConfirm,
+ onCancel,
+}: {
+ preview: ConfigPreviewResult;
+ diff: ConfigDiffResult;
+ isPublishing: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
+
+ return (
+
+ }
+ >
+
+
+
+
+ 当前激活版本
+
+
+ {diff.active_version || '无'}
+
+
+
+
+ 新增域名
+
+
+ {diff.added_domains.length}
+
+
+
+
+ 删除域名
+
+
+ {diff.removed_domains.length}
+
+
+
+
+ 修改域名
+
+
+ {diff.modified_domains.length}
+
+
+
+
+ {!canPublish ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ 渲染结果
+
+
+ Checksum:{preview.checksum}
+
+
+
+ {preview.rendered_config}
+
+
+
+
+
+
+
+ {isPublishing ? '发布中...' : '确认发布'}
+
+
+ 取消预览
+
+
+
+
+ );
+}
+
+export function ConfigVersionsPage() {
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [selectedVersionId, setSelectedVersionId] = 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 versions = useMemo(
+ () => versionsQuery.data ?? [],
+ [versionsQuery.data],
+ );
+ const selectedVersion = useMemo(
+ () => versions.find((item) => item.id === selectedVersionId) ?? null,
+ [selectedVersionId, versions],
+ );
+
+ const publishMutation = useMutation({
+ mutationFn: publishConfigVersion,
+ onSuccess: async (version) => {
+ setFeedback({
+ tone: 'success',
+ message: `发布成功,版本 ${version.version}`,
+ });
+ setPublishPreview(null);
+ setSelectedVersionId(version.id);
+ await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const activateMutation = useMutation({
+ mutationFn: activateConfigVersion,
+ onSuccess: async (version) => {
+ setFeedback({
+ tone: 'success',
+ message: `已激活版本 ${version.version}`,
+ });
+ setSelectedVersionId(version.id);
+ await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const handleOpenPublishPreview = async () => {
+ setFeedback(null);
+ setIsPreviewLoading(true);
+
+ try {
+ const [preview, diff] = await Promise.all([
+ getConfigVersionPreview(),
+ getConfigVersionDiff(),
+ ]);
+ setPublishPreview({ preview, diff });
+ } catch (error) {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ } finally {
+ setIsPreviewLoading(false);
+ }
+ };
+
+ const handleActivate = (version: ConfigVersionItem) => {
+ if (version.is_active) {
+ return;
+ }
+
+ if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
+ return;
+ }
+
+ setFeedback(null);
+ activateMutation.mutate(version.id);
+ };
+
+ return (
+ <>
+
+
+ {isPreviewLoading ? '加载预览中...' : '预览并发布'}
+
+ }
+ />
+
+ {feedback ? (
+
+ ) : null}
+
+ {publishPreview ? (
+ publishMutation.mutate()}
+ onCancel={() => setPublishPreview(null)}
+ />
+ ) : null}
+
+
+ void queryClient.invalidateQueries({
+ queryKey: versionsQueryKey,
+ })
+ }
+ >
+ 刷新列表
+
+ }
+ >
+ {versionsQuery.isLoading ? (
+
+ ) : versionsQuery.isError ? (
+
+ ) : versions.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 版本号 |
+ 状态 |
+ 创建人 |
+ Checksum |
+ 创建时间 |
+ 操作 |
+
+
+
+ {versions.map((version) => (
+
+ |
+ {version.version}
+ |
+
+
+ |
+
+ {version.created_by || '系统'}
+ |
+
+ {truncateChecksum(version.checksum)}
+ |
+
+ {formatDateTime(version.created_at)}
+ |
+
+
+ setSelectedVersionId(version.id)}
+ className="px-3 py-2 text-xs"
+ >
+ 查看快照
+
+ {!version.is_active ? (
+ handleActivate(version)}
+ disabled={activateMutation.isPending}
+ className="px-3 py-2 text-xs"
+ >
+ 重新激活
+
+ ) : null}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ setSelectedVersionId(null)}
+ />
+ >
+ );
+}
diff --git a/atsf_server/web/features/nodes/components/node-detail-page.tsx b/atsf_server/web/features/nodes/components/node-detail-page.tsx
new file mode 100644
index 00000000..1df3df0a
--- /dev/null
+++ b/atsf_server/web/features/nodes/components/node-detail-page.tsx
@@ -0,0 +1,572 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+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 { AppModal } from '@/components/ui/app-modal';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
+import { getPublicStatus } from '@/features/auth/api/public';
+import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
+import {
+ deleteNode,
+ getNodes,
+ requestNodeAgentUpdate,
+ updateNode,
+} from '@/features/nodes/api/nodes';
+import type { 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';
+import {
+ buildNodeInstallCommand,
+ getApplyLabel,
+ getApplyVariant,
+ getNodeStatusLabel,
+ getNodeStatusVariant,
+ getServerUrl,
+ getUpdateMode,
+ isMeaningfulTime,
+ shouldShowManualUpdate,
+} from '@/features/nodes/utils';
+
+const nodesQueryKey = ['nodes'];
+
+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,
+ };
+}
+
+async function copyToClipboard(value: string) {
+ await navigator.clipboard.writeText(value);
+}
+
+export function NodeDetailPage({ nodeId }: { nodeId: string }) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [feedback, setFeedback] = useState(null);
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const [serverUrl, setServerUrl] = useState('');
+
+ const form = useForm({
+ resolver: zodResolver(nodeSchema),
+ defaultValues,
+ });
+
+ const watchedAutoUpdate = useWatch({
+ control: form.control,
+ name: 'auto_update_enabled',
+ });
+
+ const nodesQuery = useQuery({
+ queryKey: nodesQueryKey,
+ queryFn: getNodes,
+ refetchInterval: 5000,
+ });
+
+ const publicStatusQuery = useQuery({
+ queryKey: ['public-status'],
+ queryFn: getPublicStatus,
+ });
+
+ const node = useMemo(() => {
+ return (
+ (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
+ );
+ }, [nodeId, nodesQuery.data]);
+
+ const applyLogsQuery = useQuery({
+ queryKey: ['apply-logs', node?.node_id ?? ''],
+ queryFn: () => getApplyLogs(node?.node_id),
+ enabled: Boolean(node?.node_id),
+ refetchInterval: 5000,
+ });
+
+ useEffect(() => {
+ if (typeof window !== 'undefined' && !serverUrl) {
+ setServerUrl(window.location.origin);
+ }
+ }, [serverUrl]);
+
+ useEffect(() => {
+ if (!node || isEditorOpen) {
+ return;
+ }
+
+ form.reset({
+ name: node.name,
+ auto_update_enabled: node.auto_update_enabled,
+ });
+ }, [form, isEditorOpen, node]);
+
+ const saveMutation = useMutation({
+ mutationFn: async (values: NodeFormValues) =>
+ updateNode(Number(nodeId), toPayload(values)),
+ onSuccess: async (updatedNode) => {
+ setFeedback({ tone: 'success', message: '节点已更新。' });
+ setIsEditorOpen(false);
+ form.reset({
+ name: updatedNode.name,
+ auto_update_enabled: updatedNode.auto_update_enabled,
+ });
+ await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const updateAgentMutation = useMutation({
+ mutationFn: () => requestNodeAgentUpdate(Number(nodeId)),
+ onSuccess: async (updatedNode) => {
+ setFeedback({
+ tone: 'success',
+ message: `已向节点 ${updatedNode.name} 下发更新指令。`,
+ });
+ await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: () => deleteNode(Number(nodeId)),
+ onSuccess: async () => {
+ setFeedback({ tone: 'success', message: '节点已删除。' });
+ await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
+ router.push('/node');
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const handleSubmit = form.handleSubmit((values) => {
+ setFeedback(null);
+ saveMutation.mutate(values);
+ });
+
+ const handleDelete = () => {
+ if (!node) {
+ return;
+ }
+
+ if (
+ !window.confirm(
+ `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
+ )
+ ) {
+ return;
+ }
+
+ setFeedback(null);
+ deleteMutation.mutate();
+ };
+
+ const handleCopy = async (value: string, message: string) => {
+ try {
+ await copyToClipboard(value);
+ setFeedback({ tone: 'success', message });
+ } catch (error) {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ }
+ };
+
+ if (nodesQuery.isLoading) {
+ return ;
+ }
+
+ if (nodesQuery.isError) {
+ return (
+
+ );
+ }
+
+ if (!node) {
+ return (
+
+ );
+ }
+
+ const normalizedServerUrl = getServerUrl(serverUrl);
+ const nodeInstallCommand =
+ normalizedServerUrl && node.agent_token
+ ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
+ : '';
+ const updateMode = getUpdateMode(node);
+ const serverVersion = publicStatusQuery.data?.version ?? '';
+ const showManualUpdate = shouldShowManualUpdate(
+ node.agent_version || '',
+ serverVersion,
+ );
+ const applyLogs = applyLogsQuery.data ?? [];
+
+ return (
+ <>
+
+
+
+ 返回列表
+
+ setIsEditorOpen(true)}
+ >
+ 编辑节点
+
+ {showManualUpdate ? (
+ updateAgentMutation.mutate()}
+ disabled={
+ updateAgentMutation.isPending || node.update_requested
+ }
+ >
+ {node.update_requested ? '等待更新中...' : '手动升级 Agent'}
+
+ ) : null}
+
+ 删除节点
+
+ >
+ }
+ />
+
+ {feedback ? (
+
+ ) : null}
+
+
+
+
+
+
+ 最近心跳:
+ {isMeaningfulTime(node.last_seen_at)
+ ? ` ${formatRelativeTime(
+ node.last_seen_at,
+ )} · ${formatDateTime(node.last_seen_at)}`
+ : ' 暂无'}
+
+
+
+
+
+
+
Agent:{node.agent_version || 'unknown'}
+
Nginx:{node.nginx_version || 'unknown'}
+
当前配置:{node.current_version || '未应用'}
+
+
+
+
+
+
+
+ {node.update_requested
+ ? '已等待节点在下一次心跳后执行更新。'
+ : node.auto_update_enabled
+ ? '节点已启用自动更新。'
+ : '当前仅支持手动触发更新。'}
+
+
+
+
+
+
+
+
+ {isMeaningfulTime(node.latest_apply_at)
+ ? `${formatRelativeTime(
+ node.latest_apply_at,
+ )} · ${formatDateTime(node.latest_apply_at)}`
+ : '暂无应用记录'}
+
+
+
+
+
+
+
+ void handleCopy(
+ nodeInstallCommand,
+ '节点专属部署命令已复制。',
+ )
+ }
+ >
+ 复制部署命令
+
+ ) : null
+ }
+ >
+
+
+
+
+ Node ID
+
+
+ {node.node_id}
+
+
+
+
+ Agent Token
+
+
+ {node.agent_token || '暂无'}
+
+
+
+
+
+ setServerUrl(event.target.value)}
+ />
+
+
+ {nodeInstallCommand ? (
+
+ {nodeInstallCommand}
+
+ ) : null}
+
+
+
+
+
+
+
+ IP 地址
+
+
{node.ip || '暂无'}
+
+
+
+ 最近错误
+
+
+ {node.last_error || '无'}
+
+
+
+
+ 创建时间
+
+
{formatDateTime(node.created_at)}
+
+
+
+ 更新时间
+
+
{formatDateTime(node.updated_at)}
+
+
+
+
+
+
+ 查看完整记录
+
+ }
+ >
+ {applyLogsQuery.isLoading ? (
+
+ ) : applyLogsQuery.isError ? (
+
+ ) : applyLogs.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 版本 |
+ 结果 |
+ 时间 |
+ 消息 |
+
+
+
+ {applyLogs.slice(0, 10).map((log) => (
+
+ |
+ {log.version}
+ |
+
+
+ |
+
+ {formatRelativeTime(log.created_at)} ·{' '}
+ {formatDateTime(log.created_at)}
+ |
+
+
+ {log.message || '—'}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ setIsEditorOpen(false)}
+ title="编辑节点"
+ description="更新模式和节点名都在详情页维护。"
+ footer={
+
+
setIsEditorOpen(false)}
+ disabled={saveMutation.isPending}
+ >
+ 取消
+
+
+ {saveMutation.isPending ? '保存中...' : '保存修改'}
+
+
+ }
+ >
+
+
+ >
+ );
+}
diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx
index 7f0f843c..42689698 100644
--- a/atsf_server/web/features/nodes/components/nodes-page.tsx
+++ b/atsf_server/web/features/nodes/components/nodes-page.tsx
@@ -3,7 +3,7 @@
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
@@ -15,17 +15,14 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
-import { getPublicStatus } from '@/features/auth/api/public';
import {
createNode,
deleteNode,
getNodes,
- requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
-import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types';
+import type { NodeMutationPayload } from '@/features/nodes/types';
import {
- CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
@@ -34,10 +31,15 @@ import {
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
+import {
+ getApplyLabel,
+ getApplyVariant,
+ getNodeStatusLabel,
+ getNodeStatusVariant,
+ isMeaningfulTime,
+} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes'];
-const installerScriptUrl =
- 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
const nodeSchema = z.object({
name: z
@@ -71,145 +73,11 @@ function toPayload(values: NodeFormValues): NodeMutationPayload {
};
}
-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 parseVersionParts(version: string) {
- const normalized = version.trim().replace(/^v/i, '');
- if (!normalized || normalized.toLowerCase() === 'unknown') {
- return null;
- }
-
- return normalized.split('.').map((segment) => {
- const matched = segment.trim().match(/^\d+/);
- return matched ? Number.parseInt(matched[0], 10) : 0;
- });
-}
-
-function isOlderVersion(current: string, target: string) {
- const currentParts = parseVersionParts(current);
- const targetParts = parseVersionParts(target);
- if (!currentParts || !targetParts) {
- return false;
- }
-
- const maxLength = Math.max(currentParts.length, targetParts.length);
- for (let index = 0; index < maxLength; index += 1) {
- const currentPart = currentParts[index] ?? 0;
- const targetPart = targetParts[index] ?? 0;
- if (currentPart < targetPart) {
- return true;
- }
- if (currentPart > targetPart) {
- return false;
- }
- }
-
- return false;
-}
-
-function shouldShowManualUpdate(agentVersion: string, serverVersion: string) {
- const normalizedServerVersion = serverVersion.trim();
- const normalizedAgentVersion = agentVersion.trim();
-
- if (
- !normalizedServerVersion ||
- normalizedServerVersion.toLowerCase() === 'dev' ||
- !normalizedAgentVersion ||
- normalizedAgentVersion.toLowerCase() === 'unknown'
- ) {
- return false;
- }
-
- return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
-}
-
-function getServerUrl(value: string) {
- return value.trim().replace(/\/+$/, '');
-}
-
-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 [isEditorOpen, setIsEditorOpen] = useState(false);
- const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
- const [selectedNode, setSelectedNode] = useState(null);
- const [serverUrl, setServerUrl] = useState('');
- const [, setRefreshTick] = useState(0);
const form = useForm({
resolver: zodResolver(nodeSchema),
@@ -224,30 +92,9 @@ export function NodesPage() {
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
+ refetchInterval: 5000,
});
- const publicStatusQuery = useQuery({
- queryKey: ['public-status'],
- queryFn: getPublicStatus,
- });
-
- 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);
@@ -255,14 +102,13 @@ export function NodesPage() {
? updateNode(editingNodeId, payload)
: createNode(payload);
},
- onSuccess: async (node) => {
+ onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。',
});
setEditingNodeId(null);
setIsEditorOpen(false);
- setSelectedNode(node);
form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
@@ -271,27 +117,11 @@ export function NodesPage() {
},
});
- 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 });
},
@@ -301,16 +131,6 @@ export function NodesPage() {
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
- const normalizedServerUrl = getServerUrl(serverUrl);
- const serverVersion = publicStatusQuery.data?.version ?? '';
-
- const selectedNodeFromList = useMemo(() => {
- if (!selectedNode) {
- return null;
- }
-
- return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode;
- }, [nodes, selectedNode]);
const handleReset = () => {
setFeedback(null);
@@ -326,43 +146,31 @@ export function NodesPage() {
setIsEditorOpen(true);
};
- const handleEdit = (node: NodeItem) => {
+ const handleEdit = (
+ nodeId: number,
+ name: string,
+ autoUpdateEnabled: boolean,
+ ) => {
setFeedback(null);
- setEditingNodeId(node.id);
- setSelectedNode(node);
+ setEditingNodeId(nodeId);
form.reset({
- name: node.name,
- auto_update_enabled: node.auto_update_enabled,
+ name,
+ auto_update_enabled: autoUpdateEnabled,
});
setIsEditorOpen(true);
};
- const handleOpenDeployModal = (node: NodeItem) => {
- setFeedback(null);
- setSelectedNode(node);
- setIsDeployModalOpen(true);
- };
-
- const handleDelete = (node: NodeItem) => {
+ const handleDelete = (nodeId: number, nodeName: string) => {
if (
!window.confirm(
- `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
+ `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
- deleteMutation.mutate(node.id);
- };
-
- const handleCopy = async (value: string, successMessage: string) => {
- try {
- await copyToClipboard(value);
- setFeedback({ tone: 'success', message: successMessage });
- } catch (error) {
- setFeedback({ tone: 'danger', message: getErrorMessage(error) });
- }
+ deleteMutation.mutate(nodeId);
};
const handleSubmit = form.handleSubmit((values) => {
@@ -370,20 +178,12 @@ export function NodesPage() {
saveMutation.mutate(values);
});
- const nodeInstallCommand =
- normalizedServerUrl && selectedNodeFromList?.agent_token
- ? buildNodeInstallCommand(
- normalizedServerUrl,
- selectedNodeFromList.agent_token,
- )
- : '';
-
return (
<>
@@ -405,6 +205,7 @@ export function NodesPage() {
- 刷新列表
+ 立即刷新
}
>
@@ -426,7 +227,7 @@ export function NodesPage() {
) : nodes.length === 0 ? (
) : (
@@ -435,231 +236,105 @@ export function NodesPage() {
| 节点 |
状态 |
- 更新模式 |
Agent / Nginx |
当前版本 |
最近应用 |
最近心跳 |
- 错误 |
操作 |
- {nodes.map((node) => {
- const updateMode = getUpdateMode(node);
- const showManualUpdate = shouldShowManualUpdate(
- node.agent_version || '',
- serverVersion,
- );
-
- 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 || '无'}
+ {nodes.map((node) => (
+ |
+ |
+ |
-
-
- {showManualUpdate ? (
-
- updateAgentMutation.mutate(node.id)
- }
- disabled={
- updateAgentMutation.isPending ||
- node.update_requested
- }
- className="px-3 py-2 text-xs"
- >
- 升级
-
- ) : null}
- handleOpenDeployModal(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"
- >
- 删除
-
-
- |
-
- );
- })}
+
+ IP:{node.ip || '暂无'}
+
+
+
+
+
+ |
+
+ {node.agent_version || 'unknown'} /{' '}
+ {node.nginx_version || 'unknown'}
+ |
+
+ {node.current_version || '未应用'}
+ |
+
+
+
+
+ {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)}`
+ : '暂无'}
+ |
+
+
+
+ 详情
+
+
+ handleEdit(
+ node.id,
+ node.name,
+ node.auto_update_enabled,
+ )
+ }
+ className="px-3 py-2 text-xs"
+ >
+ 编辑
+
+ handleDelete(node.id, node.name)}
+ disabled={deleteMutation.isPending}
+ className="px-3 py-2 text-xs"
+ >
+ 删除
+
+
+ |
+
+ ))}
)}
- setIsDeployModalOpen(false)}
- title="节点专属部署命令"
- description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。"
- footer={
-
-
setIsDeployModalOpen(false)}
- >
- 关闭
-
- {nodeInstallCommand ? (
-
- void handleCopy(
- nodeInstallCommand,
- '节点专属部署命令已复制。',
- )
- }
- >
- 复制命令
-
- ) : null}
-
- }
- >
- {selectedNodeFromList ? (
-
-
-
-
-
- {selectedNodeFromList.name}
-
-
-
-
- Node ID:{selectedNodeFromList.node_id}
-
-
- IP:{selectedNodeFromList.ip || '暂无'}
-
-
-
-
- Agent Token
-
-
- {selectedNodeFromList.agent_token || '暂无'}
-
-
-
-
- setServerUrl(event.target.value)}
- />
-
- {nodeInstallCommand ? (
-
- {nodeInstallCommand}
-
- ) : null}
-
- ) : (
-
- )}
-
{
+ const matched = segment.trim().match(/^\d+/);
+ return matched ? Number.parseInt(matched[0], 10) : 0;
+ });
+}
+
+function isOlderVersion(current: string, target: string) {
+ const currentParts = parseVersionParts(current);
+ const targetParts = parseVersionParts(target);
+ if (!currentParts || !targetParts) {
+ return false;
+ }
+
+ const maxLength = Math.max(currentParts.length, targetParts.length);
+ for (let index = 0; index < maxLength; index += 1) {
+ const currentPart = currentParts[index] ?? 0;
+ const targetPart = targetParts[index] ?? 0;
+ if (currentPart < targetPart) {
+ return true;
+ }
+ if (currentPart > targetPart) {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+export function shouldShowManualUpdate(
+ agentVersion: string,
+ serverVersion: string,
+) {
+ const normalizedServerVersion = serverVersion.trim();
+ const normalizedAgentVersion = agentVersion.trim();
+
+ if (
+ !normalizedServerVersion ||
+ normalizedServerVersion.toLowerCase() === 'dev' ||
+ !normalizedAgentVersion ||
+ normalizedAgentVersion.toLowerCase() === 'unknown'
+ ) {
+ return false;
+ }
+
+ return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
+}
+
+export function getServerUrl(value: string) {
+ return value.trim().replace(/\/+$/, '');
+}
+
+const installerScriptUrl =
+ 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
+
+export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
+ return [
+ `curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
+ ` --server-url ${serverUrl} \\`,
+ ` --agent-token ${agentToken}`,
+ ].join('\n');
+}
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
index b2fc08e6..80ad5ef6 100644
--- a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
+++ b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx
@@ -1,677 +1,815 @@
-'use client';
-
-import { zodResolver } from '@hookform/resolvers/zod';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useMemo, useState } from 'react';
-import { useFieldArray, useForm, useWatch } from 'react-hook-form';
-import { z } from 'zod';
-
-import { EmptyState } from '@/components/feedback/empty-state';
-import { ErrorState } from '@/components/feedback/error-state';
-import { InlineMessage } from '@/components/feedback/inline-message';
-import { LoadingState } from '@/components/feedback/loading-state';
-import { PageHeader } from '@/components/layout/page-header';
-import { AppModal } from '@/components/ui/app-modal';
-import { AppCard } from '@/components/ui/app-card';
-import { StatusBadge } from '@/components/ui/status-badge';
-import { 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 [isEditorOpen, setIsEditorOpen] = useState(false);
- 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);
- setIsEditorOpen(false);
- setMatchResult(null);
- form.reset(defaultValues);
- await queryClient.invalidateQueries({ queryKey: routesQueryKey });
- },
- onError: (error) => {
- setFeedback({ tone: 'danger', message: getErrorMessage(error) });
- },
- });
-
- const deleteMutation = useMutation({
- mutationFn: deleteProxyRoute,
- onSuccess: async () => {
- setFeedback({ tone: 'success', message: '规则已删除。' });
- await queryClient.invalidateQueries({ queryKey: routesQueryKey });
- },
- onError: (error) => {
- setFeedback({ tone: 'danger', message: getErrorMessage(error) });
- },
- });
-
- const publishMutation = useMutation({
- mutationFn: publishConfigVersion,
- onSuccess: (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 handleReset = () => {
- setFeedback(null);
- setEditingRouteId(null);
- setIsEditorOpen(false);
- setMatchResult(null);
- form.reset(defaultValues);
- };
-
- const handleCreate = () => {
- setFeedback(null);
- setEditingRouteId(null);
- setMatchResult(null);
- form.reset(defaultValues);
- setIsEditorOpen(true);
- };
-
- const handleSubmit = form.handleSubmit((values) => {
- setFeedback(null);
- saveMutation.mutate(values);
- });
-
- const handleEdit = (route: ProxyRouteItem) => {
- setFeedback(null);
- setEditingRouteId(route.id);
- setMatchResult(null);
- form.reset(toFormValues(route));
- setIsEditorOpen(true);
- };
-
- const handleDelete = (route: ProxyRouteItem) => {
- if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
- return;
- }
-
- setFeedback(null);
- deleteMutation.mutate(route.id);
- };
-
- const handleRemoveHeader = (index: number) => {
- if (fields.length === 1) {
- replace([{ key: '', value: '' }]);
- return;
- }
-
- remove(index);
- };
-
- const routes = routesQuery.data || [];
-
- return (
- <>
-
-
- publishMutation.mutate()} disabled={publishMutation.isPending}>
- {publishMutation.isPending ? '发布中...' : '发布当前规则'}
-
-
- 新增规则
-
- >
- }
- />
-
- {feedback ? : null}
-
-
- {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'
- >
- 删除
-
-
- |
-
- );
- })}
-
-
-
- )}
-
-
-
-
- 取消
-
-
- {saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
-
-
- }
- >
-
-
- >
- );
-}
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect, useMemo, useState } from 'react';
+import { useFieldArray, useForm, useWatch } from 'react-hook-form';
+import { z } from 'zod';
+
+import { EmptyState } from '@/components/feedback/empty-state';
+import { ErrorState } from '@/components/feedback/error-state';
+import { InlineMessage } from '@/components/feedback/inline-message';
+import { LoadingState } from '@/components/feedback/loading-state';
+import { PageHeader } from '@/components/layout/page-header';
+import { AppModal } from '@/components/ui/app-modal';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
+import {
+ getConfigVersionDiff,
+ publishConfigVersion,
+} from '@/features/config-versions/api/config-versions';
+import {
+ createProxyRoute,
+ deleteProxyRoute,
+ getProxyRoutes,
+ getTlsCertificates,
+ matchManagedDomainCertificate,
+ updateProxyRoute,
+} from '@/features/proxy-routes/api/proxy-routes';
+import type {
+ ManagedDomainMatchResult,
+ ProxyRouteCustomHeader,
+ ProxyRouteItem,
+ ProxyRouteMutationPayload,
+ TlsCertificateItem,
+} from '@/features/proxy-routes/types';
+import {
+ DangerButton,
+ PrimaryButton,
+ ResourceField,
+ ResourceInput,
+ ResourceSelect,
+ ResourceTextarea,
+ SecondaryButton,
+ ToggleField,
+} from '@/features/shared/components/resource-primitives';
+import { formatDateTime } from '@/lib/utils/date';
+
+const customHeaderSchema = z.object({
+ key: z.string(),
+ value: z.string(),
+});
+
+const proxyRouteSchema = z
+ .object({
+ domain: z.string().trim().min(1, '请输入域名'),
+ origin_url: z
+ .string()
+ .trim()
+ .min(1, '请输入源站地址')
+ .refine(
+ (value) => /^https?:\/\//.test(value),
+ '源站地址必须以 http:// 或 https:// 开头',
+ )
+ .refine((value) => {
+ try {
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ }, '请输入合法的源站地址'),
+ enabled: z.boolean(),
+ enable_https: z.boolean(),
+ cert_id: z.string(),
+ redirect_http: z.boolean(),
+ custom_headers: z.array(customHeaderSchema).min(1),
+ remark: z.string().max(255, '备注不能超过 255 个字符'),
+ })
+ .superRefine((value, context) => {
+ if (value.enable_https && !value.cert_id) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['cert_id'],
+ message: '启用 HTTPS 时必须选择证书',
+ });
+ }
+
+ value.custom_headers.forEach((header, index) => {
+ const key = header.key.trim();
+ const headerValue = header.value.trim();
+
+ if (!key && !headerValue) {
+ return;
+ }
+
+ if (!key) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['custom_headers', index, 'key'],
+ message: '请求头名称不能为空',
+ });
+ }
+
+ if (key && !/^[A-Za-z0-9_-]+$/.test(key)) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['custom_headers', index, 'key'],
+ message: '请求头名称仅支持字母、数字、下划线和中划线',
+ });
+ }
+
+ if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['custom_headers', index, 'value'],
+ message: '请求头不能包含换行',
+ });
+ }
+ });
+ });
+
+type ProxyRouteFormValues = z.infer;
+
+type FeedbackState = {
+ tone: 'info' | 'success' | 'danger';
+ message: string;
+};
+
+const defaultValues: ProxyRouteFormValues = {
+ domain: '',
+ origin_url: '',
+ enabled: true,
+ enable_https: false,
+ cert_id: '',
+ redirect_http: false,
+ custom_headers: [{ key: '', value: '' }],
+ remark: '',
+};
+
+const routesQueryKey = ['proxy-routes'];
+const certificatesQueryKey = ['tls-certificates'];
+const versionsQueryKey = ['config-versions'];
+
+function 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 [isEditorOpen, setIsEditorOpen] = useState(false);
+ 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);
+ setIsEditorOpen(false);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ await queryClient.invalidateQueries({ queryKey: routesQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: deleteProxyRoute,
+ onSuccess: async () => {
+ setFeedback({ tone: 'success', message: '规则已删除。' });
+ await queryClient.invalidateQueries({ queryKey: routesQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const publishMutation = useMutation({
+ mutationFn: publishConfigVersion,
+ onSuccess: async (version) => {
+ setFeedback({
+ tone: 'success',
+ message: `发布成功,版本 ${version.version}`,
+ });
+ await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
+ },
+ onError: (error) => {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ },
+ });
+
+ const handlePublish = async () => {
+ setFeedback(null);
+
+ try {
+ const diff = await getConfigVersionDiff();
+ const hasChanges =
+ diff.added_domains.length > 0 ||
+ diff.removed_domains.length > 0 ||
+ diff.modified_domains.length > 0 ||
+ !diff.active_version;
+
+ if (!hasChanges) {
+ setFeedback({
+ tone: 'info',
+ message: '当前规则没有变更,已阻止重复发布。',
+ });
+ return;
+ }
+
+ publishMutation.mutate();
+ } catch (error) {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ }
+ };
+
+ useEffect(() => {
+ if (!watchedEnableHttps) {
+ setMatchResult(null);
+ setIsMatching(false);
+ return;
+ }
+
+ const normalizedDomain = watchedDomain.trim().toLowerCase();
+ if (!normalizedDomain) {
+ setMatchResult(null);
+ return;
+ }
+
+ let cancelled = false;
+ const timer = window.setTimeout(async () => {
+ try {
+ setIsMatching(true);
+ const result = await matchManagedDomainCertificate(normalizedDomain);
+ if (cancelled) {
+ return;
+ }
+ setMatchResult(result);
+ if (result.candidate?.certificate_id && !form.getValues('cert_id')) {
+ form.setValue('cert_id', String(result.candidate.certificate_id), {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ }
+ } catch (error) {
+ if (!cancelled) {
+ setMatchResult(null);
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ }
+ } finally {
+ if (!cancelled) {
+ setIsMatching(false);
+ }
+ }
+ }, 400);
+
+ return () => {
+ cancelled = true;
+ window.clearTimeout(timer);
+ };
+ }, [watchedDomain, watchedEnableHttps, form]);
+
+ const certificates = useMemo(
+ () => certificatesQuery.data ?? [],
+ [certificatesQuery.data],
+ );
+ const certificateMap = useMemo(
+ () => new Map(certificates.map((item) => [item.id, item])),
+ [certificates],
+ );
+
+ const handleReset = () => {
+ setFeedback(null);
+ setEditingRouteId(null);
+ setIsEditorOpen(false);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ };
+
+ const handleCreate = () => {
+ setFeedback(null);
+ setEditingRouteId(null);
+ setMatchResult(null);
+ form.reset(defaultValues);
+ setIsEditorOpen(true);
+ };
+
+ const handleSubmit = form.handleSubmit((values) => {
+ setFeedback(null);
+ saveMutation.mutate(values);
+ });
+
+ const handleEdit = (route: ProxyRouteItem) => {
+ setFeedback(null);
+ setEditingRouteId(route.id);
+ setMatchResult(null);
+ form.reset(toFormValues(route));
+ setIsEditorOpen(true);
+ };
+
+ const handleDelete = (route: ProxyRouteItem) => {
+ if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) {
+ return;
+ }
+
+ setFeedback(null);
+ deleteMutation.mutate(route.id);
+ };
+
+ const handleRemoveHeader = (index: number) => {
+ if (fields.length === 1) {
+ replace([{ key: '', value: '' }]);
+ return;
+ }
+
+ remove(index);
+ };
+
+ const routes = routesQuery.data || [];
+
+ return (
+ <>
+
+
+ void handlePublish()}
+ disabled={publishMutation.isPending}
+ >
+ {publishMutation.isPending ? '发布中...' : '发布当前规则'}
+
+
+ 新增规则
+
+ >
+ }
+ />
+
+ {feedback ? (
+
+ ) : null}
+
+
+ {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 ? (
+
+
+
+ ) : (
+
+ )}
+ |
+
+ 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"
+ >
+ 删除
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+
+
+
+ 取消
+
+
+ {saveMutation.isPending
+ ? '保存中...'
+ : editingRouteId
+ ? '保存修改'
+ : '新增规则'}
+
+
+ }
+ >
+
+
+ >
+ );
+}
diff --git a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
index b1747bcc..6817fc8a 100644
--- a/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
+++ b/atsf_server/web/features/tls-certificates/components/tls-certificates-page.tsx
@@ -38,7 +38,11 @@ import { formatDateTime } from '@/lib/utils/date';
const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const;
const manualImportSchema = z.object({
- name: z.string().trim().min(1, '请输入证书名称').max(255, '证书名称不能超过 255 个字符'),
+ name: z
+ .string()
+ .trim()
+ .min(1, '请输入证书名称')
+ .max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'),
@@ -92,7 +96,9 @@ function getCertificateStatus(certificate: TlsCertificateItem) {
return { label: '有效', variant: 'success' as const };
}
-function toManualPayload(values: ManualImportFormValues): TlsCertificateMutationPayload {
+function toManualPayload(
+ values: ManualImportFormValues,
+): TlsCertificateMutationPayload {
return {
name: values.name.trim(),
cert_pem: values.cert_pem.trim(),
@@ -122,7 +128,9 @@ export function TlsCertificatesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
- const [fileForm, setFileForm] = useState(defaultFileValues);
+ const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
+ const [fileForm, setFileForm] =
+ useState(defaultFileValues);
const [certFile, setCertFile] = useState(null);
const [keyFile, setKeyFile] = useState(null);
const [fileInputNonce, setFileInputNonce] = useState(0);
@@ -138,12 +146,16 @@ export function TlsCertificatesPage() {
});
const manualImportMutation = useMutation({
- mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)),
+ mutationFn: async (values: ManualImportFormValues) =>
+ createTlsCertificate(toManualPayload(values)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已导入。' });
+ setImportMode('manual');
setIsImportModalOpen(false);
manualForm.reset(defaultManualValues);
- await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey });
+ await queryClient.invalidateQueries({
+ queryKey: tlsCertificatesQueryKey,
+ });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -155,12 +167,15 @@ export function TlsCertificatesPage() {
importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书文件已导入。' });
+ setImportMode('manual');
setIsImportModalOpen(false);
setFileForm(defaultFileValues);
setCertFile(null);
setKeyFile(null);
setFileInputNonce((value) => value + 1);
- await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey });
+ await queryClient.invalidateQueries({
+ queryKey: tlsCertificatesQueryKey,
+ });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -171,14 +186,19 @@ export function TlsCertificatesPage() {
mutationFn: deleteTlsCertificate,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已删除。' });
- await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey });
+ await queryClient.invalidateQueries({
+ queryKey: tlsCertificatesQueryKey,
+ });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
- const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]);
+ const certificates = useMemo(
+ () => certificatesQuery.data ?? [],
+ [certificatesQuery.data],
+ );
const handleManualSubmit = manualForm.handleSubmit((values) => {
setFeedback(null);
@@ -201,207 +221,308 @@ export function TlsCertificatesPage() {
};
const handleCloseImportModal = () => {
+ setImportMode('manual');
setIsImportModalOpen(false);
};
return (
<>
-
-
setIsImportModalOpen(true)}>
- 导入证书
-
- }
- />
-
- {feedback ? : null}
-
- void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
- >
- 刷新
-
- }
- >
- {certificatesQuery.isLoading ? (
-
- ) : certificatesQuery.isError ? (
-
- ) : certificates.length === 0 ? (
-
- ) : (
-
-
-
-
- | 名称 |
- 状态 |
- 有效期 |
- 备注 |
- 更新时间 |
- 操作 |
-
-
-
- {certificates.map((certificate) => {
- const status = getCertificateStatus(certificate);
-
- return (
-
- | {certificate.name} |
-
-
- |
-
-
- {formatDateTime(certificate.not_before)}
- {formatDateTime(certificate.not_after)}
-
- |
- {certificate.remark || '—'} |
-
- {formatDateTime(certificate.updated_at)}
- |
-
- handleDelete(certificate)}
- disabled={deleteMutation.isPending}
- className='px-3 py-2 text-xs'
- >
- 删除
-
- |
-
- );
- })}
-
-
-
- )}
-
-
-
-