Files
flvx/.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/full.jsonl
T
sagit de9ab51def feat: node management enhancements and UX improvements (#297)
* feat(nodes): add renewal cycle and auto-advance scheduling

- Add renewal_cycle field to nodes for tracking paid vs free cycles
- Implement auto-advance scheduling when renewal is processed
- Add migration test for renewal_cycle column
- Update dashboard to show renewal cycle count
- Add renewal status display on node detail page

* fix(frontend): show rule count in compact view mode for forward pages

* style(frontend): format code and improve node card layout

Entire-Checkpoint: 08b760b76538

* fix: restore renewal.ts from main to resolve build errors
2026-03-09 10:01:55 +08:00

363 lines
108 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"info": {
"id": "ses_332d83674ffeNMa9Z9UerwU3tq",
"slug": "mighty-sailor",
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"title": "提交全部变更并推送创建 PR",
"version": "1.2.21",
"summary": {
"additions": 0,
"deletions": 0,
"files": 0
},
"time": {
"created": 1772968462731,
"updated": 1772968481660
}
},
"messages": [
{
"info": {
"role": "user",
"time": {
"created": 1772968462871
},
"summary": {
"diffs": []
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_ccd27c9c4001ACb62NAlsmLkYY",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq"
},
"parts": [
{
"type": "text",
"text": "提交全部变更并且push,创建pr",
"id": "prt_ccd27c9c4002tSLe6ERY3BUO3W",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27c9c4001ACb62NAlsmLkYY"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968462874,
"completed": 1772968468365
},
"parentID": "msg_ccd27c9c4001ACb62NAlsmLkYY",
"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": 20664,
"input": 20078,
"output": 138,
"reasoning": 0,
"cache": {
"read": 448,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd27ca1a00192bEkkHqkR5bPu",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq"
},
"parts": [
{
"type": "step-start",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"id": "prt_ccd27d52d001wjk8A0i67kP693",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "reasoning",
"text": "用户要求我:\n1. 提交全部变更\n2. push\n3. 创建 PR\n\n首先我需要检查当前的 git 状态,查看有哪些变更。然后检查当前分支和远程分支的状态。让我先并行运行这些命令。",
"time": {
"start": 1772968465710,
"end": 1772968468245
},
"id": "prt_ccd27d52e001JYXyQKvtUAK1gi",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "tool",
"callID": "call_2a19227814e94d91a6c6ead1",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git status",
"description": "Show working tree status"
},
"output": "位于分支 opencode/quiet-canyon\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/forward/batch-actions.ts\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/node/use-node-realtime.ts\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\t修改: vite-frontend/src/pages/user.tsx\n\t修改: vite-frontend/src/vite-env.d.ts\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\tplans/020-ajax-no-refresh-ux.md\n\tvite-frontend/src/utils/list-state.ts\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n",
"title": "Show working tree status",
"metadata": {
"output": "位于分支 opencode/quiet-canyon\n尚未暂存以备提交的变更:\n (使用 \"git add <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t修改: vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n\t修改: vite-frontend/src/pages/forward.tsx\n\t修改: vite-frontend/src/pages/forward/batch-actions.ts\n\t修改: vite-frontend/src/pages/node.tsx\n\t修改: vite-frontend/src/pages/node/use-node-realtime.ts\n\t修改: vite-frontend/src/pages/tunnel.tsx\n\t修改: vite-frontend/src/pages/user.tsx\n\t修改: vite-frontend/src/vite-env.d.ts\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\tplans/020-ajax-no-refresh-ux.md\n\tvite-frontend/src/utils/list-state.ts\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n",
"exit": 0,
"description": "Show working tree status",
"truncated": false
},
"time": {
"start": 1772968467120,
"end": 1772968467171
}
},
"id": "prt_ccd27daab00132zF4GE1eKwuDL",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "tool",
"callID": "call_08e50ca85abd46228d3f846a",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git diff HEAD",
"description": "Show staged and unstaged changes"
},
"output": "diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\nindex f14d231..052f7fe 100644\n--- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n+++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n@@ -1,6 +1,6 @@\n import type { ForwardApiItem } from \"@/api/types\";\n \n-import { useEffect, useState } from \"react\";\n+import { useCallback, useEffect, useRef, useState } from \"react\";\n import toast from \"react-hot-toast\";\n \n import {\n@@ -52,6 +52,23 @@ export interface DashboardStatisticsFlow {\n time: string;\n }\n \n+const DASHBOARD_POLL_INTERVAL_MS = 5000;\n+const EXPIRATION_NOTIFICATION_STORAGE_KEY =\n+ \"dashboard:last-expiration-notification\";\n+\n+const buildExpirationNotificationKey = (\n+ userInfo: DashboardUserInfo,\n+ tunnels: DashboardUserTunnel[],\n+) => {\n+ const userExpTime = userInfo.expTime ?? \"permanent\";\n+ const tunnelExpirationKey = [...tunnels]\n+ .map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? \"permanent\"}`)\n+ .sort()\n+ .join(\"|\");\n+\n+ return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;\n+};\n+\n interface DashboardDataState {\n loading: boolean;\n userInfo: DashboardUserInfo;\n@@ -66,8 +83,10 @@ const checkExpirationNotifications = (\n userInfo: DashboardUserInfo,\n tunnels: DashboardUserTunnel[],\n ) => {\n- const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(\",\")}`;\n- const lastNotified = localStorage.getItem(\"lastNotified\");\n+ const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);\n+ const lastNotified = localStorage.getItem(\n+ EXPIRATION_NOTIFICATION_STORAGE_KEY,\n+ );\n \n if (lastNotified === notificationKey) {\n return;\n@@ -148,7 +167,7 @@ const checkExpirationNotifications = (\n });\n \n if (hasNotification) {\n- localStorage.setItem(\"lastNotified\", notificationKey);\n+ localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);\n }\n };\n \n@@ -188,60 +207,129 @@ export const useDashboardData = (): DashboardDataState => {\n const [announcement, setAnnouncement] = useState<AnnouncementData | null>(\n null,\n );\n+ const isMountedRef = useRef(true);\n+ const packageRequestInFlightRef = useRef(false);\n \n- useEffect(() => {\n- const loadAnnouncement = async () => {\n- try {\n- const res = await getAnnouncement();\n+ const applyPackageData = useCallback((data: {\n+ userInfo?: DashboardUserInfo;\n+ tunnelPermissions?: DashboardUserTunnel[];\n+ forwards?: ForwardApiItem[];\n+ statisticsFlows?: DashboardStatisticsFlow[];\n+ }) => {\n+ const normalizedTunnelPermissions = normalizeTunnelPermissions(\n+ data.tunnelPermissions || [],\n+ );\n+ const normalizedForwards = normalizeForwards(data.forwards || []);\n \n- if (res.code === 0 && res.data && res.data.enabled === 1) {\n- setAnnouncement(res.data);\n- }\n- } catch {}\n- };\n+ if (!isMountedRef.current) {\n+ return;\n+ }\n+\n+ setUserInfo(data.userInfo || ({} as DashboardUserInfo));\n+ setUserTunnels(normalizedTunnelPermissions);\n+ setForwardList(normalizedForwards);\n+ setStatisticsFlows(data.statisticsFlows || []);\n+\n+ checkExpirationNotifications(\n+ data.userInfo || ({} as DashboardUserInfo),\n+ normalizedTunnelPermissions,\n+ );\n+ }, []);\n+\n+ const loadPackageData = useCallback(\n+ async ({ silent = false, notifyOnError = false } = {}) => {\n+ if (packageRequestInFlightRef.current) {\n+ return;\n+ }\n+\n+ packageRequestInFlightRef.current = true;\n+\n+ if (!silent && isMountedRef.current) {\n+ setLoading(true);\n+ }\n \n- const loadPackageData = async () => {\n- setLoading(true);\n try {\n const res = await getUserPackageInfo();\n \n if (res.code === 0) {\n- const data = res.data;\n- const normalizedTunnelPermissions = normalizeTunnelPermissions(\n- data.tunnelPermissions || [],\n- );\n- const normalizedForwards = normalizeForwards(data.forwards || []);\n-\n- setUserInfo(data.userInfo || ({} as DashboardUserInfo));\n- setUserTunnels(normalizedTunnelPermissions);\n- setForwardList(normalizedForwards);\n- setStatisticsFlows(data.statisticsFlows || []);\n-\n- checkExpirationNotifications(\n- data.userInfo,\n- normalizedTunnelPermissions,\n- );\n- } else {\n+ applyPackageData(res.data || {});\n+ } else if (notifyOnError) {\n toast.error(res.msg || \"获取套餐信息失败\");\n }\n } catch {\n- toast.error(\"获取套餐信息失败\");\n+ if (notifyOnError) {\n+ toast.error(\"获取套餐信息失败\");\n+ }\n } finally {\n- setLoading(false);\n+ packageRequestInFlightRef.current = false;\n+\n+ if (!silent && isMountedRef.current) {\n+ setLoading(false);\n+ }\n+ }\n+ },\n+ [applyPackageData],\n+ );\n+\n+ const loadAnnouncement = useCallback(async () => {\n+ try {\n+ const res = await getAnnouncement();\n+\n+ if (!isMountedRef.current) {\n+ return;\n }\n- };\n \n- setLoading(true);\n- setUserInfo({} as DashboardUserInfo);\n- setUserTunnels([]);\n- setForwardList([]);\n- setStatisticsFlows([]);\n+ if (res.code === 0 && res.data && res.data.enabled === 1) {\n+ setAnnouncement(res.data);\n+ } else {\n+ setAnnouncement(null);\n+ }\n+ } catch {\n+ if (isMountedRef.current) {\n+ setAnnouncement(null);\n+ }\n+ }\n+ }, []);\n+\n+ useEffect(() => {\n+ isMountedRef.current = true;\n setIsAdmin(getAdminFlag());\n \n- loadPackageData();\n- loadAnnouncement();\n+ void loadPackageData({ notifyOnError: true });\n+ void loadAnnouncement();\n localStorage.setItem(\"e\", \"/dashboard\");\n- }, []);\n+\n+ return () => {\n+ isMountedRef.current = false;\n+ };\n+ }, [loadAnnouncement, loadPackageData]);\n+\n+ useEffect(() => {\n+ if (typeof document === \"undefined\") {\n+ return;\n+ }\n+\n+ const handleVisibilityChange = () => {\n+ if (document.visibilityState === \"visible\") {\n+ void loadPackageData({ silent: true });\n+ }\n+ };\n+\n+ const interval = window.setInterval(() => {\n+ if (document.visibilityState !== \"visible\") {\n+ return;\n+ }\n+\n+ void loadPackageData({ silent: true });\n+ }, DASHBOARD_POLL_INTERVAL_MS);\n+\n+ document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n+\n+ return () => {\n+ window.clearInterval(interval);\n+ document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n+ };\n+ }, [loadPackageData]);\n \n return {\n loading,\ndiff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex f2d3c3f..eb99d8c 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -1,6 +1,6 @@\n import type { SpeedLimitApiItem } from \"@/api/types\";\n \n-import { useState, useEffect, useMemo, useRef } from \"react\";\n+import { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\n import toast from \"react-hot-toast\";\n import {\n DndContext,\n@@ -48,6 +48,7 @@ import { Chip } from \"@/shadcn-bridge/heroui/chip\";\n import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n import { Switch } from \"@/shadcn-bridge/heroui/switch\";\n import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n+import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n import {\n createForward,\n@@ -166,6 +167,12 @@ interface ForwardTunnelGroup {\n items: Forward[];\n }\n \n+interface BatchProgressState {\n+ active: boolean;\n+ label: string;\n+ percent: number;\n+}\n+\n type ForwardGroupOrderMap = Record<string, string[]>;\n type ForwardGroupCollapsedMap = Record<string, boolean>;\n \n@@ -490,6 +497,30 @@ const isSameGroupCollapsedMap = (\n return true;\n };\n \n+const normalizeForwardItems = (items: Forward[]): Forward[] => {\n+ return items.map((forward) => ({\n+ ...forward,\n+ serviceRunning: forward.status === 1,\n+ }));\n+};\n+\n+const mapForwardApiItems = (items: any[]): Forward[] => {\n+ return (items || []).map((forward) => ({\n+ ...forward,\n+ tunnelId: forward.tunnelId ?? 0,\n+ tunnelName: forward.tunnelName || \"\",\n+ inIp: forward.inIp || \"\",\n+ inPort: forward.inPort ?? 0,\n+ remoteAddr: forward.remoteAddr || \"\",\n+ strategy: forward.strategy || \"fifo\",\n+ status: typeof forward.status === \"number\" ? forward.status : 0,\n+ inFlow: forward.inFlow ?? 0,\n+ outFlow: forward.outFlow ?? 0,\n+ createdTime: forward.createdTime || \"\",\n+ serviceRunning: forward.status === 1,\n+ }));\n+};\n+\n export default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState<Forward[]>([]);\n@@ -603,6 +634,11 @@ export default function ForwardPage() {\n null,\n );\n const [batchLoading, setBatchLoading] = useState(false);\n+ const [batchProgress, setBatchProgress] = useState<BatchProgressState>({\n+ active: false,\n+ label: \"\",\n+ percent: 0,\n+ });\n const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\n useState<ForwardGroupCollapsedMap>({});\n@@ -959,7 +995,7 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = async (\n+ const mergeFederationShareFlow = useCallback(async (\n forwardsData: Forward[],\n ): Promise<Forward[]> => {\n if (forwardsData.length === 0) {\n@@ -1135,7 +1171,7 @@ export default function ForwardPage() {\n } catch {\n return forwardsData;\n }\n- };\n+ }, []);\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1147,10 +1183,6 @@ export default function ForwardPage() {\n return forward.federationShareFlow || 0;\n };\n \n- useEffect(() => {\n- loadData();\n- }, []);\n-\n // 切换显示模式并保存到localStorage\n const handleViewModeChange = () => {\n const newMode = viewMode === \"grouped\" ? \"direct\" : \"grouped\";\n@@ -1161,12 +1193,59 @@ export default function ForwardPage() {\n } catch {}\n };\n \n+ const applyForwardList = useCallback(\n+ async (items: Forward[]) => {\n+ const mergedForwards = await mergeFederationShareFlow(\n+ normalizeForwardItems(items),\n+ );\n+\n+ setForwards(mergedForwards);\n+\n+ const currentUserId = JwtUtil.getUserIdFromToken();\n+ const { order, fromDatabase } = buildForwardOrder(\n+ mergedForwards,\n+ currentUserId,\n+ );\n+\n+ setForwardOrder(order);\n+\n+ if (fromDatabase) {\n+ saveOrder(FORWARD_ORDER_KEY, order);\n+ }\n+ },\n+ [mergeFederationShareFlow],\n+ );\n+\n+ const refreshForwardList = useCallback(\n+ async (lod = true) => {\n+ if (lod) {\n+ setLoading(true);\n+ }\n+\n+ try {\n+ const forwardsRes = await getForwardList();\n+\n+ if (forwardsRes.code === 0) {\n+ await applyForwardList(mapForwardApiItems(forwardsRes.data || []));\n+ } else {\n+ toast.error(forwardsRes.msg || \"获取规则列表失败\");\n+ }\n+ } catch {\n+ toast.error(\"获取规则列表失败\");\n+ } finally {\n+ if (lod) {\n+ setLoading(false);\n+ }\n+ }\n+ },\n+ [applyForwardList],\n+ );\n+\n // 加载所有数据\n- const loadData = async (lod = true) => {\n+ const loadData = useCallback(async (lod = true) => {\n setLoading(lod);\n try {\n- const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([\n- getForwardList(),\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n userTunnel(),\n getSpeedLimitList(),\n ]);\n@@ -1175,32 +1254,7 @@ export default function ForwardPage() {\n getNodeList(),\n ]);\n \n- if (forwardsRes.code === 0) {\n- const forwardsData =\n- forwardsRes.data?.map((forward: any) => ({\n- ...forward,\n- serviceRunning: forward.status === 1,\n- })) || [];\n-\n- const mergedForwards = await mergeFederationShareFlow(forwardsData);\n-\n- setForwards(mergedForwards);\n-\n- // 初始化拖拽排序顺序\n- const currentUserId = JwtUtil.getUserIdFromToken();\n- const { order, fromDatabase } = buildForwardOrder(\n- mergedForwards,\n- currentUserId,\n- );\n-\n- setForwardOrder(order);\n-\n- if (fromDatabase) {\n- saveOrder(FORWARD_ORDER_KEY, order);\n- }\n- } else {\n- toast.error(forwardsRes.msg || \"获取规则列表失败\");\n- }\n+ await refreshForwardList(false);\n \n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n@@ -1226,7 +1280,11 @@ export default function ForwardPage() {\n } finally {\n setLoading(false);\n }\n- };\n+ }, [refreshForwardList]);\n+\n+ useEffect(() => {\n+ loadData();\n+ }, [loadData]);\n \n // 表单验证\n const noLimitSpeedLimitIds = useMemo(() => {\n@@ -1385,7 +1443,20 @@ export default function ForwardPage() {\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n- loadData();\n+ setForwardToDelete(null);\n+ setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwardOrder((prev) => {\n+ const next = prev.filter((id) => id !== forwardToDelete.id);\n+\n+ saveOrder(FORWARD_ORDER_KEY, next);\n+ return next;\n+ });\n+ setSelectedIds((prev) => {\n+ const next = new Set(prev);\n+\n+ next.delete(forwardToDelete.id);\n+ return next;\n+ });\n } else {\n // 删除失败,询问是否强制删除\n const confirmed = window.confirm(\n@@ -1398,7 +1469,22 @@ export default function ForwardPage() {\n if (forceRes.code === 0) {\n toast.success(\"强制删除成功\");\n setDeleteModalOpen(false);\n- loadData();\n+ setForwardToDelete(null);\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n+ setForwardOrder((prev) => {\n+ const next = prev.filter((id) => id !== forwardToDelete.id);\n+\n+ saveOrder(FORWARD_ORDER_KEY, next);\n+ return next;\n+ });\n+ setSelectedIds((prev) => {\n+ const next = new Set(prev);\n+\n+ next.delete(forwardToDelete.id);\n+ return next;\n+ });\n } else {\n toast.error(forceRes.msg || \"强制删除失败\");\n }\n@@ -1496,7 +1582,7 @@ export default function ForwardPage() {\n }\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n- loadData();\n+ await refreshForwardList(false);\n } else {\n toast.error(res.msg || \"操作失败\");\n }\n@@ -2091,7 +2177,7 @@ export default function ForwardPage() {\n \n toast.success(\"导入执行完成\");\n \n- await loadData(false);\n+ await refreshForwardList(false);\n } catch {\n toast.error(\"导入过程中发生错误\");\n } finally {\n@@ -2296,6 +2382,11 @@ export default function ForwardPage() {\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在删除 ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchDelete(Array.from(selectedIds));\n \n@@ -2306,14 +2397,20 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2321,6 +2418,11 @@ export default function ForwardPage() {\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchToggleService(\n Array.from(selectedIds),\n@@ -2334,11 +2436,17 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2346,6 +2454,11 @@ export default function ForwardPage() {\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在重新下发 ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchRedeploy(\n Array.from(selectedIds),\n@@ -2358,11 +2471,17 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2370,6 +2489,11 @@ export default function ForwardPage() {\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchChangeTunnel(\n Array.from(selectedIds),\n@@ -2383,6 +2507,11 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n@@ -2391,9 +2520,10 @@ export default function ForwardPage() {\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -3817,6 +3947,23 @@ export default function ForwardPage() {\n </div>\n </div>\n \n+ {batchProgress.active && (\n+ <div className=\"mb-4\">\n+ <Alert\n+ color=\"primary\"\n+ description={batchProgress.label}\n+ variant=\"flat\"\n+ />\n+ <Progress\n+ aria-label={batchProgress.label}\n+ className=\"mt-3\"\n+ color=\"primary\"\n+ size=\"sm\"\n+ value={batchProgress.percent}\n+ />\n+ </div>\n+ )}\n+\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\ndiff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts\nindex 66af192..fc40bc0 100644\n--- a/vite-frontend/src/pages/forward/batch-actions.ts\n+++ b/vite-frontend/src/pages/forward/batch-actions.ts\n@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {\n toastVariant: \"success\" | \"error\";\n toastMessage: string;\n shouldRefresh: boolean;\n+ progressPercent?: number;\n+ progressLabel?: string;\n closeDeleteModal?: boolean;\n closeChangeTunnelModal?: boolean;\n resetTargetTunnel?: boolean;\n@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (\n return {\n ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `删除完成:成功 ${summary.successCount} 项`,\n closeDeleteModal: true,\n };\n } catch (error) {\n@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (\n : `成功停用 ${summary.successCount} 项`,\n ),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `${enable ? \"启用\" : \"停用\"}完成:成功 ${summary.successCount} 项`,\n };\n } catch (error) {\n return {\n@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (\n return {\n ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,\n };\n } catch (error) {\n return {\n@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (\n return {\n ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,\n closeChangeTunnelModal: true,\n resetTargetTunnel: true,\n };\ndiff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx\nindex fc78251..096ab2c 100644\n--- a/vite-frontend/src/pages/node.tsx\n+++ b/vite-frontend/src/pages/node.tsx\n@@ -65,6 +65,8 @@ import { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\n import { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\n import { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n \n+const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n+\n interface Node {\n id: number;\n inx?: number;\n@@ -116,6 +118,21 @@ interface NodeForm {\n socks: number; // 0 关 1 开\n }\n \n+const 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+\n const SortableItem = ({\n id,\n children,\n@@ -251,8 +268,13 @@ export default function NodePage() {\n });\n \n // 加载节点列表\n- const loadNodes = useCallback(async () => {\n- setLoading(true);\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@@ -270,7 +292,13 @@ export default function NodePage() {\n copyLoading: false,\n }));\n \n- setNodeList(nodesData);\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@@ -292,12 +320,18 @@ export default function NodePage() {\n );\n }\n } else {\n- toast.error(res.msg || \"加载节点列表失败\");\n+ if (!silent) {\n+ toast.error(res.msg || \"加载节点列表失败\");\n+ }\n }\n } catch {\n- toast.error(\"网络错误,请重试\");\n+ if (!silent) {\n+ toast.error(\"网络错误,请重试\");\n+ }\n } finally {\n- setLoading(false);\n+ if (!silent) {\n+ setLoading(false);\n+ }\n }\n }, []);\n \n@@ -370,7 +404,7 @@ export default function NodePage() {\n }\n };\n \n- const { wsConnected, wsConnecting } = useNodeRealtime({\n+ const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n \n@@ -378,6 +412,22 @@ export default function NodePage() {\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@@ -1162,7 +1212,11 @@ export default function NodePage() {\n className=\"mb-4\"\n color=\"warning\"\n description={\n- wsConnecting ? \"监控连接中...\" : \"监控连接已断开,正在重连...\"\n+ wsConnecting\n+ ? \"监控连接中...\"\n+ : usingPollingFallback\n+ ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n+ : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\ndiff --git a/vite-frontend/src/pages/node/use-node-realtime.ts b/vite-frontend/src/pages/node/use-node-realtime.ts\nindex 5ef1806..10c303d 100644\n--- a/vite-frontend/src/pages/node/use-node-realtime.ts\n+++ b/vite-frontend/src/pages/node/use-node-realtime.ts\n@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {\n enabled?: boolean;\n }\n \n+const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;\n+const STANDARD_RECONNECT_DELAY_MS = 3000;\n+const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;\n+const FALLBACK_RECONNECT_DELAY_MS = 30000;\n+\n const getRealtimeWsUrl = (): string => {\n const baseUrl =\n axios.defaults.baseURL ||\n@@ -34,14 +39,13 @@ export const useNodeRealtime = ({\n }: UseNodeRealtimeOptions) => {\n const [wsConnected, setWsConnected] = useState(false);\n const [wsConnecting, setWsConnecting] = useState(false);\n+ const [usingPollingFallback, setUsingPollingFallback] = useState(false);\n \n const websocketRef = useRef<WebSocket | null>(null);\n const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const reconnectAttemptsRef = useRef(0);\n const onMessageRef = useRef(onMessage);\n \n- const maxReconnectAttempts = 5;\n-\n useEffect(() => {\n onMessageRef.current = onMessage;\n }, [onMessage]);\n@@ -58,6 +62,7 @@ export const useNodeRealtime = ({\n reconnectAttemptsRef.current = 0;\n setWsConnected(false);\n setWsConnecting(false);\n+ setUsingPollingFallback(false);\n \n if (!websocketRef.current) {\n return;\n@@ -103,6 +108,7 @@ export const useNodeRealtime = ({\n reconnectAttemptsRef.current = 0;\n setWsConnected(true);\n setWsConnecting(false);\n+ setUsingPollingFallback(false);\n };\n \n websocketRef.current.onmessage = (event) => {\n@@ -122,15 +128,27 @@ export const useNodeRealtime = ({\n setWsConnected(false);\n setWsConnecting(false);\n \n- if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {\n+ if (!enabled) {\n return;\n }\n \n reconnectAttemptsRef.current += 1;\n+ const exhaustedStandardRetries =\n+ reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;\n+\n+ setUsingPollingFallback(exhaustedStandardRetries);\n+\n+ const reconnectDelay = exhaustedStandardRetries\n+ ? FALLBACK_RECONNECT_DELAY_MS\n+ : Math.min(\n+ STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,\n+ MAX_STANDARD_RECONNECT_DELAY_MS,\n+ );\n+\n reconnectTimerRef.current = setTimeout(() => {\n reconnectTimerRef.current = null;\n connect();\n- }, 3000 * reconnectAttemptsRef.current);\n+ }, reconnectDelay);\n };\n } catch {\n setWsConnected(false);\n@@ -153,6 +171,7 @@ export const useNodeRealtime = ({\n return {\n wsConnected,\n wsConnecting,\n+ usingPollingFallback,\n reconnectRealtime: connect,\n disconnectRealtime: disconnect,\n };\ndiff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx\nindex 0f888a2..cf96635 100644\n--- a/vite-frontend/src/pages/tunnel.tsx\n+++ b/vite-frontend/src/pages/tunnel.tsx\n@@ -1,4 +1,4 @@\n-import { useState, useEffect, useMemo, useRef } from \"react\";\n+import { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\n import toast from \"react-hot-toast\";\n import {\n DndContext,\n@@ -36,6 +36,7 @@ import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n import { Divider } from \"@/shadcn-bridge/heroui/divider\";\n import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n+import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n import {\n createTunnel,\n getTunnelList,\n@@ -115,6 +116,29 @@ interface TunnelForm {\n status: number;\n }\n \n+interface BatchProgressState {\n+ active: boolean;\n+ label: string;\n+ percent: number;\n+}\n+\n+const TUNNEL_ORDER_KEY = \"tunnel-order\";\n+\n+const mapTunnelApiItems = (items: any[]): Tunnel[] => {\n+ return (items || []).map((tunnel) => ({\n+ ...tunnel,\n+ inx: tunnel.inx ?? 0,\n+ inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],\n+ outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],\n+ chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],\n+ inIp: tunnel.inIp || \"\",\n+ flow: tunnel.flow ?? 1,\n+ trafficRatio: tunnel.trafficRatio ?? 1,\n+ status: typeof tunnel.status === \"number\" ? tunnel.status : 0,\n+ createdTime: tunnel.createdTime || \"\",\n+ }));\n+};\n+\n export default function TunnelPage() {\n const [loading, setLoading] = useState(true);\n const [tunnels, setTunnels] = useState<Tunnel[]>([]);\n@@ -203,6 +227,11 @@ export default function TunnelPage() {\n const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\n const [batchLoading, setBatchLoading] = useState(false);\n+ const [batchProgress, setBatchProgress] = useState<BatchProgressState>({\n+ active: false,\n+ label: \"\",\n+ percent: 0,\n+ });\n \n useEffect(() => {\n return () => {\n@@ -211,60 +240,78 @@ export default function TunnelPage() {\n };\n }, []);\n \n- useEffect(() => {\n- loadData();\n- }, []);\n+ const applyTunnelList = useCallback((items: Tunnel[]) => {\n+ setTunnels(items);\n \n- // 加载所有数据\n- const loadData = async () => {\n- setLoading(true);\n- try {\n- const [tunnelsRes, nodesRes] = await Promise.all([\n- getTunnelList(),\n- getNodeList(),\n- ]);\n+ const hasDbOrdering = items.some(\n+ (tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0,\n+ );\n \n- if (tunnelsRes.code === 0) {\n- const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({\n- ...t,\n- inx: t.inx ?? 0,\n- }));\n+ if (hasDbOrdering) {\n+ const dbOrder = [...items]\n+ .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n+ .map((tunnel) => tunnel.id);\n \n- setTunnels(tunnelsData);\n+ setTunnelOrder(dbOrder);\n \n- // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序\n- const hasDbOrdering = tunnelsData.some(\n- (t) => t.inx !== undefined && t.inx !== 0,\n- );\n+ return;\n+ }\n \n- if (hasDbOrdering) {\n- const dbOrder = [...tunnelsData]\n- .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0))\n- .map((t) => t.id);\n+ setTunnelOrder(\n+ loadStoredOrder(\n+ TUNNEL_ORDER_KEY,\n+ items.map((tunnel) => tunnel.id),\n+ ),\n+ );\n+ }, []);\n \n- setTunnelOrder(dbOrder);\n- } else {\n- setTunnelOrder(\n- loadStoredOrder(\n- \"tunnel-order\",\n- tunnelsData.map((t) => t.id),\n- ),\n- );\n- }\n+ const refreshTunnelList = useCallback(async (withLoading = true) => {\n+ if (withLoading) {\n+ setLoading(true);\n+ }\n+\n+ try {\n+ const tunnelsRes = await getTunnelList();\n+\n+ if (tunnelsRes.code === 0) {\n+ applyTunnelList(mapTunnelApiItems(tunnelsRes.data || []));\n } else {\n toast.error(tunnelsRes.msg || \"获取隧道列表失败\");\n }\n+ } catch {\n+ toast.error(\"获取隧道列表失败\");\n+ } finally {\n+ if (withLoading) {\n+ setLoading(false);\n+ }\n+ }\n+ }, [applyTunnelList]);\n+\n+ const refreshNodes = useCallback(async () => {\n+ try {\n+ const nodesRes = await getNodeList();\n \n if (nodesRes.code === 0) {\n setNodes(nodesRes.data || []);\n- } else {\n }\n+ } catch {}\n+ }, []);\n+\n+ // 加载所有数据\n+ const loadData = useCallback(async () => {\n+ setLoading(true);\n+ try {\n+ await Promise.all([refreshTunnelList(false), refreshNodes()]);\n } catch {\n toast.error(\"加载数据失败\");\n } finally {\n setLoading(false);\n }\n- };\n+ }, [refreshNodes, refreshTunnelList]);\n+\n+ useEffect(() => {\n+ loadData();\n+ }, [loadData]);\n \n // 表单验证\n const validateForm = (): boolean => {\n@@ -327,7 +374,21 @@ export default function TunnelPage() {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n setTunnelToDelete(null);\n- loadData();\n+ setTunnels((prev) =>\n+ prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id),\n+ );\n+ setTunnelOrder((prev) => {\n+ const next = prev.filter((id) => id !== tunnelToDelete.id);\n+\n+ saveOrder(TUNNEL_ORDER_KEY, next);\n+ return next;\n+ });\n+ setSelectedIds((prev) => {\n+ const next = new Set(prev);\n+\n+ next.delete(tunnelToDelete.id);\n+ return next;\n+ });\n } else {\n toast.error(response.msg || \"删除失败\");\n }\n@@ -500,7 +561,7 @@ export default function TunnelPage() {\n if (response.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n setModalOpen(false);\n- loadData();\n+ await refreshTunnelList(false);\n } else {\n toast.error(response.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n@@ -725,7 +786,7 @@ export default function TunnelPage() {\n \n setTunnelOrder(newOrder);\n \n- saveOrder(\"tunnel-order\", newOrder);\n+ saveOrder(TUNNEL_ORDER_KEY, newOrder);\n \n // 持久化到数据库\n try {\n@@ -779,6 +840,11 @@ export default function TunnelPage() {\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在删除 ${selectedIds.size} 条隧道...`,\n+ percent: 30,\n+ });\n try {\n const res = await batchDeleteTunnels(Array.from(selectedIds));\n \n@@ -787,21 +853,39 @@ export default function TunnelPage() {\n \n if (result.failCount === 0) {\n toast.success(`成功删除 ${result.successCount} 项`);\n+ setBatchProgress({\n+ active: true,\n+ label: `删除完成:成功 ${result.successCount} 项`,\n+ percent: 100,\n+ });\n+ setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id)));\n+ setTunnelOrder((prev) => {\n+ const next = prev.filter((id) => !selectedIds.has(id));\n+\n+ saveOrder(TUNNEL_ORDER_KEY, next);\n+ return next;\n+ });\n } else {\n toast.error(\n `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,\n );\n+ setBatchProgress({\n+ active: true,\n+ label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,\n+ percent: 75,\n+ });\n+ await refreshTunnelList(false);\n }\n setSelectedIds(new Set());\n setSelectMode(false);\n setBatchDeleteModalOpen(false);\n- loadData();\n } else {\n toast.error(res.msg || \"删除失败\");\n }\n } catch (error) {\n toast.error(extractApiErrorMessage(error, \"删除失败\"));\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -809,6 +893,11 @@ export default function TunnelPage() {\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在重新下发 ${selectedIds.size} 条隧道...`,\n+ percent: 30,\n+ });\n try {\n const res = await batchRedeployTunnels(Array.from(selectedIds));\n \n@@ -824,13 +913,19 @@ export default function TunnelPage() {\n }\n setSelectedIds(new Set());\n setSelectMode(false);\n- loadData();\n+ setBatchProgress({\n+ active: true,\n+ label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`,\n+ percent: 100,\n+ });\n+ await refreshTunnelList(false);\n } else {\n toast.error(res.msg || \"下发失败\");\n }\n } catch (error) {\n toast.error(extractApiErrorMessage(error, \"下发失败\"));\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -1038,6 +1133,23 @@ export default function TunnelPage() {\n </div>\n </div>\n \n+ {batchProgress.active && (\n+ <div className=\"mb-4\">\n+ <Alert\n+ color=\"primary\"\n+ description={batchProgress.label}\n+ variant=\"flat\"\n+ />\n+ <Progress\n+ aria-label={batchProgress.label}\n+ className=\"mt-3\"\n+ color=\"primary\"\n+ size=\"sm\"\n+ value={batchProgress.percent}\n+ />\n+ </div>\n+ )}\n+\n {/* 隧道卡片网格 */}\n {tunnels.length > 0 ? (\n <DndContext sensors={sensors} onDragEnd={handleDragEnd}>\ndiff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx\nindex 2850177..ec6bd69 100644\n--- a/vite-frontend/src/pages/user.tsx\n+++ b/vite-frontend/src/pages/user.tsx\n@@ -1,4 +1,4 @@\n-import { useState, useEffect, useMemo } from \"react\";\n+import { useState, useEffect, useMemo, useCallback, useRef } from \"react\";\n import toast from \"react-hot-toast\";\n import { parseDate } from \"@internationalized/date\";\n \n@@ -66,6 +66,10 @@ import {\n } from \"@/components/icons\";\n import { PageLoadingState } from \"@/components/page-state\";\n import { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\n+import {\n+ removeItemsById,\n+ replaceItemById,\n+} from \"@/utils/list-state\";\n \n // 工具函数\n const formatFlow = (value: number, unit: string = \"bytes\"): string => {\n@@ -122,6 +126,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => {\n return inFlow + outFlow;\n };\n \n+const USER_SEARCH_DEBOUNCE_MS = 250;\n+\n+const normalizeUserItem = (item: Partial<User>): User => {\n+ return {\n+ id: Number(item.id ?? 0),\n+ name: item.name,\n+ user: String(item.user ?? \"\"),\n+ status: Number(item.status ?? 0),\n+ flow: Number(item.flow ?? 0),\n+ num: Number(item.num ?? 0),\n+ expTime: item.expTime,\n+ flowResetTime: item.flowResetTime ?? 0,\n+ createdTime: item.createdTime,\n+ inFlow: Number(item.inFlow ?? 0),\n+ outFlow: Number(item.outFlow ?? 0),\n+ };\n+};\n+\n+const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {\n+ return {\n+ id: Number(item.id ?? 0),\n+ userId: Number(item.userId ?? 0),\n+ tunnelId: Number(item.tunnelId ?? 0),\n+ tunnelName: String(item.tunnelName ?? \"\"),\n+ status: Number(item.status ?? 0),\n+ flow: Number(item.flow ?? 0),\n+ num: Number(item.num ?? 0),\n+ expTime: Number(item.expTime ?? 0),\n+ flowResetTime: Number(item.flowResetTime ?? 0),\n+ speedId: item.speedId ?? null,\n+ speedLimitName: item.speedLimitName,\n+ inFlow: Number(item.inFlow ?? 0),\n+ outFlow: Number(item.outFlow ?? 0),\n+ tunnelFlow: item.tunnelFlow,\n+ };\n+};\n+\n export default function UserPage() {\n // 状态管理\n const [users, setUsers] = useState<User[]>([]);\n@@ -136,6 +177,7 @@ export default function UserPage() {\n size: 10,\n total: 0,\n });\n+ const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n \n // 用户表单相关状态\n const {\n@@ -259,26 +301,25 @@ export default function UserPage() {\n return !speedLimitIds.has(speedId);\n };\n \n- // 生命周期\n- useEffect(() => {\n- loadUsers();\n- loadTunnels();\n- loadSpeedLimits();\n- loadUserGroups();\n- }, [pagination.current, pagination.size, searchKeyword]);\n-\n // 数据加载函数\n- const loadUsers = async () => {\n+ const loadUsers = useCallback(\n+ async (keywordOverride?: string) => {\n setLoading(true);\n try {\n+ const keyword = keywordOverride ?? searchKeyword;\n const response = await getAllUsers({\n current: pagination.current,\n size: pagination.size,\n- keyword: searchKeyword,\n+ keyword,\n });\n \n if (response.code === 0) {\n- setUsers(Array.isArray(response.data) ? response.data : []);\n+ const nextUsers = Array.isArray(response.data)\n+ ? response.data.map((item) => normalizeUserItem(item))\n+ : [];\n+\n+ setUsers(nextUsers);\n+ setPagination((prev) => ({ ...prev, total: nextUsers.length }));\n } else {\n toast.error(response.msg || \"获取用户列表失败\");\n }\n@@ -287,9 +328,11 @@ export default function UserPage() {\n } finally {\n setLoading(false);\n }\n- };\n+ },\n+ [pagination.current, pagination.size],\n+ );\n \n- const loadTunnels = async () => {\n+ const loadTunnels = useCallback(async () => {\n try {\n const response = await getTunnelList();\n \n@@ -297,9 +340,9 @@ export default function UserPage() {\n setTunnels(Array.isArray(response.data) ? response.data : []);\n }\n } catch {}\n- };\n+ }, []);\n \n- const loadSpeedLimits = async () => {\n+ const loadSpeedLimits = useCallback(async () => {\n try {\n const response = await getSpeedLimitList();\n \n@@ -315,9 +358,9 @@ export default function UserPage() {\n setSpeedLimits(speedLimitList);\n }\n } catch {}\n- };\n+ }, []);\n \n- const loadUserGroups = async () => {\n+ const loadUserGroups = useCallback(async () => {\n try {\n const response = await getUserGroupList();\n \n@@ -325,15 +368,19 @@ export default function UserPage() {\n setUserGroups(Array.isArray(response.data) ? response.data : []);\n }\n } catch {}\n- };\n+ }, []);\n \n- const loadUserTunnels = async (userId: number) => {\n+ const loadUserTunnels = useCallback(async (userId: number) => {\n setTunnelListLoading(true);\n try {\n const response = await getUserTunnelList({ userId });\n \n if (response.code === 0) {\n- setUserTunnels(Array.isArray(response.data) ? response.data : []);\n+ setUserTunnels(\n+ Array.isArray(response.data)\n+ ? response.data.map((item) => normalizeUserTunnelItem(item))\n+ : [],\n+ );\n } else {\n toast.error(response.msg || \"获取隧道权限列表失败\");\n }\n@@ -342,12 +389,52 @@ export default function UserPage() {\n } finally {\n setTunnelListLoading(false);\n }\n- };\n+ }, []);\n+\n+ // 生命周期\n+ useEffect(() => {\n+ void loadTunnels();\n+ void loadSpeedLimits();\n+ void loadUserGroups();\n+ }, [loadSpeedLimits, loadTunnels, loadUserGroups]);\n+\n+ useEffect(() => {\n+ void loadUsers();\n+ }, [loadUsers]);\n+\n+ useEffect(() => {\n+ if (searchDebounceRef.current) {\n+ clearTimeout(searchDebounceRef.current);\n+ }\n+\n+ searchDebounceRef.current = setTimeout(() => {\n+ setPagination((prev) => {\n+ if (prev.current === 1) {\n+ void loadUsers(searchKeyword);\n+ return prev;\n+ }\n+\n+ return { ...prev, current: 1 };\n+ });\n+ }, USER_SEARCH_DEBOUNCE_MS);\n+\n+ return () => {\n+ if (searchDebounceRef.current) {\n+ clearTimeout(searchDebounceRef.current);\n+ searchDebounceRef.current = null;\n+ }\n+ };\n+ }, [loadUsers, searchKeyword]);\n \n // 用户管理操作\n const handleSearch = () => {\n+ if (searchDebounceRef.current) {\n+ clearTimeout(searchDebounceRef.current);\n+ searchDebounceRef.current = null;\n+ }\n+\n setPagination((prev) => ({ ...prev, current: 1 }));\n- loadUsers();\n+ void loadUsers(searchKeyword);\n };\n \n const handleAdd = () => {\n@@ -405,9 +492,17 @@ export default function UserPage() {\n \n if (response.code === 0) {\n toast.success(\"删除成功\");\n- loadUsers();\n onDeleteModalClose();\n+ setUsers((prev) => removeItemsById(prev, [userToDelete.id]));\n+ setPagination((prev) => ({\n+ ...prev,\n+ total: Math.max(prev.total - 1, 0),\n+ }));\n setUserToDelete(null);\n+ if (currentUser?.id === userToDelete.id) {\n+ setCurrentUser(null);\n+ setUserTunnels([]);\n+ }\n } else {\n toast.error(response.msg || \"删除失败\");\n }\n@@ -442,7 +537,26 @@ export default function UserPage() {\n if (response.code === 0) {\n toast.success(isEdit ? \"更新成功\" : \"创建成功\");\n onUserModalClose();\n- loadUsers();\n+ const responseUser = normalizeUserItem((response as any).data || {});\n+\n+ if (\n+ isEdit &&\n+ responseUser.id > 0 &&\n+ pagination.current === 1 &&\n+ !searchKeyword.trim()\n+ ) {\n+ setUsers((prev) => replaceItemById(prev, responseUser));\n+ } else if (\n+ !isEdit &&\n+ responseUser.id > 0 &&\n+ pagination.current === 1 &&\n+ !searchKeyword.trim()\n+ ) {\n+ setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size));\n+ setPagination((prev) => ({ ...prev, total: prev.total + 1 }));\n+ } else {\n+ await loadUsers();\n+ }\n } else {\n toast.error(response.msg || (isEdit ? \"更新失败\" : \"创建失败\"));\n }\n@@ -500,7 +614,7 @@ export default function UserPage() {\n }\n toast.success(response.msg || \"分配成功\");\n setBatchTunnelSelections(new Map());\n- loadUserTunnels(currentUser.id);\n+ await loadUserTunnels(currentUser.id);\n } else {\n toast.error(response.msg || \"分配失败\");\n }\n@@ -546,7 +660,19 @@ export default function UserPage() {\n toast.success(\"更新成功\");\n onEditTunnelModalClose();\n if (currentUser) {\n- loadUserTunnels(currentUser.id);\n+ const nextTunnel = normalizeUserTunnelItem({\n+ ...editTunnelForm,\n+ speedId: normalizeSpeedId(editTunnelForm.speedId),\n+ speedLimitName:\n+ normalizeSpeedId(editTunnelForm.speedId) !== null\n+ ? speedLimits.find(\n+ (speedLimit) =>\n+ speedLimit.id === normalizeSpeedId(editTunnelForm.speedId),\n+ )?.name\n+ : undefined,\n+ });\n+\n+ setUserTunnels((prev) => replaceItemById(prev, nextTunnel));\n }\n } else {\n toast.error(response.msg || \"更新失败\");\n@@ -572,7 +698,7 @@ export default function UserPage() {\n if (response.code === 0) {\n toast.success(\"删除成功\");\n if (currentUser) {\n- loadUserTunnels(currentUser.id);\n+ setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id]));\n }\n onDeleteTunnelModalClose();\n setTunnelToDelete(null);\n@@ -603,8 +729,14 @@ export default function UserPage() {\n if (response.code === 0) {\n toast.success(\"流量重置成功\");\n onResetFlowModalClose();\n+ const targetUserId = userToReset.id;\n+\n+ setUsers((prev) =>\n+ prev.map((user) =>\n+ user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user,\n+ ),\n+ );\n setUserToReset(null);\n- loadUsers(); // 重新加载用户列表\n } else {\n toast.error(response.msg || \"重置失败\");\n }\n@@ -634,10 +766,16 @@ export default function UserPage() {\n if (response.code === 0) {\n toast.success(\"隧道流量重置成功\");\n onResetTunnelFlowModalClose();\n+ const targetTunnelId = tunnelToReset.id;\n+\n+ setUserTunnels((prev) =>\n+ prev.map((userTunnel) =>\n+ userTunnel.id === targetTunnelId\n+ ? { ...userTunnel, inFlow: 0, outFlow: 0 }\n+ : userTunnel,\n+ ),\n+ );\n setTunnelToReset(null);\n- if (currentUser) {\n- loadUserTunnels(currentUser.id); // 重新加载隧道权限列表\n- }\n } else {\n toast.error(response.msg || \"重置失败\");\n }\ndiff --git a/vite-frontend/src/vite-env.d.ts b/vite-frontend/src/vite-env.d.ts\nindex 6a25d84..a61183a 100644\n--- a/vite-frontend/src/vite-env.d.ts\n+++ b/vite-frontend/src/vite-env.d.ts\n@@ -2,6 +2,7 @@\n /// <reference types=\"vite-plugin-pwa/client\" />\n \n interface ImportMetaEnv {\n+ readonly VITE_API_BASE?: string;\n readonly VITE_APP_VERSION?: string;\n readonly VITE_GITHUB_REPO?: string;\n }\n",
"title": "Show staged and unstaged changes",
"metadata": {
"output": "diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\nindex f14d231..052f7fe 100644\n--- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n+++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n@@ -1,6 +1,6 @@\n import type { ForwardApiItem } from \"@/api/types\";\n \n-import { useEffect, useState } from \"react\";\n+import { useCallback, useEffect, useRef, useState } from \"react\";\n import toast from \"react-hot-toast\";\n \n import {\n@@ -52,6 +52,23 @@ export interface DashboardStatisticsFlow {\n time: string;\n }\n \n+const DASHBOARD_POLL_INTERVAL_MS = 5000;\n+const EXPIRATION_NOTIFICATION_STORAGE_KEY =\n+ \"dashboard:last-expiration-notification\";\n+\n+const buildExpirationNotificationKey = (\n+ userInfo: DashboardUserInfo,\n+ tunnels: DashboardUserTunnel[],\n+) => {\n+ const userExpTime = userInfo.expTime ?? \"permanent\";\n+ const tunnelExpirationKey = [...tunnels]\n+ .map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? \"permanent\"}`)\n+ .sort()\n+ .join(\"|\");\n+\n+ return `user:${userExpTime};tunnels:${tunnelExpirationKey}`;\n+};\n+\n interface DashboardDataState {\n loading: boolean;\n userInfo: DashboardUserInfo;\n@@ -66,8 +83,10 @@ const checkExpirationNotifications = (\n userInfo: DashboardUserInfo,\n tunnels: DashboardUserTunnel[],\n ) => {\n- const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(\",\")}`;\n- const lastNotified = localStorage.getItem(\"lastNotified\");\n+ const notificationKey = buildExpirationNotificationKey(userInfo, tunnels);\n+ const lastNotified = localStorage.getItem(\n+ EXPIRATION_NOTIFICATION_STORAGE_KEY,\n+ );\n \n if (lastNotified === notificationKey) {\n return;\n@@ -148,7 +167,7 @@ const checkExpirationNotifications = (\n });\n \n if (hasNotification) {\n- localStorage.setItem(\"lastNotified\", notificationKey);\n+ localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey);\n }\n };\n \n@@ -188,60 +207,129 @@ export const useDashboardData = (): DashboardDataState => {\n const [announcement, setAnnouncement] = useState<AnnouncementData | null>(\n null,\n );\n+ const isMountedRef = useRef(true);\n+ const packageRequestInFlightRef = useRef(false);\n \n- useEffect(() => {\n- const loadAnnouncement = async () => {\n- try {\n- const res = await getAnnouncement();\n+ const applyPackageData = useCallback((data: {\n+ userInfo?: DashboardUserInfo;\n+ tunnelPermissions?: DashboardUserTunnel[];\n+ forwards?: ForwardApiItem[];\n+ statisticsFlows?: DashboardStatisticsFlow[];\n+ }) => {\n+ const normalizedTunnelPermissions = normalizeTunnelPermissions(\n+ data.tunnelPermissions || [],\n+ );\n+ const normalizedForwards = normalizeForwards(data.forwards || []);\n \n- if (res.code === 0 && res.data && res.data.enabled === 1) {\n- setAnnouncement(res.data);\n- }\n- } catch {}\n- };\n+ if (!isMountedRef.current) {\n+ return;\n+ }\n+\n+ setUserInfo(data.userInfo || ({} as DashboardUserInfo));\n+ setUserTunnels(normalizedTunnelPermissions);\n+ setForwardList(normalizedForwards);\n+ setStatisticsFlows(data.statisticsFlows || []);\n+\n+ checkExpirationNotifications(\n+ data.userInfo || ({} as DashboardUserInfo),\n+ normalizedTunnelPermissions,\n+ );\n+ }, []);\n+\n+ const loadPackageData = useCallback(\n+ async ({ silent = false, notifyOnError = false } = {}) => {\n+ if (packageRequestInFlightRef.current) {\n+ return;\n+ }\n+\n+ packageRequestInFlightRef.current = true;\n+\n+ if (!silent && isMountedRef.current) {\n+ setLoading(true);\n+ }\n \n- const loadPackageData = async () => {\n- setLoading(true);\n try {\n const res = await getUserPackageInfo();\n \n if (res.code === 0) {\n- const data = res.data;\n- const normalizedTunnelPermissions = normalizeTunnelPermissions(\n- data.tunnelPermissions || [],\n- );\n- const normalizedForwards = normalizeForwards(data.forwards || []);\n-\n- setUserInfo(data.userInfo || ({} as DashboardUserInfo));\n- setUserTunnels(normalizedTunnelPermissions);\n- setForwardList(normalizedForwards);\n- setStatisticsFlows(data.statisticsFlows || []);\n-\n- checkExpirationNotifications(\n- data.userInfo,\n- normalizedTunnelPermissions,\n- );\n- } else {\n+ applyPackageData(res.data || {});\n+ } else if (notifyOnError) {\n toast.error(res.msg || \"获取套餐信息失败\");\n }\n } catch {\n- toast.error(\"获取套餐信息失败\");\n+ if (notifyOnError) {\n+ toast.error(\"获取套餐信息失败\");\n+ }\n } finally {\n- setLoading(false);\n+ packageRequestInFlightRef.current = false;\n+\n+ if (!silent && isMountedRef.current) {\n+ setLoading(false);\n+ }\n+ }\n+ },\n+ [applyPackageData],\n+ );\n+\n+ const loadAnnouncement = useCallback(async () => {\n+ try {\n+ const res = await getAnnouncement();\n+\n+ if (!isMountedRef.current) {\n+ return;\n }\n- };\n \n- setLoading(true);\n- setUserInfo({} as DashboardUserInfo);\n- setUserTunnels([]);\n- setForwardList([]);\n- setStatisticsFlows([]);\n+ if (res.code === 0 && res.data && res.data.enabled === 1) {\n+ setAnnouncement(res.data);\n+ } else {\n+ setAnnouncement(null);\n+ }\n+ } catch {\n+ if (isMountedRef.current) {\n+ setAnnouncement(null);\n+ }\n+ }\n+ }, []);\n+\n+ useEffect(() => {\n+ isMountedRef.current = true;\n setIsAdmin(getAdminFlag());\n \n- loadPackageData();\n- loadAnnouncement();\n+ void loadPackageData({ notifyOnError: true });\n+ void loadAnnouncement();\n localStorage.setItem(\"e\", \"/dashboard\");\n- }, []);\n+\n+ return () => {\n+ isMountedRef.current = false;\n+ };\n+ }, [loadAnnouncement, loadPackageData]);\n+\n+ useEffect(() => {\n+ if (typeof document === \"undefined\") {\n+ return;\n+ }\n+\n+ const handleVisibilityChange = () => {\n+ if (document.visibilityState === \"visible\") {\n+ void loadPackageData({ silent: true });\n+ }\n+ };\n+\n+ const interval = window.setInterval(() => {\n+ if (document.visibilityState !== \"visible\") {\n+ return;\n+ }\n+\n+ void loadPackageData({ silent: true });\n+ }, DASHBOARD_POLL_INTERVAL_MS);\n+\n+ document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n+\n+ return () => {\n+ window.clearInterval(interval);\n+ document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n+ };\n+ }, [loadPackageData]);\n \n return {\n loading,\ndiff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx\nindex f2d3c3f..eb99d8c 100644\n--- a/vite-frontend/src/pages/forward.tsx\n+++ b/vite-frontend/src/pages/forward.tsx\n@@ -1,6 +1,6 @@\n import type { SpeedLimitApiItem } from \"@/api/types\";\n \n-import { useState, useEffect, useMemo, useRef } from \"react\";\n+import { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\n import toast from \"react-hot-toast\";\n import {\n DndContext,\n@@ -48,6 +48,7 @@ import { Chip } from \"@/shadcn-bridge/heroui/chip\";\n import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n import { Switch } from \"@/shadcn-bridge/heroui/switch\";\n import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n+import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n import {\n createForward,\n@@ -166,6 +167,12 @@ interface ForwardTunnelGroup {\n items: Forward[];\n }\n \n+interface BatchProgressState {\n+ active: boolean;\n+ label: string;\n+ percent: number;\n+}\n+\n type ForwardGroupOrderMap = Record<string, string[]>;\n type ForwardGroupCollapsedMap = Record<string, boolean>;\n \n@@ -490,6 +497,30 @@ const isSameGroupCollapsedMap = (\n return true;\n };\n \n+const normalizeForwardItems = (items: Forward[]): Forward[] => {\n+ return items.map((forward) => ({\n+ ...forward,\n+ serviceRunning: forward.status === 1,\n+ }));\n+};\n+\n+const mapForwardApiItems = (items: any[]): Forward[] => {\n+ return (items || []).map((forward) => ({\n+ ...forward,\n+ tunnelId: forward.tunnelId ?? 0,\n+ tunnelName: forward.tunnelName || \"\",\n+ inIp: forward.inIp || \"\",\n+ inPort: forward.inPort ?? 0,\n+ remoteAddr: forward.remoteAddr || \"\",\n+ strategy: forward.strategy || \"fifo\",\n+ status: typeof forward.status === \"number\" ? forward.status : 0,\n+ inFlow: forward.inFlow ?? 0,\n+ outFlow: forward.outFlow ?? 0,\n+ createdTime: forward.createdTime || \"\",\n+ serviceRunning: forward.status === 1,\n+ }));\n+};\n+\n export default function ForwardPage() {\n const [loading, setLoading] = useState(true);\n const [forwards, setForwards] = useState<Forward[]>([]);\n@@ -603,6 +634,11 @@ export default function ForwardPage() {\n null,\n );\n const [batchLoading, setBatchLoading] = useState(false);\n+ const [batchProgress, setBatchProgress] = useState<BatchProgressState>({\n+ active: false,\n+ label: \"\",\n+ percent: 0,\n+ });\n const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\n useState<ForwardGroupCollapsedMap>({});\n@@ -959,7 +995,7 @@ export default function ForwardPage() {\n return Number.isFinite(shareId) && shareId > 0 ? shareId : null;\n };\n \n- const mergeFederationShareFlow = async (\n+ const mergeFederationShareFlow = useCallback(async (\n forwardsData: Forward[],\n ): Promise<Forward[]> => {\n if (forwardsData.length === 0) {\n@@ -1135,7 +1171,7 @@ export default function ForwardPage() {\n } catch {\n return forwardsData;\n }\n- };\n+ }, []);\n \n const getForwardDisplayFlow = (forward: Forward): number => {\n const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0);\n@@ -1147,10 +1183,6 @@ export default function ForwardPage() {\n return forward.federationShareFlow || 0;\n };\n \n- useEffect(() => {\n- loadData();\n- }, []);\n-\n // 切换显示模式并保存到localStorage\n const handleViewModeChange = () => {\n const newMode = viewMode === \"grouped\" ? \"direct\" : \"grouped\";\n@@ -1161,12 +1193,59 @@ export default function ForwardPage() {\n } catch {}\n };\n \n+ const applyForwardList = useCallback(\n+ async (items: Forward[]) => {\n+ const mergedForwards = await mergeFederationShareFlow(\n+ normalizeForwardItems(items),\n+ );\n+\n+ setForwards(mergedForwards);\n+\n+ const currentUserId = JwtUtil.getUserIdFromToken();\n+ const { order, fromDatabase } = buildForwardOrder(\n+ mergedForwards,\n+ currentUserId,\n+ );\n+\n+ setForwardOrder(order);\n+\n+ if (fromDatabase) {\n+ saveOrder(FORWARD_ORDER_KEY, order);\n+ }\n+ },\n+ [mergeFederationShareFlow],\n+ );\n+\n+ const refreshForwardList = useCallback(\n+ async (lod = true) => {\n+ if (lod) {\n+ setLoading(true);\n+ }\n+\n+ try {\n+ const forwardsRes = await getForwardList();\n+\n+ if (forwardsRes.code === 0) {\n+ await applyForwardList(mapForwardApiItems(forwardsRes.data || []));\n+ } else {\n+ toast.error(forwardsRes.msg || \"获取规则列表失败\");\n+ }\n+ } catch {\n+ toast.error(\"获取规则列表失败\");\n+ } finally {\n+ if (lod) {\n+ setLoading(false);\n+ }\n+ }\n+ },\n+ [applyForwardList],\n+ );\n+\n // 加载所有数据\n- const loadData = async (lod = true) => {\n+ const loadData = useCallback(async (lod = true) => {\n setLoading(lod);\n try {\n- const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([\n- getForwardList(),\n+ const [tunnelsRes, speedLimitsRes] = await Promise.all([\n userTunnel(),\n getSpeedLimitList(),\n ]);\n@@ -1175,32 +1254,7 @@ export default function ForwardPage() {\n getNodeList(),\n ]);\n \n- if (forwardsRes.code === 0) {\n- const forwardsData =\n- forwardsRes.data?.map((forward: any) => ({\n- ...forward,\n- serviceRunning: forward.status === 1,\n- })) || [];\n-\n- const mergedForwards = await mergeFederationShareFlow(forwardsData);\n-\n- setForwards(mergedForwards);\n-\n- // 初始化拖拽排序顺序\n- const currentUserId = JwtUtil.getUserIdFromToken();\n- const { order, fromDatabase } = buildForwardOrder(\n- mergedForwards,\n- currentUserId,\n- );\n-\n- setForwardOrder(order);\n-\n- if (fromDatabase) {\n- saveOrder(FORWARD_ORDER_KEY, order);\n- }\n- } else {\n- toast.error(forwardsRes.msg || \"获取规则列表失败\");\n- }\n+ await refreshForwardList(false);\n \n if (tunnelsRes.code === 0) {\n setTunnels(tunnelsRes.data || []);\n@@ -1226,7 +1280,11 @@ export default function ForwardPage() {\n } finally {\n setLoading(false);\n }\n- };\n+ }, [refreshForwardList]);\n+\n+ useEffect(() => {\n+ loadData();\n+ }, [loadData]);\n \n // 表单验证\n const noLimitSpeedLimitIds = useMemo(() => {\n@@ -1385,7 +1443,20 @@ export default function ForwardPage() {\n if (res.code === 0) {\n toast.success(\"删除成功\");\n setDeleteModalOpen(false);\n- loadData();\n+ setForwardToDelete(null);\n+ setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id));\n+ setForwardOrder((prev) => {\n+ const next = prev.filter((id) => id !== forwardToDelete.id);\n+\n+ saveOrder(FORWARD_ORDER_KEY, next);\n+ return next;\n+ });\n+ setSelectedIds((prev) => {\n+ const next = new Set(prev);\n+\n+ next.delete(forwardToDelete.id);\n+ return next;\n+ });\n } else {\n // 删除失败,询问是否强制删除\n const confirmed = window.confirm(\n@@ -1398,7 +1469,22 @@ export default function ForwardPage() {\n if (forceRes.code === 0) {\n toast.success(\"强制删除成功\");\n setDeleteModalOpen(false);\n- loadData();\n+ setForwardToDelete(null);\n+ setForwards((prev) =>\n+ prev.filter((forward) => forward.id !== forwardToDelete.id),\n+ );\n+ setForwardOrder((prev) => {\n+ const next = prev.filter((id) => id !== forwardToDelete.id);\n+\n+ saveOrder(FORWARD_ORDER_KEY, next);\n+ return next;\n+ });\n+ setSelectedIds((prev) => {\n+ const next = new Set(prev);\n+\n+ next.delete(forwardToDelete.id);\n+ return next;\n+ });\n } else {\n toast.error(forceRes.msg || \"强制删除失败\");\n }\n@@ -1496,7 +1582,7 @@ export default function ForwardPage() {\n }\n toast.success(isEdit ? \"修改成功\" : \"创建成功\");\n setModalOpen(false);\n- loadData();\n+ await refreshForwardList(false);\n } else {\n toast.error(res.msg || \"操作失败\");\n }\n@@ -2091,7 +2177,7 @@ export default function ForwardPage() {\n \n toast.success(\"导入执行完成\");\n \n- await loadData(false);\n+ await refreshForwardList(false);\n } catch {\n toast.error(\"导入过程中发生错误\");\n } finally {\n@@ -2296,6 +2382,11 @@ export default function ForwardPage() {\n const handleBatchDelete = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在删除 ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchDelete(Array.from(selectedIds));\n \n@@ -2306,14 +2397,20 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeDeleteModal) {\n setBatchDeleteModalOpen(false);\n }\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2321,6 +2418,11 @@ export default function ForwardPage() {\n const handleBatchToggleService = async (enable: boolean) => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在${enable ? \"启用\" : \"停用\"} ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchToggleService(\n Array.from(selectedIds),\n@@ -2334,11 +2436,17 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2346,6 +2454,11 @@ export default function ForwardPage() {\n const handleBatchRedeploy = async () => {\n if (selectedIds.size === 0) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在重新下发 ${selectedIds.size} 项规则...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchRedeploy(\n Array.from(selectedIds),\n@@ -2358,11 +2471,17 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -2370,6 +2489,11 @@ export default function ForwardPage() {\n const handleBatchChangeTunnel = async () => {\n if (selectedIds.size === 0 || !batchTargetTunnelId) return;\n setBatchLoading(true);\n+ setBatchProgress({\n+ active: true,\n+ label: `正在为 ${selectedIds.size} 项规则切换隧道...`,\n+ percent: 30,\n+ });\n try {\n const outcome = await executeForwardBatchChangeTunnel(\n Array.from(selectedIds),\n@@ -2383,6 +2507,11 @@ export default function ForwardPage() {\n }\n \n if (outcome.shouldRefresh) {\n+ setBatchProgress({\n+ active: true,\n+ label: outcome.progressLabel || \"正在刷新规则列表...\",\n+ percent: outcome.progressPercent ?? 75,\n+ });\n setSelectedIds(new Set());\n setSelectMode(false);\n if (outcome.closeChangeTunnelModal) {\n@@ -2391,9 +2520,10 @@ export default function ForwardPage() {\n if (outcome.resetTargetTunnel) {\n setBatchTargetTunnelId(null);\n }\n- loadData(false);\n+ await refreshForwardList(false);\n }\n } finally {\n+ setBatchProgress({ active: false, label: \"\", percent: 0 });\n setBatchLoading(false);\n }\n };\n@@ -3817,6 +3947,23 @@ export default function ForwardPage() {\n </div>\n </div>\n \n+ {batchProgress.active && (\n+ <div className=\"mb-4\">\n+ <Alert\n+ color=\"primary\"\n+ description={batchProgress.label}\n+ variant=\"flat\"\n+ />\n+ <Progress\n+ aria-label={batchProgress.label}\n+ className=\"mt-3\"\n+ color=\"primary\"\n+ size=\"sm\"\n+ value={batchProgress.percent}\n+ />\n+ </div>\n+ )}\n+\n {/* 根据显示模式渲染不同内容 */}\n {compactMode ? (\n viewMode === \"grouped\" ? (\ndiff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts\nindex 66af192..fc40bc0 100644\n--- a/vite-frontend/src/pages/forward/batch-actions.ts\n+++ b/vite-frontend/src/pages/forward/batch-actions.ts\n@@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome {\n toastVariant: \"success\" | \"error\";\n toastMessage: string;\n shouldRefresh: boolean;\n+ progressPercent?: number;\n+ progressLabel?: string;\n closeDeleteModal?: boolean;\n closeChangeTunnelModal?: boolean;\n resetTargetTunnel?: boolean;\n@@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async (\n return {\n ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `删除完成:成功 ${summary.successCount} 项`,\n closeDeleteModal: true,\n };\n } catch (error) {\n@@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async (\n : `成功停用 ${summary.successCount} 项`,\n ),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `${enable ? \"启用\" : \"停用\"}完成:成功 ${summary.successCount} 项`,\n };\n } catch (error) {\n return {\n@@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async (\n return {\n ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,\n };\n } catch (error) {\n return {\n@@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async (\n return {\n ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),\n shouldRefresh: true,\n+ progressPercent: 100,\n+ progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,\n closeChangeTunnelModal: true,\n resetTargetTunnel: true,\n };\ndiff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx\nindex fc78251..096ab2c 100644\n--- a/vite-frontend/src/pages/node.tsx\n+++ b/vite-frontend/src/pages/node.tsx\n@@ -65,6 +65,8 @@ import { useNodeRealtime } from \"@/pages/node/use-node-realtime\";\n import { useLocalStorageState } from \"@/hooks/use-local-storage-state\";\n import { loadStoredOrder, saveOrder } from \"@/utils/order-storage\";\n \n+const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000;\n+\n interface Node {\n id: number;\n inx?: number;\n@@ -116,6 +118,21 @@ interface NodeForm {\n socks: number; // 0 关 1 开\n }\n \n+const 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+\n const SortableItem = ({\n id,\n children,\n@@ -251,8 +268,13 @@ export default function NodePage() {\n });\n \n // 加载节点列表\n- const loadNodes = useCallback(async () => {\n- setLoading(true);\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@@ -270,7 +292,13 @@ export default function NodePage() {\n copyLoading: false,\n }));\n \n- setNodeList(nodesData);\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@@ -292,12 +320,18 @@ export default function NodePage() {\n );\n }\n } else {\n- toast.error(res.msg || \"加载节点列表失败\");\n+ if (!silent) {\n+ toast.error(res.msg || \"加载节点列表失败\");\n+ }\n }\n } catch {\n- toast.error(\"网络错误,请重试\");\n+ if (!silent) {\n+ toast.error(\"网络错误,请重试\");\n+ }\n } finally {\n- setLoading(false);\n+ if (!silent) {\n+ setLoading(false);\n+ }\n }\n }, []);\n \n@@ -370,7 +404,7 @@ export default function NodePage() {\n }\n };\n \n- const { wsConnected, wsConnecting } = useNodeRealtime({\n+ const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({\n onMessage: handleWebSocketMessage,\n });\n \n@@ -378,6 +412,22 @@ export default function NodePage() {\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@@ -1162,7 +1212,11 @@ export default function NodePage() {\n className=\"mb-4\"\n color=\"warning\"\n description={\n- wsConnecting ? \"监控连接中...\" : \"监控连接已断开,正在重连...\"\n+ wsConnecting\n+ ? \"监控连接中...\"\n+ : usingPollingFallback\n+ ? \"监控连接已断开,已切换为列表自动刷新兜底模式。\"\n+ : \"监控连接已断开,正在重连...\"\n }\n variant=\"flat\"\n />\ndiff --git a/vite-frontend/src/pages/node/use-node-realtime.ts b/vite-frontend/src/pages/node/use-node-realtime.ts\nindex 5ef1806..10c303d 100644\n--- a/vite-frontend/src/pages/node/use-node-realtime.ts\n+++ b/vite-frontend/src/pages/node/use-node-realtime.ts\n@@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions {\n enabled?: boolean;\n }\n \n+const MAX_STANDARD_RECONNECT_ATTEMPTS = 5;\n+const STANDARD_RECONNECT_DELAY_MS = 3000;\n+const MAX_STANDARD_RECONNECT_DELAY_MS = 15000;\n+const FALLBACK_RECONNECT_DELAY_MS = 30000;\n+\n const getRealtimeWsUrl = (): string => {\n const baseUrl =\n axios.defaults.baseURL ||\n@@ -34,14 +39,13 @@ export const useNodeRealtime = ({\n }: UseNodeRealtimeOptions) => {\n const [wsConnected, setWsConnected] = useState(false);\n const [wsConnecting, setWsConnecting] = useState(false);\n+ const [usingPollingFallback, setUsingPollingFallback] = useState(false);\n \n const websocketRef = useRef<WebSocket | null>(null);\n const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const reconnectAttemptsRef = useRef(0);\n const onMessageRef = useRef(onMessage);\n \n- const maxReconnectAttempts = 5;\n-\n useEffect(() => {\n onMessageRef.current = onMessage;\n }, [onMessage]);\n@@ -58,6 +62,7 @@ export const useNodeRealtime = ({\n reconnectAttemptsRef.current = 0;\n setWsConnected(false);\n setWsConnecting(false);\n+ setUsingPollingFallback(false);\n \n if (!websocketRef.current) {\n return;\n@@ -103,6 +108,7 @@ export const useNodeRealtime = ({\n reconnectAttemptsRef.current = 0;\n setWsConnected(true);\n setWsConnecting(false);\n+ setUsingPollingFallback(false);\n };\n \n websocketRef.current.onmessage = (event) => {\n@@ -122,15 +128,27 @@ export const useNodeRealtime = ({\n setWsConnected(false);\n setWsConnecting(false);\n \n- if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {\n+ if (!enabled) {\n return;\n }\n \n reconnectAttemptsRef.current += 1;\n+ const exhaustedStandardRetries =\n+ reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS;\n+\n+ setUsingPollingFallback(exhaustedStandardRetries);\n+\n+ const reconnectDelay = exhaustedStandardRetries\n+ ? FALLBACK_RECONNECT_DELAY_MS\n+ : Math.min(\n+ STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current,\n+ MAX_STANDARD_RECONNECT_DELAY_MS,\n+ );\n+\n reconnectTimerRef.current = setTimeout(() => {\n reconnectTimerRef.current = null;\n connect();\n- }, 3000 * reconnectAttemptsRef.current);\n+ }, reconnectDelay);\n };\n } catch {\n setWsConnected(false);\n@@ -153,6 +171,7 @@ export const useNodeRealtime = ({\n return {\n wsConnected,\n wsConnecting,\n+ usingPollingFallback,\n reconnectRealtime: connect,\n disconnectRealtime: disconnect,\n };\ndiff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx\nindex 0f888a2..cf96635 100644\n--- a/vite-frontend/src/pages/tunnel.tsx\n+++ b/vite-frontend/src/pages/tunnel.tsx\n@@ -1,4 +1,4 @@\n-import { useState, useEffect, useMemo, useRef } from \"react\";\n+import { useState, useEffect, useMemo, useRef, useCallback } from \"react\";\n import toast from \"react-hot-toast\";\n import {\n DndContext,\n@@ -36,6 +36,7 @@ import { Spinner } from \"@/shadcn-bridge/heroui/spinner\";\n import { Divider } from \"@/shadcn-bridge/heroui/divider\";\n import { Alert } from \"@/shadcn-bridge/heroui/alert\";\n import { Checkbox } from \"@/shadcn-bridge/heroui/checkbox\";\n+import { Progress } from \"@/shadcn-bridge/heroui/progress\";\n import {\n createTunnel,\n getTunnelList,\n@@ -115,6 +116,29 @@ interface TunnelForm {\n status: number;\n }\n \n+interface BatchProgressState {\n+ active: boolean;\n+ label: string;\n+ percent: number;\n+}\n+\n+const TUNNEL_ORDER_KEY = \"tunnel-order\";\n+\n+const mapTunnelApiItems = (items: any[]): Tunnel[] => {\n+ return (items || []).map((tunnel) => ({\n+ ...tunnel,\n+ inx: tunnel.inx ?? 0,\n+ inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [],\n+ outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [],\n+ chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [],\n+ inIp: tunnel.inIp || \"\",\n+ flow: tunnel.flow ?? 1,\n+ traff\n\n...",
"exit": 0,
"description": "Show staged and unstaged changes",
"truncated": false
},
"time": {
"start": 1772968467563,
"end": 1772968467594
}
},
"id": "prt_ccd27dc6a001AkSWRdr2FZVxmI",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "tool",
"callID": "call_36139a6fd3aa41b0a48e4477",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git log --oneline -10",
"description": "Show recent commit messages"
},
"output": "bd30b61 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\ne0dd70a fix(backend): migrate PostgreSQL traffic columns from int4 to bigint\n4966a8a fix(backend): sync user tunnel status and relax forward speedId permission check (#288)\n3e11549 fix(backend): sync user tunnel status and relax forward speedId permission check\naddf83a fix(ui): improve date input parsing and add missing back navigation (#284)\nc3e35fd fix(ui): tighten date parsing and back navigation\n775dfe1 Merge branch 'main' into fix/upstream-friendly-ui-3pack\ndb3b2f6 fix: improve bind-conflict detection and forward cleanup reliability (#287)\n669323f fix: improve bind-conflict detection and forward cleanup reliability\n7202b69 Merge branch 'main' into fix/upstream-friendly-ui-3pack\n",
"title": "Show recent commit messages",
"metadata": {
"output": "bd30b61 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\ne0dd70a fix(backend): migrate PostgreSQL traffic columns from int4 to bigint\n4966a8a fix(backend): sync user tunnel status and relax forward speedId permission check (#288)\n3e11549 fix(backend): sync user tunnel status and relax forward speedId permission check\naddf83a fix(ui): improve date input parsing and add missing back navigation (#284)\nc3e35fd fix(ui): tighten date parsing and back navigation\n775dfe1 Merge branch 'main' into fix/upstream-friendly-ui-3pack\ndb3b2f6 fix: improve bind-conflict detection and forward cleanup reliability (#287)\n669323f fix: improve bind-conflict detection and forward cleanup reliability\n7202b69 Merge branch 'main' into fix/upstream-friendly-ui-3pack\n",
"exit": 0,
"description": "Show recent commit messages",
"truncated": false
},
"time": {
"start": 1772968467878,
"end": 1772968467907
}
},
"id": "prt_ccd27dda5001tj0CtrMhMwPxpW",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "tool",
"callID": "call_175ba894c156476d922112b8",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git branch -vv",
"description": "Show branch tracking info"
},
"output": " card-view 357a4b1 [origin/card-view: 丢失] Merge remote-tracking branch 'origin/main' into card-view\n claude/dazzling-stonebraker daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/focused-elgamal daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/infallible-tu daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/inspiring-agnesi daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/reverent-burnell daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/vibrant-pascal daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n entire/11d1ba0-5c3cd4 8ed51e2 提交全部变更并且push,创建pr并且合并\n entire/15e6cd6-0d0a24 db9b35c carry forward: uncommitted session files\n entire/25a87e2-399b56 71b16dd 把main合并到本仓库\n entire/2e3d5c9-b6e03f be4ee74 普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计\n entire/32474be-e3b0c4 fc3cbfe 同步 main 分支\n entire/94abbd8-399b56 002fd5c 查看IMPLEMENTATION_PLAN.md计划是什么\n entire/96aebb8-e3b0c4 b64aea6 同步 main 分支\n entire/9703e4a-e3b0c4 6ca1392 同步main\n entire/b93c259-e3b0c4 4755a8e 删除tag 2.1.8-beta1\n entire/bd30b61-19f0e2 82944ae 开始实施\n entire/be9d877-b6e03f a7ad331 增加功能:转发规则批量导入格式,兼容ny的导入格式 ny格式如下: {\"dest\":[\"151.241.129.52:23609\"],\"list\n entire/checkpoints/v1 6b73622 Checkpoint: deb90fb942ee\n feat/announcement-markdown-support fb2189c [origin/feat/announcement-markdown-support: 丢失] Merge branch 'main' into feat/announcement-markdown-support\n fix/android-batch-toolbar-scroll def9374 [origin/fix/android-batch-toolbar-scroll: 丢失] fix(frontend): restore Android swipe for mobile batch toolbar\n fix/forward-grouped-list-view fe1cace [origin/fix/forward-grouped-list-view: 丢失] Merge main into fix/forward-grouped-list-view and resolve conflicts\n fix/mobile-batch-toolbar-alignment c94ab84 [origin/fix/mobile-batch-toolbar-alignment: 丢失] fix(frontend): keep mobile batch toolbar right-aligned\n fix/postgres-traffic-bigint-migration e0dd70a [origin/fix/postgres-traffic-bigint-migration] fix(backend): migrate PostgreSQL traffic columns from int4 to bigint\n fix/shared-federation-forward-stability 9f17d63 [origin/fix/shared-federation-forward-stability: 丢失] fix(backend): keep shared federation port-forward services stable\n fix/shared-federation-runtime-payload-compat bc71c52 [origin/fix/shared-federation-runtime-payload-compat: 丢失] fix(backend): accept top-level federation service payloads\n+ main bd30b61 (/Users/sagit/Documents/github/flvx) [origin/main] fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\n opencode/cosmic-rocket a645cc6 docs: add AI Skill and PostgreSQL nav items (#226)\n opencode/diagnosis-timeout-messages d33814e [origin/opencode/diagnosis-timeout-messages: 丢失] fix(diagnosis): tighten timeout handling and clarify timeout messaging\n opencode/jolly-otter 7a40ddb fix(diagnosis): tighten timeout handling and clarify timeout messaging (#233)\n* opencode/quiet-canyon bd30b61 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\n opencode/silent-star 6104d63 fix(ui): tighten date parsing and back navigation\n release/2.1.5-alpha5 d483258 [origin/release/2.1.5-alpha5: 丢失] fix(frontend): ship pending card-view animation and interaction refinements\n release/2.1.5-alpha6 d37adee [origin/release/2.1.5-alpha6: 丢失] fix(frontend): resolve remaining font blur in node and forward cards\n release/2.1.5-rc14 804a5a2 [origin/release/2.1.5-rc14: 丢失] feat: enhance federation forward flow linkage with peer share support\n release/federation-forward-stability 466cc65 [origin/release/federation-forward-stability: 丢失] fix: stabilize federation forward runtime cleanup and wire frontend version update UX\n",
"title": "Show branch tracking info",
"metadata": {
"output": " card-view 357a4b1 [origin/card-view: 丢失] Merge remote-tracking branch 'origin/main' into card-view\n claude/dazzling-stonebraker daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/focused-elgamal daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/infallible-tu daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/inspiring-agnesi daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/reverent-burnell daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n claude/vibrant-pascal daf34d0 fix(backend): prevent login block when captcha enabled without cloudflare key (#194)\n entire/11d1ba0-5c3cd4 8ed51e2 提交全部变更并且push,创建pr并且合并\n entire/15e6cd6-0d0a24 db9b35c carry forward: uncommitted session files\n entire/25a87e2-399b56 71b16dd 把main合并到本仓库\n entire/2e3d5c9-b6e03f be4ee74 普通用户添加转发的时候应该不能有权限设置限速和设置自定义端口,请检查并规划设计\n entire/32474be-e3b0c4 fc3cbfe 同步 main 分支\n entire/94abbd8-399b56 002fd5c 查看IMPLEMENTATION_PLAN.md计划是什么\n entire/96aebb8-e3b0c4 b64aea6 同步 main 分支\n entire/9703e4a-e3b0c4 6ca1392 同步main\n entire/b93c259-e3b0c4 4755a8e 删除tag 2.1.8-beta1\n entire/bd30b61-19f0e2 82944ae 开始实施\n entire/be9d877-b6e03f a7ad331 增加功能:转发规则批量导入格式,兼容ny的导入格式 ny格式如下: {\"dest\":[\"151.241.129.52:23609\"],\"list\n entire/checkpoints/v1 6b73622 Checkpoint: deb90fb942ee\n feat/announcement-markdown-support fb2189c [origin/feat/announcement-markdown-support: 丢失] Merge branch 'main' into feat/announcement-markdown-support\n fix/android-batch-toolbar-scroll def9374 [origin/fix/android-batch-toolbar-scroll: 丢失] fix(frontend): restore Android swipe for mobile batch toolbar\n fix/forward-grouped-list-view fe1cace [origin/fix/forward-grouped-list-view: 丢失] Merge main into fix/forward-grouped-list-view and resolve conflicts\n fix/mobile-batch-toolbar-alignment c94ab84 [origin/fix/mobile-batch-toolbar-alignment: 丢失] fix(frontend): keep mobile batch toolbar right-aligned\n fix/postgres-traffic-bigint-migration e0dd70a [origin/fix/postgres-traffic-bigint-migration] fix(backend): migrate PostgreSQL traffic columns from int4 to bigint\n fix/shared-federation-forward-stability 9f17d63 [origin/fix/shared-federation-forward-stability: 丢失] fix(backend): keep shared federation port-forward services stable\n fix/shared-federation-runtime-payload-compat bc71c52 [origin/fix/shared-federation-runtime-payload-compat: 丢失] fix(backend): accept top-level federation service payloads\n+ main bd30b61 (/Users/sagit/Documents/github/flvx) [origin/main] fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\n opencode/cosmic-rocket a645cc6 docs: add AI Skill and PostgreSQL nav items (#226)\n opencode/diagnosis-timeout-messages d33814e [origin/opencode/diagnosis-timeout-messages: 丢失] fix(diagnosis): tighten timeout handling and clarify timeout messaging\n opencode/jolly-otter 7a40ddb fix(diagnosis): tighten timeout handling and clarify timeout messaging (#233)\n* opencode/quiet-canyon bd30b61 fix(backend): migrate PostgreSQL traffic columns from int4 to bigint (#290)\n opencode/silent-star 6104d63 fix(ui): tighten date parsing and back navigation\n release/2.1.5-alpha5 d483258 [origin/release/2.1.5-alpha5: 丢失] fix(frontend): ship pending card-view animation and interaction refinements\n release/2.1.5-alpha6 d37adee [origin/release/2.1.5-alpha6: 丢失] fix(frontend): resolve remaining font blur in node and forward cards\n release/2.1.5-rc14 804a5a2 [origin/release/2.1.5-rc14: 丢失] feat: enhance federation forward flow linkage with peer share support\n release/federation-forward-stability 466cc65 [origin/release/federation-forward-stability: 丢失] fix: stabilize federation forward runtime cleanup and wire frontend version update UX\n",
"exit": 0,
"description": "Show branch tracking info",
"truncated": false
},
"time": {
"start": 1772968468242,
"end": 1772968468289
}
},
"id": "prt_ccd27df0f001IDzTIcfhDx5PnE",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"cost": 0,
"tokens": {
"total": 20664,
"input": 20078,
"output": 138,
"reasoning": 0,
"cache": {
"read": 448,
"write": 0
}
},
"id": "prt_ccd27df42001d75dxUFAQVltwi",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27ca1a00192bEkkHqkR5bPu"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968468367,
"completed": 1772968481642
},
"parentID": "msg_ccd27c9c4001ACb62NAlsmLkYY",
"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": 35779,
"input": 14787,
"output": 448,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd27df8f001hIjjm70mggi9Xx",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq"
},
"parts": [
{
"type": "step-start",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"id": "prt_ccd27eb940012rc6BgZXqBTkd0",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27df8f001hIjjm70mggi9Xx"
},
{
"type": "reasoning",
"text": "好的,现在我了解了情况:\n\n1. 当前分支: `opencode/quiet-canyon`\n2. 有以下变更:\n - 修改的文件:\n - vite-frontend/src/pages/dashboard/use-dashboard-data.ts\n - vite-frontend/src/pages/forward.tsx\n - vite-frontend/src/pages/forward/batch-actions.ts\n - vite-frontend/src/pages/node.tsx\n - vite-frontend/src/pages/node/use-node-realtime.ts\n - vite-frontend/src/pages/tunnel.tsx\n - vite-frontend/src/pages/user.tsx\n - vite-frontend/src/vite-env.d.ts\n - 新文件:\n - .entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n - .entire/metadata/ses_332fc6806ffeYxhpzgAB64sSTN/\n - plans/020-ajax-no-refresh-ux.md\n - vite-frontend/src/utils/list-state.ts\n\n3. 当前分支没有追踪远程分支(`[origin/...]` 显示为丢失或者没有上游分支)\n\n我需要:\n1. 添加变更文件(但不应该提交 `.entire/` 元数据目录)\n2. 创建提交\n3. 推送到远程并设置上游分支\n4. 创建 PR\n\n从 diff 内容来看,这些变更主要是关于 UI 改进,避免页面刷新的 UX 改进(使用 AJAX 更新代替页面刷新)。\n\n我需要添加相关文件,排除 `.entire/` 目录。\n\n现在让我执行这些操作。",
"time": {
"start": 1772968471444,
"end": 1772968481570
},
"id": "prt_ccd27eb94002VAgPJyx02ePg5M",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27df8f001hIjjm70mggi9Xx"
},
{
"type": "tool",
"callID": "call_0bd2de106d78408c87a412dd",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts vite-frontend/src/pages/forward.tsx vite-frontend/src/pages/forward/batch-actions.ts vite-frontend/src/pages/node.tsx vite-frontend/src/pages/node/use-node-realtime.ts vite-frontend/src/pages/tunnel.tsx vite-frontend/src/pages/user.tsx vite-frontend/src/vite-env.d.ts vite-frontend/src/utils/list-state.ts plans/020-ajax-no-refresh-ux.md",
"description": "Stage changed files for commit"
},
"output": "",
"title": "Stage changed files for commit",
"metadata": {
"output": "",
"exit": 0,
"description": "Stage changed files for commit",
"truncated": false
},
"time": {
"start": 1772968481527,
"end": 1772968481571
}
},
"id": "prt_ccd2812f1001waSPZVnZnjPjz9",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27df8f001hIjjm70mggi9Xx"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"cost": 0,
"tokens": {
"total": 35779,
"input": 14787,
"output": 448,
"reasoning": 0,
"cache": {
"read": 20544,
"write": 0
}
},
"id": "prt_ccd281324001Hkp4YuEkXWfUqm",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq",
"messageID": "msg_ccd27df8f001hIjjm70mggi9Xx"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968481643,
"completed": 1772968484849
},
"error": {
"name": "MessageAbortedError",
"data": {
"message": "The operation was aborted."
}
},
"parentID": "msg_ccd27c9c4001ACb62NAlsmLkYY",
"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": {
"input": 0,
"output": 0,
"reasoning": 0,
"cache": {
"read": 0,
"write": 0
}
},
"id": "msg_ccd28136b001Gb1GH8GmGsuujV",
"sessionID": "ses_332d83674ffeNMa9Z9UerwU3tq"
},
"parts": []
}
]
}