From f1bdb2e2efe311bb89abfaece9472707f0d749c2 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Sun, 8 Mar 2026 19:15:02 +0800 Subject: [PATCH] feat(frontend): implement AJAX no-refresh UX improvements (#276) - Add dashboard auto-refresh with 5s polling and visibility-aware pause - Harden node realtime reconnection with exponential backoff and polling fallback - Implement local state patching for forward/tunnel/user mutations - Add batch operation progress feedback UI - Add shared list-state helpers for replace/remove operations - Preserve derived UI state during server payload merges Closes #276 Entire-Checkpoint: 7d6188355d7f --- plans/020-ajax-no-refresh-ux.md | 164 ++++++++++++ .../src/pages/dashboard/use-dashboard-data.ts | 174 +++++++++---- vite-frontend/src/pages/forward.tsx | 237 ++++++++++++++---- .../src/pages/forward/batch-actions.ts | 10 + vite-frontend/src/pages/node.tsx | 70 +++++- .../src/pages/node/use-node-realtime.ts | 27 +- vite-frontend/src/pages/tunnel.tsx | 198 +++++++++++---- vite-frontend/src/pages/user.tsx | 202 ++++++++++++--- vite-frontend/src/utils/list-state.ts | 33 +++ vite-frontend/src/vite-env.d.ts | 1 + 10 files changed, 941 insertions(+), 175 deletions(-) create mode 100644 plans/020-ajax-no-refresh-ux.md create mode 100644 vite-frontend/src/utils/list-state.ts diff --git a/plans/020-ajax-no-refresh-ux.md b/plans/020-ajax-no-refresh-ux.md new file mode 100644 index 0000000..74a3dca --- /dev/null +++ b/plans/020-ajax-no-refresh-ux.md @@ -0,0 +1,164 @@ +# 020 AJAX No-refresh UX + +## Objective + +- Implement issue `#276` as a focused frontend UX improvement initiative, not a full data-layer rewrite. +- 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. +- 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. + +## Non-goals + +- Do not introduce `@tanstack/react-query`, SWR, or other new frontend data libraries for this issue. +- Do not rewrite page architecture, routing, or modal flows that already submit asynchronously without browser reloads. +- Do not require backend changes unless a batch-progress requirement cannot be met with the current API surface. +- Do not change the raw JWT auth convention used by `vite-frontend/src/api/network.ts`. + +## Current State + +- `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. +- `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. +- `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. +- Several mutation handlers still rely on page-level reload functions such as `loadData()`, `loadUsers()`, or `loadNodes()` instead of patching only the changed records. +- Batch progress UI exists for node upgrade but not for other batch actions such as forward and tunnel operations. + +## Design Principles + +- Prefer local state patching after successful mutations when the changed record set is known. +- Prefer targeted refetches over full-page refetches when the server is the source of truth for a small dependent dataset. +- Use polling only where realtime transport does not already exist. +- Pause or reduce background refresh work when the page is hidden to avoid unnecessary traffic. +- Keep UI feedback explicit: loading states, toast feedback, and visible progress for long-running batch actions. + +## Checklist + +- [x] Refactor dashboard data loading into reusable refresh callbacks in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`. +- [x] Add dashboard traffic polling with visibility-aware pause/resume and safe notification deduplication. +- [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. +- [x] Add shared local-list patch helpers for replace/remove/upsert patterns used by page-level mutation handlers. +- [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. +- [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. +- [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. +- [x] Extend batch action UX to show visible progress or staged feedback for forward and tunnel batch operations. +- [x] Normalize search/filter behavior and document where client-side instant filtering is appropriate versus where server-side pagination must remain authoritative. +- [ ] Run focused frontend verification and record the result in this plan after implementation. + +## Implementation Plan + +### Phase 1 - Dashboard auto-refresh and node realtime resilience + +#### 1. Dashboard traffic/statistics auto-refresh + +- 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. +- Add a 5-second polling loop for package, flow, and chart data returned by `getUserPackageInfo()`. +- Keep announcement loading low-frequency or first-load only unless the API contract clearly expects live updates. +- Pause polling when `document.visibilityState !== "visible"`, then trigger an immediate refresh when the tab becomes visible again. +- Preserve current loading UX for first load, but use a silent refresh path for polling so the page does not flicker. + +#### 2. Dashboard notification safety + +- Audit `checkExpirationNotifications()` in `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` so polling does not repeatedly emit expiration warnings. +- Continue using notification deduplication, but base it on stable expiration identifiers rather than every poll cycle. +- Ensure refreshes that only change traffic counters do not retrigger expiry toasts. + +#### 3. Node realtime hardening + +- Review `vite-frontend/src/pages/node/use-node-realtime.ts` reconnect logic, which currently stops after a fixed retry budget. +- Replace the hard stop with controlled backoff reconnect behavior, or explicitly trigger a degraded polling fallback once retry exhaustion is reached. +- 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. +- Keep the existing offline debounce behavior in `vite-frontend/src/pages/node/use-node-offline-timers.ts`. + +### Phase 2 - Local mutation updates and partial refreshes + +#### 4. Shared list-patching helpers + +- Add small reusable helpers for common state operations such as: + - replace one item by `id` + - remove one or many items by `id` + - upsert a created or updated item into an ordered list + - preserve derived UI-only fields during server payload merges +- Keep these helpers local to the frontend codebase and avoid introducing a generic state-management abstraction. + +#### 5. Forward page partial refresh conversion + +- Target `vite-frontend/src/pages/forward.tsx` mutation handlers first because the page already contains some optimistic/local patterns. +- Preserve the current local behavior for service toggles, but review rollback handling so final UI state matches backend truth after success or failure. +- Change create/edit/delete flows to patch `forwards` state directly when the response payload is sufficient. +- Use targeted refetches only when an operation changes dependent datasets that are not reliably derivable from the local page state. +- Re-check grouped ordering, collapsed-state persistence, and selected-row state after local mutations. + +#### 6. Tunnel page partial refresh conversion + +- Update `vite-frontend/src/pages/tunnel.tsx` so create/edit/delete mutate `tunnels` state directly instead of always calling `loadData()`. +- Keep node reference data refresh separate from tunnel list refresh so a tunnel mutation does not force a full page data reload. +- Preserve existing drag-sort behavior and ensure local patching keeps `inx` and stored order consistent. + +#### 7. User page partial refresh conversion + +- Update `vite-frontend/src/pages/user.tsx` so create/edit/delete patch the `users` list when the current page can be updated safely. +- Update user-tunnel permission flows to patch `userTunnels` directly after assign, edit, remove, and flow-reset operations. +- 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. +- Keep current modal and toast behavior unchanged unless the local update path exposes stale-state issues. + +### Phase 3 - Batch progress UX and search/filter polish + +#### 8. Batch progress UX + +- Use the node upgrade progress model in `vite-frontend/src/pages/node.tsx` as the UI reference for long-running operations. +- 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. +- 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. +- 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. + +#### 9. Search and filter responsiveness + +- Preserve instant client-side filtering on pages that already hold the authoritative dataset locally, including node, tunnel, and forward pages. +- Audit the user page separately because it depends on server-side pagination and keyword search. +- If user-page instant filtering is desired, choose one of two explicit strategies: + - keep server-side pagination authoritative and add debounce for keyword-triggered requests, or + - load a larger local dataset only if product requirements accept the cost. +- Do not silently mix partial client filtering with incomplete paginated datasets. + +## Risks and Mitigations + +- Repeated dashboard polling may spam expiry toasts. + - Mitigation: deduplicate notifications based on expiration identity and only emit on meaningful state changes. +- Node recovery refreshes may wipe websocket-derived metrics. + - Mitigation: merge fetched node metadata into existing live state instead of replacing the whole record blindly. +- Local mutation patching may desynchronize grouped, sorted, or selected views. + - Mitigation: patch canonical source arrays first, then recompute derived memoized groupings from state. +- Batch APIs may not expose progress details. + - Mitigation: implement client-side staged progress where possible and document backend gaps where not. +- User-page local updates may conflict with pagination semantics. + - Mitigation: prefer targeted page refetch over unsafe optimistic filtering or cross-page list mutation. + +## Verification Plan + +- Dashboard: + - Open `dashboard` and confirm traffic counters and chart data refresh at least once every 5 seconds without manual reload. + - Confirm hidden-tab pause and visible-tab immediate refresh behavior. + - Confirm expiry toasts do not repeat on every polling cycle. +- Nodes: + - Confirm websocket-driven online/offline transitions still work. + - Simulate websocket interruption and verify reconnect or fallback refresh behavior. + - Confirm recovery does not clear existing live metrics unexpectedly. +- Forwards, tunnels, users: + - Create, edit, delete, enable, disable, and reset flows without browser reload. + - Confirm the affected rows update immediately and other unrelated rows stay stable. + - Confirm selection state, ordering, and modal close behavior remain correct after local patching. +- Batch actions: + - Confirm visible progress or staged status feedback exists during long-running operations. + - Confirm success and failure summaries remain accurate after completion. +- Build: + - Run `cd vite-frontend && npm run build`. + +## Rollout Notes + +- Ship Phase 1 first because it matches the issue approval priority and provides the clearest user-visible gain. +- Keep each phase in reviewable commits so regressions in local list patching can be isolated quickly. +- If backend support becomes necessary for real batch progress, land the frontend scaffolding separately and track the backend dependency explicitly. + +## Test Record + +- Command: `cd vite-frontend && npm install` +- Result: passed. +- Command: `cd vite-frontend && npm run build` +- Result: passed. diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index f14d231..052f7fe 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -1,6 +1,6 @@ import type { ForwardApiItem } from "@/api/types"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { @@ -52,6 +52,23 @@ export interface DashboardStatisticsFlow { time: string; } +const DASHBOARD_POLL_INTERVAL_MS = 5000; +const EXPIRATION_NOTIFICATION_STORAGE_KEY = + "dashboard:last-expiration-notification"; + +const buildExpirationNotificationKey = ( + userInfo: DashboardUserInfo, + tunnels: DashboardUserTunnel[], +) => { + const userExpTime = userInfo.expTime ?? "permanent"; + const tunnelExpirationKey = [...tunnels] + .map((tunnel) => `${tunnel.tunnelId}:${tunnel.expTime ?? "permanent"}`) + .sort() + .join("|"); + + return `user:${userExpTime};tunnels:${tunnelExpirationKey}`; +}; + interface DashboardDataState { loading: boolean; userInfo: DashboardUserInfo; @@ -66,8 +83,10 @@ const checkExpirationNotifications = ( userInfo: DashboardUserInfo, tunnels: DashboardUserTunnel[], ) => { - const notificationKey = `expiration-${userInfo.expTime}-${tunnels.map((t) => t.expTime).join(",")}`; - const lastNotified = localStorage.getItem("lastNotified"); + const notificationKey = buildExpirationNotificationKey(userInfo, tunnels); + const lastNotified = localStorage.getItem( + EXPIRATION_NOTIFICATION_STORAGE_KEY, + ); if (lastNotified === notificationKey) { return; @@ -148,7 +167,7 @@ const checkExpirationNotifications = ( }); if (hasNotification) { - localStorage.setItem("lastNotified", notificationKey); + localStorage.setItem(EXPIRATION_NOTIFICATION_STORAGE_KEY, notificationKey); } }; @@ -188,60 +207,129 @@ export const useDashboardData = (): DashboardDataState => { const [announcement, setAnnouncement] = useState( null, ); + const isMountedRef = useRef(true); + const packageRequestInFlightRef = useRef(false); - useEffect(() => { - const loadAnnouncement = async () => { - try { - const res = await getAnnouncement(); + const applyPackageData = useCallback((data: { + userInfo?: DashboardUserInfo; + tunnelPermissions?: DashboardUserTunnel[]; + forwards?: ForwardApiItem[]; + statisticsFlows?: DashboardStatisticsFlow[]; + }) => { + const normalizedTunnelPermissions = normalizeTunnelPermissions( + data.tunnelPermissions || [], + ); + const normalizedForwards = normalizeForwards(data.forwards || []); - if (res.code === 0 && res.data && res.data.enabled === 1) { - setAnnouncement(res.data); - } - } catch {} - }; + if (!isMountedRef.current) { + return; + } + + setUserInfo(data.userInfo || ({} as DashboardUserInfo)); + setUserTunnels(normalizedTunnelPermissions); + setForwardList(normalizedForwards); + setStatisticsFlows(data.statisticsFlows || []); + + checkExpirationNotifications( + data.userInfo || ({} as DashboardUserInfo), + normalizedTunnelPermissions, + ); + }, []); + + const loadPackageData = useCallback( + async ({ silent = false, notifyOnError = false } = {}) => { + if (packageRequestInFlightRef.current) { + return; + } + + packageRequestInFlightRef.current = true; + + if (!silent && isMountedRef.current) { + setLoading(true); + } - const loadPackageData = async () => { - setLoading(true); try { const res = await getUserPackageInfo(); if (res.code === 0) { - const data = res.data; - const normalizedTunnelPermissions = normalizeTunnelPermissions( - data.tunnelPermissions || [], - ); - const normalizedForwards = normalizeForwards(data.forwards || []); - - setUserInfo(data.userInfo || ({} as DashboardUserInfo)); - setUserTunnels(normalizedTunnelPermissions); - setForwardList(normalizedForwards); - setStatisticsFlows(data.statisticsFlows || []); - - checkExpirationNotifications( - data.userInfo, - normalizedTunnelPermissions, - ); - } else { + applyPackageData(res.data || {}); + } else if (notifyOnError) { toast.error(res.msg || "获取套餐信息失败"); } } catch { - toast.error("获取套餐信息失败"); + if (notifyOnError) { + toast.error("获取套餐信息失败"); + } } finally { - setLoading(false); + packageRequestInFlightRef.current = false; + + if (!silent && isMountedRef.current) { + setLoading(false); + } + } + }, + [applyPackageData], + ); + + const loadAnnouncement = useCallback(async () => { + try { + const res = await getAnnouncement(); + + if (!isMountedRef.current) { + return; + } + + if (res.code === 0 && res.data && res.data.enabled === 1) { + setAnnouncement(res.data); + } else { + setAnnouncement(null); + } + } catch { + if (isMountedRef.current) { + setAnnouncement(null); + } + } + }, []); + + useEffect(() => { + isMountedRef.current = true; + setIsAdmin(getAdminFlag()); + + void loadPackageData({ notifyOnError: true }); + void loadAnnouncement(); + localStorage.setItem("e", "/dashboard"); + + return () => { + isMountedRef.current = false; + }; + }, [loadAnnouncement, loadPackageData]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void loadPackageData({ silent: true }); } }; - setLoading(true); - setUserInfo({} as DashboardUserInfo); - setUserTunnels([]); - setForwardList([]); - setStatisticsFlows([]); - setIsAdmin(getAdminFlag()); + const interval = window.setInterval(() => { + if (document.visibilityState !== "visible") { + return; + } - loadPackageData(); - loadAnnouncement(); - localStorage.setItem("e", "/dashboard"); - }, []); + void loadPackageData({ silent: true }); + }, DASHBOARD_POLL_INTERVAL_MS); + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [loadPackageData]); return { loading, diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index f2d3c3f..eb99d8c 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1,6 +1,6 @@ import type { SpeedLimitApiItem } from "@/api/types"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { DndContext, @@ -48,6 +48,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Switch } from "@/shadcn-bridge/heroui/switch"; import { Alert } from "@/shadcn-bridge/heroui/alert"; +import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { createForward, @@ -166,6 +167,12 @@ interface ForwardTunnelGroup { items: Forward[]; } +interface BatchProgressState { + active: boolean; + label: string; + percent: number; +} + type ForwardGroupOrderMap = Record; type ForwardGroupCollapsedMap = Record; @@ -490,6 +497,30 @@ const isSameGroupCollapsedMap = ( return true; }; +const normalizeForwardItems = (items: Forward[]): Forward[] => { + return items.map((forward) => ({ + ...forward, + serviceRunning: forward.status === 1, + })); +}; + +const mapForwardApiItems = (items: any[]): Forward[] => { + return (items || []).map((forward) => ({ + ...forward, + tunnelId: forward.tunnelId ?? 0, + tunnelName: forward.tunnelName || "", + inIp: forward.inIp || "", + inPort: forward.inPort ?? 0, + remoteAddr: forward.remoteAddr || "", + strategy: forward.strategy || "fifo", + status: typeof forward.status === "number" ? forward.status : 0, + inFlow: forward.inFlow ?? 0, + outFlow: forward.outFlow ?? 0, + createdTime: forward.createdTime || "", + serviceRunning: forward.status === 1, + })); +}; + export default function ForwardPage() { const [loading, setLoading] = useState(true); const [forwards, setForwards] = useState([]); @@ -603,6 +634,11 @@ export default function ForwardPage() { null, ); const [batchLoading, setBatchLoading] = useState(false); + const [batchProgress, setBatchProgress] = useState({ + active: false, + label: "", + percent: 0, + }); const [groupOrderMap, setGroupOrderMap] = useState({}); const [collapsedTunnelGroups, setCollapsedTunnelGroups] = useState({}); @@ -959,7 +995,7 @@ export default function ForwardPage() { return Number.isFinite(shareId) && shareId > 0 ? shareId : null; }; - const mergeFederationShareFlow = async ( + const mergeFederationShareFlow = useCallback(async ( forwardsData: Forward[], ): Promise => { if (forwardsData.length === 0) { @@ -1135,7 +1171,7 @@ export default function ForwardPage() { } catch { return forwardsData; } - }; + }, []); const getForwardDisplayFlow = (forward: Forward): number => { const directFlow = (forward.inFlow || 0) + (forward.outFlow || 0); @@ -1147,10 +1183,6 @@ export default function ForwardPage() { return forward.federationShareFlow || 0; }; - useEffect(() => { - loadData(); - }, []); - // 切换显示模式并保存到localStorage const handleViewModeChange = () => { const newMode = viewMode === "grouped" ? "direct" : "grouped"; @@ -1161,12 +1193,59 @@ export default function ForwardPage() { } catch {} }; + const applyForwardList = useCallback( + async (items: Forward[]) => { + const mergedForwards = await mergeFederationShareFlow( + normalizeForwardItems(items), + ); + + setForwards(mergedForwards); + + const currentUserId = JwtUtil.getUserIdFromToken(); + const { order, fromDatabase } = buildForwardOrder( + mergedForwards, + currentUserId, + ); + + setForwardOrder(order); + + if (fromDatabase) { + saveOrder(FORWARD_ORDER_KEY, order); + } + }, + [mergeFederationShareFlow], + ); + + const refreshForwardList = useCallback( + async (lod = true) => { + if (lod) { + setLoading(true); + } + + try { + const forwardsRes = await getForwardList(); + + if (forwardsRes.code === 0) { + await applyForwardList(mapForwardApiItems(forwardsRes.data || [])); + } else { + toast.error(forwardsRes.msg || "获取规则列表失败"); + } + } catch { + toast.error("获取规则列表失败"); + } finally { + if (lod) { + setLoading(false); + } + } + }, + [applyForwardList], + ); + // 加载所有数据 - const loadData = async (lod = true) => { + const loadData = useCallback(async (lod = true) => { setLoading(lod); try { - const [forwardsRes, tunnelsRes, speedLimitsRes] = await Promise.all([ - getForwardList(), + const [tunnelsRes, speedLimitsRes] = await Promise.all([ userTunnel(), getSpeedLimitList(), ]); @@ -1175,32 +1254,7 @@ export default function ForwardPage() { getNodeList(), ]); - if (forwardsRes.code === 0) { - const forwardsData = - forwardsRes.data?.map((forward: any) => ({ - ...forward, - serviceRunning: forward.status === 1, - })) || []; - - const mergedForwards = await mergeFederationShareFlow(forwardsData); - - setForwards(mergedForwards); - - // 初始化拖拽排序顺序 - const currentUserId = JwtUtil.getUserIdFromToken(); - const { order, fromDatabase } = buildForwardOrder( - mergedForwards, - currentUserId, - ); - - setForwardOrder(order); - - if (fromDatabase) { - saveOrder(FORWARD_ORDER_KEY, order); - } - } else { - toast.error(forwardsRes.msg || "获取规则列表失败"); - } + await refreshForwardList(false); if (tunnelsRes.code === 0) { setTunnels(tunnelsRes.data || []); @@ -1226,7 +1280,11 @@ export default function ForwardPage() { } finally { setLoading(false); } - }; + }, [refreshForwardList]); + + useEffect(() => { + loadData(); + }, [loadData]); // 表单验证 const noLimitSpeedLimitIds = useMemo(() => { @@ -1385,7 +1443,20 @@ export default function ForwardPage() { if (res.code === 0) { toast.success("删除成功"); setDeleteModalOpen(false); - loadData(); + setForwardToDelete(null); + setForwards((prev) => prev.filter((forward) => forward.id !== forwardToDelete.id)); + setForwardOrder((prev) => { + const next = prev.filter((id) => id !== forwardToDelete.id); + + saveOrder(FORWARD_ORDER_KEY, next); + return next; + }); + setSelectedIds((prev) => { + const next = new Set(prev); + + next.delete(forwardToDelete.id); + return next; + }); } else { // 删除失败,询问是否强制删除 const confirmed = window.confirm( @@ -1398,7 +1469,22 @@ export default function ForwardPage() { if (forceRes.code === 0) { toast.success("强制删除成功"); setDeleteModalOpen(false); - loadData(); + setForwardToDelete(null); + setForwards((prev) => + prev.filter((forward) => forward.id !== forwardToDelete.id), + ); + setForwardOrder((prev) => { + const next = prev.filter((id) => id !== forwardToDelete.id); + + saveOrder(FORWARD_ORDER_KEY, next); + return next; + }); + setSelectedIds((prev) => { + const next = new Set(prev); + + next.delete(forwardToDelete.id); + return next; + }); } else { toast.error(forceRes.msg || "强制删除失败"); } @@ -1496,7 +1582,7 @@ export default function ForwardPage() { } toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); - loadData(); + await refreshForwardList(false); } else { toast.error(res.msg || "操作失败"); } @@ -2091,7 +2177,7 @@ export default function ForwardPage() { toast.success("导入执行完成"); - await loadData(false); + await refreshForwardList(false); } catch { toast.error("导入过程中发生错误"); } finally { @@ -2296,6 +2382,11 @@ export default function ForwardPage() { const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在删除 ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchDelete(Array.from(selectedIds)); @@ -2306,14 +2397,20 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeDeleteModal) { setBatchDeleteModalOpen(false); } - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2321,6 +2418,11 @@ export default function ForwardPage() { const handleBatchToggleService = async (enable: boolean) => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在${enable ? "启用" : "停用"} ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchToggleService( Array.from(selectedIds), @@ -2334,11 +2436,17 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2346,6 +2454,11 @@ export default function ForwardPage() { const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在重新下发 ${selectedIds.size} 项规则...`, + percent: 30, + }); try { const outcome = await executeForwardBatchRedeploy( Array.from(selectedIds), @@ -2358,11 +2471,17 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -2370,6 +2489,11 @@ export default function ForwardPage() { const handleBatchChangeTunnel = async () => { if (selectedIds.size === 0 || !batchTargetTunnelId) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在为 ${selectedIds.size} 项规则切换隧道...`, + percent: 30, + }); try { const outcome = await executeForwardBatchChangeTunnel( Array.from(selectedIds), @@ -2383,6 +2507,11 @@ export default function ForwardPage() { } if (outcome.shouldRefresh) { + setBatchProgress({ + active: true, + label: outcome.progressLabel || "正在刷新规则列表...", + percent: outcome.progressPercent ?? 75, + }); setSelectedIds(new Set()); setSelectMode(false); if (outcome.closeChangeTunnelModal) { @@ -2391,9 +2520,10 @@ export default function ForwardPage() { if (outcome.resetTargetTunnel) { setBatchTargetTunnelId(null); } - loadData(false); + await refreshForwardList(false); } } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -3817,6 +3947,23 @@ export default function ForwardPage() { + {batchProgress.active && ( +
+ + +
+ )} + {/* 根据显示模式渲染不同内容 */} {compactMode ? ( viewMode === "grouped" ? ( diff --git a/vite-frontend/src/pages/forward/batch-actions.ts b/vite-frontend/src/pages/forward/batch-actions.ts index 66af192..fc40bc0 100644 --- a/vite-frontend/src/pages/forward/batch-actions.ts +++ b/vite-frontend/src/pages/forward/batch-actions.ts @@ -13,6 +13,8 @@ export interface ForwardBatchActionOutcome { toastVariant: "success" | "error"; toastMessage: string; shouldRefresh: boolean; + progressPercent?: number; + progressLabel?: string; closeDeleteModal?: boolean; closeChangeTunnelModal?: boolean; resetTargetTunnel?: boolean; @@ -63,6 +65,8 @@ export const executeForwardBatchDelete = async ( return { ...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `删除完成:成功 ${summary.successCount} 项`, closeDeleteModal: true, }; } catch (error) { @@ -103,6 +107,8 @@ export const executeForwardBatchToggleService = async ( : `成功停用 ${summary.successCount} 项`, ), shouldRefresh: true, + progressPercent: 100, + progressLabel: `${enable ? "启用" : "停用"}完成:成功 ${summary.successCount} 项`, }; } catch (error) { return { @@ -132,6 +138,8 @@ export const executeForwardBatchRedeploy = async ( return { ...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `重新下发完成:成功 ${summary.successCount} 项`, }; } catch (error) { return { @@ -165,6 +173,8 @@ export const executeForwardBatchChangeTunnel = async ( return { ...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`), shouldRefresh: true, + progressPercent: 100, + progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`, closeChangeTunnelModal: true, resetTargetTunnel: true, }; diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index fc78251..096ab2c 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -65,6 +65,8 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { loadStoredOrder, saveOrder } from "@/utils/order-storage"; +const NODE_FALLBACK_REFRESH_INTERVAL_MS = 15000; + interface Node { id: number; inx?: number; @@ -116,6 +118,21 @@ interface NodeForm { socks: number; // 0 关 1 开 } +const mergeNodeRealtimeState = ( + incomingNode: Node, + existingNode?: Node, +): Node => { + return { + ...incomingNode, + systemInfo: existingNode?.systemInfo ?? incomingNode.systemInfo ?? null, + copyLoading: existingNode?.copyLoading ?? incomingNode.copyLoading ?? false, + upgradeLoading: + existingNode?.upgradeLoading ?? incomingNode.upgradeLoading ?? false, + rollbackLoading: + existingNode?.rollbackLoading ?? incomingNode.rollbackLoading ?? false, + }; +}; + const SortableItem = ({ id, children, @@ -251,8 +268,13 @@ export default function NodePage() { }); // 加载节点列表 - const loadNodes = useCallback(async () => { - setLoading(true); + const loadNodes = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + + if (!silent) { + setLoading(true); + } + try { const res = await getNodeList(); @@ -270,7 +292,13 @@ export default function NodePage() { copyLoading: false, })); - setNodeList(nodesData); + setNodeList((prev) => { + const previousById = new Map(prev.map((node) => [node.id, node])); + + return nodesData.map((node) => + mergeNodeRealtimeState(node, previousById.get(node.id)), + ); + }); // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 const hasDbOrdering = nodesData.some( @@ -292,12 +320,18 @@ export default function NodePage() { ); } } else { - toast.error(res.msg || "加载节点列表失败"); + if (!silent) { + toast.error(res.msg || "加载节点列表失败"); + } } } catch { - toast.error("网络错误,请重试"); + if (!silent) { + toast.error("网络错误,请重试"); + } } finally { - setLoading(false); + if (!silent) { + setLoading(false); + } } }, []); @@ -370,7 +404,7 @@ export default function NodePage() { } }; - const { wsConnected, wsConnecting } = useNodeRealtime({ + const { wsConnected, wsConnecting, usingPollingFallback } = useNodeRealtime({ onMessage: handleWebSocketMessage, }); @@ -378,6 +412,22 @@ export default function NodePage() { loadNodes(); }, [loadNodes]); + useEffect(() => { + if (!usingPollingFallback) { + return; + } + + void loadNodes({ silent: true }); + + const interval = window.setInterval(() => { + void loadNodes({ silent: true }); + }, NODE_FALLBACK_REFRESH_INTERVAL_MS); + + return () => { + window.clearInterval(interval); + }; + }, [loadNodes, usingPollingFallback]); + // 格式化速度 const formatSpeed = (bytesPerSecond: number): string => { if (bytesPerSecond === 0) return "0 B/s"; @@ -1162,7 +1212,11 @@ export default function NodePage() { className="mb-4" color="warning" description={ - wsConnecting ? "监控连接中..." : "监控连接已断开,正在重连..." + wsConnecting + ? "监控连接中..." + : usingPollingFallback + ? "监控连接已断开,已切换为列表自动刷新兜底模式。" + : "监控连接已断开,正在重连..." } variant="flat" /> diff --git a/vite-frontend/src/pages/node/use-node-realtime.ts b/vite-frontend/src/pages/node/use-node-realtime.ts index 5ef1806..10c303d 100644 --- a/vite-frontend/src/pages/node/use-node-realtime.ts +++ b/vite-frontend/src/pages/node/use-node-realtime.ts @@ -15,6 +15,11 @@ interface UseNodeRealtimeOptions { enabled?: boolean; } +const MAX_STANDARD_RECONNECT_ATTEMPTS = 5; +const STANDARD_RECONNECT_DELAY_MS = 3000; +const MAX_STANDARD_RECONNECT_DELAY_MS = 15000; +const FALLBACK_RECONNECT_DELAY_MS = 30000; + const getRealtimeWsUrl = (): string => { const baseUrl = axios.defaults.baseURL || @@ -34,14 +39,13 @@ export const useNodeRealtime = ({ }: UseNodeRealtimeOptions) => { const [wsConnected, setWsConnected] = useState(false); const [wsConnecting, setWsConnecting] = useState(false); + const [usingPollingFallback, setUsingPollingFallback] = useState(false); const websocketRef = useRef(null); const reconnectTimerRef = useRef | null>(null); const reconnectAttemptsRef = useRef(0); const onMessageRef = useRef(onMessage); - const maxReconnectAttempts = 5; - useEffect(() => { onMessageRef.current = onMessage; }, [onMessage]); @@ -58,6 +62,7 @@ export const useNodeRealtime = ({ reconnectAttemptsRef.current = 0; setWsConnected(false); setWsConnecting(false); + setUsingPollingFallback(false); if (!websocketRef.current) { return; @@ -103,6 +108,7 @@ export const useNodeRealtime = ({ reconnectAttemptsRef.current = 0; setWsConnected(true); setWsConnecting(false); + setUsingPollingFallback(false); }; websocketRef.current.onmessage = (event) => { @@ -122,15 +128,27 @@ export const useNodeRealtime = ({ setWsConnected(false); setWsConnecting(false); - if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) { + if (!enabled) { return; } reconnectAttemptsRef.current += 1; + const exhaustedStandardRetries = + reconnectAttemptsRef.current >= MAX_STANDARD_RECONNECT_ATTEMPTS; + + setUsingPollingFallback(exhaustedStandardRetries); + + const reconnectDelay = exhaustedStandardRetries + ? FALLBACK_RECONNECT_DELAY_MS + : Math.min( + STANDARD_RECONNECT_DELAY_MS * reconnectAttemptsRef.current, + MAX_STANDARD_RECONNECT_DELAY_MS, + ); + reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; connect(); - }, 3000 * reconnectAttemptsRef.current); + }, reconnectDelay); }; } catch { setWsConnected(false); @@ -153,6 +171,7 @@ export const useNodeRealtime = ({ return { wsConnected, wsConnecting, + usingPollingFallback, reconnectRealtime: connect, disconnectRealtime: disconnect, }; diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index 0f888a2..cf96635 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { DndContext, @@ -36,6 +36,7 @@ import { Spinner } from "@/shadcn-bridge/heroui/spinner"; import { Divider } from "@/shadcn-bridge/heroui/divider"; import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; +import { Progress } from "@/shadcn-bridge/heroui/progress"; import { createTunnel, getTunnelList, @@ -115,6 +116,29 @@ interface TunnelForm { status: number; } +interface BatchProgressState { + active: boolean; + label: string; + percent: number; +} + +const TUNNEL_ORDER_KEY = "tunnel-order"; + +const mapTunnelApiItems = (items: any[]): Tunnel[] => { + return (items || []).map((tunnel) => ({ + ...tunnel, + inx: tunnel.inx ?? 0, + inNodeId: Array.isArray(tunnel.inNodeId) ? tunnel.inNodeId : [], + outNodeId: Array.isArray(tunnel.outNodeId) ? tunnel.outNodeId : [], + chainNodes: Array.isArray(tunnel.chainNodes) ? tunnel.chainNodes : [], + inIp: tunnel.inIp || "", + flow: tunnel.flow ?? 1, + trafficRatio: tunnel.trafficRatio ?? 1, + status: typeof tunnel.status === "number" ? tunnel.status : 0, + createdTime: tunnel.createdTime || "", + })); +}; + export default function TunnelPage() { const [loading, setLoading] = useState(true); const [tunnels, setTunnels] = useState([]); @@ -203,6 +227,11 @@ export default function TunnelPage() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); const [batchLoading, setBatchLoading] = useState(false); + const [batchProgress, setBatchProgress] = useState({ + active: false, + label: "", + percent: 0, + }); useEffect(() => { return () => { @@ -211,60 +240,78 @@ export default function TunnelPage() { }; }, []); - useEffect(() => { - loadData(); + const applyTunnelList = useCallback((items: Tunnel[]) => { + setTunnels(items); + + const hasDbOrdering = items.some( + (tunnel) => tunnel.inx !== undefined && tunnel.inx !== 0, + ); + + if (hasDbOrdering) { + const dbOrder = [...items] + .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) + .map((tunnel) => tunnel.id); + + setTunnelOrder(dbOrder); + + return; + } + + setTunnelOrder( + loadStoredOrder( + TUNNEL_ORDER_KEY, + items.map((tunnel) => tunnel.id), + ), + ); }, []); - // 加载所有数据 - const loadData = async () => { - setLoading(true); + const refreshTunnelList = useCallback(async (withLoading = true) => { + if (withLoading) { + setLoading(true); + } + try { - const [tunnelsRes, nodesRes] = await Promise.all([ - getTunnelList(), - getNodeList(), - ]); + const tunnelsRes = await getTunnelList(); if (tunnelsRes.code === 0) { - const tunnelsData: Tunnel[] = (tunnelsRes.data || []).map((t: any) => ({ - ...t, - inx: t.inx ?? 0, - })); - - setTunnels(tunnelsData); - - // 优先使用数据库中的 inx 字段进行排序,否则回退到本地排序 - const hasDbOrdering = tunnelsData.some( - (t) => t.inx !== undefined && t.inx !== 0, - ); - - if (hasDbOrdering) { - const dbOrder = [...tunnelsData] - .sort((a, b) => (a.inx ?? 0) - (b.inx ?? 0)) - .map((t) => t.id); - - setTunnelOrder(dbOrder); - } else { - setTunnelOrder( - loadStoredOrder( - "tunnel-order", - tunnelsData.map((t) => t.id), - ), - ); - } + applyTunnelList(mapTunnelApiItems(tunnelsRes.data || [])); } else { toast.error(tunnelsRes.msg || "获取隧道列表失败"); } + } catch { + toast.error("获取隧道列表失败"); + } finally { + if (withLoading) { + setLoading(false); + } + } + }, [applyTunnelList]); + + const refreshNodes = useCallback(async () => { + try { + const nodesRes = await getNodeList(); if (nodesRes.code === 0) { setNodes(nodesRes.data || []); - } else { } + } catch {} + }, []); + + // 加载所有数据 + const loadData = useCallback(async () => { + setLoading(true); + try { + await Promise.all([refreshTunnelList(false), refreshNodes()]); } catch { toast.error("加载数据失败"); } finally { setLoading(false); } - }; + }, [refreshNodes, refreshTunnelList]); + + useEffect(() => { + loadData(); + }, [loadData]); // 表单验证 const validateForm = (): boolean => { @@ -327,7 +374,21 @@ export default function TunnelPage() { toast.success("删除成功"); setDeleteModalOpen(false); setTunnelToDelete(null); - loadData(); + setTunnels((prev) => + prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id), + ); + setTunnelOrder((prev) => { + const next = prev.filter((id) => id !== tunnelToDelete.id); + + saveOrder(TUNNEL_ORDER_KEY, next); + return next; + }); + setSelectedIds((prev) => { + const next = new Set(prev); + + next.delete(tunnelToDelete.id); + return next; + }); } else { toast.error(response.msg || "删除失败"); } @@ -500,7 +561,7 @@ export default function TunnelPage() { if (response.code === 0) { toast.success(isEdit ? "更新成功" : "创建成功"); setModalOpen(false); - loadData(); + await refreshTunnelList(false); } else { toast.error(response.msg || (isEdit ? "更新失败" : "创建失败")); } @@ -725,7 +786,7 @@ export default function TunnelPage() { setTunnelOrder(newOrder); - saveOrder("tunnel-order", newOrder); + saveOrder(TUNNEL_ORDER_KEY, newOrder); // 持久化到数据库 try { @@ -779,6 +840,11 @@ export default function TunnelPage() { const handleBatchDelete = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在删除 ${selectedIds.size} 条隧道...`, + percent: 30, + }); try { const res = await batchDeleteTunnels(Array.from(selectedIds)); @@ -787,21 +853,39 @@ export default function TunnelPage() { if (result.failCount === 0) { toast.success(`成功删除 ${result.successCount} 项`); + setBatchProgress({ + active: true, + label: `删除完成:成功 ${result.successCount} 项`, + percent: 100, + }); + setTunnels((prev) => prev.filter((tunnel) => !selectedIds.has(tunnel.id))); + setTunnelOrder((prev) => { + const next = prev.filter((id) => !selectedIds.has(id)); + + saveOrder(TUNNEL_ORDER_KEY, next); + return next; + }); } else { toast.error( `成功 ${result.successCount} 项,失败 ${result.failCount} 项`, ); + setBatchProgress({ + active: true, + label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`, + percent: 75, + }); + await refreshTunnelList(false); } setSelectedIds(new Set()); setSelectMode(false); setBatchDeleteModalOpen(false); - loadData(); } else { toast.error(res.msg || "删除失败"); } } catch (error) { toast.error(extractApiErrorMessage(error, "删除失败")); } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -809,6 +893,11 @@ export default function TunnelPage() { const handleBatchRedeploy = async () => { if (selectedIds.size === 0) return; setBatchLoading(true); + setBatchProgress({ + active: true, + label: `正在重新下发 ${selectedIds.size} 条隧道...`, + percent: 30, + }); try { const res = await batchRedeployTunnels(Array.from(selectedIds)); @@ -824,13 +913,19 @@ export default function TunnelPage() { } setSelectedIds(new Set()); setSelectMode(false); - loadData(); + setBatchProgress({ + active: true, + label: `重新下发完成:成功 ${result.successCount} 项,正在刷新列表...`, + percent: 100, + }); + await refreshTunnelList(false); } else { toast.error(res.msg || "下发失败"); } } catch (error) { toast.error(extractApiErrorMessage(error, "下发失败")); } finally { + setBatchProgress({ active: false, label: "", percent: 0 }); setBatchLoading(false); } }; @@ -1038,6 +1133,23 @@ export default function TunnelPage() { + {batchProgress.active && ( +
+ + +
+ )} + {/* 隧道卡片网格 */} {tunnels.length > 0 ? ( diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index 2850177..ec6bd69 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import toast from "react-hot-toast"; import { parseDate } from "@internationalized/date"; @@ -66,6 +66,10 @@ import { } from "@/components/icons"; import { PageLoadingState } from "@/components/page-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state"; +import { + removeItemsById, + replaceItemById, +} from "@/utils/list-state"; // 工具函数 const formatFlow = (value: number, unit: string = "bytes"): string => { @@ -122,6 +126,43 @@ const calculateTunnelUsedFlow = (tunnel: UserTunnel): number => { return inFlow + outFlow; }; +const USER_SEARCH_DEBOUNCE_MS = 250; + +const normalizeUserItem = (item: Partial): User => { + return { + id: Number(item.id ?? 0), + name: item.name, + user: String(item.user ?? ""), + status: Number(item.status ?? 0), + flow: Number(item.flow ?? 0), + num: Number(item.num ?? 0), + expTime: item.expTime, + flowResetTime: item.flowResetTime ?? 0, + createdTime: item.createdTime, + inFlow: Number(item.inFlow ?? 0), + outFlow: Number(item.outFlow ?? 0), + }; +}; + +const normalizeUserTunnelItem = (item: Partial): UserTunnel => { + return { + id: Number(item.id ?? 0), + userId: Number(item.userId ?? 0), + tunnelId: Number(item.tunnelId ?? 0), + tunnelName: String(item.tunnelName ?? ""), + status: Number(item.status ?? 0), + flow: Number(item.flow ?? 0), + num: Number(item.num ?? 0), + expTime: Number(item.expTime ?? 0), + flowResetTime: Number(item.flowResetTime ?? 0), + speedId: item.speedId ?? null, + speedLimitName: item.speedLimitName, + inFlow: Number(item.inFlow ?? 0), + outFlow: Number(item.outFlow ?? 0), + tunnelFlow: item.tunnelFlow, + }; +}; + export default function UserPage() { // 状态管理 const [users, setUsers] = useState([]); @@ -136,6 +177,7 @@ export default function UserPage() { size: 10, total: 0, }); + const searchDebounceRef = useRef | null>(null); // 用户表单相关状态 const { @@ -259,26 +301,25 @@ export default function UserPage() { return !speedLimitIds.has(speedId); }; - // 生命周期 - useEffect(() => { - loadUsers(); - loadTunnels(); - loadSpeedLimits(); - loadUserGroups(); - }, [pagination.current, pagination.size, searchKeyword]); - // 数据加载函数 - const loadUsers = async () => { + const loadUsers = useCallback( + async (keywordOverride?: string) => { setLoading(true); try { + const keyword = keywordOverride ?? searchKeyword; const response = await getAllUsers({ current: pagination.current, size: pagination.size, - keyword: searchKeyword, + keyword, }); if (response.code === 0) { - setUsers(Array.isArray(response.data) ? response.data : []); + const nextUsers = Array.isArray(response.data) + ? response.data.map((item) => normalizeUserItem(item)) + : []; + + setUsers(nextUsers); + setPagination((prev) => ({ ...prev, total: nextUsers.length })); } else { toast.error(response.msg || "获取用户列表失败"); } @@ -287,9 +328,11 @@ export default function UserPage() { } finally { setLoading(false); } - }; + }, + [pagination.current, pagination.size], + ); - const loadTunnels = async () => { + const loadTunnels = useCallback(async () => { try { const response = await getTunnelList(); @@ -297,9 +340,9 @@ export default function UserPage() { setTunnels(Array.isArray(response.data) ? response.data : []); } } catch {} - }; + }, []); - const loadSpeedLimits = async () => { + const loadSpeedLimits = useCallback(async () => { try { const response = await getSpeedLimitList(); @@ -315,9 +358,9 @@ export default function UserPage() { setSpeedLimits(speedLimitList); } } catch {} - }; + }, []); - const loadUserGroups = async () => { + const loadUserGroups = useCallback(async () => { try { const response = await getUserGroupList(); @@ -325,15 +368,19 @@ export default function UserPage() { setUserGroups(Array.isArray(response.data) ? response.data : []); } } catch {} - }; + }, []); - const loadUserTunnels = async (userId: number) => { + const loadUserTunnels = useCallback(async (userId: number) => { setTunnelListLoading(true); try { const response = await getUserTunnelList({ userId }); if (response.code === 0) { - setUserTunnels(Array.isArray(response.data) ? response.data : []); + setUserTunnels( + Array.isArray(response.data) + ? response.data.map((item) => normalizeUserTunnelItem(item)) + : [], + ); } else { toast.error(response.msg || "获取隧道权限列表失败"); } @@ -342,12 +389,52 @@ export default function UserPage() { } finally { setTunnelListLoading(false); } - }; + }, []); + + // 生命周期 + useEffect(() => { + void loadTunnels(); + void loadSpeedLimits(); + void loadUserGroups(); + }, [loadSpeedLimits, loadTunnels, loadUserGroups]); + + useEffect(() => { + void loadUsers(); + }, [loadUsers]); + + useEffect(() => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + } + + searchDebounceRef.current = setTimeout(() => { + setPagination((prev) => { + if (prev.current === 1) { + void loadUsers(searchKeyword); + return prev; + } + + return { ...prev, current: 1 }; + }); + }, USER_SEARCH_DEBOUNCE_MS); + + return () => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = null; + } + }; + }, [loadUsers, searchKeyword]); // 用户管理操作 const handleSearch = () => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = null; + } + setPagination((prev) => ({ ...prev, current: 1 })); - loadUsers(); + void loadUsers(searchKeyword); }; const handleAdd = () => { @@ -405,9 +492,17 @@ export default function UserPage() { if (response.code === 0) { toast.success("删除成功"); - loadUsers(); onDeleteModalClose(); + setUsers((prev) => removeItemsById(prev, [userToDelete.id])); + setPagination((prev) => ({ + ...prev, + total: Math.max(prev.total - 1, 0), + })); setUserToDelete(null); + if (currentUser?.id === userToDelete.id) { + setCurrentUser(null); + setUserTunnels([]); + } } else { toast.error(response.msg || "删除失败"); } @@ -442,7 +537,26 @@ export default function UserPage() { if (response.code === 0) { toast.success(isEdit ? "更新成功" : "创建成功"); onUserModalClose(); - loadUsers(); + const responseUser = normalizeUserItem((response as any).data || {}); + + if ( + isEdit && + responseUser.id > 0 && + pagination.current === 1 && + !searchKeyword.trim() + ) { + setUsers((prev) => replaceItemById(prev, responseUser)); + } else if ( + !isEdit && + responseUser.id > 0 && + pagination.current === 1 && + !searchKeyword.trim() + ) { + setUsers((prev) => [responseUser, ...prev].slice(0, pagination.size)); + setPagination((prev) => ({ ...prev, total: prev.total + 1 })); + } else { + await loadUsers(); + } } else { toast.error(response.msg || (isEdit ? "更新失败" : "创建失败")); } @@ -500,7 +614,7 @@ export default function UserPage() { } toast.success(response.msg || "分配成功"); setBatchTunnelSelections(new Map()); - loadUserTunnels(currentUser.id); + await loadUserTunnels(currentUser.id); } else { toast.error(response.msg || "分配失败"); } @@ -546,7 +660,19 @@ export default function UserPage() { toast.success("更新成功"); onEditTunnelModalClose(); if (currentUser) { - loadUserTunnels(currentUser.id); + const nextTunnel = normalizeUserTunnelItem({ + ...editTunnelForm, + speedId: normalizeSpeedId(editTunnelForm.speedId), + speedLimitName: + normalizeSpeedId(editTunnelForm.speedId) !== null + ? speedLimits.find( + (speedLimit) => + speedLimit.id === normalizeSpeedId(editTunnelForm.speedId), + )?.name + : undefined, + }); + + setUserTunnels((prev) => replaceItemById(prev, nextTunnel)); } } else { toast.error(response.msg || "更新失败"); @@ -572,7 +698,7 @@ export default function UserPage() { if (response.code === 0) { toast.success("删除成功"); if (currentUser) { - loadUserTunnels(currentUser.id); + setUserTunnels((prev) => removeItemsById(prev, [tunnelToDelete.id])); } onDeleteTunnelModalClose(); setTunnelToDelete(null); @@ -603,8 +729,14 @@ export default function UserPage() { if (response.code === 0) { toast.success("流量重置成功"); onResetFlowModalClose(); + const targetUserId = userToReset.id; + + setUsers((prev) => + prev.map((user) => + user.id === targetUserId ? { ...user, inFlow: 0, outFlow: 0 } : user, + ), + ); setUserToReset(null); - loadUsers(); // 重新加载用户列表 } else { toast.error(response.msg || "重置失败"); } @@ -634,10 +766,16 @@ export default function UserPage() { if (response.code === 0) { toast.success("隧道流量重置成功"); onResetTunnelFlowModalClose(); + const targetTunnelId = tunnelToReset.id; + + setUserTunnels((prev) => + prev.map((userTunnel) => + userTunnel.id === targetTunnelId + ? { ...userTunnel, inFlow: 0, outFlow: 0 } + : userTunnel, + ), + ); setTunnelToReset(null); - if (currentUser) { - loadUserTunnels(currentUser.id); // 重新加载隧道权限列表 - } } else { toast.error(response.msg || "重置失败"); } diff --git a/vite-frontend/src/utils/list-state.ts b/vite-frontend/src/utils/list-state.ts new file mode 100644 index 0000000..576d340 --- /dev/null +++ b/vite-frontend/src/utils/list-state.ts @@ -0,0 +1,33 @@ +export const removeItemsById = ( + items: T[], + ids: Iterable, +): T[] => { + const idSet = new Set(ids); + + return items.filter((item) => !idSet.has(item.id)); +}; + +export const replaceItemById = ( + items: T[], + nextItem: T, +): T[] => { + return items.map((item) => (item.id === nextItem.id ? nextItem : item)); +}; + +export const upsertItemById = ( + items: T[], + nextItem: T, + options?: { prepend?: boolean }, +): T[] => { + const existingIndex = items.findIndex((item) => item.id === nextItem.id); + + if (existingIndex >= 0) { + return replaceItemById(items, nextItem); + } + + if (options?.prepend) { + return [nextItem, ...items]; + } + + return [...items, nextItem]; +}; diff --git a/vite-frontend/src/vite-env.d.ts b/vite-frontend/src/vite-env.d.ts index 6a25d84..a61183a 100644 --- a/vite-frontend/src/vite-env.d.ts +++ b/vite-frontend/src/vite-env.d.ts @@ -2,6 +2,7 @@ /// interface ImportMetaEnv { + readonly VITE_API_BASE?: string; readonly VITE_APP_VERSION?: string; readonly VITE_GITHUB_REPO?: string; }