refactor(frontend): extract reusable hooks and page helper modules

This commit is contained in:
Sagit
2026-02-19 05:14:11 +00:00
parent 6d57b49595
commit c5124a01e6
24 changed files with 2072 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
export const getConnectionStatusMeta = (
status: "online" | "offline",
): { color: "success" | "danger"; text: string } => {
if (status === "online") {
return { color: "success", text: "在线" };
}
return { color: "danger", text: "离线" };
};
export const getRemoteSyncErrorMessage = (syncError: string): string => {
if (syncError === "provider_share_deleted") {
return "提供方已删除该分享";
}
if (syncError === "provider_share_disabled") {
return "提供方已禁用该分享";
}
if (syncError === "provider_share_expired") {
return "提供方分享已过期";
}
return `远程同步失败: ${syncError}`;
};
@@ -0,0 +1,11 @@
export const tryCopyInstallCommand = async (
command: string,
): Promise<boolean> => {
try {
await navigator.clipboard.writeText(command);
return true;
} catch {
return false;
}
};
@@ -0,0 +1,86 @@
export interface NodeSystemInfo {
cpuUsage: number;
memoryUsage: number;
uploadTraffic: number;
downloadTraffic: number;
uploadSpeed: number;
downloadSpeed: number;
uptime: number;
}
type RawSystemInfo = Record<string, string | number | undefined>;
const toInteger = (value: string | number | undefined): number => {
return Number.parseInt(String(value ?? 0), 10) || 0;
};
const toFloat = (value: string | number | undefined): number => {
return Number.parseFloat(String(value ?? 0)) || 0;
};
const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
if (typeof messageData === "string") {
try {
const parsed = JSON.parse(messageData);
if (parsed && typeof parsed === "object") {
return parsed as RawSystemInfo;
}
return null;
} catch {
return null;
}
}
if (messageData && typeof messageData === "object") {
return messageData as RawSystemInfo;
}
return null;
};
export const buildNodeSystemInfo = (
messageData: unknown,
previous: NodeSystemInfo | null | undefined,
): NodeSystemInfo | null => {
const raw = parseRawSystemInfo(messageData);
if (!raw) {
return null;
}
const uploadTraffic = toInteger(raw.bytes_transmitted);
const downloadTraffic = toInteger(raw.bytes_received);
const uptime = toInteger(raw.uptime);
let uploadSpeed = 0;
let downloadSpeed = 0;
if (previous && previous.uptime) {
const timeDiff = uptime - previous.uptime;
if (timeDiff > 0 && timeDiff <= 10) {
const uploadDiff = uploadTraffic - previous.uploadTraffic;
const downloadDiff = downloadTraffic - previous.downloadTraffic;
if (uploadTraffic >= previous.uploadTraffic && uploadDiff >= 0) {
uploadSpeed = uploadDiff / timeDiff;
}
if (downloadTraffic >= previous.downloadTraffic && downloadDiff >= 0) {
downloadSpeed = downloadDiff / timeDiff;
}
}
}
return {
cpuUsage: toFloat(raw.cpu_usage),
memoryUsage: toFloat(raw.memory_usage),
uploadTraffic,
downloadTraffic,
uploadSpeed,
downloadSpeed,
uptime,
};
};
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef } from "react";
interface UseNodeOfflineTimersOptions {
delayMs?: number;
onNodeOffline: (nodeId: number) => void;
}
export const useNodeOfflineTimers = ({
delayMs = 3000,
onNodeOffline,
}: UseNodeOfflineTimersOptions) => {
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(
new Map(),
);
const clearOfflineTimer = useCallback((nodeId: number) => {
const timer = timersRef.current.get(nodeId);
if (!timer) {
return;
}
clearTimeout(timer);
timersRef.current.delete(nodeId);
}, []);
const clearAllOfflineTimers = useCallback(() => {
timersRef.current.forEach((timer) => {
clearTimeout(timer);
});
timersRef.current.clear();
}, []);
const scheduleNodeOffline = useCallback(
(nodeId: number) => {
if (timersRef.current.has(nodeId)) {
return;
}
const timer = setTimeout(() => {
timersRef.current.delete(nodeId);
onNodeOffline(nodeId);
}, delayMs);
timersRef.current.set(nodeId, timer);
},
[delayMs, onNodeOffline],
);
useEffect(() => {
return () => {
clearAllOfflineTimers();
};
}, [clearAllOfflineTimers]);
return {
clearOfflineTimer,
scheduleNodeOffline,
clearAllOfflineTimers,
};
};
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from "react";
import axios from "axios";
import { getToken } from "@/utils/session";
interface NodeRealtimeMessage {
id?: string | number;
type?: string;
data?: unknown;
message?: string;
}
interface UseNodeRealtimeOptions {
onMessage: (message: NodeRealtimeMessage) => void;
enabled?: boolean;
}
const getRealtimeWsUrl = (): string => {
const baseUrl =
axios.defaults.baseURL ||
(import.meta.env.VITE_API_BASE
? `${import.meta.env.VITE_API_BASE}/api/v1/`
: "/api/v1/");
return (
baseUrl.replace(/^http/, "ws").replace(/\/api\/v1\/$/, "") +
`/system-info?type=0&secret=${getToken() || ""}`
);
};
export const useNodeRealtime = ({
onMessage,
enabled = true,
}: UseNodeRealtimeOptions) => {
const [wsConnected, setWsConnected] = useState(false);
const [wsConnecting, setWsConnecting] = 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]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const disconnect = useCallback(() => {
clearReconnectTimer();
reconnectAttemptsRef.current = 0;
setWsConnected(false);
setWsConnecting(false);
if (!websocketRef.current) {
return;
}
websocketRef.current.onopen = null;
websocketRef.current.onmessage = null;
websocketRef.current.onerror = null;
websocketRef.current.onclose = null;
if (
websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING
) {
websocketRef.current.close();
}
websocketRef.current = null;
}, [clearReconnectTimer]);
const connect = useCallback(() => {
if (!enabled) {
return;
}
if (
websocketRef.current &&
(websocketRef.current.readyState === WebSocket.OPEN ||
websocketRef.current.readyState === WebSocket.CONNECTING)
) {
return;
}
if (websocketRef.current) {
disconnect();
}
try {
setWsConnecting(true);
websocketRef.current = new WebSocket(getRealtimeWsUrl());
websocketRef.current.onopen = () => {
reconnectAttemptsRef.current = 0;
setWsConnected(true);
setWsConnecting(false);
};
websocketRef.current.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data);
if (parsed && typeof parsed === "object") {
onMessageRef.current(parsed as NodeRealtimeMessage);
}
} catch {}
};
websocketRef.current.onerror = () => {};
websocketRef.current.onclose = () => {
websocketRef.current = null;
setWsConnected(false);
setWsConnecting(false);
if (!enabled || reconnectAttemptsRef.current >= maxReconnectAttempts) {
return;
}
reconnectAttemptsRef.current += 1;
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
connect();
}, 3000 * reconnectAttemptsRef.current);
};
} catch {
setWsConnected(false);
setWsConnecting(false);
}
}, [disconnect, enabled]);
useEffect(() => {
if (!enabled) {
return;
}
connect();
return () => {
disconnect();
};
}, [connect, disconnect, enabled]);
return {
wsConnected,
wsConnecting,
reconnectRealtime: connect,
disconnectRealtime: disconnect,
};
};