'use client'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { 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 { getConfigVersions } from '@/features/config-versions/api/config-versions'; import { createNode, deleteNode, getNodes, updateNode, } from '@/features/nodes/api/nodes'; import type { NodeMutationPayload } from '@/features/nodes/types'; import { DangerButton, PrimaryButton, ResourceField, ResourceInput, SecondaryButton, ToggleField, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { getApplyLabel, getApplyVariant, getNodeStatusLabel, getNodeStatusVariant, getOpenrestyStatusLabel, getOpenrestyStatusVariant, isMeaningfulTime, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; const supportedRiskFilters = [ 'all', 'offline', 'unhealthy', 'lagging', ] as const; type NodeRiskFilter = (typeof supportedRiskFilters)[number]; const nodeSchema = z.object({ name: z .string() .trim() .min(1, '请输入节点名') .max(128, '节点名不能超过 128 个字符'), auto_update_enabled: z.boolean(), geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'), geo_latitude: z.string().trim(), geo_longitude: z.string().trim(), }).superRefine((values, ctx) => { const hasLatitude = values.geo_latitude !== ''; const hasLongitude = values.geo_longitude !== ''; if (hasLatitude !== hasLongitude) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_latitude'], message: '纬度和经度需要同时填写', }); ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_longitude'], message: '纬度和经度需要同时填写', }); return; } if (hasLatitude) { const latitude = Number(values.geo_latitude); if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_latitude'], message: '纬度必须在 -90 到 90 之间', }); } } if (hasLongitude) { const longitude = Number(values.geo_longitude); if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['geo_longitude'], message: '经度必须在 -180 到 180 之间', }); } } }); type NodeFormValues = z.infer; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; const defaultValues: NodeFormValues = { name: '', auto_update_enabled: false, geo_name: '', geo_latitude: '', geo_longitude: '', }; 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, geo_name: values.geo_name.trim(), geo_latitude: values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude), geo_longitude: values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude), }; } export function NodesPage() { const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [editingNodeId, setEditingNodeId] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const form = useForm({ resolver: zodResolver(nodeSchema), defaultValues, }); const watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', }); const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, refetchInterval: 5000, }); const configVersionsQuery = useQuery({ queryKey: ['config-versions'], queryFn: getConfigVersions, refetchInterval: 5000, }); const saveMutation = useMutation({ mutationFn: async (values: NodeFormValues) => { const payload = toPayload(values); return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload); }, onSuccess: async () => { setFeedback({ tone: 'success', message: editingNodeId ? '节点已更新。' : '节点已创建。', }); setEditingNodeId(null); setIsEditorOpen(false); form.reset(defaultValues); 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); form.reset(defaultValues); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const activeVersion = useMemo( () => (configVersionsQuery.data ?? []).find((item) => item.is_active) ?.version ?? '', [configVersionsQuery.data], ); const riskFilter = useMemo(() => { const current = searchParams.get('risk')?.trim().toLowerCase() ?? 'all'; return supportedRiskFilters.includes(current as NodeRiskFilter) ? (current as NodeRiskFilter) : 'all'; }, [searchParams]); const filteredNodes = useMemo(() => { switch (riskFilter) { case 'offline': return nodes.filter((node) => node.status === 'offline'); case 'unhealthy': return nodes.filter((node) => node.openresty_status === 'unhealthy'); case 'lagging': return nodes.filter((node) => { if (!activeVersion) { return false; } return node.current_version !== activeVersion; }); default: return nodes; } }, [activeVersion, nodes, riskFilter]); const filterDescription = useMemo(() => { switch (riskFilter) { case 'offline': return '当前仅展示离线节点。'; case 'unhealthy': return '当前仅展示 OpenResty 异常节点。'; case 'lagging': return activeVersion ? `当前仅展示未追平激活版本 ${activeVersion} 的节点。` : '当前没有激活版本,无法筛选配置落后节点。'; default: return '列表每 5 秒自动刷新一次。'; } }, [activeVersion, riskFilter]); const handleReset = () => { setFeedback(null); setEditingNodeId(null); setIsEditorOpen(false); form.reset(defaultValues); }; const handleCreate = () => { setFeedback(null); setEditingNodeId(null); form.reset(defaultValues); setIsEditorOpen(true); }; const handleEdit = ( nodeId: number, name: string, autoUpdateEnabled: boolean, geoName: string, geoLatitude?: number | null, geoLongitude?: number | null, ) => { setFeedback(null); setEditingNodeId(nodeId); form.reset({ name, auto_update_enabled: autoUpdateEnabled, geo_name: geoName, geo_latitude: geoLatitude === undefined || geoLatitude === null ? '' : String(geoLatitude), geo_longitude: geoLongitude === undefined || geoLongitude === null ? '' : String(geoLongitude), }); setIsEditorOpen(true); }; const handleDelete = (nodeId: number, nodeName: string) => { if ( !window.confirm( `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`, ) ) { return; } setFeedback(null); deleteMutation.mutate(nodeId); }; const handleSubmit = form.handleSubmit((values) => { setFeedback(null); saveMutation.mutate(values); }); return ( <>
新增节点 应用记录 } /> {feedback ? ( ) : null} void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) } > 立即刷新 } > {nodesQuery.isLoading ? ( ) : nodesQuery.isError ? ( ) : filteredNodes.length === 0 ? ( ) : (
全部节点 离线节点 OpenResty 异常 配置落后
{filteredNodes.map((node) => ( ))}
节点 状态 Agent / OpenResty 运行健康 当前版本 最近应用 最近心跳 操作

{node.name}

IP:{node.ip || 'null'}

位置:{node.geo_name || '未配置地图点位'}

{node.agent_version || 'unknown'} /{' '} {node.nginx_version || 'unknown'}
{node.current_version || '未应用'}
{isMeaningfulTime(node.last_seen_at) ? `${formatRelativeTime( node.last_seen_at, )} · ${formatDateTime(node.last_seen_at)}` : '暂无'}
详情 handleEdit( node.id, node.name, node.auto_update_enabled, node.geo_name, node.geo_latitude, node.geo_longitude, ) } className="px-3 py-2 text-xs" > 编辑 handleDelete(node.id, node.name)} disabled={deleteMutation.isPending} className="px-3 py-2 text-xs" > 删除
)}
取消 {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} } >
form.setValue('auto_update_enabled', checked, { shouldDirty: true, shouldValidate: true, }) } />
); }