\n4436: \n4437: {isEdit ? \"编辑规则\" : \"新增规则\"}\n4438:
\n4439: \n\n(Showing lines 4370-4439 of 5836. Use offset=4440 to continue.)\n",
+ "title": "vite-frontend/src/pages/forward.tsx",
+ "metadata": {
+ "preview": " wrapperClassName=\"rounded-xl border border-secondary/20 bg-secondary/5 space-y-3\"\n onToggleCollapsed={() =>\n toggleTunnelGroupCollapsed(\n group.userId,\n tunnel.tunnelKey,\n )\n }\n >\n {}}\n >\n \n \n {tunnel.items.map((forward) =>",
+ "truncated": true,
+ "loaded": []
+ },
+ "time": {
+ "start": 1772970564213,
+ "end": 1772970564225
+ }
+ },
+ "id": "prt_ccd47da74001YTyA2U5uHblaLG",
+ "sessionID": "ses_332b8eae1ffepYTzeoNOc1AdfD",
+ "messageID": "msg_ccd47ba210023foVnvAbcCnk1s"
+ },
+ {
+ "type": "step-finish",
+ "reason": "tool-calls",
+ "snapshot": "d4b9834ff9066e6ac6d89b6fa7996f15db6d6350",
+ "cost": 0,
+ "tokens": {
+ "total": 37362,
+ "input": 5778,
+ "output": 352,
+ "reasoning": 53,
+ "cache": {
+ "read": 31232,
+ "write": 0
+ }
+ },
+ "id": "prt_ccd47da820016GQrAAjJjJgMm4",
+ "sessionID": "ses_332b8eae1ffepYTzeoNOc1AdfD",
+ "messageID": "msg_ccd47ba210023foVnvAbcCnk1s"
+ }
+ ]
+ },
+ {
+ "info": {
+ "role": "assistant",
+ "time": {
+ "created": 1772970564284,
+ "completed": 1772970570981
+ },
+ "parentID": "msg_ccd47152b001bN67hpg5X6pIZb",
+ "modelID": "gpt-5.4",
+ "providerID": "api",
+ "mode": "plan",
+ "agent": "plan",
+ "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": 40747,
+ "input": 4140,
+ "output": 255,
+ "reasoning": 27,
+ "cache": {
+ "read": 36352,
+ "write": 0
+ }
+ },
+ "finish": "tool-calls",
+ "id": "msg_ccd47dabc002pDqg9YMn5LVzAx",
+ "sessionID": "ses_332b8eae1ffepYTzeoNOc1AdfD"
+ },
+ "parts": [
+ {
+ "type": "step-start",
+ "snapshot": "d4b9834ff9066e6ac6d89b6fa7996f15db6d6350",
+ "id": "prt_ccd47eaa4001WVHWvyUShRcvj7",
+ "sessionID": "ses_332b8eae1ffepYTzeoNOc1AdfD",
+ "messageID": "msg_ccd47dabc002pDqg9YMn5LVzAx"
+ },
+ {
+ "type": "reasoning",
+ "text": "**Inspecting container header**\n\nI’m thinking that I need to inspect the grouped container header to see if the tunnel name is displayed elsewhere. I want to search for \"tunnelName\" specifically in the grouped section, around the 2890 mark. It’ll be crucial to locate this information accurately. I’ll make sure to go through the necessary details to find any references and make sense of the structure. Let’s see what I can find!",
+ "time": {
+ "start": 1772970568356,
+ "end": 1772970570903
+ },
+ "id": "prt_ccd47eaa40024cxDAi4hUQJf5Y",
+ "sessionID": "ses_332b8eae1ffepYTzeoNOc1AdfD",
+ "messageID": "msg_ccd47dabc002pDqg9YMn5LVzAx"
+ },
+ {
+ "type": "tool",
+ "callID": "call_mvYe2RHW0IWnNKypsD6XgF60",
+ "tool": "read",
+ "state": {
+ "status": "completed",
+ "input": {
+ "filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx",
+ "offset": 2890,
+ "limit": 50
+ },
+ "output": "
/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/quiet-canyon/vite-frontend/src/pages/forward.tsx\n
file\n
2890: willChange: isDragging ? \"transform\" : undefined,\n2891: zIndex: isDragging ? 1 : undefined,\n2892: };\n2893: \n2894: return (\n2895: \n2896:
\n2897:
\n2898:
\n2919:
{tunnel.tunnelName}\n2920:
\n2921:
\n2922:
{tunnel.items.length} 条规则\n2923:
\n2929:
\n2937:
\n2938:
\n2939:
\n\n(Showing lines 2890-2939 of 5836. Use offset=2940 to continue.)\n",
+ "title": "vite-frontend/src/pages/forward.tsx",
+ "metadata": {
+ "preview": " willChange: isDragging ? \"transform\" : undefined,\n zIndex: isDragging ? 1 : undefined,\n };\n\n return (\n
\n
\n
\n
\\n
\\n \\n+ {batchProgress.active && (\\n+
\\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
(null);\\n const reconnectTimerRef = useRef | 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([]);\\n@@ -203,6 +227,11 @@ export default function TunnelPage() {\\n const [selectedIds, setSelectedIds] = useState>(new Set());\\n const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);\\n const [batchLoading, setBatchLoading] = useState(false);\\n+ const [batchProgress, setBatchProgress] = useState({\\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 \\n
\\n \\n+ {batchProgress.active && (\\n+ \\n+ )}\\n+\\n {/* 隧道卡片网格 */}\\n {tunnels.length > 0 ? (\\n \\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 => {\\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 => {\\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([]);\\n@@ -136,6 +177,7 @@ export default function UserPage() {\\n size: 10,\\n total: 0,\\n });\\n+ const searchDebounceRef = useRef | 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 /// \\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(\\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;\\n type ForwardGroupCollapsedMap = Record;\\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([]);\\n@@ -603,6 +634,11 @@ export default function ForwardPage() {\\n null,\\n );\\n const [batchLoading, setBatchLoading] = useState(false);\\n+ const [batchProgress, setBatchProgress] = useState({\\n+ active: false,\\n+ label: \\\"\\\",\\n+ percent: 0,\\n+ });\\n const [groupOrderMap, setGroupOrderMap] = useState({});\\n const [collapsedTunnelGroups, setCollapsedTunnelGroups] =\\n useState({});\\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 => {\\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 \\n