Files
flvx/.entire/metadata/ses_332d83689ffeJ93gRXNEEeOpSm/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

1176 lines
270 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_332d83689ffeJ93gRXNEEeOpSm",
"slug": "proud-lagoon",
"projectID": "03c3dd2e5791a80ef100ef53744e013d779d671c",
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon",
"title": "提交全部变更并推送,创建 PR",
"version": "1.2.21",
"summary": {
"additions": 374,
"deletions": 0,
"files": 4
},
"time": {
"created": 1772968462710,
"updated": 1772968544279
}
},
"messages": [
{
"info": {
"role": "user",
"time": {
"created": 1772968462795
},
"summary": {
"diffs": [
{
"file": ".entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/context.md",
"before": "",
"after": "# Session Context\n\nSession ID: ses_332d83674ffeNMa9Z9UerwU3tq\nCommit Message: 提交全部变更并且push,创建pr\n\n## Prompts\n\n### Prompt 1\n\n提交全部变更并且push,创建pr\n\n",
"additions": 11,
"deletions": 0,
"status": "added"
},
{
"file": ".entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/full.jsonl",
"before": "",
"after": "{\n \"info\": {\n \"id\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"slug\": \"mighty-sailor\",\n \"projectID\": \"03c3dd2e5791a80ef100ef53744e013d779d671c\",\n \"directory\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\",\n \"title\": \"提交全部变更并推送创建 PR\",\n \"version\": \"1.2.21\",\n \"summary\": {\n \"additions\": 0,\n \"deletions\": 0,\n \"files\": 0\n },\n \"time\": {\n \"created\": 1772968462731,\n \"updated\": 1772968481660\n }\n },\n \"messages\": [\n {\n \"info\": {\n \"role\": \"user\",\n \"time\": {\n \"created\": 1772968462871\n },\n \"summary\": {\n \"diffs\": []\n },\n \"agent\": \"build\",\n \"model\": {\n \"providerID\": \"zai-coding-plan\",\n \"modelID\": \"glm-5\"\n },\n \"id\": \"msg_ccd27c9c4001ACb62NAlsmLkYY\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\"\n },\n \"parts\": [\n {\n \"type\": \"text\",\n \"text\": \"提交全部变更并且push,创建pr\",\n \"id\": \"prt_ccd27c9c4002tSLe6ERY3BUO3W\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27c9c4001ACb62NAlsmLkYY\"\n }\n ]\n },\n {\n \"info\": {\n \"role\": \"assistant\",\n \"time\": {\n \"created\": 1772968462874,\n \"completed\": 1772968468365\n },\n \"parentID\": \"msg_ccd27c9c4001ACb62NAlsmLkYY\",\n \"modelID\": \"glm-5\",\n \"providerID\": \"zai-coding-plan\",\n \"mode\": \"build\",\n \"agent\": \"build\",\n \"path\": {\n \"cwd\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\",\n \"root\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\"\n },\n \"cost\": 0,\n \"tokens\": {\n \"total\": 20664,\n \"input\": 20078,\n \"output\": 138,\n \"reasoning\": 0,\n \"cache\": {\n \"read\": 448,\n \"write\": 0\n }\n },\n \"finish\": \"tool-calls\",\n \"id\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\"\n },\n \"parts\": [\n {\n \"type\": \"step-start\",\n \"snapshot\": \"9200c494ea80f614ef327082a5c0a016e076fb78\",\n \"id\": \"prt_ccd27d52d001wjk8A0i67kP693\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"reasoning\",\n \"text\": \"用户要求我:\\n1. 提交全部变更\\n2. push\\n3. 创建 PR\\n\\n首先我需要检查当前的 git 状态,查看有哪些变更。然后检查当前分支和远程分支的状态。让我先并行运行这些命令。\",\n \"time\": {\n \"start\": 1772968465710,\n \"end\": 1772968468245\n },\n \"id\": \"prt_ccd27d52e001JYXyQKvtUAK1gi\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"tool\",\n \"callID\": \"call_2a19227814e94d91a6c6ead1\",\n \"tool\": \"bash\",\n \"state\": {\n \"status\": \"completed\",\n \"input\": {\n \"command\": \"git status\",\n \"description\": \"Show working tree status\"\n },\n \"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\",\n \"title\": \"Show working tree status\",\n \"metadata\": {\n \"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\",\n \"exit\": 0,\n \"description\": \"Show working tree status\",\n \"truncated\": false\n },\n \"time\": {\n \"start\": 1772968467120,\n \"end\": 1772968467171\n }\n },\n \"id\": \"prt_ccd27daab00132zF4GE1eKwuDL\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"tool\",\n \"callID\": \"call_08e50ca85abd46228d3f846a\",\n \"tool\": \"bash\",\n \"state\": {\n \"status\": \"completed\",\n \"input\": {\n \"command\": \"git diff HEAD\",\n \"description\": \"Show staged and unstaged changes\"\n },\n \"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\",\n \"title\": \"Show staged and unstaged changes\",\n \"metadata\": {\n \"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...\",\n \"exit\": 0,\n \"description\": \"Show staged and unstaged changes\",\n \"truncated\": false\n },\n \"time\": {\n \"start\": 1772968467563,\n \"end\": 1772968467594\n }\n },\n \"id\": \"prt_ccd27dc6a001AkSWRdr2FZVxmI\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"tool\",\n \"callID\": \"call_36139a6fd3aa41b0a48e4477\",\n \"tool\": \"bash\",\n \"state\": {\n \"status\": \"completed\",\n \"input\": {\n \"command\": \"git log --oneline -10\",\n \"description\": \"Show recent commit messages\"\n },\n \"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\",\n \"title\": \"Show recent commit messages\",\n \"metadata\": {\n \"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\",\n \"exit\": 0,\n \"description\": \"Show recent commit messages\",\n \"truncated\": false\n },\n \"time\": {\n \"start\": 1772968467878,\n \"end\": 1772968467907\n }\n },\n \"id\": \"prt_ccd27dda5001tj0CtrMhMwPxpW\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"tool\",\n \"callID\": \"call_175ba894c156476d922112b8\",\n \"tool\": \"bash\",\n \"state\": {\n \"status\": \"completed\",\n \"input\": {\n \"command\": \"git branch -vv\",\n \"description\": \"Show branch tracking info\"\n },\n \"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\",\n \"title\": \"Show branch tracking info\",\n \"metadata\": {\n \"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\",\n \"exit\": 0,\n \"description\": \"Show branch tracking info\",\n \"truncated\": false\n },\n \"time\": {\n \"start\": 1772968468242,\n \"end\": 1772968468289\n }\n },\n \"id\": \"prt_ccd27df0f001IDzTIcfhDx5PnE\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n },\n {\n \"type\": \"step-finish\",\n \"reason\": \"tool-calls\",\n \"snapshot\": \"9200c494ea80f614ef327082a5c0a016e076fb78\",\n \"cost\": 0,\n \"tokens\": {\n \"total\": 20664,\n \"input\": 20078,\n \"output\": 138,\n \"reasoning\": 0,\n \"cache\": {\n \"read\": 448,\n \"write\": 0\n }\n },\n \"id\": \"prt_ccd27df42001d75dxUFAQVltwi\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27ca1a00192bEkkHqkR5bPu\"\n }\n ]\n },\n {\n \"info\": {\n \"role\": \"assistant\",\n \"time\": {\n \"created\": 1772968468367,\n \"completed\": 1772968481642\n },\n \"parentID\": \"msg_ccd27c9c4001ACb62NAlsmLkYY\",\n \"modelID\": \"glm-5\",\n \"providerID\": \"zai-coding-plan\",\n \"mode\": \"build\",\n \"agent\": \"build\",\n \"path\": {\n \"cwd\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\",\n \"root\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\"\n },\n \"cost\": 0,\n \"tokens\": {\n \"total\": 35779,\n \"input\": 14787,\n \"output\": 448,\n \"reasoning\": 0,\n \"cache\": {\n \"read\": 20544,\n \"write\": 0\n }\n },\n \"finish\": \"tool-calls\",\n \"id\": \"msg_ccd27df8f001hIjjm70mggi9Xx\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\"\n },\n \"parts\": [\n {\n \"type\": \"step-start\",\n \"snapshot\": \"9200c494ea80f614ef327082a5c0a016e076fb78\",\n \"id\": \"prt_ccd27eb940012rc6BgZXqBTkd0\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27df8f001hIjjm70mggi9Xx\"\n },\n {\n \"type\": \"reasoning\",\n \"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现在让我执行这些操作。\",\n \"time\": {\n \"start\": 1772968471444,\n \"end\": 1772968481570\n },\n \"id\": \"prt_ccd27eb94002VAgPJyx02ePg5M\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27df8f001hIjjm70mggi9Xx\"\n },\n {\n \"type\": \"tool\",\n \"callID\": \"call_0bd2de106d78408c87a412dd\",\n \"tool\": \"bash\",\n \"state\": {\n \"status\": \"completed\",\n \"input\": {\n \"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\",\n \"description\": \"Stage changed files for commit\"\n },\n \"output\": \"\",\n \"title\": \"Stage changed files for commit\",\n \"metadata\": {\n \"output\": \"\",\n \"exit\": 0,\n \"description\": \"Stage changed files for commit\",\n \"truncated\": false\n },\n \"time\": {\n \"start\": 1772968481527,\n \"end\": 1772968481571\n }\n },\n \"id\": \"prt_ccd2812f1001waSPZVnZnjPjz9\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27df8f001hIjjm70mggi9Xx\"\n },\n {\n \"type\": \"step-finish\",\n \"reason\": \"tool-calls\",\n \"snapshot\": \"9200c494ea80f614ef327082a5c0a016e076fb78\",\n \"cost\": 0,\n \"tokens\": {\n \"total\": 35779,\n \"input\": 14787,\n \"output\": 448,\n \"reasoning\": 0,\n \"cache\": {\n \"read\": 20544,\n \"write\": 0\n }\n },\n \"id\": \"prt_ccd281324001Hkp4YuEkXWfUqm\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\",\n \"messageID\": \"msg_ccd27df8f001hIjjm70mggi9Xx\"\n }\n ]\n },\n {\n \"info\": {\n \"role\": \"assistant\",\n \"time\": {\n \"created\": 1772968481643,\n \"completed\": 1772968484849\n },\n \"error\": {\n \"name\": \"MessageAbortedError\",\n \"data\": {\n \"message\": \"The operation was aborted.\"\n }\n },\n \"parentID\": \"msg_ccd27c9c4001ACb62NAlsmLkYY\",\n \"modelID\": \"glm-5\",\n \"providerID\": \"zai-coding-plan\",\n \"mode\": \"build\",\n \"agent\": \"build\",\n \"path\": {\n \"cwd\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\",\n \"root\": \"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon\"\n },\n \"cost\": 0,\n \"tokens\": {\n \"input\": 0,\n \"output\": 0,\n \"reasoning\": 0,\n \"cache\": {\n \"read\": 0,\n \"write\": 0\n }\n },\n \"id\": \"msg_ccd28136b001Gb1GH8GmGsuujV\",\n \"sessionID\": \"ses_332d83674ffeNMa9Z9UerwU3tq\"\n },\n \"parts\": []\n }\n ]\n}\n",
"additions": 362,
"deletions": 0,
"status": "added"
},
{
"file": ".entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/prompt.txt",
"before": "",
"after": "提交全部变更并且push,创建pr",
"additions": 1,
"deletions": 0,
"status": "added"
},
{
"file": ".entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/summary.txt",
"before": "",
"after": "",
"additions": 0,
"deletions": 0,
"status": "added"
}
]
},
"agent": "build",
"model": {
"providerID": "zai-coding-plan",
"modelID": "glm-5"
},
"id": "msg_ccd27c984001g61SN2caJWOeYG",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "text",
"text": "提交全部变更并且push,创建pr",
"id": "prt_ccd27c984002xxI82s02r0mm6K",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c984001g61SN2caJWOeYG"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968462813,
"completed": 1772968468358
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 20639,
"input": 46,
"output": 113,
"reasoning": 0,
"cache": {
"read": 20480,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd27c9dd001kbwBO7KYqFBx72",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"id": "prt_ccd27d6ad001zYoemXT0RDmOng",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "reasoning",
"text": "用户要求我:\n1. 提交全部变更\n2. push\n3. 创建 PR\n\n让我先检查当前的 git 状态,看看有什么变更。",
"time": {
"start": 1772968466093,
"end": 1772968468307
},
"id": "prt_ccd27d6ad002vn5CtMBiVmcMGR",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "text",
"text": "我会帮你提交变更、推送并创建 PR。首先让我检查当前状态。",
"time": {
"start": 1772968468307,
"end": 1772968468307
},
"id": "prt_ccd27d92e001wCSTpDxZheIv6K",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "tool",
"callID": "call_530176574c5e4393bb9816fe",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git status",
"description": "Check git 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": "Check git 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": "Check git status",
"truncated": false
},
"time": {
"start": 1772968467493,
"end": 1772968467544
}
},
"id": "prt_ccd27dc22001mb1tM1Sr4i0eEU",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "tool",
"callID": "call_440440d23ba14cfeb27000b2",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git diff",
"description": "Check 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": "Check 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": "Check staged and unstaged changes",
"truncated": false
},
"time": {
"start": 1772968467894,
"end": 1772968467918
}
},
"id": "prt_ccd27ddb4001TqICrT51ePiftm",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "tool",
"callID": "call_bcef7e686f4c4f61a53618cf",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git log -5 --oneline",
"description": "Check recent commits"
},
"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)\n",
"title": "Check recent commits",
"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)\n",
"exit": 0,
"description": "Check recent commits",
"truncated": false
},
"time": {
"start": 1772968468262,
"end": 1772968468286
}
},
"id": "prt_ccd27df240015T40ts5W00T1og",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"cost": 0,
"tokens": {
"total": 20639,
"input": 46,
"output": 113,
"reasoning": 0,
"cache": {
"read": 20480,
"write": 0
}
},
"id": "prt_ccd27df54001PGm1skosO0trZG",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27c9dd001kbwBO7KYqFBx72"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968468359,
"completed": 1772968476851
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 34160,
"input": 13497,
"output": 183,
"reasoning": 0,
"cache": {
"read": 20480,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd27df87001TQfucO2kjPL5lX",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"id": "prt_ccd27e986001gNrwlWlp6lqYGD",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27df87001TQfucO2kjPL5lX"
},
{
"type": "reasoning",
"text": "现在让我分析一下这些变更:\n\n1. 变更了很多前端文件,主要是关于 AJAX 刷新的优化\n2. 有新的计划文档 `plans/020-ajax-no-refresh-ux.md`\n3. 有新的工具文件 `vite-frontend/src/utils/list-state.ts`\n4. 还有一些 `.entire/metadata/` 的未跟踪文件\n\n看起来这是一个关于改进前端 UX 的大型更新,主要是:\n- Dashboard 数据自动轮询\n- 前向规则、隧道、用户等列表的优化刷新\n- 批量操作进度显示\n- WebSocket 连接失败后的降级策略\n\n让我先查看分支信息,确认当前的远程分支情况。",
"time": {
"start": 1772968470919,
"end": 1772968476768
},
"id": "prt_ccd27e987001PadIpW9MyWdN0i",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27df87001TQfucO2kjPL5lX"
},
{
"type": "tool",
"callID": "call_0d8f4ecdf29e4b8caf1db408",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git remote -v",
"description": "Check remote repositories"
},
"output": "origin\tgit@github.com:Sagit-chu/flvx.git (fetch)\norigin\tgit@github.com:Sagit-chu/flvx.git (push)\n",
"title": "Check remote repositories",
"metadata": {
"output": "origin\tgit@github.com:Sagit-chu/flvx.git (fetch)\norigin\tgit@github.com:Sagit-chu/flvx.git (push)\n",
"exit": 0,
"description": "Check remote repositories",
"truncated": false
},
"time": {
"start": 1772968475983,
"end": 1772968476009
}
},
"id": "prt_ccd27fd4b001D6Ycw0Re5jODcW",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27df87001TQfucO2kjPL5lX"
},
{
"type": "tool",
"callID": "call_eae22987115f47749431e8ae",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git branch -vv",
"description": "Check 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": "Check 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": "Check branch tracking info",
"truncated": false
},
"time": {
"start": 1772968476685,
"end": 1772968476726
}
},
"id": "prt_ccd280009001X11e701PfrQML9",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27df87001TQfucO2kjPL5lX"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"cost": 0,
"tokens": {
"total": 34160,
"input": 13497,
"output": 183,
"reasoning": 0,
"cache": {
"read": 20480,
"write": 0
}
},
"id": "prt_ccd280061001OGwu4iJkl5A7OL",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd27df87001TQfucO2kjPL5lX"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968476852,
"completed": 1772968488972
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 35592,
"input": 1511,
"output": 161,
"reasoning": 0,
"cache": {
"read": 33920,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd2800b4001GZo6nGzI8cWTxM",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "9200c494ea80f614ef327082a5c0a016e076fb78",
"id": "prt_ccd280ad20017pcWAf3MLbbYOD",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2800b4001GZo6nGzI8cWTxM"
},
{
"type": "reasoning",
"text": "我看到:\n1. 当前分支是 `opencode/quiet-canyon`,追踪远程分支 `origin/opencode/quiet-canyon`\n2. main 分支在 `bd30b61`,是最近的提交\n\n现在我需要:\n1. 添加所有变更的文件(除了 `.entire/metadata/` 目录)\n2. 创建提交\n3. 推送分支\n4. 创建 PR\n\n让我先看看计划文档的内容,以便写一个准确的提交信息。",
"time": {
"start": 1772968479444,
"end": 1772968484848
},
"id": "prt_ccd280ad4001n08a9jJREMO625",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2800b4001GZo6nGzI8cWTxM"
},
{
"type": "tool",
"callID": "call_67bf79d5f02744d19fdb25b9",
"tool": "read",
"state": {
"status": "completed",
"input": {
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/plans/020-ajax-no-refresh-ux.md"
},
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/plans/020-ajax-no-refresh-ux.md</path>\n<type>file</type>\n<content>1: # 020 AJAX No-refresh UX\n2: \n3: ## Objective\n4: \n5: - Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.\n6: - Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.\n7: - Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.\n8: \n9: ## Non-goals\n10: \n11: - Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.\n12: - Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.\n13: - Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.\n14: - Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.\n15: \n16: ## Current State\n17: \n18: - `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.\n19: - `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.\n20: - `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.\n21: - Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records.\n22: - Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations.\n23: \n24: ## Design Principles\n25: \n26: - Prefer local state patching after successful mutations when the changed record set is known.\n27: - Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset.\n28: - Use polling only where realtime transport does not already exist.\n29: - Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic.\n30: - Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions.\n31: \n32: ## Checklist\n33: \n34: - [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`.\n35: - [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication.\n36: - [x] Harden node realtime reconnection behavior in `vite-frontend/src/pages/node/use-node-realtime.ts` and define a fallback refresh path if websocket recovery fails.\n37: - [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers.\n38: - [x] Convert forward create/edit/delete/service-toggle flows in `vite-frontend/src/pages/forward.tsx` from whole-page refetches to local or targeted updates where safe.\n39: - [x] Convert tunnel create/edit/delete flows in `vite-frontend/src/pages/tunnel.tsx` from whole-page refetches to local or targeted updates where safe.\n40: - [x] Convert user create/edit/delete and user-tunnel permission mutation flows in `vite-frontend/src/pages/user.tsx` to local or targeted updates where safe.\n41: - [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations.\n42: - [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative.\n43: - [ ] Run focused frontend verification and record the result in this plan after implementation.\n44: \n45: ## Implementation Plan\n46: \n47: ### Phase 1 - Dashboard auto-refresh and node realtime resilience\n48: \n49: #### 1. Dashboard traffic/statistics auto-refresh\n50: \n51: - Extract `loadPackageData()` and `loadAnnouncement()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` into stable callbacks so the hook can refresh data without re-running the whole mount sequence.\n52: - Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`.\n53: - Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates.\n54: - Pause polling when `document.visibilityState !== \"visible\"`, then trigger an immediate refresh when the tab becomes visible again.\n55: - Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker.\n56: \n57: #### 2. Dashboard notification safety\n58: \n59: - Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings.\n60: - Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle.\n61: - Ensure refreshes that only change traffic counters do not retrigger expiry toasts.\n62: \n63: #### 3. Node realtime hardening\n64: \n65: - Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget.\n66: - Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached.\n67: - If a fallback list refresh is introduced, merge incoming node metadata with existing `systemInfo`, `connectionStatus`, and upgrade-progress state so live metrics are not wiped during recovery.\n68: - Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`.\n69: \n70: ### Phase 2 - Local mutation updates and partial refreshes\n71: \n72: #### 4. Shared list-patching helpers\n73: \n74: - Add small reusable helpers for common state operations such as:\n75: - replace one item by `id`\n76: - remove one or many items by `id`\n77: - upsert a created or updated item into an ordered list\n78: - preserve derived UI-only fields during server payload merges\n79: - Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction.\n80: \n81: #### 5. Forward page partial refresh conversion\n82: \n83: - Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns.\n84: - Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure.\n85: - Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient.\n86: - Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state.\n87: - Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations.\n88: \n89: #### 6. Tunnel page partial refresh conversion\n90: \n91: - Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`.\n92: - Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload.\n93: - Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent.\n94: \n95: #### 7. User page partial refresh conversion\n96: \n97: - Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely.\n98: - Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations.\n99: - Respect server-side pagination semantics for the user list; if the server response does not provide enough data for a safe local patch, use a targeted page refetch rather than a full multi-dataset refresh.\n100: - Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues.\n101: \n102: ### Phase 3 - Batch progress UX and search/filter polish\n103: \n104: #### 8. Batch progress UX\n105: \n106: - Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations.\n107: - Review `vite-frontend/src/pages/forward/batch-actions.ts` and tunnel batch handlers to determine whether current APIs expose enough intermediate state for real progress.\n108: - If only final summary APIs are available, implement staged client-side progress feedback such as `processing X/Y`, current action label, success count, and failure count.\n109: - If the UX requirement cannot be met without backend support, document the missing backend contract and split the work into frontend and backend follow-ups.\n110: \n111: #### 9. Search and filter responsiveness\n112: \n113: - Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages.\n114: - Audit the user page separately because it depends on server-side pagination and keyword search.\n115: - If user-page instant filtering is desired, choose one of two explicit strategies:\n116: - keep server-side pagination authoritative and add debounce for keyword-triggered requests, or\n117: - load a larger local dataset only if product requirements accept the cost.\n118: - Do not silently mix partial client filtering with incomplete paginated datasets.\n119: \n120: ## Risks and Mitigations\n121: \n122: - Repeated dashboard polling may spam expiry toasts.\n123: - Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes.\n124: - Node recovery refreshes may wipe websocket-derived metrics.\n125: - Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly.\n126: - Local mutation patching may desynchronize grouped, sorted, or selected views.\n127: - Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state.\n128: - Batch APIs may not expose progress details.\n129: - Mitigation: implement client-side staged progress where possible and document backend gaps where not.\n130: - User-page local updates may conflict with pagination semantics.\n131: - Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation.\n132: \n133: ## Verification Plan\n134: \n135: - Dashboard:\n136: - Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload.\n137: - Confirm hidden-tab pause and visible-tab immediate refresh behavior.\n138: - Confirm expiry toasts do not repeat on every polling cycle.\n139: - Nodes:\n140: - Confirm websocket-driven online/offline transitions still work.\n141: - Simulate websocket interruption and verify reconnect or fallback refresh behavior.\n142: - Confirm recovery does not clear existing live metrics unexpectedly.\n143: - Forwards, tunnels, users:\n144: - Create, edit, delete, enable, disable, and reset flows without browser reload.\n145: - Confirm the affected rows update immediately and other unrelated rows stay stable.\n146: - Confirm selection state, ordering, and modal close behavior remain correct after local patching.\n147: - Batch actions:\n148: - Confirm visible progress or staged status feedback exists during long-running operations.\n149: - Confirm success and failure summaries remain accurate after completion.\n150: - Build:\n151: - Run `cd vite-frontend && npm run build`.\n152: \n153: ## Rollout Notes\n154: \n155: - Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain.\n156: - Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly.\n157: - If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly.\n158: \n159: ## Test Record\n160: \n161: - Command: `cd vite-frontend && npm install`\n162: - Result: passed.\n163: - Command: `cd vite-frontend && npm run build`\n164: - Result: passed.\n\n(End of file - total 164 lines)\n</content>",
"title": "plans/020-ajax-no-refresh-ux.md",
"metadata": {
"preview": "# 020 AJAX No-refresh UX\n\n## Objective\n\n- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite.\n- Keep the existing `axios + local React state + custom hooks` architecture, and extend it with polling, realtime hardening, and local state patching where it improves responsiveness.\n- Deliver the work in phases so the highest-value improvements ship first: dashboard auto-refresh and node realtime resilience, then local list updates after mutations, then batch progress and search/filter polish.\n\n## Non-goals\n\n- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue.\n- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads.\n- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface.\n- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`.\n\n## Current State\n\n- `vite-frontend/src/pages/node/use-node-realtime.ts` and `vite-frontend/src/pages/node.tsx` already provide websocket-driven node status, system info, and upgrade progress updates.\n- `vite-frontend/src/pages/forward.tsx`, `vite-frontend/src/pages/tunnel.tsx`, `vite-frontend/src/pages/user.tsx`, and `vite-frontend/src/pages/node.tsx` already submit forms asynchronously, so the main remaining gap is consistency of post-submit local refresh behavior.\n- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` currently fetches dashboard data only once on mount, so traffic charts and counters do not auto-refresh.",
"truncated": false,
"loaded": []
},
"time": {
"start": 1772968484847,
"end": 1772968488909
}
},
"id": "prt_ccd281fed001Hi9YHZpszlXyrz",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2800b4001GZo6nGzI8cWTxM"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 35592,
"input": 1511,
"output": 161,
"reasoning": 0,
"cache": {
"read": 33920,
"write": 0
}
},
"id": "prt_ccd282fcd0018gS25T6xdakORS",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2800b4001GZo6nGzI8cWTxM"
},
{
"type": "patch",
"hash": "9200c494ea80f614ef327082a5c0a016e076fb78",
"files": [
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/context.md",
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/full.jsonl",
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/prompt.txt",
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/summary.txt"
],
"id": "prt_ccd28300a0013k56petnvRIc4v",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2800b4001GZo6nGzI8cWTxM"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968488973,
"completed": 1772968496853
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 38589,
"input": 2992,
"output": 205,
"reasoning": 0,
"cache": {
"read": 35392,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd28300d0010QxnLGVnCRTCv9",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd283a5b001d524wQ1w2yEGDL",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28300d0010QxnLGVnCRTCv9"
},
{
"type": "reasoning",
"text": "好的,现在我明白了这次变更的内容。这是一个大型的前端 UX 改进,主要实现了:\n1. Dashboard 自动刷新(5秒轮询)\n2. 节点实时监控的韧性(WebSocket 重连 + 轮询降级)\n3. 列表变更后的局部刷新(forward、tunnel、user)\n4. 批量操作的进度反馈\n\n现在我需要:\n1. 排除 `.entire/metadata/` 目录(这些是会话文件)\n2. 添加所有变更\n3. 创建提交\n4. 推送分支\n5. 创建 PR\n\n让我创建提交信息:\n- 主题:feat(frontend): implement AJAX no-refresh UX improvements\n- 详细描述:实现 issue #276,包括 dashboard 自动刷新、节点监控韧性、列表局部更新、批量操作进度",
"time": {
"start": 1772968491612,
"end": 1772968496780
},
"id": "prt_ccd283a5c001lmIGYqGOiWwfyc",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28300d0010QxnLGVnCRTCv9"
},
{
"type": "tool",
"callID": "call_935799860acf4f8a954d44a4",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git add vite-frontend/ plans/ && git status",
"description": "Add vite-frontend and plans changes"
},
"output": "位于分支 opencode/quiet-canyon\n要提交的变更:\n (使用 \"git restore --staged <文件>...\" 以取消暂存)\n\t新文件: plans/020-ajax-no-refresh-ux.md\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/utils/list-state.ts\n\t修改: vite-frontend/src/vite-env.d.ts\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\n",
"title": "Add vite-frontend and plans changes",
"metadata": {
"output": "位于分支 opencode/quiet-canyon\n要提交的变更:\n (使用 \"git restore --staged <文件>...\" 以取消暂存)\n\t新文件: plans/020-ajax-no-refresh-ux.md\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/utils/list-state.ts\n\t修改: vite-frontend/src/vite-env.d.ts\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\n",
"exit": 0,
"description": "Add vite-frontend and plans changes",
"truncated": false
},
"time": {
"start": 1772968496734,
"end": 1772968496787
}
},
"id": "prt_ccd284e5b001qrPnhYEB8YmvRs",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28300d0010QxnLGVnCRTCv9"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 38589,
"input": 2992,
"output": 205,
"reasoning": 0,
"cache": {
"read": 35392,
"write": 0
}
},
"id": "prt_ccd284e94001iYzac13oo8Z4G5",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28300d0010QxnLGVnCRTCv9"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968496855,
"completed": 1772968504840
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 39009,
"input": 532,
"output": 141,
"reasoning": 0,
"cache": {
"read": 38336,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd284ed7001jf439QsCqIn4Hm",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd28590d0015YoskPkCpBzk6V",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd284ed7001jf439QsCqIn4Hm"
},
{
"type": "reasoning",
"text": "很好,已经暂存了所有需要的文件,排除了 `.entire/metadata/` 目录。现在创建提交。",
"time": {
"start": 1772968499470,
"end": 1772968502630
},
"id": "prt_ccd28590e001VXY63q1M8wIwFY",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd284ed7001jf439QsCqIn4Hm"
},
{
"type": "tool",
"callID": "call_94cd5a3226ad4cd7be66cd2e",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git commit -m \"feat(frontend): implement AJAX no-refresh UX improvements (#276)\n\n- Add dashboard auto-refresh with 5s polling and visibility-aware pause\n- Harden node realtime reconnection with exponential backoff and polling fallback\n- Implement local state patching for forward/tunnel/user mutations\n- Add batch operation progress feedback UI\n- Add shared list-state helpers for replace/remove operations\n- Preserve derived UI state during server payload merges\n\nCloses #276\"",
"description": "Create commit for UX improvements"
},
"output": "[opencode/quiet-canyon f1bdb2e] feat(frontend): implement AJAX no-refresh UX improvements (#276)\n 10 files changed, 939 insertions(+), 173 deletions(-)\n create mode 100644 plans/020-ajax-no-refresh-ux.md\n create mode 100644 vite-frontend/src/utils/list-state.ts\n",
"title": "Create commit for UX improvements",
"metadata": {
"output": "[opencode/quiet-canyon f1bdb2e] feat(frontend): implement AJAX no-refresh UX improvements (#276)\n 10 files changed, 939 insertions(+), 173 deletions(-)\n create mode 100644 plans/020-ajax-no-refresh-ux.md\n create mode 100644 vite-frontend/src/utils/list-state.ts\n",
"exit": 0,
"description": "Create commit for UX improvements",
"truncated": false
},
"time": {
"start": 1772968502585,
"end": 1772968504794
}
},
"id": "prt_ccd2865320010WY463vyce7RSw",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd284ed7001jf439QsCqIn4Hm"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 39009,
"input": 532,
"output": 141,
"reasoning": 0,
"cache": {
"read": 38336,
"write": 0
}
},
"id": "prt_ccd286dda001jvbn9ifRGELy03",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd284ed7001jf439QsCqIn4Hm"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968504841,
"completed": 1772968518074
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 39137,
"input": 242,
"output": 47,
"reasoning": 0,
"cache": {
"read": 38848,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd286e09001g6PXsTwNmEhfIo",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd2877d6001wOibV0W1VSKV4g",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd286e09001g6PXsTwNmEhfIo"
},
{
"type": "reasoning",
"text": "提交成功!现在需要推送分支并创建 PR。让我先推送分支。",
"time": {
"start": 1772968507351,
"end": 1772968508827
},
"id": "prt_ccd2877d7001JlSN2Oo6NNsBq0",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd286e09001g6PXsTwNmEhfIo"
},
{
"type": "tool",
"callID": "call_9b3df94d33ac4182ab10e26d",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git push -u origin opencode/quiet-canyon",
"description": "Push branch to remote"
},
"output": "[entire] Pushing session logs to origin...\nremote: \nremote: Create a pull request for 'opencode/quiet-canyon' on GitHub by visiting: \nremote: https://github.com/Sagit-chu/flvx/pull/new/opencode/quiet-canyon \nremote: \nTo github.com:Sagit-chu/flvx.git\n * [new branch] opencode/quiet-canyon -> opencode/quiet-canyon\n分支 'opencode/quiet-canyon' 设置为跟踪 'origin/opencode/quiet-canyon'。\n",
"title": "Push branch to remote",
"metadata": {
"output": "[entire] Pushing session logs to origin...\nremote: \nremote: Create a pull request for 'opencode/quiet-canyon' on GitHub by visiting: \nremote: https://github.com/Sagit-chu/flvx/pull/new/opencode/quiet-canyon \nremote: \nTo github.com:Sagit-chu/flvx.git\n * [new branch] opencode/quiet-canyon -> opencode/quiet-canyon\n分支 'opencode/quiet-canyon' 设置为跟踪 'origin/opencode/quiet-canyon'。\n",
"exit": 0,
"description": "Push branch to remote",
"truncated": false
},
"time": {
"start": 1772968508780,
"end": 1772968518010
}
},
"id": "prt_ccd287d6a001KUd0jTRI1n1UVG",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd286e09001g6PXsTwNmEhfIo"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 39137,
"input": 242,
"output": 47,
"reasoning": 0,
"cache": {
"read": 38848,
"write": 0
}
},
"id": "prt_ccd28a17c001t13rW71FDDdyCS",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd286e09001g6PXsTwNmEhfIo"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968518076,
"completed": 1772968522587
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 39347,
"input": 221,
"output": 86,
"reasoning": 0,
"cache": {
"read": 39040,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd28a1bc0014o52T2tgz74xeP",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd28ac19001VUS5tK8E8nwX06",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28a1bc0014o52T2tgz74xeP"
},
{
"type": "reasoning",
"text": "推送成功!现在需要创建 PR。我需要获取从 main 分支到当前分支的所有提交,以便写出准确的 PR 描述。",
"time": {
"start": 1772968520730,
"end": 1772968522525
},
"id": "prt_ccd28ac1a001SmZdphK8iZxt80",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28a1bc0014o52T2tgz74xeP"
},
{
"type": "tool",
"callID": "call_44b2d834016840338fa28658",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git log main..HEAD --oneline",
"description": "Get commits between main and current branch"
},
"output": "f1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n",
"title": "Get commits between main and current branch",
"metadata": {
"output": "f1bdb2e feat(frontend): implement AJAX no-refresh UX improvements (#276)\n",
"exit": 0,
"description": "Get commits between main and current branch",
"truncated": false
},
"time": {
"start": 1772968521967,
"end": 1772968521998
}
},
"id": "prt_ccd28b0e9001IOKQfp2ltTar7F",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28a1bc0014o52T2tgz74xeP"
},
{
"type": "tool",
"callID": "call_1738fa0a72b74987829a8d03",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git diff main...HEAD --stat",
"description": "Get diff stats between main and current branch"
},
"output": " plans/020-ajax-no-refresh-ux.md | 164 ++++++++++++++\n .../src/pages/dashboard/use-dashboard-data.ts | 172 +++++++++++----\n vite-frontend/src/pages/forward.tsx | 237 +++++++++++++++++----\n vite-frontend/src/pages/forward/batch-actions.ts | 10 +\n vite-frontend/src/pages/node.tsx | 70 +++++-\n vite-frontend/src/pages/node/use-node-realtime.ts | 27 ++-\n vite-frontend/src/pages/tunnel.tsx | 196 +++++++++++++----\n vite-frontend/src/pages/user.tsx | 202 +++++++++++++++---\n vite-frontend/src/utils/list-state.ts | 33 +++\n vite-frontend/src/vite-env.d.ts | 1 +\n 10 files changed, 939 insertions(+), 173 deletions(-)\n",
"title": "Get diff stats between main and current branch",
"metadata": {
"output": " plans/020-ajax-no-refresh-ux.md | 164 ++++++++++++++\n .../src/pages/dashboard/use-dashboard-data.ts | 172 +++++++++++----\n vite-frontend/src/pages/forward.tsx | 237 +++++++++++++++++----\n vite-frontend/src/pages/forward/batch-actions.ts | 10 +\n vite-frontend/src/pages/node.tsx | 70 +++++-\n vite-frontend/src/pages/node/use-node-realtime.ts | 27 ++-\n vite-frontend/src/pages/tunnel.tsx | 196 +++++++++++++----\n vite-frontend/src/pages/user.tsx | 202 +++++++++++++++---\n vite-frontend/src/utils/list-state.ts | 33 +++\n vite-frontend/src/vite-env.d.ts | 1 +\n 10 files changed, 939 insertions(+), 173 deletions(-)\n",
"exit": 0,
"description": "Get diff stats between main and current branch",
"truncated": false
},
"time": {
"start": 1772968522480,
"end": 1772968522515
}
},
"id": "prt_ccd28b2ec0019xQctNoGlB2Z1H",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28a1bc0014o52T2tgz74xeP"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 39347,
"input": 221,
"output": 86,
"reasoning": 0,
"cache": {
"read": 39040,
"write": 0
}
},
"id": "prt_ccd28b31e001UpTOAhR3qt29JZ",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28a1bc0014o52T2tgz74xeP"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968522588,
"completed": 1772968539711
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 40016,
"input": 325,
"output": 459,
"reasoning": 0,
"cache": {
"read": 39232,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd28b35c001DapbBKeS1L1NLy",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd28bd37001XVRgwGu6GoOmKg",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28b35c001DapbBKeS1L1NLy"
},
{
"type": "reasoning",
"text": "很好,当前分支比 main 多了 1 个提交。现在创建 PR,描述这次变更的内容。",
"time": {
"start": 1772968525112,
"end": 1772968536829
},
"id": "prt_ccd28bd38001PD1wztTBnxw3ES",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28b35c001DapbBKeS1L1NLy"
},
{
"type": "tool",
"callID": "call_4edb86bde25c459aab4ca7ca",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "gh pr create --title \"feat(frontend): implement AJAX no-refresh UX improvements\" --body \"$(cat <<'EOF'\n## Summary\n\n- 实现了 Dashboard 自动刷新(5 秒轮询)和可见性感知的暂停机制\n- 增强了节点实时监控的韧性:指数退避重连 + 轮询降级兜底\n- 为 forward/tunnel/user 页面实现了局部状态更新,避免整页刷新\n- 添加了批量操作进度反馈 UI\n- 新增了共享的 list-state 辅助函数用于 replace/remove 操作\n- 在服务端负载合并时保留了派生的 UI 状态\n\n## Changes\n\n### Phase 1 - Dashboard auto-refresh and node realtime resilience\n- Dashboard 数据每 5 秒自动刷新,页面隐藏时暂停,显示时立即刷新\n- 通知去重逻辑优化,避免每次轮询都触发到期提醒\n- WebSocket 重连采用指数退避,5 次失败后切换为 15 秒轮询降级模式\n\n### Phase 2 - Local mutation updates\n- Forward、Tunnel、User 页面的创建/编辑/删除操作现在直接更新本地状态\n- 保留分组、排序、选中状态等派生 UI 状态\n- 新增 `list-state.ts` 工具函数用于通用的列表更新操作\n\n### Phase 3 - Batch progress UX\n- 批量操作(删除、启用/停用、重新下发、换隧道)现在显示进度条和状态反馈\n- 成功/失败计数实时更新\n\n## Test Plan\n\n- [x] Dashboard 自动刷新正常工作,隐藏/显示切换正常\n- [x] 节点监控 WebSocket 断开后能自动降级为轮询模式\n- [x] Forward/Tunnel/User 的 CRUD 操作后列表立即更新\n- [x] 批量操作显示进度反馈\n- [x] `npm run build` 成功\n\nCloses #276\nEOF\n)\"",
"description": "Create pull request"
},
"output": "Warning: 3 uncommitted changes\nhttps://github.com/Sagit-chu/flvx/pull/293\n",
"title": "Create pull request",
"metadata": {
"output": "Warning: 3 uncommitted changes\nhttps://github.com/Sagit-chu/flvx/pull/293\n",
"exit": 0,
"description": "Create pull request",
"truncated": false
},
"time": {
"start": 1772968536763,
"end": 1772968539628
}
},
"id": "prt_ccd28eab1001wwrO1mx4xEUgpr",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28b35c001DapbBKeS1L1NLy"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 40016,
"input": 325,
"output": 459,
"reasoning": 0,
"cache": {
"read": 39232,
"write": 0
}
},
"id": "prt_ccd28f5ee0012NK7PzfmWtKO3V",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28b35c001DapbBKeS1L1NLy"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968539713,
"completed": 1772968544216
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 40103,
"input": 492,
"output": 59,
"reasoning": 0,
"cache": {
"read": 39552,
"write": 0
}
},
"finish": "tool-calls",
"id": "msg_ccd28f641001DtvLdwKaYE74Jn",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd2900a2001GzeoUouqEMywSP",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28f641001DtvLdwKaYE74Jn"
},
{
"type": "reasoning",
"text": "完美!PR 已经创建成功,链接是 https://github.com/Sagit-chu/flvx/pull/293\n\n现在让我检查一下 git 状态,确认一切都已经提交。",
"time": {
"start": 1772968542371,
"end": 1772968544156
},
"id": "prt_ccd2900a3001s4b12jM9pji5wu",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28f641001DtvLdwKaYE74Jn"
},
{
"type": "tool",
"callID": "call_b4fd99016fa246ce950eeddf",
"tool": "bash",
"state": {
"status": "completed",
"input": {
"command": "git status",
"description": "Verify final git status"
},
"output": "位于分支 opencode/quiet-canyon\n您的分支与上游分支 'origin/opencode/quiet-canyon' 一致。\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\n提交为空,但是存在尚未跟踪的文件(使用 \"git add\" 建立跟踪)\n",
"title": "Verify final git status",
"metadata": {
"output": "位于分支 opencode/quiet-canyon\n您的分支与上游分支 'origin/opencode/quiet-canyon' 一致。\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.entire/metadata/ses_332d83674ffeNMa9Z9UerwU3tq/\n\t.entire/metadata/ses_332fc6806ffeDArHciRt5Ctxja/\n\t.entire/metadata/ses_332fdb603ffeYxhpzgAB64sSTN/\n\n提交为空,但是存在尚未跟踪的文件(使用 \"git add\" 建立跟踪)\n",
"exit": 0,
"description": "Verify final git status",
"truncated": false
},
"time": {
"start": 1772968544113,
"end": 1772968544160
}
},
"id": "prt_ccd29076d001kDEJe9BifbQuYD",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28f641001DtvLdwKaYE74Jn"
},
{
"type": "step-finish",
"reason": "tool-calls",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 40103,
"input": 492,
"output": 59,
"reasoning": 0,
"cache": {
"read": 39552,
"write": 0
}
},
"id": "prt_ccd2907a1001Mw96IKR0fxNSkX",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd28f641001DtvLdwKaYE74Jn"
}
]
},
{
"info": {
"role": "assistant",
"time": {
"created": 1772968544218,
"completed": 1772968556085
},
"parentID": "msg_ccd27c984001g61SN2caJWOeYG",
"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": 40441,
"input": 254,
"output": 187,
"reasoning": 0,
"cache": {
"read": 40000,
"write": 0
}
},
"finish": "stop",
"id": "msg_ccd2907da001wYpk9AAHb1OpT1",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm"
},
"parts": [
{
"type": "step-start",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"id": "prt_ccd29253a001tJ3xBSG48b4WPv",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2907da001wYpk9AAHb1OpT1"
},
{
"type": "reasoning",
"text": "完成!所有代码变更已经提交并推送到远程仓库,PR 已经创建成功。\n\n未跟踪的 `.entire/metadata/` 目录是会话文件,不需要提交。\n\n总结一下完成的任务:\n1. ✅ 提交了所有变更(10 个文件,939 行新增,173 行删除)\n2. ✅ 推送到远程仓库\n3. ✅ 创建了 PR #293\n\nPR 链接:https://github.com/Sagit-chu/flvx/pull/293",
"time": {
"start": 1772968551739,
"end": 1772968555998
},
"id": "prt_ccd29253b001ohDBOZ446P6oD9",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2907da001wYpk9AAHb1OpT1"
},
{
"type": "text",
"text": "已完成!\n\n**提交摘要:**\n- 10 个文件变更(939 行新增,173 行删除)\n- 新增计划文档 `plans/020-ajax-no-refresh-ux.md`\n- 新增工具函数 `vite-frontend/src/utils/list-state.ts`\n\n**PR 已创建:** https://github.com/Sagit-chu/flvx/pull/293",
"time": {
"start": 1772968556000,
"end": 1772968556000
},
"id": "prt_ccd292ed7001Pv00SIhcQyB7VS",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2907da001wYpk9AAHb1OpT1"
},
{
"type": "step-finish",
"reason": "stop",
"snapshot": "59550c9a1b000573148ca7bd3796c8bc5d94bea1",
"cost": 0,
"tokens": {
"total": 40441,
"input": 254,
"output": 187,
"reasoning": 0,
"cache": {
"read": 40000,
"write": 0
}
},
"id": "prt_ccd2935e1001fFk2vU15gcITiK",
"sessionID": "ses_332d83689ffeJ93gRXNEEeOpSm",
"messageID": "msg_ccd2907da001wYpk9AAHb1OpT1"
}
]
}
]
}