Refactor code structure for improved readability and maintainability

This commit is contained in:
ryan
2026-03-11 22:58:24 +08:00
parent ca1c147dfe
commit 27f96fa353
15 changed files with 2594 additions and 1745 deletions
+18
View File
@@ -73,6 +73,24 @@ func TestPhase1PublishLifecycle(t *testing.T) {
t.Fatal("expected published version to contain snapshot, rendered config and checksum") 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 initialSnapshot := version1.SnapshotJSON
initialRendered := version1.RenderedConfig initialRendered := version1.RenderedConfig
+9 -8
View File
@@ -7,14 +7,15 @@ import (
"atsflare/service" "atsflare/service"
"bytes" "bytes"
"encoding/json" "encoding/json"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
) )
func TestPhase2RateLimitOptionsHotReload(t *testing.T) { func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
@@ -180,8 +181,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}) })
var createdNode service.NodeView var createdNode service.NodeView
decodeResponseData(t, createdNodeResp, &createdNode) decodeResponseData(t, createdNodeResp, &createdNode)
if createdNode.AgentToken == "" || !createdNode.Pending { if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending {
t.Fatal("expected created node to expose agent token while pending") t.Fatal("expected created node to expose agent token with pending status")
} }
heartbeatPayload := map[string]any{ heartbeatPayload := map[string]any{
@@ -237,8 +238,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if len(nodes) != 1 { if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes)) t.Fatalf("expected 1 node, got %d", len(nodes))
} }
if nodes[0].Pending { if nodes[0].Status != service.NodeStatusOnline {
t.Fatal("expected registered node to clear pending state") t.Fatal("expected registered node to become online")
} }
if nodes[0].AgentToken != createdNode.AgentToken { if nodes[0].AgentToken != createdNode.AgentToken {
t.Fatal("expected node auth token to remain stable after occupancy") t.Fatal("expected node auth token to remain stable after occupancy")
@@ -394,7 +395,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
if len(nodes) != 1 { if len(nodes) != 1 {
t.Fatalf("expected 1 discovered node, got %d", len(nodes)) 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") t.Fatal("expected discovered node to be created online with issued agent token")
} }
} }
-1
View File
@@ -63,7 +63,6 @@ type NodeView struct {
Name string `json:"name"` Name string `json:"name"`
IP string `json:"ip"` IP string `json:"ip"`
AgentToken string `json:"agent_token"` AgentToken string `json:"agent_token"`
Pending bool `json:"pending"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"` UpdateRequested bool `json:"update_requested"`
AgentVersion string `json:"agent_version"` AgentVersion string `json:"agent_version"`
+22
View File
@@ -137,6 +137,21 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
return result, nil 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) { func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
bundle, err := buildCurrentConfigBundle(true) bundle, err := buildCurrentConfigBundle(true)
if err != nil { if err != nil {
@@ -145,6 +160,13 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
if len(bundle.Routes) == 0 { if len(bundle.Routes) == 0 {
return nil, errors.New("没有可发布的启用规则") 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) supportFilesJSON, err := json.Marshal(bundle.SupportFiles)
if err != nil { if err != nil {
return nil, err return nil, err
-1
View File
@@ -178,7 +178,6 @@ func buildNodeView(node *model.Node) *NodeView {
LastError: node.LastError, LastError: node.LastError,
CreatedAt: node.CreatedAt, CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt, UpdatedAt: node.UpdatedAt,
Pending: status == NodeStatusPending,
AutoUpdateEnabled: node.AutoUpdateEnabled, AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested, UpdateRequested: node.UpdateRequested,
} }
@@ -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 <NodeDetailPage nodeId={searchParams.get('id') ?? ''} />;
}
@@ -1,376 +1,531 @@
'use client'; 'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react';
import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card'; import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge'; import { AppCard } from '@/components/ui/app-card';
import { import { StatusBadge } from '@/components/ui/status-badge';
activateConfigVersion, import {
getConfigVersionDiff, activateConfigVersion,
getConfigVersionPreview, getConfigVersionDiff,
getConfigVersions, getConfigVersionPreview,
publishConfigVersion, getConfigVersions,
} from '@/features/config-versions/api/config-versions'; publishConfigVersion,
import type { } from '@/features/config-versions/api/config-versions';
ConfigDiffResult, import type {
ConfigPreviewResult, ConfigDiffResult,
ConfigVersionItem, ConfigPreviewResult,
SupportFile, ConfigVersionItem,
} from '@/features/config-versions/types'; SupportFile,
import { } from '@/features/config-versions/types';
CodeBlock, import {
PrimaryButton, CodeBlock,
SecondaryButton, PrimaryButton,
} from '@/features/shared/components/resource-primitives'; SecondaryButton,
import { formatDateTime } from '@/lib/utils/date'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
const versionsQueryKey = ['config-versions'];
const versionsQueryKey = ['config-versions'];
type FeedbackState = {
tone: 'info' | 'success' | 'danger'; type FeedbackState = {
message: string; tone: 'info' | 'success' | 'danger';
}; message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; function getErrorMessage(error: unknown) {
} return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function truncateChecksum(checksum: string) {
if (!checksum) { function truncateChecksum(checksum: string) {
return '—'; if (!checksum) {
} return '—';
}
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
} return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
}
function DiffList({ title, items }: { title: string; items: string[] }) {
return ( function hasConfigDiff(diff: ConfigDiffResult) {
<div className='space-y-3'> return (
<div className='flex items-center justify-between gap-3'> diff.added_domains.length > 0 ||
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{title}</p> diff.removed_domains.length > 0 ||
<StatusBadge label={`${items.length} 项`} variant={items.length > 0 ? 'info' : 'warning'} /> diff.modified_domains.length > 0 ||
</div> !diff.active_version
{items.length > 0 ? ( );
<div className='flex flex-wrap gap-2'> }
{items.map((item) => (
<span function DiffList({ title, items }: { title: string; items: string[] }) {
key={item} return (
className='rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]' <div className="space-y-3">
> <div className="flex items-center justify-between gap-3">
{item} <p className="text-sm font-semibold text-[var(--foreground-primary)]">
</span> {title}
))} </p>
</div> <StatusBadge
) : ( label={`${items.length} 项`}
<p className='text-sm text-[var(--foreground-secondary)]'>当前无相关变更。</p> variant={items.length > 0 ? 'info' : 'warning'}
)} />
</div> </div>
); {items.length > 0 ? (
} <div className="flex flex-wrap gap-2">
{items.map((item) => (
function SupportFilesList({ files }: { files: SupportFile[] }) { <span
if (files.length === 0) { key={item}
return <p className='text-sm text-[var(--foreground-secondary)]'>当前发布不需要额外支持文件。</p>; className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
} >
{item}
return ( </span>
<div className='space-y-3'> ))}
{files.map((file) => ( </div>
<details ) : (
key={file.path} <p className="text-sm text-[var(--foreground-secondary)]">
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3' 当前无相关变更。
> </p>
<summary className='cursor-pointer text-sm font-medium text-[var(--foreground-primary)]'> )}
{file.path} </div>
</summary> );
<CodeBlock className='mt-3 max-h-72 whitespace-pre-wrap'>{file.content}</CodeBlock> }
</details>
))} function SupportFilesList({ files }: { files: SupportFile[] }) {
</div> if (files.length === 0) {
); return (
} <p className="text-sm text-[var(--foreground-secondary)]">
当前发布不需要额外支持文件。
function VersionDetailCard({ </p>
version, );
}: { }
version: ConfigVersionItem;
}) { return (
return ( <div className="space-y-3">
<AppCard {files.map((file) => (
title={`版本 ${version.version}`} <details
description='查看当前版本保存的快照 JSON 与最终渲染配置,便于比对发布结果。' key={file.path}
action={ className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
<StatusBadge label={version.is_active ? '当前激活' : '历史版本'} variant={version.is_active ? 'success' : 'info'} /> >
} <summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
> {file.path}
<div className='grid gap-4 md:grid-cols-3'> </summary>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> <CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Checksum</p> {file.content}
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>{version.checksum}</p> </CodeBlock>
</div> </details>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> ))}
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建人</p> </div>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{version.created_by || '系统'}</p> );
</div> }
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建时间</p> function SnapshotModal({
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{formatDateTime(version.created_at)}</p> version,
</div> onClose,
</div> }: {
version: ConfigVersionItem | null;
<div className='mt-5 space-y-4'> onClose: () => void;
<div> }) {
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>快照 JSON</p> return (
<CodeBlock className='max-h-96 whitespace-pre-wrap'>{version.snapshot_json}</CodeBlock> <AppModal
</div> isOpen={Boolean(version)}
<div> onClose={onClose}
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>渲染结果</p> title={version ? `版本 ${version.version}` : '查看快照'}
<CodeBlock className='max-h-[32rem] whitespace-pre-wrap'>{version.rendered_config}</CodeBlock> description="在弹窗中查看快照 JSON 与渲染结果,避免把版本页主视图撑长。"
</div> size="xl"
</div> footer={
</AppCard> <div className="flex justify-end">
); <SecondaryButton type="button" onClick={onClose}>
} 关闭
</SecondaryButton>
function PublishPreviewCard({ </div>
preview, }
diff, >
isPublishing, {version ? (
onConfirm, <div className="space-y-5">
onCancel, <div className="grid gap-4 md:grid-cols-3">
}: { <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
preview: ConfigPreviewResult; <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
diff: ConfigDiffResult; Checksum
isPublishing: boolean; </p>
onConfirm: () => void; <p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
onCancel: () => void; {version.checksum}
}) { </p>
return ( </div>
<AppCard <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
title='发布前预览' <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
description='先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。' 创建人
action={<StatusBadge label={`启用规则 ${preview.route_count} 条`} variant='info' />} </p>
> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
<div className='space-y-5'> {version.created_by || '系统'}
<div className='grid gap-4 md:grid-cols-4'> </p>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> </div>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>当前激活版本</p> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{diff.active_version || '无'}</p> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
</div> 创建时间
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4'> </p>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-success-foreground)]'>新增域名</p> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
<p className='mt-2 text-lg font-semibold text-[var(--status-success-foreground)]'>{diff.added_domains.length}</p> {formatDateTime(version.created_at)}
</div> </p>
<div className='rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4'> </div>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-warning-foreground)]'>删除域名</p> </div>
<p className='mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]'>{diff.removed_domains.length}</p>
</div> <div>
<div className='rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4'> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-info-foreground)]'>修改域名</p> 快照 JSON
<p className='mt-2 text-lg font-semibold text-[var(--status-info-foreground)]'>{diff.modified_domains.length}</p> </p>
</div> <CodeBlock className="max-h-96 whitespace-pre-wrap">
</div> {version.snapshot_json}
</CodeBlock>
<div className='grid gap-5 xl:grid-cols-3'> </div>
<DiffList title='新增域名' items={diff.added_domains} /> <div>
<DiffList title='删除域名' items={diff.removed_domains} /> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
<DiffList title='修改域名' items={diff.modified_domains} /> 渲染结果
</div> </p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
<div> {version.rendered_config}
<div className='mb-2 flex flex-wrap items-center justify-between gap-3'> </CodeBlock>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>渲染结果</p> </div>
<p className='text-xs text-[var(--foreground-secondary)]'>Checksum:{preview.checksum}</p> </div>
</div> ) : null}
<CodeBlock className='max-h-[32rem] whitespace-pre-wrap'>{preview.rendered_config}</CodeBlock> </AppModal>
</div> );
}
<div>
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>支持文件</p> function PublishPreviewCard({
<SupportFilesList files={preview.support_files} /> preview,
</div> diff,
isPublishing,
<div className='flex flex-wrap gap-3'> onConfirm,
<PrimaryButton type='button' onClick={onConfirm} disabled={isPublishing}> onCancel,
{isPublishing ? '发布中...' : '确认发布'} }: {
</PrimaryButton> preview: ConfigPreviewResult;
<SecondaryButton type='button' onClick={onCancel} disabled={isPublishing}> diff: ConfigDiffResult;
取消预览 isPublishing: boolean;
</SecondaryButton> onConfirm: () => void;
</div> onCancel: () => void;
</div> }) {
</AppCard> const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
);
} return (
<AppCard
export function ConfigVersionsPage() { title="发布前预览"
const queryClient = useQueryClient(); description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
const [feedback, setFeedback] = useState<FeedbackState | null>(null); action={
const [selectedVersion, setSelectedVersion] = useState<ConfigVersionItem | null>(null); <StatusBadge
const [publishPreview, setPublishPreview] = useState<{ label={`启用规则 ${preview.route_count} 条`}
preview: ConfigPreviewResult; variant="info"
diff: ConfigDiffResult; />
} | null>(null); }
const [isPreviewLoading, setIsPreviewLoading] = useState(false); >
<div className="space-y-5">
const versionsQuery = useQuery({ <div className="grid gap-4 md:grid-cols-4">
queryKey: versionsQueryKey, <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
queryFn: getConfigVersions, <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
}); 当前激活版本
</p>
const publishMutation = useMutation({ <p className="mt-2 text-sm text-[var(--foreground-primary)]">
mutationFn: publishConfigVersion, {diff.active_version || '无'}
onSuccess: async (version) => { </p>
setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` }); </div>
setPublishPreview(null); <div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
setSelectedVersion(version); <p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); 新增域名
}, </p>
onError: (error) => { <p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); {diff.added_domains.length}
}, </p>
}); </div>
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
const activateMutation = useMutation({ <p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
mutationFn: activateConfigVersion, 删除域名
onSuccess: async (version) => { </p>
setFeedback({ tone: 'success', message: `已激活版本 ${version.version}` }); <p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
setSelectedVersion(version); {diff.removed_domains.length}
await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); </p>
}, </div>
onError: (error) => { <div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); <p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
}, 修改域名
}); </p>
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
const handleOpenPublishPreview = async () => { {diff.modified_domains.length}
setFeedback(null); </p>
setIsPreviewLoading(true); </div>
</div>
try {
const [preview, diff] = await Promise.all([getConfigVersionPreview(), getConfigVersionDiff()]); {!canPublish ? (
setPublishPreview({ preview, diff }); <InlineMessage
} catch (error) { tone="info"
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); message="当前规则与已激活版本一致,已阻止重复发布。"
} finally { />
setIsPreviewLoading(false); ) : null}
}
}; <div className="grid gap-5 xl:grid-cols-3">
<DiffList title="新增域名" items={diff.added_domains} />
const handleActivate = (version: ConfigVersionItem) => { <DiffList title="删除域名" items={diff.removed_domains} />
if (version.is_active) { <DiffList title="修改域名" items={diff.modified_domains} />
return; </div>
}
<div>
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) { <div className="mb-2 flex flex-wrap items-center justify-between gap-3">
return; <p className="text-sm font-semibold text-[var(--foreground-primary)]">
} 渲染结果
</p>
setFeedback(null); <p className="text-xs text-[var(--foreground-secondary)]">
activateMutation.mutate(version.id); Checksum:{preview.checksum}
}; </p>
</div>
const versions = versionsQuery.data || []; <CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.rendered_config}
return ( </CodeBlock>
<div className='space-y-6'> </div>
<PageHeader
title='配置版本' <div>
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。' <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
action={ 支持文件
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}> </p>
{isPreviewLoading ? '加载预览中...' : '预览并发布'} <SupportFilesList files={preview.support_files} />
</PrimaryButton> </div>
}
/> <div className="flex flex-wrap gap-3">
<PrimaryButton
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} type="button"
onClick={onConfirm}
<div className='grid gap-6'> disabled={isPublishing || !canPublish}
{publishPreview ? ( >
<PublishPreviewCard {isPublishing ? '发布中...' : '确认发布'}
preview={publishPreview.preview} </PrimaryButton>
diff={publishPreview.diff} <SecondaryButton
isPublishing={publishMutation.isPending} type="button"
onConfirm={() => publishMutation.mutate()} onClick={onCancel}
onCancel={() => setPublishPreview(null)} disabled={isPublishing}
/> >
) : null} 取消预览
</div> </SecondaryButton>
</div>
<AppCard title='历史版本'> </div>
{versionsQuery.isLoading ? ( </AppCard>
<LoadingState /> );
) : versionsQuery.isError ? ( }
<ErrorState title='版本列表加载失败' description={getErrorMessage(versionsQuery.error)} />
) : versions.length === 0 ? ( export function ConfigVersionsPage() {
<EmptyState title='暂无历史版本' description='当前还没有可查看的发布记录,请先从反代规则页触发一次发布。' /> const queryClient = useQueryClient();
) : ( const [feedback, setFeedback] = useState<FeedbackState | null>(null);
<div className='overflow-x-auto'> const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'> null,
<thead> );
<tr className='text-[var(--foreground-secondary)]'> const [publishPreview, setPublishPreview] = useState<{
<th className='px-3 py-3 font-medium'>版本号</th> preview: ConfigPreviewResult;
<th className='px-3 py-3 font-medium'>状态</th> diff: ConfigDiffResult;
<th className='px-3 py-3 font-medium'>创建人</th> } | null>(null);
<th className='px-3 py-3 font-medium'>Checksum</th> const [isPreviewLoading, setIsPreviewLoading] = useState(false);
<th className='px-3 py-3 font-medium'>创建时间</th>
<th className='px-3 py-3 font-medium'>操作</th> const versionsQuery = useQuery({
</tr> queryKey: versionsQueryKey,
</thead> queryFn: getConfigVersions,
<tbody className='divide-y divide-[var(--border-default)]'> });
{versions.map((version) => (
<tr key={version.id} className='align-top'> const versions = useMemo(
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{version.version}</td> () => versionsQuery.data ?? [],
<td className='px-3 py-4'> [versionsQuery.data],
<StatusBadge );
label={version.is_active ? '当前激活' : '历史版本'} const selectedVersion = useMemo(
variant={version.is_active ? 'success' : 'info'} () => versions.find((item) => item.id === selectedVersionId) ?? null,
/> [selectedVersionId, versions],
</td> );
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{version.created_by || '系统'}</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]' title={version.checksum}> const publishMutation = useMutation({
{truncateChecksum(version.checksum)} mutationFn: publishConfigVersion,
</td> onSuccess: async (version) => {
<td className='px-3 py-4 text-[var(--foreground-secondary)]'> setFeedback({
{formatDateTime(version.created_at)} tone: 'success',
</td> message: `发布成功,版本 ${version.version}`,
<td className='px-3 py-4'> });
<div className='flex flex-wrap gap-2'> setPublishPreview(null);
<SecondaryButton setSelectedVersionId(version.id);
type='button' await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
onClick={() => setSelectedVersion(version)} },
className='px-3 py-2 text-xs' onError: (error) => {
> setFeedback({ tone: 'danger', message: getErrorMessage(error) });
查看快照 },
</SecondaryButton> });
{!version.is_active ? (
<PrimaryButton const activateMutation = useMutation({
type='button' mutationFn: activateConfigVersion,
onClick={() => handleActivate(version)} onSuccess: async (version) => {
disabled={activateMutation.isPending} setFeedback({
className='px-3 py-2 text-xs' tone: 'success',
> message: `已激活版本 ${version.version}`,
重新激活 });
</PrimaryButton> setSelectedVersionId(version.id);
) : null} await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
</div> },
</td> onError: (error) => {
</tr> setFeedback({ tone: 'danger', message: getErrorMessage(error) });
))} },
</tbody> });
</table>
</div> const handleOpenPublishPreview = async () => {
)} setFeedback(null);
</AppCard> setIsPreviewLoading(true);
{selectedVersion ? <VersionDetailCard version={selectedVersion} /> : null} try {
</div> const [preview, diff] = await Promise.all([
); getConfigVersionPreview(),
} getConfigVersionDiff(),
]);
setPublishPreview({ preview, diff });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
} finally {
setIsPreviewLoading(false);
}
};
const handleActivate = (version: ConfigVersionItem) => {
if (version.is_active) {
return;
}
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
return;
}
setFeedback(null);
activateMutation.mutate(version.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="配置版本"
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
action={
<PrimaryButton
type="button"
onClick={handleOpenPublishPreview}
disabled={isPreviewLoading}
>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
/>
) : null}
<AppCard
title="历史版本"
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: versionsQueryKey,
})
}
>
刷新列表
</SecondaryButton>
}
>
{versionsQuery.isLoading ? (
<LoadingState />
) : versionsQuery.isError ? (
<ErrorState
title="版本列表加载失败"
description={getErrorMessage(versionsQuery.error)}
/>
) : versions.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">版本号</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">创建人</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">创建时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{versions.map((version) => (
<tr key={version.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{version.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={version.is_active ? '当前激活' : '历史版本'}
variant={version.is_active ? 'success' : 'info'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{version.created_by || '系统'}
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(version.created_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setSelectedVersionId(version.id)}
className="px-3 py-2 text-xs"
>
查看快照
</SecondaryButton>
{!version.is_active ? (
<PrimaryButton
type="button"
onClick={() => handleActivate(version)}
disabled={activateMutation.isPending}
className="px-3 py-2 text-xs"
>
重新激活
</PrimaryButton>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<SnapshotModal
version={selectedVersion}
onClose={() => setSelectedVersionId(null)}
/>
</>
);
}
@@ -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<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(values: NodeFormValues): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
};
}
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<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [serverUrl, setServerUrl] = useState('');
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
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 <LoadingState />;
}
if (nodesQuery.isError) {
return (
<ErrorState
title="节点详情加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
);
}
if (!node) {
return (
<EmptyState
title="节点不存在"
description="该节点可能已被删除,或当前 ID 无法匹配到节点记录。"
/>
);
}
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 (
<>
<div className="space-y-6">
<PageHeader
title={node.name}
description="节点详情每 5 秒自动刷新一次。部署命令、Node ID、Token、更新模式和最近应用记录统一在这里查看。"
action={
<>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回列表
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑节点
</SecondaryButton>
{showManualUpdate ? (
<PrimaryButton
type="button"
onClick={() => updateAgentMutation.mutate()}
disabled={
updateAgentMutation.isPending || node.update_requested
}
>
{node.update_requested ? '等待更新中...' : '手动升级 Agent'}
</PrimaryButton>
) : null}
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除节点
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="连接状态">
<div className="space-y-3">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
最近心跳:
{isMeaningfulTime(node.last_seen_at)
? ` ${formatRelativeTime(
node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
</div>
</AppCard>
<AppCard title="版本信息">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>Agent:{node.agent_version || 'unknown'}</p>
<p>Nginx:{node.nginx_version || 'unknown'}</p>
<p>当前配置:{node.current_version || '未应用'}</p>
</div>
</AppCard>
<AppCard title="更新模式">
<div className="space-y-3">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{node.update_requested
? '已等待节点在下一次心跳后执行更新。'
: node.auto_update_enabled
? '节点已启用自动更新。'
: '当前仅支持手动触发更新。'}
</p>
</div>
</AppCard>
<AppCard title="最近应用">
<div className="space-y-3">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(node.latest_apply_result)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(
node.latest_apply_at,
)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无应用记录'}
</p>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="节点标识与部署"
description="Node ID、Token 与部署命令不再在列表中展示,统一收敛到详情页。"
action={
nodeInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
nodeInstallCommand,
'节点专属部署命令已复制。',
)
}
>
复制部署命令
</PrimaryButton>
) : null
}
>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Node ID
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.node_id}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Agent Token
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.agent_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL"
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{nodeInstallCommand ? (
<CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand}
</CodeBlock>
) : null}
</div>
</AppCard>
<AppCard title="运行信息">
<div className="space-y-4 text-sm text-[var(--foreground-secondary)]">
<div>
<p className="font-medium text-[var(--foreground-primary)]">
IP 地址
</p>
<p className="mt-1">{node.ip || '暂无'}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
最近错误
</p>
<p className="mt-1 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
创建时间
</p>
<p className="mt-1">{formatDateTime(node.created_at)}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
更新时间
</p>
<p className="mt-1">{formatDateTime(node.updated_at)}</p>
</div>
</div>
</AppCard>
</div>
<AppCard
title="最近应用记录"
description="仅展示当前节点的应用历史。"
action={
<Link
href={`/apply-log?node_id=${encodeURIComponent(node.node_id)}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
查看完整记录
</Link>
}
>
{applyLogsQuery.isLoading ? (
<LoadingState />
) : applyLogsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(applyLogsQuery.error)}
/>
) : applyLogs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="当前节点还没有上报过配置应用结果。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">消息</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.slice(0, 10).map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={log.result === 'success' ? '成功' : '失败'}
variant={
log.result === 'success' ? 'success' : 'danger'
}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatRelativeTime(log.created_at)} ·{' '}
{formatDateTime(log.created_at)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-80 break-words whitespace-pre-wrap">
{log.message || '—'}
</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
title="编辑节点"
description="更新模式和节点名都在详情页维护。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(false)}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-detail-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="node-detail-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</form>
</AppModal>
</>
);
}
@@ -3,7 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -15,17 +15,14 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public';
import { import {
createNode, createNode,
deleteNode, deleteNode,
getNodes, getNodes,
requestNodeAgentUpdate,
updateNode, updateNode,
} from '@/features/nodes/api/nodes'; } from '@/features/nodes/api/nodes';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; import type { NodeMutationPayload } from '@/features/nodes/types';
import { import {
CodeBlock,
DangerButton, DangerButton,
PrimaryButton, PrimaryButton,
ResourceField, ResourceField,
@@ -34,10 +31,15 @@ import {
ToggleField, ToggleField,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
isMeaningfulTime,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes']; const nodesQueryKey = ['nodes'];
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
const nodeSchema = z.object({ const nodeSchema = z.object({
name: z 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() { export function NodesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null); const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [, setRefreshTick] = useState(0);
const form = useForm<NodeFormValues>({ const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema), resolver: zodResolver(nodeSchema),
@@ -224,30 +92,9 @@ export function NodesPage() {
const nodesQuery = useQuery({ const nodesQuery = useQuery({
queryKey: nodesQueryKey, queryKey: nodesQueryKey,
queryFn: getNodes, 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({ const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) => { mutationFn: async (values: NodeFormValues) => {
const payload = toPayload(values); const payload = toPayload(values);
@@ -255,14 +102,13 @@ export function NodesPage() {
? updateNode(editingNodeId, payload) ? updateNode(editingNodeId, payload)
: createNode(payload); : createNode(payload);
}, },
onSuccess: async (node) => { onSuccess: async () => {
setFeedback({ setFeedback({
tone: 'success', tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。', message: editingNodeId ? '节点已更新。' : '节点已创建。',
}); });
setEditingNodeId(null); setEditingNodeId(null);
setIsEditorOpen(false); setIsEditorOpen(false);
setSelectedNode(node);
form.reset(defaultValues); form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); 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({ const deleteMutation = useMutation({
mutationFn: deleteNode, mutationFn: deleteNode,
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' }); setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNodeId(null); setEditingNodeId(null);
setSelectedNode(null);
form.reset(defaultValues); form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
@@ -301,16 +131,6 @@ export function NodesPage() {
}); });
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); 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 = () => { const handleReset = () => {
setFeedback(null); setFeedback(null);
@@ -326,43 +146,31 @@ export function NodesPage() {
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
const handleEdit = (node: NodeItem) => { const handleEdit = (
nodeId: number,
name: string,
autoUpdateEnabled: boolean,
) => {
setFeedback(null); setFeedback(null);
setEditingNodeId(node.id); setEditingNodeId(nodeId);
setSelectedNode(node);
form.reset({ form.reset({
name: node.name, name,
auto_update_enabled: node.auto_update_enabled, auto_update_enabled: autoUpdateEnabled,
}); });
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
const handleOpenDeployModal = (node: NodeItem) => { const handleDelete = (nodeId: number, nodeName: string) => {
setFeedback(null);
setSelectedNode(node);
setIsDeployModalOpen(true);
};
const handleDelete = (node: NodeItem) => {
if ( if (
!window.confirm( !window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
) )
) { ) {
return; return;
} }
setFeedback(null); setFeedback(null);
deleteMutation.mutate(node.id); deleteMutation.mutate(nodeId);
};
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) => { const handleSubmit = form.handleSubmit((values) => {
@@ -370,20 +178,12 @@ export function NodesPage() {
saveMutation.mutate(values); saveMutation.mutate(values);
}); });
const nodeInstallCommand =
normalizedServerUrl && selectedNodeFromList?.agent_token
? buildNodeInstallCommand(
normalizedServerUrl,
selectedNodeFromList.agent_token,
)
: '';
return ( return (
<> <>
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="节点管理" title="节点管理"
description="查看节点在线状态、最近心跳、部署入口与 Agent 更新动作,并支持预创建节点。" description="节点列表只保留状态、版本与最近活动等关键内容。节点部署、Token、更新模式与手动升级入口统一收敛到详情页。"
action={ action={
<> <>
<SecondaryButton type="button" onClick={handleCreate}> <SecondaryButton type="button" onClick={handleCreate}>
@@ -405,6 +205,7 @@ export function NodesPage() {
<AppCard <AppCard
title="节点列表" title="节点列表"
description="列表每 5 秒自动刷新一次。"
action={ action={
<SecondaryButton <SecondaryButton
type="button" type="button"
@@ -412,7 +213,7 @@ export function NodesPage() {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
} }
> >
刷新列表 立即刷新
</SecondaryButton> </SecondaryButton>
} }
> >
@@ -426,7 +227,7 @@ export function NodesPage() {
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<EmptyState <EmptyState
title="暂无节点" title="暂无节点"
description="请先创建一个节点,然后在列表中打开该节点的部署弹窗。" description="请先创建一个节点,然后进入详情页查看专属部署命令。"
/> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -435,231 +236,105 @@ export function NodesPage() {
<tr className="text-[var(--foreground-secondary)]"> <tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">节点</th> <th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态</th> <th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">更新模式</th>
<th className="px-3 py-3 font-medium">Agent / Nginx</th> <th className="px-3 py-3 font-medium">Agent / Nginx</th>
<th className="px-3 py-3 font-medium">当前版本</th> <th className="px-3 py-3 font-medium">当前版本</th>
<th className="px-3 py-3 font-medium">最近应用</th> <th className="px-3 py-3 font-medium">最近应用</th>
<th className="px-3 py-3 font-medium">最近心跳</th> <th className="px-3 py-3 font-medium">最近心跳</th>
<th className="px-3 py-3 font-medium">错误</th>
<th className="px-3 py-3 font-medium">操作</th> <th className="px-3 py-3 font-medium">操作</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{nodes.map((node) => { {nodes.map((node) => (
const updateMode = getUpdateMode(node); <tr key={node.id} className="align-top">
const showManualUpdate = shouldShowManualUpdate( <td className="px-3 py-4">
node.agent_version || '', <div className="space-y-1">
serverVersion, <p className="font-medium text-[var(--foreground-primary)]">
); {node.name}
return (
<tr key={node.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-2">
<p className="font-medium text-[var(--foreground-primary)]">
{node.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Node ID:{node.node_id}
</p>
<p className="text-xs break-all text-[var(--foreground-secondary)]">
Token:{node.agent_token || '暂无'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
IP:{node.ip || '暂无'}
</p>
</div>
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getStatusLabel(node.status)}
variant={getStatusVariant(node.status)}
/>
<StatusBadge
label={node.pending ? '未占用' : '已绑定'}
variant={node.pending ? 'warning' : 'success'}
/>
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.agent_version || 'unknown'} /{' '}
{node.nginx_version || 'unknown'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
<p className="max-w-56 text-xs leading-5 text-[var(--foreground-secondary)]">
{node.latest_apply_message || '暂无记录'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-56 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p> </p>
</td> <p className="text-xs text-[var(--foreground-secondary)]">
<td className="px-3 py-4"> IP:{node.ip || '暂无'}
<div className="flex flex-wrap gap-2"> </p>
{showManualUpdate ? ( </div>
<PrimaryButton </td>
type="button" <td className="px-3 py-4">
onClick={() => <StatusBadge
updateAgentMutation.mutate(node.id) label={getNodeStatusLabel(node.status)}
} variant={getNodeStatusVariant(node.status)}
disabled={ />
updateAgentMutation.isPending || </td>
node.update_requested <td className="px-3 py-4 text-[var(--foreground-secondary)]">
} {node.agent_version || 'unknown'} /{' '}
className="px-3 py-2 text-xs" {node.nginx_version || 'unknown'}
> </td>
升级 <td className="px-3 py-4 text-[var(--foreground-secondary)]">
</PrimaryButton> {node.current_version || '未应用'}
) : null} </td>
<SecondaryButton <td className="px-3 py-4">
type="button" <div className="space-y-2">
onClick={() => handleOpenDeployModal(node)} <StatusBadge
className="px-3 py-2 text-xs" label={getApplyLabel(node.latest_apply_result)}
> variant={getApplyVariant(node.latest_apply_result)}
部署 />
</SecondaryButton> <p className="text-xs text-[var(--foreground-secondary)]">
<SecondaryButton {isMeaningfulTime(node.latest_apply_at)
type="button" ? `${formatRelativeTime(
onClick={() => handleEdit(node)} node.latest_apply_at,
className="px-3 py-2 text-xs" )} · ${formatDateTime(node.latest_apply_at)}`
> : '暂无'}
编辑 </p>
</SecondaryButton> </div>
<DangerButton </td>
type="button" <td className="px-3 py-4 text-[var(--foreground-secondary)]">
onClick={() => handleDelete(node)} {isMeaningfulTime(node.last_seen_at)
disabled={deleteMutation.isPending} ? `${formatRelativeTime(
className="px-3 py-2 text-xs" node.last_seen_at,
> )} · ${formatDateTime(node.last_seen_at)}`
删除 : '暂无'}
</DangerButton> </td>
</div> <td className="px-3 py-4">
</td> <div className="flex flex-wrap gap-2">
</tr> <Link
); href={`/node/detail?id=${node.id}`}
})} className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<SecondaryButton
type="button"
onClick={() =>
handleEdit(
node.id,
node.name,
node.auto_update_enabled,
)
}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(node.id, node.name)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</AppCard> </AppCard>
</div> </div>
<AppModal
isOpen={isDeployModalOpen}
onClose={() => setIsDeployModalOpen(false)}
title="节点专属部署命令"
description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsDeployModalOpen(false)}
>
关闭
</SecondaryButton>
{nodeInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
nodeInstallCommand,
'节点专属部署命令已复制。',
)
}
>
复制命令
</PrimaryButton>
) : null}
</div>
}
>
{selectedNodeFromList ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{selectedNodeFromList.name}
</p>
<StatusBadge
label={selectedNodeFromList.pending ? '未占用' : '已绑定'}
variant={
selectedNodeFromList.pending ? 'warning' : 'success'
}
/>
</div>
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
Node ID:{selectedNodeFromList.node_id}
</p>
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
IP:{selectedNodeFromList.ip || '暂无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Agent Token
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedNodeFromList.agent_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL"
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{nodeInstallCommand ? (
<CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand}
</CodeBlock>
) : null}
</div>
) : (
<EmptyState
title="尚未选择节点"
description="请从节点列表中点击“部署”打开当前节点的专属安装命令。"
/>
)}
</AppModal>
<AppModal <AppModal
isOpen={isEditorOpen} isOpen={isEditorOpen}
onClose={handleReset} onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'} title={editingNodeId ? '编辑节点' : '新增节点'}
description="预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。" description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
footer={ footer={
<div className="flex flex-wrap justify-end gap-3"> <div className="flex flex-wrap justify-end gap-3">
<SecondaryButton <SecondaryButton
-1
View File
@@ -4,7 +4,6 @@ export interface NodeItem {
name: string; name: string;
ip: string; ip: string;
agent_token: string; agent_token: string;
pending: boolean;
auto_update_enabled: boolean; auto_update_enabled: boolean;
update_requested: boolean; update_requested: boolean;
agent_version: string; agent_version: string;
+133
View File
@@ -0,0 +1,133 @@
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
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;
}
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');
}
File diff suppressed because it is too large Load Diff
@@ -38,7 +38,11 @@ import { formatDateTime } from '@/lib/utils/date';
const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const; const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const;
const manualImportSchema = z.object({ 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 内容'), cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'), key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'), remark: z.string().max(255, '备注不能超过 255 个字符'),
@@ -92,7 +96,9 @@ function getCertificateStatus(certificate: TlsCertificateItem) {
return { label: '有效', variant: 'success' as const }; return { label: '有效', variant: 'success' as const };
} }
function toManualPayload(values: ManualImportFormValues): TlsCertificateMutationPayload { function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
return { return {
name: values.name.trim(), name: values.name.trim(),
cert_pem: values.cert_pem.trim(), cert_pem: values.cert_pem.trim(),
@@ -122,7 +128,9 @@ export function TlsCertificatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [fileForm, setFileForm] = useState<FileImportFormValues>(defaultFileValues); const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [fileForm, setFileForm] =
useState<FileImportFormValues>(defaultFileValues);
const [certFile, setCertFile] = useState<File | null>(null); const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null); const [keyFile, setKeyFile] = useState<File | null>(null);
const [fileInputNonce, setFileInputNonce] = useState(0); const [fileInputNonce, setFileInputNonce] = useState(0);
@@ -138,12 +146,16 @@ export function TlsCertificatesPage() {
}); });
const manualImportMutation = useMutation({ const manualImportMutation = useMutation({
mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)), mutationFn: async (values: ManualImportFormValues) =>
createTlsCertificate(toManualPayload(values)),
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已导入。' }); setFeedback({ tone: 'success', message: '证书已导入。' });
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
manualForm.reset(defaultManualValues); manualForm.reset(defaultManualValues);
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -155,12 +167,15 @@ export function TlsCertificatesPage() {
importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)), importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)),
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书文件已导入。' }); setFeedback({ tone: 'success', message: '证书文件已导入。' });
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
setFileForm(defaultFileValues); setFileForm(defaultFileValues);
setCertFile(null); setCertFile(null);
setKeyFile(null); setKeyFile(null);
setFileInputNonce((value) => value + 1); setFileInputNonce((value) => value + 1);
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -171,14 +186,19 @@ export function TlsCertificatesPage() {
mutationFn: deleteTlsCertificate, mutationFn: deleteTlsCertificate,
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已删除。' }); setFeedback({ tone: 'success', message: '证书已删除。' });
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(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) => { const handleManualSubmit = manualForm.handleSubmit((values) => {
setFeedback(null); setFeedback(null);
@@ -201,207 +221,308 @@ export function TlsCertificatesPage() {
}; };
const handleCloseImportModal = () => { const handleCloseImportModal = () => {
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
}; };
return ( return (
<> <>
<div className='space-y-6'> <div className="space-y-6">
<PageHeader <PageHeader
title='TLS 证书' title="TLS 证书"
description='支持手动粘贴 PEM 导入和文件上传导入,并展示证书有效期与到期风险。' description="支持手动粘贴 PEM 导入和文件上传导入,并展示证书有效期与到期风险。"
action={ action={
<PrimaryButton type='button' onClick={() => setIsImportModalOpen(true)}> <PrimaryButton
导入证书 type="button"
</PrimaryButton> onClick={() => {
} setImportMode('manual');
/> setIsImportModalOpen(true);
}}
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} >
导入证书
<AppCard
title='证书列表'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
>
刷新
</SecondaryButton>
}
>
{certificatesQuery.isLoading ? (
<LoadingState />
) : certificatesQuery.isError ? (
<ErrorState title='证书列表加载失败' description={getErrorMessage(certificatesQuery.error)} />
) : certificates.length === 0 ? (
<EmptyState title='暂无证书' description='请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。' />
) : (
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
<thead>
<tr className='text-[var(--foreground-secondary)]'>
<th className='px-3 py-3 font-medium'>名称</th>
<th className='px-3 py-3 font-medium'>状态</th>
<th className='px-3 py-3 font-medium'>有效期</th>
<th className='px-3 py-3 font-medium'>备注</th>
<th className='px-3 py-3 font-medium'>更新时间</th>
<th className='px-3 py-3 font-medium'>操作</th>
</tr>
</thead>
<tbody className='divide-y divide-[var(--border-default)]'>
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
return (
<tr key={certificate.id} className='align-top'>
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{certificate.name}</td>
<td className='px-3 py-4'>
<StatusBadge label={status.label} variant={status.variant} />
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
<div className='space-y-1'>
<p>{formatDateTime(certificate.not_before)}</p>
<p>{formatDateTime(certificate.not_after)}</p>
</div>
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{certificate.remark || '—'}</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
{formatDateTime(certificate.updated_at)}
</td>
<td className='px-3 py-4'>
<DangerButton
type='button'
onClick={() => handleDelete(certificate)}
disabled={deleteMutation.isPending}
className='px-3 py-2 text-xs'
>
删除
</DangerButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isImportModalOpen}
onClose={handleCloseImportModal}
title='导入证书'
description='手动粘贴和文件导入都保留在同一个弹窗中,避免压缩主列表空间。'
size='xl'
>
<div className='grid gap-6 xl:grid-cols-2'>
<AppCard title='手动导入' description='直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。'>
<form className='space-y-5' onSubmit={handleManualSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<ResourceField label='证书名称' error={manualForm.formState.errors.name?.message}>
<ResourceInput placeholder='example-com' {...manualForm.register('name')} />
</ResourceField>
<ResourceField
label='备注'
hint='可选,用于记录证书用途或来源。'
error={manualForm.formState.errors.remark?.message}
>
<ResourceInput placeholder='例如:主站生产证书' {...manualForm.register('remark')} />
</ResourceField>
</div>
<ResourceField label='证书 PEM' error={manualForm.formState.errors.cert_pem?.message}>
<ResourceTextarea
placeholder='-----BEGIN CERTIFICATE-----'
className='min-h-40 font-mono text-xs'
{...manualForm.register('cert_pem')}
/>
</ResourceField>
<ResourceField label='私钥 PEM' error={manualForm.formState.errors.key_pem?.message}>
<ResourceTextarea
placeholder='-----BEGIN PRIVATE KEY-----'
className='min-h-40 font-mono text-xs'
{...manualForm.register('key_pem')}
/>
</ResourceField>
<PrimaryButton type='submit' disabled={manualImportMutation.isPending}>
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
</PrimaryButton> </PrimaryButton>
</form> }
</AppCard> />
<AppCard title='文件导入' description='上传证书文件和私钥文件,适合直接复用现有 PEM 文件。'> {feedback ? (
<form className='space-y-5' onSubmit={handleFileSubmit}> <InlineMessage tone={feedback.tone} message={feedback.message} />
<div className='grid gap-4 md:grid-cols-2'> ) : null}
<ResourceField label='证书名称'>
<ResourceInput
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({ ...current, name: event.target.value }))
}
placeholder='wildcard-example'
/>
</ResourceField>
<ResourceField label='备注' hint='可选,用于记录部署环境或域名范围。'>
<ResourceInput
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({ ...current, remark: event.target.value }))
}
placeholder='例如:泛域名生产证书'
/>
</ResourceField>
</div>
<div className='grid gap-4 md:grid-cols-2'> <AppCard
<ResourceField title="证书列表"
label='证书文件' action={
hint={certFile ? `已选择:${certFile.name}` : '请选择 PEM/CRT 文件'} <SecondaryButton
> type="button"
<ResourceInput onClick={() =>
key={`cert-${fileInputNonce}`} void queryClient.invalidateQueries({
type='file' queryKey: tlsCertificatesQueryKey,
accept='.pem,.crt,.cer' })
onChange={(event) => setCertFile(event.target.files?.[0] ?? null)} }
/> >
</ResourceField> 刷新
<ResourceField </SecondaryButton>
label='私钥文件' }
hint={keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'} >
> {certificatesQuery.isLoading ? (
<ResourceInput <LoadingState />
key={`key-${fileInputNonce}`} ) : certificatesQuery.isError ? (
type='file' <ErrorState
accept='.key,.pem' title="证书列表加载失败"
onChange={(event) => setKeyFile(event.target.files?.[0] ?? null)} description={getErrorMessage(certificatesQuery.error)}
/> />
</ResourceField> ) : certificates.length === 0 ? (
</div> <EmptyState
title="暂无证书"
description="请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">名称</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">有效期</th>
<th className="px-3 py-3 font-medium">备注</th>
<th className="px-3 py-3 font-medium">更新时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
<div className='flex flex-wrap gap-3'> return (
<PrimaryButton type='submit' disabled={fileImportMutation.isPending}> <tr key={certificate.id} className="align-top">
{fileImportMutation.isPending ? '上传中...' : '上传文件'} <td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
</PrimaryButton> {certificate.name}
<SecondaryButton </td>
type='button' <td className="px-3 py-4">
onClick={() => { <StatusBadge
setFileForm(defaultFileValues); label={status.label}
setCertFile(null); variant={status.variant}
setKeyFile(null); />
setFileInputNonce((value) => value + 1); </td>
}} <td className="px-3 py-4 text-[var(--foreground-secondary)]">
disabled={fileImportMutation.isPending} <div className="space-y-1">
> <p>{formatDateTime(certificate.not_before)}</p>
清空文件 <p>{formatDateTime(certificate.not_after)}</p>
</SecondaryButton> </div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{certificate.remark || '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(certificate.updated_at)}
</td>
<td className="px-3 py-4">
<DangerButton
type="button"
onClick={() => handleDelete(certificate)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
</form> )}
</AppCard> </AppCard>
</div> </div>
</AppModal> <AppModal
isOpen={isImportModalOpen}
onClose={handleCloseImportModal}
title="导入证书"
description="手动导入和文档导入通过标签切换,避免在同一层同时堆叠两套表单。"
size="xl"
>
<div className="space-y-6">
<div className="inline-flex rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-1">
<button
type="button"
onClick={() => setImportMode('manual')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'manual'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
手动导入
</button>
<button
type="button"
onClick={() => setImportMode('file')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'file'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
文档导入
</button>
</div>
{importMode === 'manual' ? (
<AppCard
title="手动导入"
description="直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。"
>
<form className="space-y-5" onSubmit={handleManualSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={manualForm.formState.errors.name?.message}
>
<ResourceInput
placeholder="example-com"
{...manualForm.register('name')}
/>
</ResourceField>
<ResourceField
label="备注"
hint="可选,用于记录证书用途或来源。"
error={manualForm.formState.errors.remark?.message}
>
<ResourceInput
placeholder="例如:主站生产证书"
{...manualForm.register('remark')}
/>
</ResourceField>
</div>
<ResourceField
label="证书 PEM"
error={manualForm.formState.errors.cert_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN CERTIFICATE-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('cert_pem')}
/>
</ResourceField>
<ResourceField
label="私钥 PEM"
error={manualForm.formState.errors.key_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN PRIVATE KEY-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('key_pem')}
/>
</ResourceField>
<PrimaryButton
type="submit"
disabled={manualImportMutation.isPending}
>
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
</PrimaryButton>
</form>
</AppCard>
) : (
<AppCard
title="文档导入"
description="上传证书文件和私钥文件,适合直接复用现有 PEM 文件。"
>
<form className="space-y-5" onSubmit={handleFileSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField label="证书名称">
<ResourceInput
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="wildcard-example"
/>
</ResourceField>
<ResourceField
label="备注"
hint="可选,用于记录部署环境或域名范围。"
>
<ResourceInput
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({
...current,
remark: event.target.value,
}))
}
placeholder="例如:泛域名生产证书"
/>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书文件"
hint={
certFile
? `已选择:${certFile.name}`
: '请选择 PEM/CRT 文件'
}
>
<ResourceInput
key={`cert-${fileInputNonce}`}
type="file"
accept=".pem,.crt,.cer"
onChange={(event) =>
setCertFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
<ResourceField
label="私钥文件"
hint={
keyFile
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'
}
>
<ResourceInput
key={`key-${fileInputNonce}`}
type="file"
accept=".key,.pem"
onChange={(event) =>
setKeyFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
</div>
<div className="flex flex-wrap gap-3">
<PrimaryButton
type="submit"
disabled={fileImportMutation.isPending}
>
{fileImportMutation.isPending ? '上传中...' : '上传文件'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={() => {
setFileForm(defaultFileValues);
setCertFile(null);
setKeyFile(null);
setFileInputNonce((value) => value + 1);
}}
disabled={fileImportMutation.isPending}
>
清空文件
</SecondaryButton>
</div>
</form>
</AppCard>
)}
</div>
</AppModal>
</> </>
); );
} }
+58 -53
View File
@@ -1,53 +1,58 @@
import { publicEnv } from '@/lib/env/public-env'; import { publicEnv } from '@/lib/env/public-env';
import type { ApiEnvelope } from '@/types/api'; import type { ApiEnvelope } from '@/types/api';
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
message: string, message: string,
public readonly status: number, public readonly status: number,
) { ) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
} }
} }
export function getApiUrl(path: string) { export function getApiUrl(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`; const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${publicEnv.apiBaseUrl}${normalizedPath}`; return `${publicEnv.apiBaseUrl}${normalizedPath}`;
} }
export async function apiRequest<T>(path: string, init?: RequestInit) { export async function apiRequest<T>(path: string, init?: RequestInit) {
const headers = new Headers(init?.headers ?? {}); const headers = new Headers(init?.headers ?? {});
const method = init?.method?.toUpperCase() ?? 'GET';
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
} headers.set('Content-Type', 'application/json');
}
const response = await fetch(getApiUrl(path), {
credentials: 'include', const response = await fetch(getApiUrl(path), {
headers, credentials: 'include',
...init, headers,
}); cache: method === 'GET' ? 'no-store' : init?.cache,
...init,
let payload: ApiEnvelope<T> | null = null; });
try { let payload: ApiEnvelope<T> | null = null;
payload = (await response.json()) as ApiEnvelope<T>;
} catch { try {
payload = null; payload = (await response.json()) as ApiEnvelope<T>;
} } catch {
payload = null;
if (!response.ok) { }
throw new ApiError(payload?.message || `请求失败(${response.status})`, response.status);
} if (!response.ok) {
throw new ApiError(
if (!payload) { payload?.message || `请求失败(${response.status})`,
throw new ApiError('响应格式无效', response.status); response.status,
} );
}
if (!payload.success) {
throw new ApiError(payload.message || '请求失败', response.status); if (!payload) {
} throw new ApiError('响应格式无效', response.status);
}
return payload.data;
} if (!payload.success) {
throw new ApiError(payload.message || '请求失败', response.status);
}
return payload.data;
}
File diff suppressed because one or more lines are too long