From 0ad4904e20c0728b0fe755aca54f4c5484e00b7c Mon Sep 17 00:00:00 2001 From: Su-cyber-art <72259479+Su-cyber-art@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:43:41 +0800 Subject: [PATCH 1/5] feat(node): split local and remote nodes into separate tabs (cherry picked from commit 30793b2997040dd29ff5fe6397d0bcab174ecc9e) --- vite-frontend/src/pages/node.tsx | 159 ++++++++++++++++++++++++------- 1 file changed, 127 insertions(+), 32 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index fc78251..23ed558 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; import { DndContext, KeyboardSensor, @@ -64,6 +65,7 @@ import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; +import { PlusIcon, DeleteIcon, CheckAllIcon, XCircleIcon, ListCheckIcon, XIcon, ArrowUpIcon, ShareIcon } from "@/components/icons"; interface Node { id: number; @@ -116,6 +118,8 @@ interface NodeForm { socks: number; // 0 关 1 开 } +type NodeTab = "local" | "remote"; + const SortableItem = ({ id, children, @@ -158,6 +162,7 @@ const SortableItem = ({ }; export default function NodePage() { + const navigate = useNavigate(); const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); @@ -165,6 +170,10 @@ export default function NodePage() { "node-search-keyword", "", ); + const [activeTab, setActiveTab] = useLocalStorageState( + "node-active-tab", + "local", + ); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -963,7 +972,7 @@ export default function NodePage() { }; const selectAll = () => { - setSelectedIds(new Set(sortedNodes.map((n) => n.id))); + setSelectedIds(new Set(displayNodes.map((n) => n.id))); }; const deselectAll = () => { @@ -1062,94 +1071,169 @@ export default function NodePage() { return sortedByDb; }, [nodeList, nodeOrder, searchKeyword]); - const sortableNodeIds = useMemo( - () => sortedNodes.map((n) => n.id), + const localNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote !== 1), [sortedNodes], ); + const remoteNodes = useMemo( + () => sortedNodes.filter((node) => node.isRemote === 1), + [sortedNodes], + ); + + const displayNodes = useMemo( + () => (activeTab === "remote" ? remoteNodes : localNodes), + [activeTab, localNodes, remoteNodes], + ); + + const sortableNodeIds = useMemo( + () => displayNodes.map((n) => n.id), + [displayNodes], + ); + return ( -
-
- setIsSearchVisible(false)} - onOpen={() => setIsSearchVisible(true)} - /> +
+
+ +
-
-
+
+
+ setIsSearchVisible(false)} + onOpen={() => setIsSearchVisible(true)} + /> +
+ +
{selectMode ? ( <> - 已选 {selectedIds.size} 项 + 已选择 {selectedIds.size} 项 ) : ( <> + )} @@ -1176,6 +1260,15 @@ export default function NodePage() { className="h-64" message="暂无节点配置,点击上方按钮开始创建" /> + ) : displayNodes.length === 0 ? ( + ) : (
- {sortedNodes.map((node) => { + {displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; return ( @@ -1195,18 +1288,20 @@ export default function NodePage() { >
-
+
{selectMode && ( toggleSelect(node.id)} /> )} -

- {node.name} -

+
+

+ {node.name} +

+
-
+
Date: Sun, 8 Mar 2026 12:19:16 +0800 Subject: [PATCH 2/5] feat(node): render remote usage details on remote node cards (cherry picked from commit 67294fa76422f994de8d95ec88f8f03e02ea6b9f) --- vite-frontend/src/pages/node.tsx | 191 ++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 23ed558..37a3ff7 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -52,6 +52,7 @@ import { batchUpgradeNodes, getNodeReleases, rollbackNode, + getPeerRemoteUsageList, type ReleaseChannel, } from "@/api"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; @@ -120,6 +121,33 @@ interface NodeForm { type NodeTab = "local" | "remote"; +interface RemoteUsageBinding { + bindingId: number; + tunnelId: number; + tunnelName: string; + chainType: number; + hopInx: number; + allocatedPort: number; + resourceKey: string; + remoteBindingId: string; + updatedTime: number; +} + +interface RemoteUsageNode { + nodeId: number; + nodeName: string; + remoteUrl: string; + shareId: number; + portRangeStart: number; + portRangeEnd: number; + maxBandwidth: number; + currentFlow: number; + usedPorts: number[]; + bindings: RemoteUsageBinding[]; + activeBindingNum: number; + syncError?: string; +} + const SortableItem = ({ id, children, @@ -174,6 +202,9 @@ export default function NodePage() { "node-active-tab", "local", ); + const [remoteUsageMap, setRemoteUsageMap] = useState< + Record + >({}); const [isSearchVisible, setIsSearchVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); @@ -259,6 +290,25 @@ export default function NodePage() { onNodeOffline: handleNodeOffline, }); + const loadRemoteUsage = useCallback(async () => { + try { + const res = await getPeerRemoteUsageList(); + + if (res.code === 0 && Array.isArray(res.data)) { + const nextMap: Record = {}; + + (res.data as unknown as RemoteUsageNode[]).forEach((item) => { + if (!item || typeof item.nodeId !== "number") return; + nextMap[item.nodeId] = item; + }); + + setRemoteUsageMap(nextMap); + } + } catch { + // ignore remote usage errors in node page + } + }, []); + // 加载节点列表 const loadNodes = useCallback(async () => { setLoading(true); @@ -385,7 +435,8 @@ export default function NodePage() { useEffect(() => { loadNodes(); - }, [loadNodes]); + loadRemoteUsage(); + }, [loadNodes, loadRemoteUsage]); // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { @@ -428,6 +479,32 @@ export default function NodePage() { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; + const formatFlow = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + }; + + const formatChainType = (chainType: number, hopInx: number) => { + if (chainType === 1) { + return "入口节点"; + } + if (chainType === 2) { + return `中继跳点 #${hopInx}`; + } + if (chainType === 3) { + return "出口节点"; + } + + return "未知链路"; + }; + // 获取进度条颜色 const getProgressColor = ( value: number, @@ -1278,6 +1355,7 @@ export default function NodePage() {
{displayNodes.map((node) => { const isRemoteNode = node.isRemote === 1; + const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null; return ( @@ -1427,6 +1505,117 @@ export default function NodePage() { )}
+ {isRemoteNode && ( +
+ {remoteUsage ? ( + <> +
+
+ 远程地址 + + {remoteUsage.remoteUrl || node.remoteUrl || "-"} + +
+
+ 共享ID + #{remoteUsage.shareId} +
+
+ 流量 + + {formatFlow(remoteUsage.currentFlow)} + +
+
+ 带宽上限 + + {remoteUsage.maxBandwidth > 0 + ? formatSpeed(remoteUsage.maxBandwidth) + : "不限"} + +
+
+ +
+
+ 占用端口 + + {remoteUsage.usedPorts.length}/{Math.max( + remoteUsage.portRangeEnd - + remoteUsage.portRangeStart + + 1, + 0, + )} + +
+
+ {remoteUsage.usedPorts.length > 0 ? ( +
+ {remoteUsage.usedPorts.map((port) => ( + + {port} + + ))} +
+ ) : ( +
暂无占用端口
+ )} +
+
+ +
+
+ 绑定明细 + + {remoteUsage.activeBindingNum} + +
+
+ {remoteUsage.bindings.length > 0 ? ( + remoteUsage.bindings.map((binding) => ( +
+
+ + {binding.tunnelName} + + + #{binding.tunnelId} + +
+
+ {formatChainType(binding.chainType, binding.hopInx)} + 端口 {binding.allocatedPort} +
+
+ )) + ) : ( +
暂无绑定明细
+ )} +
+
+ + ) : ( +
+ 暂未获取到远程占用数据 +
+ )} +
+ )} + {!isRemoteNode && ( <> {/* 系统监控 */} From 61b71a11c79d250198c8a01c44312ed977bcb96c Mon Sep 17 00:00:00 2001 From: Su-cyber-art <72259479+Su-cyber-art@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:45:56 +0800 Subject: [PATCH 3/5] feat(node): split node page into local and remote sections --- vite-frontend/src/pages/node.tsx | 50 ++++++++------------------------ 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 37a3ff7..8438fb1 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; + import { DndContext, KeyboardSensor, @@ -66,7 +66,7 @@ import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; -import { PlusIcon, DeleteIcon, CheckAllIcon, XCircleIcon, ListCheckIcon, XIcon, ArrowUpIcon, ShareIcon } from "@/components/icons"; + interface Node { id: number; @@ -190,7 +190,6 @@ const SortableItem = ({ }; export default function NodePage() { - const navigate = useNavigate(); const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); @@ -1222,95 +1221,70 @@ export default function NodePage() { {selectMode ? ( <> - 已选择 {selectedIds.size} 项 + 已选 {selectedIds.size} 项 ) : ( <> - )} From 647446a2a2aa5a6c9a0c62602aa237e993630edb Mon Sep 17 00:00:00 2001 From: sagitchu Date: Mon, 9 Mar 2026 12:17:25 +0800 Subject: [PATCH 4/5] fix(node): isolate tab search and selection state --- vite-frontend/src/pages/node.tsx | 131 +++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 34 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 8438fb1..5da8f61 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -193,8 +193,12 @@ export default function NodePage() { const [nodeList, setNodeList] = useState([]); const [nodeOrder, setNodeOrder] = useState([]); const [loading, setLoading] = useState(false); - const [searchKeyword, setSearchKeyword] = useLocalStorageState( - "node-search-keyword", + const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState( + "node-search-keyword-local", + "", + ); + const [remoteSearchKeyword, setRemoteSearchKeyword] = useLocalStorageState( + "node-search-keyword-remote", "", ); const [activeTab, setActiveTab] = useLocalStorageState( @@ -437,6 +441,10 @@ export default function NodePage() { loadRemoteUsage(); }, [loadNodes, loadRemoteUsage]); + useEffect(() => { + setSelectedIds(new Set()); + }, [activeTab]); + // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond === 0) return "0 B/s"; @@ -849,17 +857,30 @@ export default function NodePage() { ); } } else if (upgradeTarget === "batch") { + const selectedLocalIds = Array.from(selectedIds).filter((id) => { + const matchedNode = nodeList.find((node) => node.id === id); + + return matchedNode?.isRemote !== 1; + }); + + if (selectedLocalIds.length === 0) { + toast.error("请选择本地节点进行升级"); + setUpgradeModalOpen(false); + + return; + } + setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { const res = await batchUpgradeNodes( - Array.from(selectedIds), + selectedLocalIds, version, releaseChannel, ); if (res.code === 0) { - toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`); } else { toast.error(res.msg || "批量升级失败"); } @@ -990,12 +1011,26 @@ export default function NodePage() { if (isNaN(activeId) || isNaN(overId)) return; - const oldIndex = nodeOrder.indexOf(activeId); - const newIndex = nodeOrder.indexOf(overId); + const displayNodeIds = displayNodes.map((node) => node.id); + const oldIndex = displayNodeIds.indexOf(activeId); + const newIndex = displayNodeIds.indexOf(overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - const newOrder = arrayMove(nodeOrder, oldIndex, newIndex); + const reorderedDisplayIds = arrayMove(displayNodeIds, oldIndex, newIndex); + const displayIdSet = new Set(displayNodeIds); + let reorderedDisplayIndex = 0; + + const newOrder = nodeOrder.map((id) => { + if (!displayIdSet.has(id)) { + return id; + } + + const nextId = reorderedDisplayIds[reorderedDisplayIndex]; + reorderedDisplayIndex += 1; + + return nextId; + }); setNodeOrder(newOrder); @@ -1099,21 +1134,7 @@ export default function NodePage() { const sortedNodes = useMemo((): Node[] => { if (!nodeList || nodeList.length === 0) return []; - let filteredNodes = nodeList; - - if (searchKeyword.trim()) { - const lowerKeyword = searchKeyword.toLowerCase(); - - filteredNodes = filteredNodes.filter( - (n) => - (n.name && n.name.toLowerCase().includes(lowerKeyword)) || - (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), - ); - } - - const sortedByDb = [...filteredNodes].sort((a, b) => { + const sortedByDb = [...nodeList].sort((a, b) => { const aInx = a.inx ?? 0; const bInx = b.inx ?? 0; @@ -1126,7 +1147,7 @@ export default function NodePage() { nodeOrder.length > 0 && sortedByDb.every((n) => n.inx === undefined || n.inx === 0) ) { - const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const)); + const nodeMap = new Map(nodeList.map((n) => [n.id, n] as const)); const localSorted: Node[] = []; nodeOrder.forEach((id) => { @@ -1135,7 +1156,7 @@ export default function NodePage() { if (node) localSorted.push(node); }); - filteredNodes.forEach((node) => { + nodeList.forEach((node) => { if (!nodeOrder.includes(node.id)) { localSorted.push(node); } @@ -1145,7 +1166,26 @@ export default function NodePage() { } return sortedByDb; - }, [nodeList, nodeOrder, searchKeyword]); + }, [nodeList, nodeOrder]); + + const filterNodesByKeyword = useCallback((nodes: Node[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase(); + + if (!normalizedKeyword) { + return nodes; + } + + return nodes.filter( + (node) => + (node.name && node.name.toLowerCase().includes(normalizedKeyword)) || + (node.serverIp && + node.serverIp.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV4 && + node.serverIpV4.toLowerCase().includes(normalizedKeyword)) || + (node.serverIpV6 && + node.serverIpV6.toLowerCase().includes(normalizedKeyword)), + ); + }, []); const localNodes = useMemo( () => sortedNodes.filter((node) => node.isRemote !== 1), @@ -1157,11 +1197,30 @@ export default function NodePage() { [sortedNodes], ); - const displayNodes = useMemo( - () => (activeTab === "remote" ? remoteNodes : localNodes), - [activeTab, localNodes, remoteNodes], + const filteredLocalNodes = useMemo( + () => filterNodesByKeyword(localNodes, localSearchKeyword), + [filterNodesByKeyword, localNodes, localSearchKeyword], ); + const filteredRemoteNodes = useMemo( + () => filterNodesByKeyword(remoteNodes, remoteSearchKeyword), + [filterNodesByKeyword, remoteNodes, remoteSearchKeyword], + ); + + const currentSearchKeyword = + activeTab === "remote" ? remoteSearchKeyword : localSearchKeyword; + + const setCurrentSearchKeyword = + activeTab === "remote" ? setRemoteSearchKeyword : setLocalSearchKeyword; + + const displayNodes = useMemo( + () => (activeTab === "remote" ? filteredRemoteNodes : filteredLocalNodes), + [activeTab, filteredLocalNodes, filteredRemoteNodes], + ); + + const canBatchUpgrade = activeTab === "local"; + const isSearchFiltered = currentSearchKeyword.trim().length > 0; + const sortableNodeIds = useMemo( () => displayNodes.map((n) => n.id), [displayNodes], @@ -1210,8 +1269,8 @@ export default function NodePage() { ? "搜索远程节点名称或IP" : "搜索本地节点名称或IP" } - value={searchKeyword} - onChange={setSearchKeyword} + value={currentSearchKeyword} + onChange={setCurrentSearchKeyword} onClose={() => setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} /> @@ -1241,7 +1300,7 @@ export default function NodePage() {
\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState([]);\n const [nodeOrder, setNodeOrder] = useState([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState(null);\n const [installChannel, setInstallChannel] =\n useState(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n \n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n \n 全部节点\n \n \n 7天内续费 ({nodeExpiryStats.expiringSoon})\n \n \n 已逾期 ({nodeExpiryStats.expired})\n \n \n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n \n \n
\n\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n openUpgradeModal(\"batch\")}\n >\n 升级\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n \n 退出\n \n \n ) : (\n <>\n \n 批量\n \n \n 新增\n \n \n )}\n
\n
\n
\n\n {!wsConnected && (\n \n )}\n\n {/* 节点列表 */}\n {loading ? (\n \n ) : nodeList.length === 0 ? (\n \n ) : (\n \n \n
\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n \n {(listeners) => (\n \n \n
\n
\n {selectMode && (\n toggleSelect(node.id)}\n />\n )}\n

\n {node.name}\n

\n
\n
\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n \n {expiryMeta.label}\n \n )}\n \n \n \n \n
\n {isRemoteNode && (\n \n 远程\n \n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n \n {connectionStatusMeta.text}\n \n );\n })()}\n
\n
\n \n\n \n {isRemoteNode && node.syncError && (\n
\n {getRemoteSyncErrorMessage(node.syncError)}\n
\n )}\n {expiryMeta.isHighlighted && (\n \n 节点到期提醒\n {expiryMeta.label}\n
\n )}\n {/* 基础信息 */}\n
\n {(node.remark?.trim() || node.tags?.trim()) && (\n
\n {node.remark?.trim() && (\n
\n \n {node.remark.trim()}\n
\n
\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n
\n {normalizeNodeTags(node.tags).map((tag) => (\n \n #{tag}\n \n ))}\n
\n )}\n
\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle &&
}\n
\n \n IP\n \n
\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n
\n {node.serverIpV4?.trim() && (\n \n {node.serverIpV4.trim()}\n \n )}\n {node.serverIpV6?.trim() && (\n \n {node.serverIpV6.trim()}\n \n )}\n
\n ) : (\n \n {node.serverIp.trim()}\n \n )}\n
\n
\n {!isRemoteNode && (\n <>\n
\n 版本\n \n {node.version || \"未知\"}\n \n
\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n
\n \n
\n )}\n
\n \n 开机时间\n \n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n \n
\n \n )}\n
\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n
\n
\n
\n
\n CPU\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n
\n 内存\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n\n
\n
\n
\n 上传\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n
\n
\n
\n
\n 下载\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n
\n
\n
\n\n {/* 流量统计 */}\n
\n
\n
\n ↑ 上行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n ↓ 下行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n \n )}\n\n {/* 操作按钮 */}\n
\n {!isRemoteNode && (\n
\n openInstallSelector(node)}\n >\n 安装\n \n \n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n \n handleRollbackNode(node)}\n >\n 回退\n \n
\n )}\n \n {!isRemoteNode && (\n handleEdit(node)}\n >\n 编辑\n \n )}\n handleDelete(node)}\n >\n 删除\n \n
\n
\n \n \n )}\n \n );\n })}\n
\n \n \n )}\n\n {/* 新增/编辑节点对话框 */}\n setDialogVisible(false)}\n >\n \n {dialogTitle}\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n \n 月付\n \n \n 季付\n \n \n 年付\n \n \n
\n\n 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n \n\n \n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n
\n\n \n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n \n \n
\n \n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n \n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n
\n \n \n TCP\n \n
\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n \n \n UDP\n \n
\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n
\n {/* 屏蔽协议 */}\n
\n
\n 屏蔽协议\n
\n
\n 开启开关以屏蔽对应协议\n
\n {protocolDisabled && (\n \n )}\n \n {/* HTTP tile */}\n
\n
\n \n \n \n \n
\n HTTP\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* TLS tile */}\n
\n
\n \n \n \n \n
\n TLS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n
\n
\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* SOCKS tile */}\n
\n
\n \n \n \n \n \n
\n SOCKS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n
\n
\n\n \n
\n \n \n\n \n
\n \n \n \n \n {submitLoading ? \"提交中...\" : \"确定\"}\n \n \n \n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除节点{\" \"}\n "{nodeToDelete?.name}" 吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n\n \n \n {(onClose) => (\n <>\n \n

\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n

\n
\n \n
\n {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n
\n
\n \n \n \n \n \n )}\n
\n \n\n {/* 安装命令模态框 */}\n setInstallCommandModal(false)}\n >\n \n 安装命令 - {currentNodeName}\n \n
\n

\n 请复制以下安装命令到服务器上执行:\n

\n
\n \n \n 复制\n \n
\n
\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n
\n
\n
\n \n setInstallCommandModal(false)}\n >\n 关闭\n \n \n
\n \n\n {/* 版本选择升级模态框 */}\n \n \n {(onClose) => (\n <>\n \n

\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n

\n
\n \n {releasesLoading ? (\n
\n \n
\n ) : (\n
\n {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n \n
\n {r.version}\n \n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n \n 测试\n \n )}\n \n
\n
\n ))}\n \n

\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n

\n
\n )}\n
\n \n \n \n 确认升级\n \n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除选中的 {selectedIds.size}{\" \"}\n 个节点吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {batchLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n \n );\n}\n", + "after": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n \n {children(listeners)}\n
\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState([]);\n const [nodeOrder, setNodeOrder] = useState([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState(null);\n const [installChannel, setInstallChannel] =\n useState(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n \n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n \n 全部节点\n \n \n 7天内续费 ({nodeExpiryStats.expiringSoon})\n \n \n 已逾期 ({nodeExpiryStats.expired})\n \n \n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n \n \n
\n\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n openUpgradeModal(\"batch\")}\n >\n 升级\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n \n 退出\n \n \n ) : (\n <>\n \n 批量\n \n \n 新增\n \n \n )}\n
\n
\n
\n\n {!wsConnected && (\n \n )}\n\n {/* 节点列表 */}\n {loading ? (\n \n ) : nodeList.length === 0 ? (\n \n ) : (\n \n \n
\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n \n {(listeners) => (\n \n \n
\n
\n {selectMode && (\n toggleSelect(node.id)}\n />\n )}\n

\n {node.name}\n

\n
\n
\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n \n {expiryMeta.label}\n \n )}\n \n \n \n \n
\n {isRemoteNode && (\n \n 远程\n \n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n \n {connectionStatusMeta.text}\n \n );\n })()}\n
\n
\n \n\n \n {isRemoteNode && node.syncError && (\n
\n {getRemoteSyncErrorMessage(node.syncError)}\n
\n )}\n {expiryMeta.isHighlighted && (\n \n 节点到期提醒\n {expiryMeta.label}\n
\n )}\n {/* 基础信息 */}\n
\n {(node.remark?.trim() || node.tags?.trim()) && (\n
\n {node.remark?.trim() && (\n
\n \n {node.remark.trim()}\n
\n
\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n
\n {normalizeNodeTags(node.tags).map((tag) => (\n \n #{tag}\n \n ))}\n
\n )}\n
\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle &&
}\n
\n \n IP\n \n
\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n
\n {node.serverIpV4?.trim() && (\n \n {node.serverIpV4.trim()}\n \n )}\n {node.serverIpV6?.trim() && (\n \n {node.serverIpV6.trim()}\n \n )}\n
\n ) : (\n \n {node.serverIp.trim()}\n \n )}\n
\n
\n {!isRemoteNode && (\n <>\n
\n 版本\n \n {node.version || \"未知\"}\n \n
\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n
\n \n
\n )}\n
\n \n 开机时间\n \n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n \n
\n \n )}\n
\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n
\n
\n
\n
\n CPU\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n
\n 内存\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n\n
\n
\n
\n 上传\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n
\n
\n
\n
\n 下载\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n
\n
\n
\n\n {/* 流量统计 */}\n
\n
\n
\n ↑ 上行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n ↓ 下行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n \n )}\n\n {/* 操作按钮 */}\n
\n {!isRemoteNode && (\n
\n openInstallSelector(node)}\n >\n 安装\n \n \n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n \n handleRollbackNode(node)}\n >\n 回退\n \n
\n )}\n \n {!isRemoteNode && (\n handleEdit(node)}\n >\n 编辑\n \n )}\n handleDelete(node)}\n >\n 删除\n \n
\n
\n \n \n )}\n \n );\n })}\n
\n \n \n )}\n\n {/* 新增/编辑节点对话框 */}\n setDialogVisible(false)}\n >\n \n {dialogTitle}\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n \n 月付\n \n \n 季付\n \n \n 年付\n \n \n
\n\n 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n \n\n \n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n
\n\n \n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n \n \n
\n \n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n \n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n
\n \n \n TCP\n \n
\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n \n \n UDP\n \n
\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n
\n {/* 屏蔽协议 */}\n
\n
\n 屏蔽协议\n
\n
\n 开启开关以屏蔽对应协议\n
\n {protocolDisabled && (\n \n )}\n \n {/* HTTP tile */}\n
\n
\n \n \n \n \n
\n HTTP\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* TLS tile */}\n
\n
\n \n \n \n \n
\n TLS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n
\n
\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* SOCKS tile */}\n
\n
\n \n \n \n \n \n
\n SOCKS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n
\n
\n\n \n
\n \n \n\n \n \n \n \n \n \n {submitLoading ? \"提交中...\" : \"确定\"}\n \n \n \n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除节点{\" \"}\n "{nodeToDelete?.name}" 吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n\n \n \n {(onClose) => (\n <>\n \n

\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n

\n
\n \n
\n {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n
\n
\n \n \n \n \n \n )}\n
\n \n\n {/* 安装命令模态框 */}\n setInstallCommandModal(false)}\n >\n \n 安装命令 - {currentNodeName}\n \n
\n

\n 请复制以下安装命令到服务器上执行:\n

\n
\n \n \n 复制\n \n
\n
\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n
\n
\n
\n \n setInstallCommandModal(false)}\n >\n 关闭\n \n \n
\n \n\n {/* 版本选择升级模态框 */}\n \n \n {(onClose) => (\n <>\n \n

\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n

\n
\n \n {releasesLoading ? (\n
\n \n
\n ) : (\n
\n {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n \n
\n {r.version}\n \n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n \n 测试\n \n )}\n \n
\n
\n ))}\n \n

\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n

\n
\n )}\n
\n \n \n \n 确认升级\n \n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除选中的 {selectedIds.size}{\" \"}\n 个节点吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {batchLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n
\n );\n}\n", + "additions": 2, + "deletions": 0, + "status": "modified" + } + ] + }, + "agent": "build", + "model": { + "providerID": "zai-coding-plan", + "modelID": "glm-5" + }, + "id": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "text", + "text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送", + "id": "prt_cd08cf2e5002IWDBM1PgtTYwKH", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08cf2e5001UoNixpQwGQiGYH" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025424208, + "completed": 1773025453357 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 25994, + "input": 1583, + "output": 155, + "reasoning": 0, + "cache": { + "read": 24256, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08cf350001s54Kz845iXm86b", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "id": "prt_cd08d034f001eGHicZWBP7GlSv", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08cf350001s54Kz845iXm86b" + }, + { + "type": "reasoning", + "text": "用户再次要求编译并推送 docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta,架构为 linux/amd64。这是一个重复的请求,我之前已经完成了这个任务。我应该直接执行这个命令。", + "time": { + "start": 1773025428304, + "end": 1773025433693 + }, + "id": "prt_cd08d0350001F5YITSsdbqW1RR", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08cf350001s54Kz845iXm86b" + }, + { + "type": "tool", + "callID": "call_f420a784161e4f419746bdce", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .", + "description": "Build and push vite-frontend Docker image", + "timeout": 600000 + }, + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [internal] load build context\n#6 ...\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#7 DONE 0.1s\n\n#6 [internal] load build context\n#6 transferring context: 2.66MB 1.0s done\n#6 DONE 1.0s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.419 \n#12 0.419 > flvx@0.0.0 build\n#12 0.419 > tsc && vite build\n#12 0.419 \n#12 13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n#12 13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n#12 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n------\n > [builder 6/6] RUN npm run build:\n0.419 \n0.419 > flvx@0.0.0 build\n0.419 > tsc && vite build\n0.419 \n13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n------\nDockerfile:10\n--------------------\n 8 | \n 9 | COPY . .\n 10 | >>> RUN npm run build\n 11 | \n 12 | # 生产阶段\n--------------------\nERROR: failed to build: failed to solve: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n", + "title": "Build and push vite-frontend Docker image", + "metadata": { + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [internal] load build context\n#6 ...\n\n#7 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#7 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#7 DONE 0.1s\n\n#6 [internal] load build context\n#6 transferring context: 2.66MB 1.0s done\n#6 DONE 1.0s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.419 \n#12 0.419 > flvx@0.0.0 build\n#12 0.419 > tsc && vite build\n#12 0.419 \n#12 13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n#12 13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n#12 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n------\n > [builder 6/6] RUN npm run build:\n0.419 \n0.419 > flvx@0.0.0 build\n0.419 > tsc && vite build\n0.419 \n13.72 src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n13.72 src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n------\nDockerfile:10\n--------------------\n 8 | \n 9 | COPY . .\n 10 | >>> RUN npm run build\n 11 | \n 12 | # 生产阶段\n--------------------\nERROR: failed to build: failed to solve: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 2\n", + "exit": 1, + "description": "Build and push vite-frontend Docker image", + "truncated": false + }, + "time": { + "start": 1773025433596, + "end": 1773025453099 + } + }, + "id": "prt_cd08d17f6001KnzMesDNd0bc0f", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08cf350001s54Kz845iXm86b" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "cost": 0, + "tokens": { + "total": 25994, + "input": 1583, + "output": 155, + "reasoning": 0, + "cache": { + "read": 24256, + "write": 0 + } + }, + "id": "prt_cd08d642e001iIQ80oQ1AYmeO0", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08cf350001s54Kz845iXm86b" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025453359, + "completed": 1773025465366 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 27143, + "input": 1109, + "output": 242, + "reasoning": 0, + "cache": { + "read": 25792, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08d652f001t28GCHihEM8wQb", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "id": "prt_cd08d76db001kzU2bRQpyR9qYh", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d652f001t28GCHihEM8wQb" + }, + { + "type": "reasoning", + "text": "构建失败了,有 TypeScript 编译错误。我需要查看并修复这些错误:\n- src/pages/node.tsx(1469,47): error TS2304: Cannot find name 'formatNodeRenewalTime'.\n- src/pages/node.tsx(1469,97): error TS2304: Cannot find name 'getNodeRenewalCycleLabel'.\n\n让我先查看这个文件,找到这些缺失的函数。", + "time": { + "start": 1773025457883, + "end": 1773025465123 + }, + "id": "prt_cd08d76db002VJeXVgRhSpZvXk", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d652f001t28GCHihEM8wQb" + }, + { + "type": "text", + "text": "构建失败,TypeScript 错误。让我查看并修复:", + "time": { + "start": 1773025465124, + "end": 1773025465124 + }, + "id": "prt_cd08d8233001gUwstFOP9wLKdb", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d652f001t28GCHihEM8wQb" + }, + { + "type": "tool", + "callID": "call_30ca1eb38ed149d39064005f", + "tool": "read", + "state": { + "status": "completed", + "input": { + "filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "offset": 1460, + "limit": 30 + }, + "output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\nfile\n1460: \n1461:
\n1462: {node.expiryTime &&\n1463: node.expiryTime > 0 &&\n1464: node.renewalCycle && (\n1465: \n1472: {expiryMeta.label}\n1473: \n1474: )}\n1475: \n1481: \n1487: \n1488: \n1489:
\n\n(Showing lines 1460-1489 of 2562. Use offset=1490 to continue.)\n
", + "title": "vite-frontend/src/pages/node.tsx", + "metadata": { + "preview": " \n
\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n \n {expiryMeta.label}\n \n )}\n {\n Line 112: export const formatNodeRenewalTime = (timestamp?: number): string => {", + "title": "formatNodeRenewalTime|getNodeRenewalCycleLabel", + "metadata": { + "matches": 7, + "truncated": false + }, + "time": { + "start": 1773025465051, + "end": 1773025465075 + } + }, + "id": "prt_cd08d92d100115XxWcwAxZnk7F", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d652f001t28GCHihEM8wQb" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "cost": 0, + "tokens": { + "total": 27143, + "input": 1109, + "output": 242, + "reasoning": 0, + "cache": { + "read": 25792, + "write": 0 + } + }, + "id": "prt_cd08d93260015nDMM3wGHB9q3r", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d652f001t28GCHihEM8wQb" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025465369, + "completed": 1773025480260 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 28285, + "input": 1216, + "output": 189, + "reasoning": 0, + "cache": { + "read": 26880, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08d9419001W1KMqd4fxHLjwr", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "id": "prt_cd08db8c9001HAo9TiY2OkrFUG", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d9419001W1KMqd4fxHLjwr" + }, + { + "type": "reasoning", + "text": "现在我看到问题了。这些函数定义在 `src/pages/node/renewal.ts` 中,在 `dashboard.tsx` 中被导入了,但在 `node.tsx` 中没有导入。\n\n让我查看 node.tsx 的导入部分,看看是否需要添加这些导入。", + "time": { + "start": 1773025474762, + "end": 1773025480025 + }, + "id": "prt_cd08db8ca0013Q73bTEstsiLfx", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d9419001W1KMqd4fxHLjwr" + }, + { + "type": "tool", + "callID": "call_6d4a266590e848ee98342331", + "tool": "grep", + "state": { + "status": "completed", + "input": { + "path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "pattern": "^import.*renewal" + }, + "output": "No files found", + "title": "^import.*renewal", + "metadata": { + "matches": 0, + "truncated": false + }, + "time": { + "start": 1773025478488, + "end": 1773025478501 + } + }, + "id": "prt_cd08dc74f0015IGG02WAO3X5xP", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d9419001W1KMqd4fxHLjwr" + }, + { + "type": "tool", + "callID": "call_dad61f576a1747c78910d119", + "tool": "read", + "state": { + "status": "completed", + "input": { + "filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "limit": 50 + }, + "output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\nfile\n1: import { useState, useEffect, useMemo, useCallback } from \"react\";\n2: import toast from \"react-hot-toast\";\n3: import {\n4: DndContext,\n5: KeyboardSensor,\n6: MouseSensor,\n7: TouchSensor,\n8: type DragEndEvent,\n9: useSensor,\n10: useSensors,\n11: } from \"@dnd-kit/core\";\n12: import {\n13: SortableContext,\n14: arrayMove,\n15: rectSortingStrategy,\n16: sortableKeyboardCoordinates,\n17: useSortable,\n18: } from \"@dnd-kit/sortable\";\n19: import { CSS } from \"@dnd-kit/utilities\";\n20: \n21: import { SearchBar } from \"@/components/search-bar\";\n22: import { AnimatedPage } from \"@/components/animated-page\";\n23: import { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\n24: import { Button } from \"@/shadcn-bridge/heroui/button\";\n25: import { Input } from \"@/shadcn-bridge/heroui/input\";\n26: import { Textarea } from \"@/shadcn-bridge/heroui/input\";\n27: import {\n28: Modal,\n29: ModalContent,\n30: ModalHeader,\n31: ModalBody,\n32: ModalFooter,\n33: } from \"@/shadcn-bridge/heroui/modal\";\n34: import { Chip } from \"@/shadcn-bridge/heroui/chip\";\n35: import { Switch } from \"@/shadcn-bridge/heroui/switch\";\n36: import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n37: import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n38: import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n39: import { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\n40: import { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\n41: import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n42: import {\n43: createNode,\n44: getNodeList,\n45: updateNode,\n46: deleteNode,\n47: getNodeInstallCommand,\n48: updateNodeOrder,\n49: batchDeleteNodes,\n50: upgradeNode,\n\n(Showing lines 1-50 of 2562. Use offset=51 to continue.)\n", + "title": "vite-frontend/src/pages/node.tsx", + "metadata": { + "preview": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n", + "truncated": true, + "loaded": [] + }, + "time": { + "start": 1773025479979, + "end": 1773025479993 + } + }, + "id": "prt_cd08dcd26001v98AsU5mk5vFIQ", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d9419001W1KMqd4fxHLjwr" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "cost": 0, + "tokens": { + "total": 28285, + "input": 1216, + "output": 189, + "reasoning": 0, + "cache": { + "read": 26880, + "write": 0 + } + }, + "id": "prt_cd08dcd5b0016QnDhsaeG5207I", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08d9419001W1KMqd4fxHLjwr" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025480262, + "completed": 1773025489386 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 28983, + "input": 854, + "output": 97, + "reasoning": 0, + "cache": { + "read": 28032, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08dce46001PNlok5YsEpEpN3", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "id": "prt_cd08de304001hbv952WnFPc2qU", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08dce46001PNlok5YsEpEpN3" + }, + { + "type": "reasoning", + "text": "我需要查看更多的 import 部分,看看 renewal 相关的函数是否已经从别处导入。让我继续看更多的导入部分。", + "time": { + "start": 1773025485572, + "end": 1773025489133 + }, + "id": "prt_cd08de304002nO5Vzi6wUyzjDU", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08dce46001PNlok5YsEpEpN3" + }, + { + "type": "tool", + "callID": "call_96761b75543745dcadd29398", + "tool": "read", + "state": { + "status": "completed", + "input": { + "filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "limit": 80, + "offset": 50 + }, + "output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\nfile\n50: upgradeNode,\n51: batchUpgradeNodes,\n52: getNodeReleases,\n53: rollbackNode,\n54: type ReleaseChannel,\n55: } from \"@/api\";\n56: import { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\n57: import {\n58: getConnectionStatusMeta,\n59: getRemoteSyncErrorMessage,\n60: } from \"@/pages/node/display\";\n61: import { tryCopyInstallCommand } from \"@/pages/node/install-command\";\n62: import {\n63: getNodeRenewalSnapshot,\n64: type NodeRenewalCycle,\n65: } from \"@/pages/node/renewal\";\n66: import { buildNodeSystemInfo } from \"@/pages/node/system-info\";\n67: import { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\n68: import { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\n69: import { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\n70: import { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n71: \n72: const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n73: \n74: interface Node {\n75: id: number;\n76: inx?: number;\n77: name: string;\n78: remark?: string;\n79: tags?: string;\n80: expiryTime?: number;\n81: renewalCycle?: NodeRenewalCycle;\n82: ip: string;\n83: serverIp: string;\n84: serverIpV4?: string;\n85: serverIpV6?: string;\n86: port: string;\n87: tcpListenAddr?: string;\n88: udpListenAddr?: string;\n89: extraIPs?: string;\n90: version?: string;\n91: http?: number; // 0 关 1 开\n92: tls?: number; // 0 关 1 开\n93: socks?: number; // 0 关 1 开\n94: status: number;\n95: isRemote?: number;\n96: remoteUrl?: string;\n97: syncError?: string;\n98: connectionStatus: \"online\" | \"offline\";\n99: systemInfo?: {\n100: cpuUsage: number;\n101: memoryUsage: number;\n102: uploadTraffic: number;\n103: downloadTraffic: number;\n104: uploadSpeed: number;\n105: downloadSpeed: number;\n106: uptime: number;\n107: } | null;\n108: copyLoading?: boolean;\n109: upgradeLoading?: boolean;\n110: rollbackLoading?: boolean;\n111: }\n112: \n113: interface NodeForm {\n114: id: number | null;\n115: name: string;\n116: remark: string;\n117: tags: string;\n118: expiryTime: number;\n119: renewalCycle: NodeRenewalCycle;\n120: serverHost: string;\n121: serverIpV4: string;\n122: serverIpV6: string;\n123: port: string;\n124: tcpListenAddr: string;\n125: udpListenAddr: string;\n126: interfaceName: string;\n127: extraIPs: string;\n128: http: number; // 0 关 1 开\n129: tls: number; // 0 关 1 开\n\n(Showing lines 50-129 of 2562. Use offset=130 to continue.)\n", + "title": "vite-frontend/src/pages/node.tsx", + "metadata": { + "preview": " upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";", + "truncated": true, + "loaded": [] + }, + "time": { + "start": 1773025489051, + "end": 1773025489057 + } + }, + "id": "prt_cd08df096001WGhezq15rE7jyG", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08dce46001PNlok5YsEpEpN3" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "cost": 0, + "tokens": { + "total": 28983, + "input": 854, + "output": 97, + "reasoning": 0, + "cache": { + "read": 28032, + "write": 0 + } + }, + "id": "prt_cd08df0ef001SBUPLu9UUbilho", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08dce46001PNlok5YsEpEpN3" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025489388, + "completed": 1773025500253 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 30011, + "input": 934, + "output": 213, + "reasoning": 0, + "cache": { + "read": 28864, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08df1ec0016p8ElQV7HboEkQ", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "id": "prt_cd08e057b001QV4ndtT3PWa23R", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ" + }, + { + "type": "reasoning", + "text": "好的,我看到了。在第 62-65 行,node.tsx 从 `@/pages/node/renewal` 导入了 `getNodeRenewalSnapshot` 和 `type NodeRenewalCycle`,但没有导入 `formatNodeRenewalTime` 和 `getNodeRenewalCycleLabel`。\n\n我需要修改这个导入,添加这两个函数。", + "time": { + "start": 1773025494395, + "end": 1773025499976 + }, + "id": "prt_cd08e057b002Di46cCubns5awm", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ" + }, + { + "type": "tool", + "callID": "call_d4633a42dfa7434aa777f2cf", + "tool": "edit", + "state": { + "status": "completed", + "input": { + "filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "oldString": "import {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";", + "newString": "import {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";" + }, + "output": "Edit applied successfully.", + "title": "vite-frontend/src/pages/node.tsx", + "metadata": { + "diagnostics": { + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository.go": [ + { + "range": { + "start": { + "line": 161, + "character": 13 + }, + "end": { + "line": 161, + "character": 24 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 356, + "character": 77 + }, + "end": { + "line": 356, + "character": 88 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 436, + "character": 74 + }, + "end": { + "line": 436, + "character": 85 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 573, + "character": 77 + }, + "end": { + "line": 573, + "character": 88 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 583, + "character": 77 + }, + "end": { + "line": 583, + "character": 88 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 596, + "character": 28 + }, + "end": { + "line": 596, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 603, + "character": 28 + }, + "end": { + "line": 603, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 611, + "character": 29 + }, + "end": { + "line": 611, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 624, + "character": 47 + }, + "end": { + "line": 624, + "character": 58 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 632, + "character": 28 + }, + "end": { + "line": 632, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 634, + "character": 35 + }, + "end": { + "line": 634, + "character": 46 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 658, + "character": 47 + }, + "end": { + "line": 658, + "character": 58 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 666, + "character": 28 + }, + "end": { + "line": 666, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 668, + "character": 35 + }, + "end": { + "line": 668, + "character": 46 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 680, + "character": 53 + }, + "end": { + "line": 680, + "character": 64 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 688, + "character": 28 + }, + "end": { + "line": 688, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 690, + "character": 21 + }, + "end": { + "line": 690, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 700, + "character": 50 + }, + "end": { + "line": 700, + "character": 61 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 733, + "character": 28 + }, + "end": { + "line": 733, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 739, + "character": 21 + }, + "end": { + "line": 739, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 756, + "character": 75 + }, + "end": { + "line": 756, + "character": 86 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 782, + "character": 28 + }, + "end": { + "line": 782, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 784, + "character": 21 + }, + "end": { + "line": 784, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 794, + "character": 64 + }, + "end": { + "line": 794, + "character": 75 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 815, + "character": 28 + }, + "end": { + "line": 815, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 817, + "character": 21 + }, + "end": { + "line": 817, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 902, + "character": 10 + }, + "end": { + "line": 902, + "character": 23 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq" + }, + "source": "stringsseq", + "message": "Ranging over SplitSeq is more efficient" + }, + { + "range": { + "start": { + "line": 948, + "character": 49 + }, + "end": { + "line": 948, + "character": 60 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 958, + "character": 40 + }, + "end": { + "line": 958, + "character": 51 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 962, + "character": 31 + }, + "end": { + "line": 962, + "character": 42 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 968, + "character": 37 + }, + "end": { + "line": 968, + "character": 48 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 969, + "character": 37 + }, + "end": { + "line": 969, + "character": 48 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 970, + "character": 39 + }, + "end": { + "line": 970, + "character": 50 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 990, + "character": 47 + }, + "end": { + "line": 990, + "character": 58 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1007, + "character": 24 + }, + "end": { + "line": 1007, + "character": 35 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1024, + "character": 54 + }, + "end": { + "line": 1024, + "character": 65 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1030, + "character": 51 + }, + "end": { + "line": 1030, + "character": 62 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1034, + "character": 56 + }, + "end": { + "line": 1034, + "character": 67 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1048, + "character": 33 + }, + "end": { + "line": 1048, + "character": 44 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1061, + "character": 29 + }, + "end": { + "line": 1061, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1072, + "character": 54 + }, + "end": { + "line": 1072, + "character": 65 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1080, + "character": 29 + }, + "end": { + "line": 1080, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1086, + "character": 37 + }, + "end": { + "line": 1086, + "character": 48 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1095, + "character": 52 + }, + "end": { + "line": 1095, + "character": 63 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1103, + "character": 29 + }, + "end": { + "line": 1103, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1109, + "character": 37 + }, + "end": { + "line": 1109, + "character": 48 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1118, + "character": 58 + }, + "end": { + "line": 1118, + "character": 69 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1141, + "character": 29 + }, + "end": { + "line": 1141, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1143, + "character": 37 + }, + "end": { + "line": 1143, + "character": 48 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1215, + "character": 84 + }, + "end": { + "line": 1215, + "character": 95 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1281, + "character": 27 + }, + "end": { + "line": 1281, + "character": 38 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1297, + "character": 83 + }, + "end": { + "line": 1297, + "character": 94 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1321, + "character": 90 + }, + "end": { + "line": 1321, + "character": 101 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1335, + "character": 85 + }, + "end": { + "line": 1335, + "character": 96 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1555, + "character": 85 + }, + "end": { + "line": 1555, + "character": 96 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1572, + "character": 130 + }, + "end": { + "line": 1572, + "character": 141 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1593, + "character": 21 + }, + "end": { + "line": 1593, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2541, + "character": 23 + }, + "end": { + "line": 2541, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2556, + "character": 23 + }, + "end": { + "line": 2556, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2663, + "character": 36 + }, + "end": { + "line": 2663, + "character": 47 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2757, + "character": 27 + }, + "end": { + "line": 2757, + "character": 38 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2960, + "character": 16 + }, + "end": { + "line": 2960, + "character": 29 + } + }, + "severity": 4, + "code": "stringscut", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut" + }, + "source": "stringscut", + "message": "strings.Index can be simplified using strings.Cut" + }, + { + "range": { + "start": { + "line": 3036, + "character": 38 + }, + "end": { + "line": 3036, + "character": 49 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3043, + "character": 38 + }, + "end": { + "line": 3043, + "character": 49 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3051, + "character": 36 + }, + "end": { + "line": 3051, + "character": 47 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + } + ], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository_migrate_test.go": [], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/model/model.go": [ + { + "range": { + "start": { + "line": 234, + "character": 27 + }, + "end": { + "line": 234, + "character": 85 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero" + }, + "source": "omitzero", + "message": "Omitempty has no effect on nested struct fields" + } + ], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/store/repo/repository_mutations.go": [ + { + "range": { + "start": { + "line": 79, + "character": 21 + }, + "end": { + "line": 79, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 97, + "character": 21 + }, + "end": { + "line": 97, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 114, + "character": 21 + }, + "end": { + "line": 114, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 157, + "character": 21 + }, + "end": { + "line": 157, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 164, + "character": 21 + }, + "end": { + "line": 164, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 173, + "character": 21 + }, + "end": { + "line": 173, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 199, + "character": 156 + }, + "end": { + "line": 199, + "character": 167 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 199, + "character": 317 + }, + "end": { + "line": 199, + "character": 328 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 246, + "character": 159 + }, + "end": { + "line": 246, + "character": 170 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 252, + "character": 21 + }, + "end": { + "line": 252, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 303, + "character": 21 + }, + "end": { + "line": 303, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 380, + "character": 21 + }, + "end": { + "line": 380, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 389, + "character": 21 + }, + "end": { + "line": 389, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 568, + "character": 107 + }, + "end": { + "line": 568, + "character": 118 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 574, + "character": 21 + }, + "end": { + "line": 574, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 610, + "character": 70 + }, + "end": { + "line": 610, + "character": 81 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 629, + "character": 62 + }, + "end": { + "line": 629, + "character": 73 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 635, + "character": 21 + }, + "end": { + "line": 635, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 657, + "character": 122 + }, + "end": { + "line": 657, + "character": 133 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 663, + "character": 21 + }, + "end": { + "line": 663, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 679, + "character": 21 + }, + "end": { + "line": 679, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 688, + "character": 21 + }, + "end": { + "line": 688, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 743, + "character": 149 + }, + "end": { + "line": 743, + "character": 160 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 749, + "character": 21 + }, + "end": { + "line": 749, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 807, + "character": 23 + }, + "end": { + "line": 807, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 858, + "character": 23 + }, + "end": { + "line": 858, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1200, + "character": 192 + }, + "end": { + "line": 1200, + "character": 203 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1249, + "character": 80 + }, + "end": { + "line": 1249, + "character": 91 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1258, + "character": 137 + }, + "end": { + "line": 1258, + "character": 148 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1286, + "character": 31 + }, + "end": { + "line": 1286, + "character": 42 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1312, + "character": 30 + }, + "end": { + "line": 1312, + "character": 41 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1355, + "character": 27 + }, + "end": { + "line": 1355, + "character": 38 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1384, + "character": 10 + }, + "end": { + "line": 1384, + "character": 23 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq" + }, + "source": "stringsseq", + "message": "Ranging over SplitSeq is more efficient" + } + ], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/mutations.go": [ + { + "range": { + "start": { + "line": 34, + "character": 20 + }, + "end": { + "line": 34, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 88, + "character": 20 + }, + "end": { + "line": 88, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 215, + "character": 20 + }, + "end": { + "line": 215, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 240, + "character": 20 + }, + "end": { + "line": 240, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 292, + "character": 20 + }, + "end": { + "line": 292, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 463, + "character": 20 + }, + "end": { + "line": 463, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 635, + "character": 2 + }, + "end": { + "line": 635, + "character": 23 + } + }, + "severity": 3, + "code": "default", + "codeDescription": { + "href": "https://staticcheck.dev/docs/checks/#QF1003" + }, + "source": "QF1003", + "message": "could use tagged switch on row.ChainType" + }, + { + "range": { + "start": { + "line": 636, + "character": 67 + }, + "end": { + "line": 636, + "character": 78 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 638, + "character": 67 + }, + "end": { + "line": 638, + "character": 78 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 639, + "character": 68 + }, + "end": { + "line": 639, + "character": 79 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 641, + "character": 68 + }, + "end": { + "line": 641, + "character": 79 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 674, + "character": 20 + }, + "end": { + "line": 674, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 861, + "character": 46 + }, + "end": { + "line": 861, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1011, + "character": 46 + }, + "end": { + "line": 1011, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1015, + "character": 20 + }, + "end": { + "line": 1015, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1040, + "character": 18 + }, + "end": { + "line": 1040, + "character": 29 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1065, + "character": 20 + }, + "end": { + "line": 1065, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1131, + "character": 20 + }, + "end": { + "line": 1131, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1233, + "character": 20 + }, + "end": { + "line": 1233, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1377, + "character": 47 + }, + "end": { + "line": 1377, + "character": 58 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1529, + "character": 46 + }, + "end": { + "line": 1529, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1560, + "character": 46 + }, + "end": { + "line": 1560, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1591, + "character": 46 + }, + "end": { + "line": 1591, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1618, + "character": 46 + }, + "end": { + "line": 1618, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1714, + "character": 46 + }, + "end": { + "line": 1714, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1718, + "character": 20 + }, + "end": { + "line": 1718, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1743, + "character": 20 + }, + "end": { + "line": 1743, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1926, + "character": 20 + }, + "end": { + "line": 1926, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 1945, + "character": 20 + }, + "end": { + "line": 1945, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2036, + "character": 71 + }, + "end": { + "line": 2036, + "character": 82 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2222, + "character": 55 + }, + "end": { + "line": 2222, + "character": 66 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2248, + "character": 46 + }, + "end": { + "line": 2248, + "character": 57 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2296, + "character": 20 + }, + "end": { + "line": 2296, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2637, + "character": 90 + }, + "end": { + "line": 2637, + "character": 101 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2648, + "character": 67 + }, + "end": { + "line": 2648, + "character": 78 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2667, + "character": 63 + }, + "end": { + "line": 2667, + "character": 74 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2678, + "character": 62 + }, + "end": { + "line": 2678, + "character": 73 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2699, + "character": 153 + }, + "end": { + "line": 2699, + "character": 164 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2707, + "character": 32 + }, + "end": { + "line": 2707, + "character": 43 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2722, + "character": 26 + }, + "end": { + "line": 2722, + "character": 37 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2726, + "character": 38 + }, + "end": { + "line": 2726, + "character": 49 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2728, + "character": 43 + }, + "end": { + "line": 2728, + "character": 54 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2732, + "character": 24 + }, + "end": { + "line": 2732, + "character": 35 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2739, + "character": 19 + }, + "end": { + "line": 2739, + "character": 30 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2741, + "character": 25 + }, + "end": { + "line": 2741, + "character": 36 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2752, + "character": 19 + }, + "end": { + "line": 2752, + "character": 30 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2754, + "character": 23 + }, + "end": { + "line": 2754, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2758, + "character": 111 + }, + "end": { + "line": 2758, + "character": 122 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2763, + "character": 26 + }, + "end": { + "line": 2763, + "character": 37 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2767, + "character": 38 + }, + "end": { + "line": 2767, + "character": 49 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2769, + "character": 23 + }, + "end": { + "line": 2769, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2773, + "character": 25 + }, + "end": { + "line": 2773, + "character": 36 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2778, + "character": 33 + }, + "end": { + "line": 2778, + "character": 44 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2781, + "character": 35 + }, + "end": { + "line": 2781, + "character": 46 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2783, + "character": 21 + }, + "end": { + "line": 2783, + "character": 32 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 2910, + "character": 84 + }, + "end": { + "line": 2910, + "character": 95 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3112, + "character": 10 + }, + "end": { + "line": 3112, + "character": 23 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq" + }, + "source": "stringsseq", + "message": "Ranging over SplitSeq is more efficient" + }, + { + "range": { + "start": { + "line": 3221, + "character": 50 + }, + "end": { + "line": 3221, + "character": 61 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3398, + "character": 18 + }, + "end": { + "line": 3398, + "character": 29 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3398, + "character": 33 + }, + "end": { + "line": 3398, + "character": 44 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3402, + "character": 20 + }, + "end": { + "line": 3402, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3408, + "character": 18 + }, + "end": { + "line": 3408, + "character": 29 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3408, + "character": 44 + }, + "end": { + "line": 3408, + "character": 55 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3413, + "character": 26 + }, + "end": { + "line": 3413, + "character": 37 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3415, + "character": 29 + }, + "end": { + "line": 3415, + "character": 40 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3422, + "character": 16 + }, + "end": { + "line": 3422, + "character": 27 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3441, + "character": 13 + }, + "end": { + "line": 3441, + "character": 24 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3453, + "character": 15 + }, + "end": { + "line": 3453, + "character": 26 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3465, + "character": 15 + }, + "end": { + "line": 3465, + "character": 26 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3477, + "character": 23 + }, + "end": { + "line": 3477, + "character": 34 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3498, + "character": 20 + }, + "end": { + "line": 3498, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3516, + "character": 20 + }, + "end": { + "line": 3516, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3533, + "character": 1 + }, + "end": { + "line": 3533, + "character": 11 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#slicessort" + }, + "source": "slicessort", + "message": "sort.Slice can be modernized using slices.Sort" + }, + { + "range": { + "start": { + "line": 3537, + "character": 28 + }, + "end": { + "line": 3537, + "character": 39 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3544, + "character": 32 + }, + "end": { + "line": 3544, + "character": 43 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3560, + "character": 27 + }, + "end": { + "line": 3560, + "character": 38 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3582, + "character": 20 + }, + "end": { + "line": 3582, + "character": 31 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any" + }, + "source": "any", + "message": "interface{} can be replaced by any" + }, + { + "range": { + "start": { + "line": 3634, + "character": 10 + }, + "end": { + "line": 3634, + "character": 23 + } + }, + "severity": 4, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq" + }, + "source": "stringsseq", + "message": "Ranging over SplitSeq is more efficient" + } + ], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/control_plane_test.go": [ + { + "range": { + "start": { + "line": 204, + "character": 19 + }, + "end": { + "line": 204, + "character": 19 + } + }, + "severity": 3, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite" + }, + "source": "unusedwrite", + "message": "unused write to field repo" + }, + { + "range": { + "start": { + "line": 205, + "character": 23 + }, + "end": { + "line": 205, + "character": 23 + } + }, + "severity": 3, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite" + }, + "source": "unusedwrite", + "message": "unused write to field ID" + }, + { + "range": { + "start": { + "line": 205, + "character": 32 + }, + "end": { + "line": 205, + "character": 32 + } + }, + "severity": 3, + "code": "default", + "codeDescription": { + "href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/unusedwrite" + }, + "source": "unusedwrite", + "message": "unused write to field Name" + } + ], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/app/app.go": [], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/cmd/paneld/main.go": [], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs.go": [], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs_renewal_test.go": [], + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/go-backend/internal/http/handler/jobs_test.go": [] + }, + "diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx\n@@ -60,8 +60,10 @@\n } from \"@/pages/node/display\";\n import { tryCopyInstallCommand } from \"@/pages/node/install-command\";\n import {\n getNodeRenewalSnapshot,\n+ formatNodeRenewalTime,\n+ getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n } from \"@/pages/node/renewal\";\n import { buildNodeSystemInfo } from \"@/pages/node/system-info\";\n import { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\n", + "filediff": { + "file": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx", + "before": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n \n {children(listeners)}\n
\n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState([]);\n const [nodeOrder, setNodeOrder] = useState([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState(null);\n const [installChannel, setInstallChannel] =\n useState(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n \n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n \n 全部节点\n \n \n 7天内续费 ({nodeExpiryStats.expiringSoon})\n \n \n 已逾期 ({nodeExpiryStats.expired})\n \n \n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n \n \n
\n\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n openUpgradeModal(\"batch\")}\n >\n 升级\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n \n 退出\n \n \n ) : (\n <>\n \n 批量\n \n \n 新增\n \n \n )}\n
\n
\n
\n\n {!wsConnected && (\n \n )}\n\n {/* 节点列表 */}\n {loading ? (\n \n ) : nodeList.length === 0 ? (\n \n ) : (\n \n \n
\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n \n {(listeners) => (\n \n \n
\n
\n {selectMode && (\n toggleSelect(node.id)}\n />\n )}\n

\n {node.name}\n

\n
\n
\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n \n {expiryMeta.label}\n \n )}\n \n \n \n \n
\n {isRemoteNode && (\n \n 远程\n \n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n \n {connectionStatusMeta.text}\n \n );\n })()}\n
\n
\n \n\n \n {isRemoteNode && node.syncError && (\n
\n {getRemoteSyncErrorMessage(node.syncError)}\n
\n )}\n {expiryMeta.isHighlighted && (\n \n 节点到期提醒\n {expiryMeta.label}\n \n )}\n {/* 基础信息 */}\n
\n {(node.remark?.trim() || node.tags?.trim()) && (\n
\n {node.remark?.trim() && (\n
\n \n {node.remark.trim()}\n
\n
\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n
\n {normalizeNodeTags(node.tags).map((tag) => (\n \n #{tag}\n \n ))}\n
\n )}\n
\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle &&
}\n
\n \n IP\n \n
\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n
\n {node.serverIpV4?.trim() && (\n \n {node.serverIpV4.trim()}\n \n )}\n {node.serverIpV6?.trim() && (\n \n {node.serverIpV6.trim()}\n \n )}\n
\n ) : (\n \n {node.serverIp.trim()}\n \n )}\n
\n
\n {!isRemoteNode && (\n <>\n
\n 版本\n \n {node.version || \"未知\"}\n \n
\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n
\n \n
\n )}\n
\n \n 开机时间\n \n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n \n
\n \n )}\n
\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n
\n
\n
\n
\n CPU\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n
\n 内存\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n\n
\n
\n
\n 上传\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n
\n
\n
\n
\n 下载\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n
\n
\n
\n\n {/* 流量统计 */}\n
\n
\n
\n ↑ 上行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n ↓ 下行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n \n )}\n\n {/* 操作按钮 */}\n
\n {!isRemoteNode && (\n
\n openInstallSelector(node)}\n >\n 安装\n \n \n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n \n handleRollbackNode(node)}\n >\n 回退\n \n
\n )}\n \n {!isRemoteNode && (\n handleEdit(node)}\n >\n 编辑\n \n )}\n handleDelete(node)}\n >\n 删除\n \n
\n \n
\n \n )}\n \n );\n })}\n \n \n
\n )}\n\n {/* 新增/编辑节点对话框 */}\n setDialogVisible(false)}\n >\n \n {dialogTitle}\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n \n 月付\n \n \n 季付\n \n \n 年付\n \n \n
\n\n 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n \n\n \n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n
\n\n \n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n \n \n
\n \n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n \n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n
\n \n \n TCP\n \n
\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n \n \n UDP\n \n
\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n
\n {/* 屏蔽协议 */}\n
\n
\n 屏蔽协议\n
\n
\n 开启开关以屏蔽对应协议\n
\n {protocolDisabled && (\n \n )}\n \n {/* HTTP tile */}\n
\n
\n \n \n \n \n
\n HTTP\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* TLS tile */}\n
\n
\n \n \n \n \n
\n TLS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n
\n
\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* SOCKS tile */}\n
\n
\n \n \n \n \n \n
\n SOCKS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n
\n \n\n \n \n \n \n\n \n \n
\n \n \n \n {submitLoading ? \"提交中...\" : \"确定\"}\n \n \n
\n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除节点{\" \"}\n "{nodeToDelete?.name}" 吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n\n \n \n {(onClose) => (\n <>\n \n

\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n

\n
\n \n
\n {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n
\n
\n \n \n \n \n \n )}\n
\n \n\n {/* 安装命令模态框 */}\n setInstallCommandModal(false)}\n >\n \n 安装命令 - {currentNodeName}\n \n
\n

\n 请复制以下安装命令到服务器上执行:\n

\n
\n \n \n 复制\n \n
\n
\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n
\n
\n
\n \n setInstallCommandModal(false)}\n >\n 关闭\n \n \n
\n \n\n {/* 版本选择升级模态框 */}\n \n \n {(onClose) => (\n <>\n \n

\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n

\n
\n \n {releasesLoading ? (\n
\n \n
\n ) : (\n
\n {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n \n
\n {r.version}\n \n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n \n 测试\n \n )}\n \n
\n
\n ))}\n \n

\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n

\n
\n )}\n
\n \n \n \n 确认升级\n \n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除选中的 {selectedIds.size}{\" \"}\n 个节点吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {batchLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n
\n );\n}\n", + "after": "import { useState, useEffect, useMemo, useCallback } from \"react\";\nimport toast from \"react-hot-toast\";\nimport {\n DndContext,\n KeyboardSensor,\n MouseSensor,\n TouchSensor,\n type DragEndEvent,\n useSensor,\n useSensors,\n} from \"@dnd-kit/core\";\nimport {\n SortableContext,\n arrayMove,\n rectSortingStrategy,\n sortableKeyboardCoordinates,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nimport { SearchBar } from \"@/components/search-bar\";\nimport { AnimatedPage } from \"@/components/animated-page\";\nimport { Card, CardBody, CardHeader } from \"@/shadcn-bridge/heroui/card\";\nimport { Button } from \"@/shadcn-bridge/heroui/button\";\nimport { Input } from \"@/shadcn-bridge/heroui/input\";\nimport { Textarea } from \"@/shadcn-bridge/heroui/input\";\nimport {\n Modal,\n ModalContent,\n ModalHeader,\n ModalBody,\n ModalFooter,\n} from \"@/shadcn-bridge/heroui/modal\";\nimport { Chip } from \"@/shadcn-bridge/heroui/chip\";\nimport { Switch } from \"@/shadcn-bridge/heroui/switch\";\nimport { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\nimport { Alert } from \"@/shadcn-bridge/heroui/alert\";\nimport { Progress } from \"@/shadcn-bridge/heroui/progress\";\nimport { Accordion, AccordionItem } from \"@/shadcn-bridge/heroui/accordion\";\nimport { Select, SelectItem } from \"@/shadcn-bridge/heroui/select\";\nimport { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\nimport {\n createNode,\n getNodeList,\n updateNode,\n deleteNode,\n getNodeInstallCommand,\n updateNodeOrder,\n batchDeleteNodes,\n upgradeNode,\n batchUpgradeNodes,\n getNodeReleases,\n rollbackNode,\n type ReleaseChannel,\n} from \"@/api\";\nimport { PageEmptyState, PageLoadingState } from \"@/components/page-state\";\nimport {\n getConnectionStatusMeta,\n getRemoteSyncErrorMessage,\n} from \"@/pages/node/display\";\nimport { tryCopyInstallCommand } from \"@/pages/node/install-command\";\nimport {\n getNodeRenewalSnapshot,\n formatNodeRenewalTime,\n getNodeRenewalCycleLabel,\n type NodeRenewalCycle,\n} from \"@/pages/node/renewal\";\nimport { buildNodeSystemInfo } from \"@/pages/node/system-info\";\nimport { useNodeOfflineTimers } from \"@/pages/node/use-node-offline-timers\";\nimport { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\nimport { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\nimport { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n\nconst NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n\ninterface Node {\n id: number;\n inx?: number;\n name: string;\n remark?: string;\n tags?: string;\n expiryTime?: number;\n renewalCycle?: NodeRenewalCycle;\n ip: string;\n serverIp: string;\n serverIpV4?: string;\n serverIpV6?: string;\n port: string;\n tcpListenAddr?: string;\n udpListenAddr?: string;\n extraIPs?: string;\n version?: string;\n http?: number; // 0 关 1 开\n tls?: number; // 0 关 1 开\n socks?: number; // 0 关 1 开\n status: number;\n isRemote?: number;\n remoteUrl?: string;\n syncError?: string;\n connectionStatus: \"online\" | \"offline\";\n systemInfo?: {\n cpuUsage: number;\n memoryUsage: number;\n uploadTraffic: number;\n downloadTraffic: number;\n uploadSpeed: number;\n downloadSpeed: number;\n uptime: number;\n } | null;\n copyLoading?: boolean;\n upgradeLoading?: boolean;\n rollbackLoading?: boolean;\n}\n\ninterface NodeForm {\n id: number | null;\n name: string;\n remark: string;\n tags: string;\n expiryTime: number;\n renewalCycle: NodeRenewalCycle;\n serverHost: string;\n serverIpV4: string;\n serverIpV6: string;\n port: string;\n tcpListenAddr: string;\n udpListenAddr: string;\n interfaceName: string;\n extraIPs: string;\n http: number; // 0 关 1 开\n tls: number; // 0 关 1 开\n socks: number; // 0 关 1 开\n}\n\nconst EXPIRING_SOON_DAYS = 7;\n\ntype NodeExpiryState = \"permanent\" | \"healthy\" | \"expiringSoon\" | \"expired\";\n\ntype NodeFilterMode = \"all\" | \"expiringSoon\" | \"expired\" | \"withExpiry\";\n\nconst getNodeReminderEnabled = (node: Node): boolean => {\n return !!node.expiryTime && node.expiryTime > 0 && !!node.renewalCycle;\n};\n\nconst getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => {\n const renewal = getNodeRenewalSnapshot(timestamp, cycle, EXPIRING_SOON_DAYS);\n\n if (renewal.state === \"unset\") {\n return {\n state: \"permanent\" as NodeExpiryState,\n label: \"未设置续费周期\",\n tone: \"default\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 3,\n nextDueTime: undefined,\n };\n }\n\n if (renewal.state === \"expired\") {\n return {\n state: \"expired\" as NodeExpiryState,\n label: \"已过期\",\n tone: \"danger\" as const,\n accentClassName:\n \"border-red-300/80 bg-red-50/70 shadow-red-100 dark:border-red-500/40 dark:bg-red-950/20\",\n bannerClassName:\n \"bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-300\",\n isHighlighted: true,\n sortWeight: 0,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n if (renewal.state === \"dueSoon\") {\n return {\n state: \"expiringSoon\" as NodeExpiryState,\n label: renewal.label,\n tone: \"warning\" as const,\n accentClassName:\n \"border-amber-300/80 bg-amber-50/80 shadow-amber-100 dark:border-amber-500/40 dark:bg-amber-950/20\",\n bannerClassName:\n \"bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300\",\n isHighlighted: true,\n sortWeight: 1,\n nextDueTime: renewal.nextDueTime,\n };\n }\n\n return {\n state: \"healthy\" as NodeExpiryState,\n label: renewal.label,\n tone: \"success\" as const,\n accentClassName: \"\",\n bannerClassName: \"\",\n isHighlighted: false,\n sortWeight: 2,\n nextDueTime: renewal.nextDueTime,\n };\n};\n\nconst normalizeNodeTags = (tags?: string): string[] => {\n if (!tags) return [];\n\n return tags\n .split(\",\")\n .map((tag) => tag.trim())\n .filter(Boolean);\n};\n\nconst mergeNodeRealtimeState = (\n incomingNode: Node,\n existingNode?: Node,\n): Node => {\n return {\n ...incomingNode,\n systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null,\n copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false,\n upgradeLoading:\n existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false,\n rollbackLoading:\n existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false,\n };\n};\n\nconst SortableItem = ({\n id,\n children,\n}: {\n id: number;\n children: (listeners: any) => any;\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id });\n\n const style: React.CSSProperties = {\n transform: transform\n ? CSS.Transform.toString({\n ...transform,\n x: Math.round(transform.x),\n y: Math.round(transform.y),\n })\n : undefined,\n transition: isDragging ? undefined : transition || undefined,\n opacity: isDragging ? 0.5 : 1,\n willChange: isDragging ? \"transform\" : undefined,\n };\n\n return (\n \n {children(listeners)}\n \n );\n};\n\nexport default function NodePage() {\n const [nodeList, setNodeList] = useState([]);\n const [nodeOrder, setNodeOrder] = useState([]);\n const [loading, setLoading] = useState(false);\n const [searchKeyword, setSearchKeyword] = useLocalStorageState(\n \"node-search-keyword\",\n \"\",\n );\n const [nodeFilterMode, setNodeFilterMode] =\n useLocalStorageState(\"node-expiry-filter-mode\", \"all\");\n const [isSearchVisible, setIsSearchVisible] = useState(false);\n const [dialogVisible, setDialogVisible] = useState(false);\n const [dialogTitle, setDialogTitle] = useState(\"\");\n const [isEdit, setIsEdit] = useState(false);\n const [submitLoading, setSubmitLoading] = useState(false);\n const [deleteModalOpen, setDeleteModalOpen] = useState(false);\n const [deleteLoading, setDeleteLoading] = useState(false);\n const [nodeToDelete, setNodeToDelete] = useState(null);\n const [protocolDisabled, setProtocolDisabled] = useState(false);\n const [protocolDisabledReason, setProtocolDisabledReason] = useState(\"\");\n const [form, setForm] = useState({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n const [errors, setErrors] = useState>({});\n\n const [selectMode, setSelectMode] = useState(false);\n const [selectedIds, setSelectedIds] = useState>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n\n // 安装命令相关状态\n const [installCommandModal, setInstallCommandModal] = useState(false);\n const [installCommand, setInstallCommand] = useState(\"\");\n const [currentNodeName, setCurrentNodeName] = useState(\"\");\n const [installSelectorOpen, setInstallSelectorOpen] = useState(false);\n const [installTargetNode, setInstallTargetNode] = useState(null);\n const [installChannel, setInstallChannel] =\n useState(\"stable\");\n\n // 升级相关状态\n const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);\n const [upgradeTarget, setUpgradeTarget] = useState<\"single\" | \"batch\">(\n \"single\",\n );\n const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(\n null,\n );\n const [releases, setReleases] = useState<\n Array<{\n version: string;\n name: string;\n publishedAt: string;\n prerelease: boolean;\n channel: ReleaseChannel;\n }>\n >([]);\n const [releasesLoading, setReleasesLoading] = useState(false);\n const [releaseChannel, setReleaseChannel] =\n useState(\"stable\");\n const [selectedVersion, setSelectedVersion] = useState(\"\");\n const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);\n const [upgradeProgress, setUpgradeProgress] = useState<\n Record\n >({});\n\n const handleNodeOffline = useCallback((nodeId: number) => {\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"offline\" && node.systemInfo === null) {\n return node;\n }\n\n return { ...node, connectionStatus: \"offline\", systemInfo: null };\n }),\n );\n }, []);\n\n const { clearOfflineTimer, scheduleNodeOffline } = useNodeOfflineTimers({\n delayMs: 3000,\n onNodeOffline: handleNodeOffline,\n });\n\n // 加载节点列表\n const loadNodes = useCallback(async (options?: { silent?: boolean }) => {\n const silent = options?.silent ?? false;\n\n if (!silent) {\n setLoading(true);\n }\n\n try {\n const res = await getNodeList();\n\n if (res.code === 0) {\n const nodesData: Node[] = (res.data || []).map((node: any) => ({\n ...node,\n inx: node.inx ?? 0,\n connectionStatus: node.syncError\n ? \"offline\"\n : node.status === 1\n ? \"online\"\n : \"offline\",\n syncError: node.syncError || undefined,\n systemInfo: null,\n copyLoading: false,\n }));\n\n setNodeList((prev) => {\n const previousById = new Map(prev.map((node) => [node.id, node]));\n\n return nodesData.map((node) =>\n mergeNodeRealtimeState(node, previousById.get(node.id)),\n );\n });\n\n // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n const hasDbOrdering = nodesData.some(\n (n) => n.inx !== undefined && n.inx !== 0,\n );\n\n if (hasDbOrdering) {\n const dbOrder = [...nodesData]\n .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n .map((n) => n.id);\n\n setNodeOrder(dbOrder);\n } else {\n setNodeOrder(\n loadStoredOrder(\n \"node-order\",\n nodesData.map((n) => n.id),\n ),\n );\n }\n } else {\n if (!silent) {\n toast.error(res.msg || \"加载节点列表失败\");\n }\n }\n } catch {\n if (!silent) {\n toast.error(\"网络错误,请重试\");\n }\n } finally {\n if (!silent) {\n setLoading(false);\n }\n }\n }, []);\n\n // 处理WebSocket消息\n const handleWebSocketMessage = (data: any) => {\n const { id, type, data: messageData } = data;\n const nodeId = Number(id);\n\n if (Number.isNaN(nodeId)) return;\n\n if (type === \"status\") {\n if (messageData === 1) {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id !== nodeId) return node;\n if (node.connectionStatus === \"online\") return node;\n\n return { ...node, connectionStatus: \"online\" };\n }),\n );\n } else {\n // 离线事件做延迟处理,避免短抖动导致频繁闪烁\n scheduleNodeOffline(nodeId);\n }\n } else if (type === \"info\") {\n clearOfflineTimer(nodeId);\n setNodeList((prev) =>\n prev.map((node) => {\n if (node.id === nodeId) {\n const systemInfo = buildNodeSystemInfo(\n messageData,\n node.systemInfo,\n );\n\n if (!systemInfo) {\n return node;\n }\n\n return {\n ...node,\n connectionStatus: \"online\",\n systemInfo,\n };\n }\n\n return node;\n }),\n );\n } else if (type === \"upgrade_progress\") {\n try {\n const progressData =\n typeof messageData === \"string\"\n ? JSON.parse(messageData)\n : messageData;\n\n if (progressData?.data) {\n setUpgradeProgress((prev) => ({\n ...prev,\n [nodeId]: {\n stage: progressData.data.stage || \"\",\n percent: progressData.data.percent || 0,\n message: progressData.message || \"\",\n },\n }));\n }\n } catch {\n // ignore parse errors\n }\n }\n };\n\n const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n\n useEffect(() => {\n loadNodes();\n }, [loadNodes]);\n\n useEffect(() => {\n if (!usingPollingFallback) {\n return;\n }\n\n void loadNodes({ silent: true });\n\n const interval = window.setInterval(() => {\n void loadNodes({ silent: true });\n }, NODE_FALLBACK_REFRESH_INTERVAL_MS);\n\n return () => {\n window.clearInterval(interval);\n };\n }, [loadNodes, usingPollingFallback]);\n\n // 格式化速度\n const formatSpeed = (bytesPerSecond: number): string => {\n if (bytesPerSecond === 0) return \"0 B/s\";\n\n const k = 1024;\n const sizes = [\"B/s\", \"KB/s\", \"MB/s\", \"GB/s\", \"TB/s\"];\n const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));\n\n return (\n parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i]\n );\n };\n\n // 格式化开机时间\n const formatUptime = (seconds: number): string => {\n if (seconds === 0) return \"-\";\n\n const days = Math.floor(seconds / 86400);\n const hours = Math.floor((seconds % 86400) / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n\n if (days > 0) {\n return `${days}天${hours}小时`;\n } else if (hours > 0) {\n return `${hours}小时${minutes}分钟`;\n } else {\n return `${minutes}分钟`;\n }\n };\n\n // 格式化流量\n const formatTraffic = (bytes: number): string => {\n if (bytes === 0) return \"0 B\";\n\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + \" \" + sizes[i];\n };\n\n // 获取进度条颜色\n const getProgressColor = (\n value: number,\n offline = false,\n ): \"default\" | \"primary\" | \"secondary\" | \"success\" | \"warning\" | \"danger\" => {\n if (offline) return \"default\";\n if (value <= 50) return \"success\";\n if (value <= 80) return \"warning\";\n\n return \"danger\";\n };\n\n // IPv4/IPv6 格式验证(仅用于判定地址族)\n const ipv4Regex =\n /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;\n const ipv6Regex =\n /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;\n\n const validateIpv4Literal = (ip: string): boolean =>\n ipv4Regex.test(ip.trim());\n const validateIpv6Literal = (ip: string): boolean =>\n ipv6Regex.test(ip.trim());\n\n // Hostname/domain validation (no scheme/port)\n const hostnameRegex =\n /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;\n const validateHostname = (host: string): boolean => {\n const v = host.trim();\n\n if (!v) return false;\n if (v === \"localhost\") return true;\n\n return hostnameRegex.test(v);\n };\n\n // 验证端口格式:支持 80,443,100-600\n const validatePort = (\n portStr: string,\n ): { valid: boolean; error?: string } => {\n if (!portStr || !portStr.trim()) {\n return { valid: false, error: \"请输入端口\" };\n }\n\n const trimmed = portStr.trim();\n const parts = trimmed\n .split(\",\")\n .map((p) => p.trim())\n .filter((p) => p);\n\n if (parts.length === 0) {\n return { valid: false, error: \"请输入有效的端口\" };\n }\n\n for (const part of parts) {\n // 检查是否是端口范围 (如 100-600)\n if (part.includes(\"-\")) {\n const range = part.split(\"-\").map((p) => p.trim());\n\n if (range.length !== 2) {\n return { valid: false, error: `端口范围格式错误: ${part}` };\n }\n\n const start = parseInt(range[0]);\n const end = parseInt(range[1]);\n\n if (isNaN(start) || isNaN(end)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (start < 1 || start > 65535 || end < 1 || end > 65535) {\n return {\n valid: false,\n error: `端口范围必须在 1-65535 之间: ${part}`,\n };\n }\n\n if (start >= end) {\n return { valid: false, error: `起始端口必须小于结束端口: ${part}` };\n }\n } else {\n // 单个端口\n const port = parseInt(part);\n\n if (isNaN(port)) {\n return { valid: false, error: `端口必须是数字: ${part}` };\n }\n\n if (port < 1 || port > 65535) {\n return { valid: false, error: `端口必须在 1-65535 之间: ${part}` };\n }\n }\n }\n\n return { valid: true };\n };\n\n // 表单验证\n const validateForm = (): boolean => {\n const newErrors: Record = {};\n\n if (!form.name.trim()) {\n newErrors.name = \"请输入节点名称\";\n } else if (form.name.trim().length < 2) {\n newErrors.name = \"节点名称长度至少2位\";\n } else if (form.name.trim().length > 50) {\n newErrors.name = \"节点名称长度不能超过50位\";\n }\n\n if (\n (form.renewalCycle && !form.expiryTime) ||\n (!form.renewalCycle && form.expiryTime)\n ) {\n newErrors.expiryTime = \"请同时设置续费周期和续费基准时间\";\n }\n\n const v4 = form.serverIpV4.trim();\n const v6 = form.serverIpV6.trim();\n const host = form.serverHost.trim();\n\n if (!v4 && !v6 && !host) {\n const msg = \"请至少填写一个 IPv4/IPv6 地址或域名\";\n\n newErrors.serverIpV4 = msg;\n newErrors.serverIpV6 = msg;\n newErrors.serverHost = msg;\n } else {\n if (v4 && !validateIpv4Literal(v4)) {\n newErrors.serverIpV4 = \"请输入有效的IPv4地址\";\n }\n if (v6 && !validateIpv6Literal(v6)) {\n newErrors.serverIpV6 = \"请输入有效的IPv6地址\";\n }\n if (host && !validateHostname(host)) {\n newErrors.serverHost = \"请输入有效的域名/主机名\";\n }\n }\n\n const portValidation = validatePort(form.port);\n\n if (!portValidation.valid) {\n newErrors.port = portValidation.error || \"端口格式错误\";\n }\n\n setErrors(newErrors);\n\n return Object.keys(newErrors).length === 0;\n };\n\n // 新增节点\n const handleAdd = () => {\n setDialogTitle(\"新增节点\");\n setIsEdit(false);\n setDialogVisible(true);\n resetForm();\n setProtocolDisabled(true);\n setProtocolDisabledReason(\"节点未在线,等待节点上线后再设置\");\n };\n\n // 编辑节点\n const handleEdit = (node: Node) => {\n setDialogTitle(\"编辑节点\");\n setIsEdit(true);\n\n const legacy = (node.serverIp || \"\").trim();\n const normalizedV4 =\n node.serverIpV4?.trim() || (validateIpv4Literal(legacy) ? legacy : \"\");\n const normalizedV6 =\n node.serverIpV6?.trim() || (validateIpv6Literal(legacy) ? legacy : \"\");\n const normalizedHost =\n !normalizedV4 && !normalizedV6 && legacy ? legacy : \"\";\n\n setForm({\n id: node.id,\n name: node.name,\n remark: node.remark || \"\",\n tags: node.tags || \"\",\n expiryTime: node.expiryTime || 0,\n renewalCycle: node.renewalCycle || \"\",\n serverHost: normalizedHost,\n serverIpV4: normalizedV4,\n serverIpV6: normalizedV6,\n port: node.port || \"1000-65535\",\n tcpListenAddr: node.tcpListenAddr || \"[::]\",\n udpListenAddr: node.udpListenAddr || \"[::]\",\n interfaceName: (node as any).interfaceName || \"\",\n extraIPs: node.extraIPs || \"\",\n http: typeof node.http === \"number\" ? node.http : 1,\n tls: typeof node.tls === \"number\" ? node.tls : 1,\n socks: typeof node.socks === \"number\" ? node.socks : 1,\n });\n const offline = node.connectionStatus !== \"online\";\n\n setProtocolDisabled(offline);\n setProtocolDisabledReason(\n offline ? \"节点未在线,等待节点上线后再设置\" : \"\",\n );\n setDialogVisible(true);\n };\n\n // 删除节点\n const handleDelete = (node: Node) => {\n setNodeToDelete(node);\n setDeleteModalOpen(true);\n };\n\n const confirmDelete = async () => {\n if (!nodeToDelete) return;\n\n setDeleteLoading(true);\n try {\n const res = await deleteNode(nodeToDelete.id);\n\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setNodeList((prev) => prev.filter((n) => n.id !== nodeToDelete.id));\n setDeleteModalOpen(false);\n setNodeToDelete(null);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setDeleteLoading(false);\n }\n };\n\n const openInstallSelector = (node: Node) => {\n setInstallTargetNode(node);\n setInstallChannel(\"stable\");\n setInstallSelectorOpen(true);\n };\n\n // 复制安装命令\n const handleCopyInstallCommand = async (\n node: Node,\n channel: ReleaseChannel,\n ) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)),\n );\n\n try {\n const res = await getNodeInstallCommand(node.id, channel);\n\n if (res.code === 0 && res.data) {\n const copied = await tryCopyInstallCommand(res.data);\n\n if (copied) {\n toast.success(\n `${channel === \"stable\" ? \"正式版\" : \"测试版\"}安装命令已复制到剪贴板`,\n );\n } else {\n setInstallCommand(res.data);\n setCurrentNodeName(node.name);\n setInstallCommandModal(true);\n }\n } else {\n toast.error(res.msg || \"获取安装命令失败\");\n }\n } catch {\n toast.error(\"获取安装命令失败\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, copyLoading: false } : n)),\n );\n }\n };\n\n const handleConfirmInstallCommand = async () => {\n if (!installTargetNode) return;\n\n setInstallSelectorOpen(false);\n await handleCopyInstallCommand(installTargetNode, installChannel);\n };\n\n // 手动复制安装命令\n const handleManualCopy = async () => {\n try {\n await navigator.clipboard.writeText(installCommand);\n toast.success(\"安装命令已复制到剪贴板\");\n setInstallCommandModal(false);\n } catch {\n toast.error(\"复制失败,请手动选择文本复制\");\n }\n };\n\n const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => {\n setReleasesLoading(true);\n try {\n const res = await getNodeReleases(channel);\n\n if (res.code === 0 && Array.isArray(res.data)) {\n setReleases(res.data);\n } else {\n toast.error(res.msg || \"获取版本列表失败\");\n }\n } catch {\n toast.error(\"获取版本列表失败\");\n } finally {\n setReleasesLoading(false);\n }\n }, []);\n\n // 打开版本选择弹窗\n const openUpgradeModal = async (\n target: \"single\" | \"batch\",\n nodeId?: number,\n ) => {\n const defaultChannel: ReleaseChannel = \"stable\";\n\n setUpgradeTarget(target);\n setUpgradeTargetNodeId(nodeId || null);\n setReleaseChannel(defaultChannel);\n setSelectedVersion(\"\");\n setUpgradeModalOpen(true);\n await loadReleasesByChannel(defaultChannel);\n };\n\n // 确认升级(从版本弹窗)\n const handleConfirmUpgrade = async () => {\n const version = selectedVersion || undefined;\n\n if (upgradeTarget === \"single\" && upgradeTargetNodeId) {\n setUpgradeModalOpen(false);\n // Find the node\n const node = nodeList.find((n) => n.id === upgradeTargetNodeId);\n\n if (!node) return;\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,\n ),\n );\n try {\n const res = await upgradeNode(\n upgradeTargetNodeId,\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`节点升级命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,\n ),\n );\n }\n } else if (upgradeTarget === \"batch\") {\n setBatchUpgradeLoading(true);\n setUpgradeModalOpen(false);\n try {\n const res = await batchUpgradeNodes(\n Array.from(selectedIds),\n version,\n releaseChannel,\n );\n\n if (res.code === 0) {\n toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);\n } else {\n toast.error(res.msg || \"批量升级失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchUpgradeLoading(false);\n }\n }\n };\n\n // 回退节点\n const handleRollbackNode = async (node: Node) => {\n setNodeList((prev) =>\n prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),\n );\n try {\n const res = await rollbackNode(node.id);\n\n if (res.code === 0) {\n toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);\n } else {\n toast.error(res.msg || \"回退失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === node.id ? { ...n, rollbackLoading: false } : n,\n ),\n );\n }\n };\n\n // 提交表单\n const handleSubmit = async () => {\n if (!validateForm()) return;\n\n setSubmitLoading(true);\n\n try {\n const apiCall = isEdit ? updateNode : createNode;\n const { serverHost, ...rest } = form;\n const data = {\n ...rest,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n extraIPs: form.extraIPs,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n serverHost?.trim() ||\n \"\",\n };\n\n const res = await apiCall(data);\n\n if (res.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setDialogVisible(false);\n\n if (isEdit) {\n setNodeList((prev) =>\n prev.map((n) =>\n n.id === form.id\n ? {\n ...n,\n name: form.name,\n remark: form.remark.trim(),\n tags: form.tags.trim(),\n expiryTime: form.expiryTime,\n renewalCycle: form.renewalCycle,\n serverIp:\n form.serverIpV4?.trim() ||\n form.serverIpV6?.trim() ||\n form.serverHost?.trim() ||\n \"\",\n serverIpV4: form.serverIpV4,\n serverIpV6: form.serverIpV6,\n port: form.port,\n tcpListenAddr: form.tcpListenAddr,\n udpListenAddr: form.udpListenAddr,\n interfaceName: form.interfaceName,\n http: form.http,\n tls: form.tls,\n socks: form.socks,\n }\n : n,\n ),\n );\n } else {\n loadNodes();\n }\n } else {\n toast.error(res.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setSubmitLoading(false);\n }\n };\n\n // 重置表单\n const resetForm = () => {\n setForm({\n id: null,\n name: \"\",\n remark: \"\",\n tags: \"\",\n expiryTime: 0,\n renewalCycle: \"\",\n serverHost: \"\",\n serverIpV4: \"\",\n serverIpV6: \"\",\n port: \"1000-65535\",\n tcpListenAddr: \"[::]\",\n udpListenAddr: \"[::]\",\n interfaceName: \"\",\n extraIPs: \"\",\n http: 0,\n tls: 0,\n socks: 0,\n });\n setErrors({});\n };\n\n // 处理拖拽结束\n const handleDragEnd = async (event: DragEndEvent) => {\n const { active, over } = event;\n\n if (!active || !over || active.id === over.id) return;\n if (!nodeOrder || nodeOrder.length === 0) return;\n\n const activeId = Number(active.id);\n const overId = Number(over.id);\n\n if (isNaN(activeId) || isNaN(overId)) return;\n\n const oldIndex = nodeOrder.indexOf(activeId);\n const newIndex = nodeOrder.indexOf(overId);\n\n if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;\n\n const newOrder = arrayMove(nodeOrder, oldIndex, newIndex);\n\n setNodeOrder(newOrder);\n\n saveOrder(\"node-order\", newOrder);\n\n // 持久化到数据库\n try {\n const nodesToUpdate = newOrder.map((id, index) => ({ id, inx: index }));\n const response = await updateNodeOrder({ nodes: nodesToUpdate });\n\n if (response.code === 0) {\n setNodeList((prev) =>\n prev.map((node) => {\n const updated = nodesToUpdate.find((n) => n.id === node.id);\n\n return updated ? { ...node, inx: updated.inx } : node;\n }),\n );\n } else {\n toast.error(\"保存排序失败:\" + (response.msg || \"未知错误\"));\n }\n } catch {\n toast.error(\"保存排序失败,请重试\");\n }\n };\n\n // 批量操作处理函数\n const toggleSelectMode = () => {\n setSelectMode((prev) => {\n if (prev) {\n setSelectedIds(new Set());\n }\n\n return !prev;\n });\n };\n\n const toggleSelect = (id: number) => {\n setSelectedIds((prev) => {\n const next = new Set(prev);\n\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n\n return next;\n });\n };\n\n const selectAll = () => {\n setSelectedIds(new Set(sortedNodes.map((n) => n.id)));\n };\n\n const deselectAll = () => {\n setSelectedIds(new Set());\n };\n\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n try {\n const res = await batchDeleteNodes(Array.from(selectedIds));\n\n if (res.code === 0) {\n toast.success(`成功删除 ${selectedIds.size} 个节点`);\n setNodeList((prev) => prev.filter((n) => !selectedIds.has(n.id)));\n setSelectedIds(new Set());\n setBatchDeleteModalOpen(false);\n setSelectMode(false);\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch {\n toast.error(\"网络错误,请重试\");\n } finally {\n setBatchLoading(false);\n }\n };\n\n // 传感器配置\n const sensors = useSensors(\n useSensor(MouseSensor, {\n activationConstraint: {\n distance: 8,\n },\n }),\n useSensor(TouchSensor, {\n activationConstraint: {\n delay: 250,\n tolerance: 8,\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n }),\n );\n\n const nodeExpiryStats = useMemo(() => {\n return nodeList.reduce(\n (acc, node) => {\n const meta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);\n\n if (meta.state === \"expired\") acc.expired += 1;\n if (meta.state === \"expiringSoon\") acc.expiringSoon += 1;\n if (getNodeReminderEnabled(node)) {\n acc.withExpiry += 1;\n }\n\n return acc;\n },\n { expired: 0, expiringSoon: 0, withExpiry: 0 },\n );\n }, [nodeList]);\n\n // 根据排序顺序获取节点列表\n const sortedNodes = useMemo((): Node[] => {\n if (!nodeList || nodeList.length === 0) return [];\n\n let filteredNodes = nodeList;\n\n if (searchKeyword.trim()) {\n const lowerKeyword = searchKeyword.toLowerCase();\n\n filteredNodes = filteredNodes.filter(\n (n) =>\n (n.name && n.name.toLowerCase().includes(lowerKeyword)) ||\n (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) ||\n (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||\n (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),\n );\n }\n\n if (nodeFilterMode !== \"all\") {\n filteredNodes = filteredNodes.filter((node) => {\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n switch (nodeFilterMode) {\n case \"expiringSoon\":\n return expiryMeta.state === \"expiringSoon\";\n case \"expired\":\n return expiryMeta.state === \"expired\";\n case \"withExpiry\":\n return getNodeReminderEnabled(node);\n default:\n return true;\n }\n });\n }\n\n const sortedByDb = [...filteredNodes].sort((a, b) => {\n const expiryDiff =\n getNodeExpiryMeta(a.expiryTime, a.renewalCycle).sortWeight -\n getNodeExpiryMeta(b.expiryTime, b.renewalCycle).sortWeight;\n\n if (expiryDiff !== 0) {\n return expiryDiff;\n }\n\n const aInx = a.inx ?? 0;\n const bInx = b.inx ?? 0;\n\n return aInx - bInx;\n });\n\n // 如果数据库中没有排序信息,则使用本地存储的顺序\n if (\n nodeOrder &&\n nodeOrder.length > 0 &&\n sortedByDb.every((n) => n.inx === undefined || n.inx === 0)\n ) {\n const nodeMap = new Map(filteredNodes.map((n) => [n.id, n] as const));\n const localSorted: Node[] = [];\n\n nodeOrder.forEach((id) => {\n const node = nodeMap.get(id);\n\n if (node) localSorted.push(node);\n });\n\n filteredNodes.forEach((node) => {\n if (!nodeOrder.includes(node.id)) {\n localSorted.push(node);\n }\n });\n\n return localSorted;\n }\n\n return sortedByDb;\n }, [nodeFilterMode, nodeList, nodeOrder, searchKeyword]);\n\n const sortableNodeIds = useMemo(\n () => sortedNodes.map((n) => n.id),\n [sortedNodes],\n );\n\n return (\n \n
\n
\n setIsSearchVisible(false)}\n onOpen={() => setIsSearchVisible(true)}\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeFilterMode\n | undefined;\n\n setNodeFilterMode(selected || \"all\");\n }}\n >\n \n 全部节点\n \n \n 7天内续费 ({nodeExpiryStats.expiringSoon})\n \n \n 已逾期 ({nodeExpiryStats.expired})\n \n \n 已启用续费提醒 ({nodeExpiryStats.withExpiry})\n \n \n
\n\n
\n
*]:shrink-0\">\n {selectMode ? (\n <>\n \n 已选 {selectedIds.size} 项\n \n \n 全选\n \n \n 清空\n \n openUpgradeModal(\"batch\")}\n >\n 升级\n \n setBatchDeleteModalOpen(true)}\n >\n 删除\n \n \n 退出\n \n \n ) : (\n <>\n \n 批量\n \n \n 新增\n \n \n )}\n
\n
\n
\n\n {!wsConnected && (\n \n )}\n\n {/* 节点列表 */}\n {loading ? (\n \n ) : nodeList.length === 0 ? (\n \n ) : (\n \n \n
\n {sortedNodes.map((node) => {\n const isRemoteNode = node.isRemote === 1;\n const expiryMeta = getNodeExpiryMeta(\n node.expiryTime,\n node.renewalCycle,\n );\n\n return (\n \n {(listeners) => (\n \n \n
\n
\n {selectMode && (\n toggleSelect(node.id)}\n />\n )}\n

\n {node.name}\n

\n
\n
\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle && (\n \n {expiryMeta.label}\n \n )}\n \n \n \n \n
\n {isRemoteNode && (\n \n 远程\n \n )}\n {(() => {\n const connectionStatusMeta =\n getConnectionStatusMeta(\n node.connectionStatus,\n );\n\n return (\n \n {connectionStatusMeta.text}\n \n );\n })()}\n
\n
\n \n\n \n {isRemoteNode && node.syncError && (\n
\n {getRemoteSyncErrorMessage(node.syncError)}\n
\n )}\n {expiryMeta.isHighlighted && (\n \n 节点到期提醒\n {expiryMeta.label}\n \n )}\n {/* 基础信息 */}\n
\n {(node.remark?.trim() || node.tags?.trim()) && (\n
\n {node.remark?.trim() && (\n
\n \n {node.remark.trim()}\n
\n
\n )}\n {normalizeNodeTags(node.tags).length > 0 && (\n
\n {normalizeNodeTags(node.tags).map((tag) => (\n \n #{tag}\n \n ))}\n
\n )}\n
\n )}\n {node.expiryTime &&\n node.expiryTime > 0 &&\n node.renewalCycle &&
}\n
\n \n IP\n \n
\n {node.serverIpV4?.trim() ||\n node.serverIpV6?.trim() ? (\n
\n {node.serverIpV4?.trim() && (\n \n {node.serverIpV4.trim()}\n \n )}\n {node.serverIpV6?.trim() && (\n \n {node.serverIpV6.trim()}\n \n )}\n
\n ) : (\n \n {node.serverIp.trim()}\n \n )}\n
\n
\n {!isRemoteNode && (\n <>\n
\n 版本\n \n {node.version || \"未知\"}\n \n
\n {upgradeProgress[node.id] &&\n upgradeProgress[node.id].percent < 100 && (\n
\n \n
\n )}\n
\n \n 开机时间\n \n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatUptime(node.systemInfo.uptime)\n : \"-\"}\n \n
\n \n )}\n
\n\n {!isRemoteNode && (\n <>\n {/* 系统监控 */}\n
\n
\n
\n
\n CPU\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.cpuUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n
\n 内存\n \n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? `${node.systemInfo.memoryUsage.toFixed(1)}%`\n : \"-\"}\n \n
\n \n
\n
\n\n
\n
\n
\n 上传\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.uploadSpeed,\n )\n : \"-\"}\n
\n
\n
\n
\n 下载\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatSpeed(\n node.systemInfo.downloadSpeed,\n )\n : \"-\"}\n
\n
\n
\n\n {/* 流量统计 */}\n
\n
\n
\n ↑ 上行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.uploadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n ↓ 下行流量\n
\n
\n {node.connectionStatus === \"online\" &&\n node.systemInfo\n ? formatTraffic(\n node.systemInfo.downloadTraffic,\n )\n : \"-\"}\n
\n
\n
\n
\n \n )}\n\n {/* 操作按钮 */}\n
\n {!isRemoteNode && (\n
\n openInstallSelector(node)}\n >\n 安装\n \n \n openUpgradeModal(\"single\", node.id)\n }\n >\n 升级\n \n handleRollbackNode(node)}\n >\n 回退\n \n
\n )}\n \n {!isRemoteNode && (\n handleEdit(node)}\n >\n 编辑\n \n )}\n handleDelete(node)}\n >\n 删除\n \n
\n \n
\n \n )}\n \n );\n })}\n \n \n
\n )}\n\n {/* 新增/编辑节点对话框 */}\n setDialogVisible(false)}\n >\n \n {dialogTitle}\n \n
\n \n setForm((prev) => ({ ...prev, name: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, remark: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, tags: e.target.value }))\n }\n />\n\n {\n const selected = Array.from(keys)[0] as\n | NodeRenewalCycle\n | undefined;\n\n setForm((prev) => ({\n ...prev,\n renewalCycle: selected || \"\",\n }));\n }}\n >\n \n 月付\n \n \n 季付\n \n \n 年付\n \n \n
\n\n 0\n ? new Date(form.expiryTime).toISOString().slice(0, 16)\n : \"\"\n }\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n expiryTime: e.target.value\n ? new Date(e.target.value).getTime()\n : 0,\n }))\n }\n />\n\n \n\n \n setForm((prev) => ({ ...prev, serverHost: e.target.value }))\n }\n />\n\n
\n \n setForm((prev) => ({ ...prev, serverIpV4: e.target.value }))\n }\n />\n\n \n setForm((prev) => ({ ...prev, serverIpV6: e.target.value }))\n }\n />\n
\n\n \n setForm((prev) => ({ ...prev, port: e.target.value }))\n }\n />\n\n {/* 高级配置 */}\n \n \n
\n \n setForm((prev) => ({\n ...prev,\n interfaceName: e.target.value,\n }))\n }\n />\n\n \n setForm((prev) => ({\n ...prev,\n extraIPs: e.target.value,\n }))\n }\n />\n\n
\n \n \n TCP\n \n
\n }\n value={form.tcpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n tcpListenAddr: e.target.value,\n }))\n }\n />\n\n \n \n UDP\n \n
\n }\n value={form.udpListenAddr}\n variant=\"bordered\"\n onChange={(e) =>\n setForm((prev) => ({\n ...prev,\n udpListenAddr: e.target.value,\n }))\n }\n />\n
\n {/* 屏蔽协议 */}\n
\n
\n 屏蔽协议\n
\n
\n 开启开关以屏蔽对应协议\n
\n {protocolDisabled && (\n \n )}\n \n {/* HTTP tile */}\n
\n
\n \n \n \n \n
\n HTTP\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n http: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.http === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* TLS tile */}\n
\n
\n \n \n \n \n
\n TLS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({ ...prev, tls: v ? 1 : 0 }))\n }\n />\n
\n
\n {form.tls === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n\n {/* SOCKS tile */}\n
\n
\n \n \n \n \n \n
\n SOCKS\n
\n
\n
\n
\n 禁用/启用\n
\n \n setForm((prev) => ({\n ...prev,\n socks: v ? 1 : 0,\n }))\n }\n />\n
\n
\n {form.socks === 1 ? \"已开启\" : \"已关闭\"}\n
\n
\n
\n \n\n \n \n \n \n\n \n \n
\n \n \n \n {submitLoading ? \"提交中...\" : \"确定\"}\n \n \n
\n \n\n {/* 删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除节点{\" \"}\n "{nodeToDelete?.name}" 吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {deleteLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n\n \n \n {(onClose) => (\n <>\n \n

\n 选择安装通道\n {installTargetNode ? ` - ${installTargetNode.name}` : \"\"}\n

\n
\n \n
\n {\n const selected = Array.from(keys)[0] as ReleaseChannel;\n\n setInstallChannel(selected || \"stable\");\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n
\n
\n \n \n \n \n \n )}\n
\n \n\n {/* 安装命令模态框 */}\n setInstallCommandModal(false)}\n >\n \n 安装命令 - {currentNodeName}\n \n
\n

\n 请复制以下安装命令到服务器上执行:\n

\n
\n \n \n 复制\n \n
\n
\n 💡 提示:如果复制按钮失效,请手动选择上方文本进行复制\n
\n
\n
\n \n setInstallCommandModal(false)}\n >\n 关闭\n \n \n
\n \n\n {/* 版本选择升级模态框 */}\n \n \n {(onClose) => (\n <>\n \n

\n {upgradeTarget === \"batch\"\n ? `批量升级 (${selectedIds.size} 个节点)`\n : \"升级节点\"}\n

\n
\n \n {releasesLoading ? (\n
\n \n
\n ) : (\n
\n {\n const selected =\n (Array.from(keys)[0] as ReleaseChannel) || \"stable\";\n\n setReleaseChannel(selected);\n setSelectedVersion(\"\");\n void loadReleasesByChannel(selected);\n }}\n >\n \n 正式版(纯数字版本,如 2.1.4)\n \n \n 测试版(含 alpha / beta / rc)\n \n \n {\n const selected = Array.from(keys)[0] as string;\n\n setSelectedVersion(selected || \"\");\n }}\n >\n {releases.map((r) => (\n \n
\n {r.version}\n \n {r.publishedAt\n ? new Date(r.publishedAt).toLocaleDateString()\n : \"\"}\n {r.channel === \"dev\" && (\n \n 测试\n \n )}\n \n
\n
\n ))}\n \n

\n {selectedVersion\n ? `将升级到版本 ${selectedVersion}`\n : `未选择版本,将自动使用最新${releaseChannel === \"stable\" ? \"正式\" : \"测试\"}版`}\n

\n
\n )}\n
\n \n \n \n 确认升级\n \n \n \n )}\n
\n \n\n {/* 批量删除确认模态框 */}\n \n \n {(onClose) => (\n <>\n \n

确认删除

\n
\n \n

\n 确定要删除选中的 {selectedIds.size}{\" \"}\n 个节点吗?\n

\n

\n 此操作不可恢复,请谨慎操作。\n

\n
\n \n \n \n {batchLoading ? \"删除中...\" : \"确认删除\"}\n \n \n \n )}\n
\n \n
\n );\n}\n", + "additions": 2, + "deletions": 0 + }, + "truncated": false + }, + "time": { + "start": 1773025499974, + "end": 1773025500013 + } + }, + "id": "prt_cd08e1b42001GX0VCW38a4pr3E", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a", + "cost": 0, + "tokens": { + "total": 30011, + "input": 934, + "output": 213, + "reasoning": 0, + "cache": { + "read": 28864, + "write": 0 + } + }, + "id": "prt_cd08e1b73001KTcw1yXoO02pkI", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ" + }, + { + "type": "patch", + "hash": "0ac8bcf194b1d53f2d11f5b7c097711d68a623bc", + "files": [ + "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/node.tsx" + ], + "id": "prt_cd08e1c5b001X2tW6765N28K6Y", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08df1ec0016p8ElQV7HboEkQ" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025500256, + "completed": 1773025537042 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 30137, + "input": 259, + "output": 118, + "reasoning": 0, + "cache": { + "read": 29760, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd08e1c60001MGJmguxKTeXd7M", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a", + "id": "prt_cd08e2a8c001knPGRcXAvGIQGP", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08e1c60001MGJmguxKTeXd7M" + }, + { + "type": "reasoning", + "text": "现在让我再次尝试构建和推送 Docker 镜像。", + "time": { + "start": 1773025503885, + "end": 1773025507370 + }, + "id": "prt_cd08e2a8d001XYktnAN825govH", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08e1c60001MGJmguxKTeXd7M" + }, + { + "type": "tool", + "callID": "call_3f4c04c5473e47188c58d08c", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .", + "description": "Build and push vite-frontend Docker image", + "timeout": 600000 + }, + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.0s\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.0s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.2s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.418 \n#12 0.418 > flvx@0.0.0 build\n#12 0.418 > tsc && vite build\n#12 0.418 \n#12 14.14 rolldown-vite v7.3.1 building client environment for production...\n#12 14.33 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.98 rendering chunks...\n#12 15.48 computing gzip size...\n#12 15.53 dist/manifest.webmanifest 0.43 kB\n#12 15.53 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.53 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.53 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.53 dist/assets/index-B17jGxVh.js 4,010.27 kB │ gzip: 827.77 kB\n#12 15.53 ✓ built in 1.39s\n#12 16.77 \n#12 16.77 PWA v1.2.0\n#12 16.77 mode generateSW\n#12 16.77 precache 9 entries (4044.58 KiB)\n#12 16.77 files generated\n#12 16.77 dist/sw.js\n#12 16.77 dist/workbox-8c29f6e4.js\n#12 DONE 16.9s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:4a360776a1358bf3e0b11ce4559463ebf82aefa197c6b5cd160eba164f225e8a done\n#15 exporting config sha256:7c231d03a9baac4032dd009a4349cd80ae740e1711001e1524a747d1762be049 done\n#15 exporting attestation manifest sha256:14db9dc48f0fdb5fd38e18457bfe23d20c14cd1b0b79a8c1d60f3c3a303eefd5 done\n#15 exporting manifest list sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e 2.6s done\n#15 DONE 7.3s\n", + "title": "Build and push vite-frontend Docker image", + "metadata": { + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/node:20.19.0\n#2 DONE 1.0s\n\n#3 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#3 DONE 1.1s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.0s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.0s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.2s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.418 \n#12 0.418 > flvx@0.0.0 build\n#12 0.418 > tsc && vite build\n#12 0.418 \n#12 14.14 rolldown-vite v7.3.1 building client environment for production...\n#12 14.33 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.98 rendering chunks...\n#12 15.48 computing gzip size...\n#12 15.53 dist/manifest.webmanifest 0.43 kB\n#12 15.53 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.53 dist/assets/index-CAG6-7s5.css 115.55 kB │ gzip: 17.83 kB\n#12 15.53 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.53 dist/assets/index-B17jGxVh.js 4,010.27 kB │ gzip: 827.77 kB\n#12 15.53 ✓ built in 1.39s\n#12 16.77 \n#12 16.77 PWA v1.2.0\n#12 16.77 mode generateSW\n#12 16.77 precache 9 entries (4044.58 KiB)\n#12 16.77 files generated\n#12 16.77 dist/sw.js\n#12 16.77 dist/workbox-8c29f6e4.js\n#12 DONE 16.9s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:4a360776a1358bf3e0b11ce4559463ebf82aefa197c6b5cd160eba164f225e8a done\n#15 exporting config sha256:7c231d03a9baac4032dd009a4349cd80ae740e1711001e1524a747d1762be049 done\n#15 exporting attestation manifest sha256:14db9dc48f0fdb5fd38e18457bfe23d20c14cd1b0b79a8c1d60f3c3a303eefd5 done\n#15 exporting manifest list sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.6s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:5a72e2e6230b76c2b1ae62d41e7ea3c0e4af6d1ef2965224d7a9c249428f5a0e 2.6s done\n#15 DONE 7.3s\n", + "exit": 0, + "description": "Build and push vite-frontend Docker image", + "truncated": false + }, + "time": { + "start": 1773025507298, + "end": 1773025536775 + } + }, + "id": "prt_cd08e37dc001YqQfOC7Fg6K4Yc", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08e1c60001MGJmguxKTeXd7M" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a", + "cost": 0, + "tokens": { + "total": 30137, + "input": 259, + "output": 118, + "reasoning": 0, + "cache": { + "read": 29760, + "write": 0 + } + }, + "id": "prt_cd08eab0a001Gj0C7rfWuYu2Z6", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08e1c60001MGJmguxKTeXd7M" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773025537045, + "completed": 1773025545302 + }, + "parentID": "msg_cd08cf2e5001UoNixpQwGQiGYH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 31684, + "input": 1553, + "output": 115, + "reasoning": 0, + "cache": { + "read": 30016, + "write": 0 + } + }, + "finish": "stop", + "id": "msg_cd08eac15001GyEg3wFqg0e0NK", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a", + "id": "prt_cd08ebc3d001T0PXxFykb0ddNH", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08eac15001GyEg3wFqg0e0NK" + }, + { + "type": "reasoning", + "text": "成功!已经修复了 TypeScript 错误(添加了缺失的函数导入),并成功构建并推送了 Docker 镜像 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)。", + "time": { + "start": 1773025541181, + "end": 1773025545037 + }, + "id": "prt_cd08ebc3d002X0VPJQt5YNXpWy", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08eac15001GyEg3wFqg0e0NK" + }, + { + "type": "text", + "text": "已成功修复构建错误并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)\n\n修复了 `src/pages/node.tsx:62` 缺失的函数导入:\n- `formatNodeRenewalTime`\n- `getNodeRenewalCycleLabel`", + "time": { + "start": 1773025545038, + "end": 1773025545038 + }, + "id": "prt_cd08ec2ed001IAsHlzMkfTrbur", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08eac15001GyEg3wFqg0e0NK" + }, + { + "type": "step-finish", + "reason": "stop", + "snapshot": "b73b5a5c4323de33ac1b0c9c768c49b0ce5bf96a", + "cost": 0, + "tokens": { + "total": 31684, + "input": 1553, + "output": 115, + "reasoning": 0, + "cache": { + "read": 30016, + "write": 0 + } + }, + "id": "prt_cd08ecb50001doh3UrOnGkIgSj", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd08eac15001GyEg3wFqg0e0NK" + } + ] + }, + { + "info": { + "role": "user", + "time": { + "created": 1773026101933 + }, + "summary": { + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "zai-coding-plan", + "modelID": "glm-5" + }, + "id": "msg_cd09749f9001NoVXJ4UDVnvCoH", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "text", + "text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送", + "id": "prt_cd09749f9002uNbd23iQqOsGu3", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd09749f9001NoVXJ4UDVnvCoH" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773026101945, + "completed": 1773026150191 + }, + "parentID": "msg_cd09749f9001NoVXJ4UDVnvCoH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 31478, + "input": 5561, + "output": 125, + "reasoning": 0, + "cache": { + "read": 25792, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd0974ab9001IY0UdT2b4QHUWc", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899", + "id": "prt_cd09767ce001Gf05jp8OaQeBiJ", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc" + }, + { + "type": "reasoning", + "text": "用户要求重新编译并推送 Docker 镜像。我将直接执行构建和推送命令。", + "time": { + "start": 1773026109390, + "end": 1773026119321 + }, + "id": "prt_cd09767ce0020oHHd0RzP21iM5", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc" + }, + { + "type": "tool", + "callID": "call_d20a65732da64cf1b091261d", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .", + "description": "Build and push vite-frontend Docker image", + "timeout": 600000 + }, + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.4s\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.4s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#6 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.1s done\n#7 DONE 1.1s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.432 \n#12 0.432 > flvx@0.0.0 build\n#12 0.432 > tsc && vite build\n#12 0.432 \n#12 14.15 rolldown-vite v7.3.1 building client environment for production...\n#12 14.34 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.99 rendering chunks...\n#12 15.46 computing gzip size...\n#12 15.51 dist/manifest.webmanifest 0.43 kB\n#12 15.51 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.51 dist/assets/index-CnmCYymI.css 115.54 kB │ gzip: 17.84 kB\n#12 15.51 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.51 dist/assets/index-BtDQkGJs.js 4,010.23 kB │ gzip: 827.74 kB\n#12 15.51 ✓ built in 1.36s\n#12 16.73 \n#12 16.73 PWA v1.2.0\n#12 16.73 mode generateSW\n#12 16.73 precache 9 entries (4044.53 KiB)\n#12 16.73 files generated\n#12 16.73 dist/sw.js\n#12 16.73 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:56f055021f97e8b8168e1f45b5abb5f0b12f4c5023ef53e9ff643d70ff937d8d done\n#15 exporting config sha256:101683a67202e2ad0d5edc295b7b303f498a9aff5b55125f5520348ab0b3495c done\n#15 exporting attestation manifest sha256:edc472c2dd33a6f2b9890480f7c3fb1780c071f08285243bc8212c482b9ae728 done\n#15 exporting manifest list sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec 2.8s done\n#15 DONE 7.8s\n", + "title": "Build and push vite-frontend Docker image", + "metadata": { + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.4s\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.4s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#5 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#5 DONE 0.0s\n\n#6 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#6 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.1s done\n#7 DONE 1.1s\n\n#8 [builder 3/6] COPY package*.json ./\n#8 CACHED\n\n#9 [builder 2/6] WORKDIR /app\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.6s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.432 \n#12 0.432 > flvx@0.0.0 build\n#12 0.432 > tsc && vite build\n#12 0.432 \n#12 14.15 rolldown-vite v7.3.1 building client environment for production...\n#12 14.34 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 14.99 rendering chunks...\n#12 15.46 computing gzip size...\n#12 15.51 dist/manifest.webmanifest 0.43 kB\n#12 15.51 dist/index.html 7.51 kB │ gzip: 2.50 kB\n#12 15.51 dist/assets/index-CnmCYymI.css 115.54 kB │ gzip: 17.84 kB\n#12 15.51 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.51 dist/assets/index-BtDQkGJs.js 4,010.23 kB │ gzip: 827.74 kB\n#12 15.51 ✓ built in 1.36s\n#12 16.73 \n#12 16.73 PWA v1.2.0\n#12 16.73 mode generateSW\n#12 16.73 precache 9 entries (4044.53 KiB)\n#12 16.73 files generated\n#12 16.73 dist/sw.js\n#12 16.73 dist/workbox-8c29f6e4.js\n#12 DONE 16.8s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers 0.1s done\n#15 exporting manifest sha256:56f055021f97e8b8168e1f45b5abb5f0b12f4c5023ef53e9ff643d70ff937d8d done\n#15 exporting config sha256:101683a67202e2ad0d5edc295b7b303f498a9aff5b55125f5520348ab0b3495c done\n#15 exporting attestation manifest sha256:edc472c2dd33a6f2b9890480f7c3fb1780c071f08285243bc8212c482b9ae728 done\n#15 exporting manifest list sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:0c1c2d165809a1840ee88bfcf5bb030c8d4e6508f217419a9b36ae2b6613b7ec 2.8s done\n#15 DONE 7.8s\n", + "exit": 0, + "description": "Build and push vite-frontend Docker image", + "truncated": false + }, + "time": { + "start": 1773026119243, + "end": 1773026149937 + } + }, + "id": "prt_cd0978e46001bIQ63I4a9mWW7I", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899", + "cost": 0, + "tokens": { + "total": 31478, + "input": 5561, + "output": 125, + "reasoning": 0, + "cache": { + "read": 25792, + "write": 0 + } + }, + "id": "prt_cd0980633001rym719Z3ET7LU0", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0974ab9001IY0UdT2b4QHUWc" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773026150194, + "completed": 1773026153288 + }, + "parentID": "msg_cd09749f9001NoVXJ4UDVnvCoH", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 32941, + "input": 1609, + "output": 36, + "reasoning": 0, + "cache": { + "read": 31296, + "write": 0 + } + }, + "finish": "stop", + "id": "msg_cd0980732001AyJlwtIQC176Q0", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899", + "id": "prt_cd0980e64001MCbR3FllNvnri2", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0980732001AyJlwtIQC176Q0" + }, + { + "type": "reasoning", + "text": "构建成功完成,镜像已推送。", + "time": { + "start": 1773026152036, + "end": 1773026153032 + }, + "id": "prt_cd0980e64002pCmTQggvUSmlKj", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0980732001AyJlwtIQC176Q0" + }, + { + "type": "text", + "text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)", + "time": { + "start": 1773026153033, + "end": 1773026153033 + }, + "id": "prt_cd0980fd50015jamtnK6osgI38", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0980732001AyJlwtIQC176Q0" + }, + { + "type": "step-finish", + "reason": "stop", + "snapshot": "61caed67021faa2beec21d9a9c4ba34cebbef899", + "cost": 0, + "tokens": { + "total": 32941, + "input": 1609, + "output": 36, + "reasoning": 0, + "cache": { + "read": 31296, + "write": 0 + } + }, + "id": "prt_cd098124a001qlkn2rZuKgt2PY", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0980732001AyJlwtIQC176Q0" + } + ] + }, + { + "info": { + "role": "user", + "time": { + "created": 1773026787572 + }, + "summary": { + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "zai-coding-plan", + "modelID": "glm-5" + }, + "id": "msg_cd0a1c0b4001cSh2gogvuLZ5zm", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "text", + "text": "编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送", + "id": "prt_cd0a1c0b4002Miy1VXAQZPcAGD", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773026787585, + "completed": 1773026824007 + }, + "parentID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 33062, + "input": 1644, + "output": 122, + "reasoning": 0, + "cache": { + "read": 31296, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd0a1c101001txuuXScozzdjYZ", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1", + "id": "prt_cd0a1c7ee001h4u4qJGCwFT392", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a1c101001txuuXScozzdjYZ" + }, + { + "type": "reasoning", + "text": "用户再次请求构建并推送相同的Docker镜像。我直接执行命令即可。", + "time": { + "start": 1773026789359, + "end": 1773026792700 + }, + "id": "prt_cd0a1c7ef001lEobqFkEh46Jdm", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a1c101001txuuXScozzdjYZ" + }, + { + "type": "tool", + "callID": "call_80a8dfd19cbd44b9827b16b6", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "cd /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend && docker buildx build --platform linux/amd64 -t ghcr.io/sagit-chu/vite-frontend:beta --push .", + "description": "Build and push vite-frontend Docker image", + "timeout": 600000 + }, + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.1s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.433 \n#12 0.433 > flvx@0.0.0 build\n#12 0.433 > tsc && vite build\n#12 0.433 \n#12 14.22 rolldown-vite v7.3.1 building client environment for production...\n#12 14.43 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 15.11 rendering chunks...\n#12 15.64 computing gzip size...\n#12 15.69 dist/manifest.webmanifest 0.43 kB\n#12 15.69 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.69 dist/assets/index-RHpHIZQG.css 115.51 kB │ gzip: 17.83 kB\n#12 15.69 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.69 dist/assets/index-CzPYPNjX.js 4,012.78 kB │ gzip: 828.11 kB\n#12 15.69 ✓ built in 1.48s\n#12 16.99 \n#12 16.99 PWA v1.2.0\n#12 16.99 mode generateSW\n#12 16.99 precache 9 entries (4046.99 KiB)\n#12 16.99 files generated\n#12 16.99 dist/sw.js\n#12 16.99 dist/workbox-8c29f6e4.js\n#12 DONE 17.1s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb done\n#15 exporting config sha256:8b439f8ffdf71de36200a2a1bc5644a2bf2ff990a7af259990082b79d6c9358f done\n#15 exporting attestation manifest sha256:4e5e440464fa21d9f5bd94d76b8389c1ef5a5ae9a2f28be14783b0607f431f73 0.0s done\n#15 exporting manifest list sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 0.0s done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 2.5s done\n#15 DONE 7.6s\n", + "title": "Build and push vite-frontend Docker image", + "metadata": { + "output": "#0 building with \"multiarch\" instance using docker-container driver\n\n#1 [internal] load build definition from Dockerfile\n#1 transferring dockerfile: 401B done\n#1 DONE 0.0s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 ...\n\n#3 [internal] load metadata for docker.io/library/node:20.19.0\n#3 DONE 1.5s\n\n#2 [internal] load metadata for docker.io/library/nginx:stable-alpine\n#2 DONE 1.5s\n\n#4 [internal] load .dockerignore\n#4 transferring context: 2B done\n#4 DONE 0.0s\n\n#5 [production-stage 1/3] FROM docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab\n#5 resolve docker.io/library/nginx:stable-alpine@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab 0.0s done\n#5 DONE 0.0s\n\n#6 [builder 1/6] FROM docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5\n#6 resolve docker.io/library/node:20.19.0@sha256:a5fb035ac1dff34a4ecaea85f90f7321185695d3fd22c12ba12f4535a4647cc5 0.0s done\n#6 DONE 0.1s\n\n#7 [internal] load build context\n#7 transferring context: 2.66MB 1.0s done\n#7 DONE 1.1s\n\n#8 [builder 2/6] WORKDIR /app\n#8 CACHED\n\n#9 [builder 3/6] COPY package*.json ./\n#9 CACHED\n\n#10 [builder 4/6] RUN npm install --legacy-peer-deps\n#10 CACHED\n\n#11 [builder 5/6] COPY . .\n#11 DONE 2.8s\n\n#12 [builder 6/6] RUN npm run build\n#12 0.433 \n#12 0.433 > flvx@0.0.0 build\n#12 0.433 > tsc && vite build\n#12 0.433 \n#12 14.22 rolldown-vite v7.3.1 building client environment for production...\n#12 14.43 \u001b[2K\rtransforming...✓ 3193 modules transformed.\n#12 15.11 rendering chunks...\n#12 15.64 computing gzip size...\n#12 15.69 dist/manifest.webmanifest 0.43 kB\n#12 15.69 dist/index.html 7.51 kB │ gzip: 2.51 kB\n#12 15.69 dist/assets/index-RHpHIZQG.css 115.51 kB │ gzip: 17.83 kB\n#12 15.69 dist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\n#12 15.69 dist/assets/index-CzPYPNjX.js 4,012.78 kB │ gzip: 828.11 kB\n#12 15.69 ✓ built in 1.48s\n#12 16.99 \n#12 16.99 PWA v1.2.0\n#12 16.99 mode generateSW\n#12 16.99 precache 9 entries (4046.99 KiB)\n#12 16.99 files generated\n#12 16.99 dist/sw.js\n#12 16.99 dist/workbox-8c29f6e4.js\n#12 DONE 17.1s\n\n#13 [production-stage 2/3] COPY nginx.conf /etc/nginx/nginx.conf\n#13 CACHED\n\n#14 [production-stage 3/3] COPY --from=builder /app/dist /usr/share/nginx/html\n#14 DONE 0.0s\n\n#15 exporting to image\n#15 exporting layers\n#15 exporting layers 0.2s done\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb\n#15 exporting manifest sha256:15918da3e65b1a1afceea74f596ea54c686db63a56dc99508c1a68f68f22b9fb done\n#15 exporting config sha256:8b439f8ffdf71de36200a2a1bc5644a2bf2ff990a7af259990082b79d6c9358f done\n#15 exporting attestation manifest sha256:4e5e440464fa21d9f5bd94d76b8389c1ef5a5ae9a2f28be14783b0607f431f73 0.0s done\n#15 exporting manifest list sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 0.0s done\n#15 pushing layers\n#15 ...\n\n#16 [auth] sagit-chu/vite-frontend:pull,push token for ghcr.io\n#16 DONE 0.0s\n\n#15 exporting to image\n#15 pushing layers 4.8s done\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117\n#15 pushing manifest for ghcr.io/sagit-chu/vite-frontend:beta@sha256:d019c26eb35f0d0205ec278d66d7cd1866fff5e90774689df2d00d4698944117 2.5s done\n#15 DONE 7.6s\n", + "exit": 0, + "description": "Build and push vite-frontend Docker image", + "truncated": false + }, + "time": { + "start": 1773026792660, + "end": 1773026823756 + } + }, + "id": "prt_cd0a1d4ce001jU69DgRpI68o2L", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a1c101001txuuXScozzdjYZ" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1", + "cost": 0, + "tokens": { + "total": 33062, + "input": 1644, + "output": 122, + "reasoning": 0, + "cache": { + "read": 31296, + "write": 0 + } + }, + "id": "prt_cd0a24e4f001DhoQHXDUDQzcrw", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a1c101001txuuXScozzdjYZ" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773026824011, + "completed": 1773026827727 + }, + "parentID": "msg_cd0a1c0b4001cSh2gogvuLZ5zm", + "modelID": "glm-5", + "providerID": "zai-coding-plan", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 34595, + "input": 1665, + "output": 34, + "reasoning": 0, + "cache": { + "read": 32896, + "write": 0 + } + }, + "finish": "stop", + "id": "msg_cd0a24f4b00157qwgrDBSP1LSt", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1", + "id": "prt_cd0a25a75001E3eTRyLGIR2qA0", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt" + }, + { + "type": "reasoning", + "text": "构建和推送成功完成。", + "time": { + "start": 1773026826870, + "end": 1773026827445 + }, + "id": "prt_cd0a25a76001Wl5aIDxt5nVnJk", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt" + }, + { + "type": "text", + "text": "已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64)", + "time": { + "start": 1773026827446, + "end": 1773026827446 + }, + "id": "prt_cd0a25a76002pH4LdzTRrrs3VO", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt" + }, + { + "type": "step-finish", + "reason": "stop", + "snapshot": "b7337e6c039f4bfbe2c2371dc5dd65337ca209e1", + "cost": 0, + "tokens": { + "total": 34595, + "input": 1665, + "output": 34, + "reasoning": 0, + "cache": { + "read": 32896, + "write": 0 + } + }, + "id": "prt_cd0a25cb70019nz7mc8Tq8wgEJ", + "sessionID": "ses_32f7e3b05ffeK8IxNvZAZISGWO", + "messageID": "msg_cd0a24f4b00157qwgrDBSP1LSt" + } + ] + } + ] +} diff --git a/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/prompt.txt b/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/prompt.txt new file mode 100644 index 0000000..085ab5c --- /dev/null +++ b/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/prompt.txt @@ -0,0 +1,17 @@ +编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送 + +--- + +编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送 + +--- + +编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送 + +--- + +编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送 + +--- + +编译docker 镜像 ghcr.io/sagit-chu/vite-frontend:beta linux/amd64架构 推送 \ No newline at end of file diff --git a/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/summary.txt b/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/summary.txt new file mode 100644 index 0000000..bced568 --- /dev/null +++ b/.entire/metadata/ses_32f7e3b05ffeK8IxNvZAZISGWO/summary.txt @@ -0,0 +1 @@ +已成功构建并推送 `ghcr.io/sagit-chu/vite-frontend:beta` (linux/amd64) \ No newline at end of file diff --git a/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/full.jsonl b/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/full.jsonl index 35de563..911a44e 100644 --- a/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/full.jsonl +++ b/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/full.jsonl @@ -7,13 +7,13 @@ "title": "规则页精简模式显示规则数量", "version": "1.2.21", "summary": { - "additions": 67, - "deletions": 57, - "files": 1 + "additions": 4529, + "deletions": 65, + "files": 11 }, "time": { "created": 1773020783005, - "updated": 1773020978012 + "updated": 1773022814998 } }, "messages": [ @@ -3536,6 +3536,505 @@ "messageID": "msg_cd0491b35001Gyu7B4fNEHivH2" } ] + }, + { + "info": { + "role": "user", + "time": { + "created": 1773022792451 + }, + "summary": { + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "api", + "modelID": "gemini-3.1-pro-high" + }, + "id": "msg_cd064ca87001mKUQE2EyR8PezH", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" + }, + "parts": [ + { + "type": "text", + "text": "这个没改对,页面没变化,请检查", + "id": "prt_cd064ca87002we266x5BvrANpB", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd064ca87001mKUQE2EyR8PezH" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773022792467, + "completed": 1773022801627 + }, + "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", + "modelID": "gemini-3.1-pro-high", + "providerID": "api", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 58585, + "input": 58084, + "output": 59, + "reasoning": 442, + "cache": { + "read": 0, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd064cb13001TWoWh6nBESUPZl", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "id": "prt_cd064ee23001PcfPcy835V87tU", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd064cb13001TWoWh6nBESUPZl" + }, + { + "type": "tool", + "callID": "bash-1773022801288808061-252", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "git show HEAD:vite-frontend/src/pages/forward.tsx | grep -B 5 -A 20 \"根据显示模式渲染不同内容\"", + "description": "Check original code structure for rendering modes" + }, + "output": " value={batchProgress.percent}\n />\n \n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \n
\n )}\n\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\n sortedForwards.length > 0 ? (\n <>\n
\n \n 全部规则\n \n \n {sortedForwards.length} 条规则\n \n
\n
\n \n \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n", + "title": "Check latest commit", + "metadata": { + "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n", + "exit": 0, + "description": "Check latest commit", + "truncated": false + }, + "time": { + "start": 1773022810506, + "end": 1773022810506 + } + }, + "id": "prt_cd065118a0014hx1B9opNcSfJl", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065028100100U8RbhbWKxrf3" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "cost": 0, + "tokens": { + "total": 58460, + "input": 1540, + "output": 25, + "reasoning": 0, + "cache": { + "read": 56895, + "write": 0 + } + }, + "id": "prt_cd065118b001SISeN0O2IRDH5C", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065028100100U8RbhbWKxrf3" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773022810683, + "completed": 1773022814810 + }, + "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", + "modelID": "gemini-3.1-pro-high", + "providerID": "api", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 58595, + "input": 1681, + "output": 24, + "reasoning": 0, + "cache": { + "read": 56890, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd065123a001uXPsKZwVH7RDJb", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "id": "prt_cd06521a8001yiaSoVddfbN1tj", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" + }, + { + "type": "tool", + "callID": "bash-1773022814469937121-255", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "description": "Check history", + "command": "git log -3" + }, + "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu \nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu \nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n", + "title": "Check history", + "metadata": { + "output": "commit 78af992b6fde82eb3af4d09daa02a43866210636\nAuthor: sagitchu \nDate: Mon Mar 9 09:59:59 2026 +0800\n\n fix: restore renewal.ts from main to resolve build errors\n\ncommit f790becda8ead611b847637daafad6e7d40d8115\nMerge: e7548e7 413081f\nAuthor: sagitchu \nDate: Mon Mar 9 09:58:02 2026 +0800\n\n merge: resolve conflicts with main branch\n\ncommit e7548e7b30ecd1c73e4ef98f5d36e6b6394c209a\nAuthor: sagitchu \nDate: Mon Mar 9 09:51:28 2026 +0800\n\n style(frontend): format code and improve node card layout\n \n Entire-Checkpoint: 08b760b76538\n", + "exit": 0, + "description": "Check history", + "truncated": false + }, + "time": { + "start": 1773022814633, + "end": 1773022814633 + } + }, + "id": "prt_cd06521a8002I2rlXCrLkmKM1d", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "cost": 0, + "tokens": { + "total": 58595, + "input": 1681, + "output": 24, + "reasoning": 0, + "cache": { + "read": 56890, + "write": 0 + } + }, + "id": "prt_cd06521a9001CzTlcvvzi6vd8Y", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065123a001uXPsKZwVH7RDJb" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773022814814, + "completed": 1773022818942 + }, + "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", + "modelID": "gemini-3.1-pro-high", + "providerID": "api", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "total": 58974, + "input": 2054, + "output": 33, + "reasoning": 0, + "cache": { + "read": 56887, + "write": 0 + } + }, + "finish": "tool-calls", + "id": "msg_cd065225e0010wlkl67le6h46g", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" + }, + "parts": [ + { + "type": "step-start", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "id": "prt_cd06531d8001vtXeqUZ9z4fx14", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065225e0010wlkl67le6h46g" + }, + { + "type": "tool", + "callID": "bash-1773022818624349202-256", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "git log --oneline | head -n 10", + "description": "Check recent commits" + }, + "output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n", + "title": "Check recent commits", + "metadata": { + "output": "78af992 fix: restore renewal.ts from main to resolve build errors\nf790bec merge: resolve conflicts with main branch\ne7548e7 style(frontend): format code and improve node card layout\n50e4107 fix(frontend): show rule count in compact view mode for forward pages\n413081f feat(nodes): add renewal cycle and auto-advance scheduling (#296)\nbfac640 feat(nodes): add renewal cycle and auto-advance scheduling\n508a37a feat(nodes): add node metadata and expiry reminders (#293)\nd606550 feat(forward): display tunnel traffic ratio on forward page\n31ef861 feat(nodes): add node metadata and expiry reminders (#246)\nf1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n", + "exit": 0, + "description": "Check recent commits", + "truncated": false + }, + "time": { + "start": 1773022818776, + "end": 1773022818776 + } + }, + "id": "prt_cd06531d8002HjsfEaDHfZzBM7", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065225e0010wlkl67le6h46g" + }, + { + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "97ed982edddf0655a654981a72da08c33b9e467d", + "cost": 0, + "tokens": { + "total": 58974, + "input": 2054, + "output": 33, + "reasoning": 0, + "cache": { + "read": 56887, + "write": 0 + } + }, + "id": "prt_cd06531d9001eYxQ5nQBoU1rsk", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8", + "messageID": "msg_cd065225e0010wlkl67le6h46g" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1773022818946, + "completed": 1773022824318 + }, + "error": { + "name": "MessageAbortedError", + "data": { + "message": "The operation was aborted." + } + }, + "parentID": "msg_cd064ca87001mKUQE2EyR8PezH", + "modelID": "gemini-3.1-pro-high", + "providerID": "api", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon", + "root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "id": "msg_cd0653282001hDYLdWTwLVss9i", + "sessionID": "ses_32fb9de62ffeXG3aOGlPDusGE8" + }, + "parts": [] } ] } diff --git a/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/prompt.txt b/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/prompt.txt index 0b56eb8..b68a4a2 100644 --- a/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/prompt.txt +++ b/.entire/metadata/ses_32fb9de62ffeXG3aOGlPDusGE8/prompt.txt @@ -1 +1 @@ -规则标签页下,精简模式,和非精简模式一样要显示规则数量 \ No newline at end of file +这个没改对,页面没变化,请检查 \ No newline at end of file diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 6ea763e..65a0373 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -262,7 +262,6 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) { nullableText(asString(req["interfaceName"])), nullableText(""), nullableText(strings.TrimSpace(asString(req["remark"]))), - nullableText(strings.TrimSpace(asString(req["tags"]))), nullableUnixMilli(asInt64(req["expiryTime"], 0)), nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))), asInt(req["http"], 0), @@ -331,7 +330,6 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) { nullableText(asString(req["interfaceName"])), nullableText(asString(req["extraIPs"])), nullableText(strings.TrimSpace(asString(req["remark"]))), - nullableText(strings.TrimSpace(asString(req["tags"]))), nullableUnixMilli(asInt64(req["expiryTime"], 0)), nullableText(normalizeNodeRenewalCycle(asString(req["renewalCycle"]))), newHTTP, diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index ede2d01..ae97478 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -61,7 +61,6 @@ type Node struct { ID int64 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"type:varchar(100);not null"` Remark sql.NullString `gorm:"column:remark;type:text"` - Tags sql.NullString `gorm:"column:tags;type:text"` ExpiryTime sql.NullInt64 `gorm:"column:expiry_time"` RenewalCycle sql.NullString `gorm:"column:renewal_cycle;type:varchar(20)"` Secret string `gorm:"type:varchar(100);not null"` @@ -341,7 +340,6 @@ type NodeBackup struct { ID int64 `json:"id"` Name string `json:"name"` Remark string `json:"remark,omitempty"` - Tags string `json:"tags,omitempty"` ExpiryTime int64 `json:"expiryTime,omitempty"` RenewalCycle string `json:"renewalCycle,omitempty"` Secret string `json:"secret"` diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index d5168cd..d415622 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -260,7 +260,7 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error { m := db.Migrator() if m.HasTable(&model.Node{}) { - for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "Tags", "ExpiryTime", "RenewalCycle"} { + for _, field := range []string{"ServerIPV4", "ServerIPV6", "ExtraIPs", "TCPListenAddr", "UDPListenAddr", "Inx", "IsRemote", "RemoteURL", "RemoteToken", "RemoteConfig", "Remark", "ExpiryTime", "RenewalCycle"} { if m.HasColumn(&model.Node{}, field) { continue } @@ -635,7 +635,6 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) { items = append(items, map[string]interface{}{ "id": n.ID, "inx": n.Inx, "name": n.Name, "remark": nullableString(n.Remark), - "tags": nullableString(n.Tags), "expiryTime": nullableInt64(n.ExpiryTime), "renewalCycle": nullableString(n.RenewalCycle), "ip": n.ServerIP, "serverIp": n.ServerIP, @@ -1820,7 +1819,7 @@ func (r *Repository) exportNodes() ([]model.NodeBackup, error) { for _, n := range nodes { b := model.NodeBackup{ ID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP, - Remark: n.Remark.String, Tags: n.Tags.String, RenewalCycle: n.RenewalCycle.String, + Remark: n.Remark.String, RenewalCycle: n.RenewalCycle.String, Port: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks, CreatedTime: n.CreatedTime, Status: n.Status, TCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr, @@ -2179,7 +2178,6 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) ID: n.ID, Name: n.Name, Remark: sql.NullString{String: n.Remark, Valid: n.Remark != ""}, - Tags: sql.NullString{String: n.Tags, Valid: n.Tags != ""}, ExpiryTime: sql.NullInt64{Int64: n.ExpiryTime, Valid: n.ExpiryTime > 0}, RenewalCycle: sql.NullString{String: n.RenewalCycle, Valid: n.RenewalCycle != ""}, Secret: n.Secret, @@ -2206,7 +2204,7 @@ func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "remark", "tags", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", + "name", "remark", "expiry_time", "renewal_cycle", "secret", "server_ip", "server_ip_v4", "server_ip_v6", "port", "interface_name", "version", "http", "tls", "socks", "updated_time", "status", "tcp_listen_addr", "udp_listen_addr", "inx", "is_remote", "remote_url", "remote_token", "remote_config", }), diff --git a/go-backend/internal/store/repo/repository_migrate_test.go b/go-backend/internal/store/repo/repository_migrate_test.go index 55859a4..f83fbd8 100644 --- a/go-backend/internal/store/repo/repository_migrate_test.go +++ b/go-backend/internal/store/repo/repository_migrate_test.go @@ -51,7 +51,7 @@ func TestPrepareSQLiteLegacyColumnsAddsNodeMetadataColumns(t *testing.T) { } m := db.Migrator() - for _, field := range []string{"Remark", "Tags", "ExpiryTime", "RenewalCycle"} { + for _, field := range []string{"Remark", "ExpiryTime", "RenewalCycle"} { if !m.HasColumn(&model.Node{}, field) { t.Fatalf("expected node.%s column to exist", field) } diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 76787e6..f368ce9 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -197,14 +197,13 @@ func (r *Repository) GetUserDefaultsForTunnel(userID int64) (flow int64, num int return user.Flow, user.Num, user.ExpTime, user.FlowResetTime, nil } -func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { +func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } node := model.Node{ Name: name, Remark: nullStringFromInterface(remark), - Tags: nullStringFromInterface(tags), ExpiryTime: nullInt64FromInterface(expiryTime), RenewalCycle: nullStringFromInterface(renewalCycle), Secret: secret, @@ -244,7 +243,7 @@ func (r *Repository) GetNodeStatusFields(nodeID int64) (status, httpFlag, tlsFla return node.Status, node.HTTP, node.TLS, node.Socks, nil } -func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, tags, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { +func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs, remark, expiryTime, renewalCycle interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") } @@ -253,7 +252,6 @@ func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, ser Updates(map[string]interface{}{ "name": name, "remark": nullStringFromInterface(remark), - "tags": nullStringFromInterface(tags), "expiry_time": nullInt64FromInterface(expiryTime), "renewal_cycle": nullStringFromInterface(renewalCycle), "server_ip": serverIP, diff --git a/plans/026-node-remove-tags-fullstack.md b/plans/026-node-remove-tags-fullstack.md new file mode 100644 index 0000000..fcd6812 --- /dev/null +++ b/plans/026-node-remove-tags-fullstack.md @@ -0,0 +1,5 @@ +# Node Full-Stack Tags Removal Plan + +- [x] Remove node tags usage from frontend node management and dashboard views +- [x] Remove node tags fields from backend models, handlers, repository, and backup logic +- [x] Verify frontend build and backend tests pass after the removal diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 8fd8893..22be0c3 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -4,7 +4,6 @@ export interface NodeApiItem { status: number; inx?: number; remark?: string; - tags?: string; expiryTime?: number; renewalCycle?: "month" | "quarter" | "year" | ""; syncError?: string; @@ -220,7 +219,6 @@ export interface NodeMutationPayload { status?: number; inx?: number; remark?: string; - tags?: string; expiryTime?: number; renewalCycle?: "month" | "quarter" | "year" | ""; serverIp?: string; diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx index 54db938..e3ec554 100644 --- a/vite-frontend/src/pages/dashboard.tsx +++ b/vite-frontend/src/pages/dashboard.tsx @@ -90,7 +90,7 @@ export default function DashboardPage() { }; } -const diffDays = Math.ceil( + const diffDays = Math.ceil( (nextDueTime - Date.now()) / (1000 * 60 * 60 * 24), ); @@ -122,16 +122,6 @@ const diffDays = Math.ceil( }; }; - const parseNodeTags = (tags?: string) => { - if (!tags) return []; - - return tags - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean) - .slice(0, 3); - }; - const renderNodeExpiryCard = (node: DashboardNodeExpiryItem) => { const renewalSnapshot = getNodeRenewalSnapshot( node.expiryTime, @@ -141,7 +131,6 @@ const diffDays = Math.ceil( renewalSnapshot.nextDueTime, renewalSnapshot.state, ); - const tags = parseNodeTags(node.tags); return (
)} - - {tags.length > 0 && ( -
- {tags.map((tag) => ( - - #{tag} - - ))} -
- )}
); }; diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index 324db10..bfc14b3 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -58,7 +58,6 @@ export interface DashboardNodeExpiryItem { id: number; name: string; remark?: string; - tags?: string; expiryTime?: number; renewalCycle?: "month" | "quarter" | "year" | ""; } @@ -222,7 +221,6 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => { id: item.id, name: item.name || "", remark: typeof item.remark === "string" ? item.remark : "", - tags: typeof item.tags === "string" ? item.tags : "", renewalCycle: normalizeDashboardRenewalCycle(item.renewalCycle), expiryTime: typeof item.expiryTime === "number" && item.expiryTime > 0 diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index b33274c..1d4f3a7 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -60,9 +60,9 @@ import { } from "@/pages/node/display"; import { tryCopyInstallCommand } from "@/pages/node/install-command"; import { + getNodeRenewalSnapshot, formatNodeRenewalTime, getNodeRenewalCycleLabel, - getNodeRenewalSnapshot, type NodeRenewalCycle, } from "@/pages/node/renewal"; import { buildNodeSystemInfo } from "@/pages/node/system-info"; @@ -78,7 +78,6 @@ interface Node { inx?: number; name: string; remark?: string; - tags?: string; expiryTime?: number; renewalCycle?: NodeRenewalCycle; ip: string; @@ -116,7 +115,6 @@ interface NodeForm { id: number | null; name: string; remark: string; - tags: string; expiryTime: number; renewalCycle: NodeRenewalCycle; serverHost: string; @@ -200,15 +198,6 @@ const getNodeExpiryMeta = (timestamp?: number, cycle?: NodeRenewalCycle) => { }; }; -const normalizeNodeTags = (tags?: string): string[] => { - if (!tags) return []; - - return tags - .split(",") - .map((tag) => tag.trim()) - .filter(Boolean); -}; - const mergeNodeRealtimeState = ( incomingNode: Node, existingNode?: Node, @@ -274,9 +263,10 @@ export default function NodePage() { "node-search-keyword", "", ); - const [nodeFilterMode, setNodeFilterMode] = + const [nodeFilterMode, setNodeFilterMode, resetNodeFilterMode] = useLocalStorageState("node-expiry-filter-mode", "all"); const [isSearchVisible, setIsSearchVisible] = useState(false); + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); const [dialogVisible, setDialogVisible] = useState(false); const [dialogTitle, setDialogTitle] = useState(""); const [isEdit, setIsEdit] = useState(false); @@ -290,7 +280,6 @@ export default function NodePage() { id: null, name: "", remark: "", - tags: "", expiryTime: 0, renewalCycle: "", serverHost: "", @@ -675,7 +664,7 @@ export default function NodePage() { newErrors.name = "节点名称长度不能超过50位"; } -if ( + if ( (form.renewalCycle && !form.expiryTime) || (!form.renewalCycle && form.expiryTime) ) { @@ -742,7 +731,6 @@ if ( id: node.id, name: node.name, remark: node.remark || "", - tags: node.tags || "", expiryTime: node.expiryTime || 0, renewalCycle: node.renewalCycle || "", serverHost: normalizedHost, @@ -981,7 +969,6 @@ if ( const data = { ...rest, remark: form.remark.trim(), - tags: form.tags.trim(), expiryTime: form.expiryTime, renewalCycle: form.renewalCycle, extraIPs: form.extraIPs, @@ -1006,7 +993,6 @@ if ( ...n, name: form.name, remark: form.remark.trim(), - tags: form.tags.trim(), expiryTime: form.expiryTime, renewalCycle: form.renewalCycle, serverIp: @@ -1046,7 +1032,6 @@ if ( id: null, name: "", remark: "", - tags: "", expiryTime: 0, renewalCycle: "", serverHost: "", @@ -1207,14 +1192,13 @@ if ( if (searchKeyword.trim()) { const lowerKeyword = searchKeyword.toLowerCase(); - filteredNodes = filteredNodes.filter( - (n) => - (n.name && n.name.toLowerCase().includes(lowerKeyword)) || - (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) || - (n.tags && n.tags.toLowerCase().includes(lowerKeyword)) || - (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || - (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), + filteredNodes = filteredNodes.filter( + (n) => + (n.name && n.name.toLowerCase().includes(lowerKeyword)) || + (n.remark && n.remark.toLowerCase().includes(lowerKeyword)) || + (n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) || + (n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) || + (n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)), ); } @@ -1288,7 +1272,7 @@ if ( return (
-
+
setIsSearchVisible(false)} onOpen={() => setIsSearchVisible(true)} /> - -
-
+
{selectMode ? ( <> - 已选 {selectedIds.size} 项 + 已选择 {selectedIds.size} 项 - - +
+ {node.remark?.trim() && ( +
+
+ {node.remark.trim()} +
)} -
+ + {/* 操作按钮 */} +
{!isRemoteNode && ( +
+ + + +
+ )} +
+ {!isRemoteNode && ( + + )} - )} - +
@@ -1883,17 +1846,6 @@ if ( />
- - setForm((prev) => ({ ...prev, tags: e.target.value })) - } - /> - { + const selected = Array.from(keys)[0] as + | NodeFilterMode + | undefined; + + setNodeFilterMode(selected || "all"); + }} + > + 全部节点 + + 7天内续费 ({nodeExpiryStats.expiringSoon}) + + + 已逾期 ({nodeExpiryStats.expired}) + + + 已启用续费提醒 ({nodeExpiryStats.withExpiry}) + + +
+
+ + + + + + + )} + + ); }