mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 16:06:36 +08:00
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
This commit is contained in:
@@ -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.
|
||||
@@ -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<AnnouncementData | null>(
|
||||
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,
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
||||
|
||||
@@ -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<Forward[]>([]);
|
||||
@@ -603,6 +634,11 @@ export default function ForwardPage() {
|
||||
null,
|
||||
);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
active: false,
|
||||
label: "",
|
||||
percent: 0,
|
||||
});
|
||||
const [groupOrderMap, setGroupOrderMap] = useState<ForwardGroupOrderMap>({});
|
||||
const [collapsedTunnelGroups, setCollapsedTunnelGroups] =
|
||||
useState<ForwardGroupCollapsedMap>({});
|
||||
@@ -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<Forward[]> => {
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchProgress.active && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
color="primary"
|
||||
description={batchProgress.label}
|
||||
variant="flat"
|
||||
/>
|
||||
<Progress
|
||||
aria-label={batchProgress.label}
|
||||
className="mt-3"
|
||||
color="primary"
|
||||
size="sm"
|
||||
value={batchProgress.percent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 根据显示模式渲染不同内容 */}
|
||||
{compactMode ? (
|
||||
viewMode === "grouped" ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
|
||||
};
|
||||
|
||||
@@ -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<Tunnel[]>([]);
|
||||
@@ -203,6 +227,11 @@ export default function TunnelPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchProgress.active && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
color="primary"
|
||||
description={batchProgress.label}
|
||||
variant="flat"
|
||||
/>
|
||||
<Progress
|
||||
aria-label={batchProgress.label}
|
||||
className="mt-3"
|
||||
color="primary"
|
||||
size="sm"
|
||||
value={batchProgress.percent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
|
||||
@@ -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>): 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>): 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<User[]>([]);
|
||||
@@ -136,6 +177,7 @@ export default function UserPage() {
|
||||
size: 10,
|
||||
total: 0,
|
||||
});
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | 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 || "重置失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const removeItemsById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
ids: Iterable<number>,
|
||||
): T[] => {
|
||||
const idSet = new Set(ids);
|
||||
|
||||
return items.filter((item) => !idSet.has(item.id));
|
||||
};
|
||||
|
||||
export const replaceItemById = <T extends { id: number }>(
|
||||
items: T[],
|
||||
nextItem: T,
|
||||
): T[] => {
|
||||
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||
};
|
||||
|
||||
export const upsertItemById = <T extends { id: number }>(
|
||||
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];
|
||||
};
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
readonly VITE_GITHUB_REPO?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user