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
+62 -8
View File
@@ -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"
/>