mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 10:16:38 +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:
@@ -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" ? (
|
||||
|
||||
Reference in New Issue
Block a user