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:
sagitchu
2026-03-08 19:15:02 +08:00
parent bd30b61018
commit f1bdb2e2ef
10 changed files with 941 additions and 175 deletions
+192 -45
View File
@@ -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" ? (