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
@@ -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<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
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>({
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 (
<>
<div className="space-y-6">
<PageHeader
title="节点管理"
description="查看节点在线状态、最近心跳、部署入口与 Agent 更新动作,并支持预创建节点。"
description="节点列表只保留状态、版本与最近活动等关键内容。节点部署、Token、更新模式与手动升级入口统一收敛到详情页。"
action={
<>
<SecondaryButton type="button" onClick={handleCreate}>
@@ -405,6 +205,7 @@ export function NodesPage() {
<AppCard
title="节点列表"
description="列表每 5 秒自动刷新一次。"
action={
<SecondaryButton
type="button"
@@ -412,7 +213,7 @@ export function NodesPage() {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
}
>
刷新列表
立即刷新
</SecondaryButton>
}
>
@@ -426,7 +227,7 @@ export function NodesPage() {
) : nodes.length === 0 ? (
<EmptyState
title="暂无节点"
description="请先创建一个节点,然后在列表中打开该节点的部署弹窗。"
description="请先创建一个节点,然后进入详情页查看专属部署命令。"
/>
) : (
<div className="overflow-x-auto">
@@ -435,231 +236,105 @@ export function NodesPage() {
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">更新模式</th>
<th className="px-3 py-3 font-medium">Agent / Nginx</th>
<th className="px-3 py-3 font-medium">当前版本</th>
<th className="px-3 py-3 font-medium">最近应用</th>
<th className="px-3 py-3 font-medium">最近心跳</th>
<th className="px-3 py-3 font-medium">错误</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{nodes.map((node) => {
const updateMode = getUpdateMode(node);
const showManualUpdate = shouldShowManualUpdate(
node.agent_version || '',
serverVersion,
);
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 || '无'}
{nodes.map((node) => (
<tr key={node.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<p className="font-medium text-[var(--foreground-primary)]">
{node.name}
</p>
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
{showManualUpdate ? (
<PrimaryButton
type="button"
onClick={() =>
updateAgentMutation.mutate(node.id)
}
disabled={
updateAgentMutation.isPending ||
node.update_requested
}
className="px-3 py-2 text-xs"
>
升级
</PrimaryButton>
) : null}
<SecondaryButton
type="button"
onClick={() => handleOpenDeployModal(node)}
className="px-3 py-2 text-xs"
>
部署
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => handleEdit(node)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(node)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
<p className="text-xs text-[var(--foreground-secondary)]">
IP:{node.ip || '暂无'}
</p>
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
</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="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">
<div className="flex flex-wrap gap-2">
<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>
</table>
</div>
)}
</AppCard>
</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
isOpen={isEditorOpen}
onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'}
description="预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。"
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton