From ee94f1420dc99cb47ec223f7226ac6a3661acf12 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 24 Feb 2026 00:37:41 -0800 Subject: [PATCH] =?UTF-8?q?fix(snapshot):=20=E5=89=8D=E7=AB=AF=20API=20?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=20Next.js=20rewrites=20=E4=BB=A3=E7=90=86=20?= =?UTF-8?q?+=20WebSocket=20=E6=94=B9=20REST=20=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - snapshot.api.ts: 从直连 localhost:port 改为 /api/snapshots/* 走 Next.js 代理 - next.config: 两个前端都添加 /api/snapshots/:path* → snapshot-service 代理规则 - docker-compose.2.0-snapshot.yml: overlay 中追加 mining-admin-web 的 SNAPSHOT_SERVICE_URL - useSnapshotWebSocket → useSnapshotPolling: 2秒轮询 GET /snapshots/:id 获取进度 - 移除 socket.io-client 依赖(Next.js standalone 不支持 WebSocket proxy) Co-Authored-By: Claude Opus 4.6 --- .../services/docker-compose.2.0-snapshot.yml | 5 + frontend/admin-web/next.config.ts | 9 ++ frontend/admin-web/package.json | 1 - .../src/app/(dashboard)/snapshots/page.tsx | 11 +- .../admin-web/src/hooks/useSnapshotPolling.ts | 117 ++++++++++++++++++ .../src/hooks/useSnapshotWebSocket.ts | 117 ------------------ .../src/infrastructure/api/snapshot.api.ts | 8 +- frontend/mining-admin-web/next.config.js | 9 ++ frontend/mining-admin-web/package.json | 1 - .../src/app/(dashboard)/snapshots/page.tsx | 15 +-- .../src/lib/api/snapshot.api.ts | 6 +- .../src/lib/hooks/useSnapshotPolling.ts | 117 ++++++++++++++++++ .../src/lib/hooks/useSnapshotWebSocket.ts | 116 ----------------- 13 files changed, 270 insertions(+), 262 deletions(-) create mode 100644 frontend/admin-web/src/hooks/useSnapshotPolling.ts delete mode 100644 frontend/admin-web/src/hooks/useSnapshotWebSocket.ts create mode 100644 frontend/mining-admin-web/src/lib/hooks/useSnapshotPolling.ts delete mode 100644 frontend/mining-admin-web/src/lib/hooks/useSnapshotWebSocket.ts diff --git a/backend/services/docker-compose.2.0-snapshot.yml b/backend/services/docker-compose.2.0-snapshot.yml index 28808574..7889e9db 100644 --- a/backend/services/docker-compose.2.0-snapshot.yml +++ b/backend/services/docker-compose.2.0-snapshot.yml @@ -4,6 +4,11 @@ # 纯新增 overlay,不修改任何现有服务配置 services: + # 为 mining-admin-web 补充 snapshot-service 代理地址 + mining-admin-web: + environment: + - SNAPSHOT_SERVICE_URL=http://snapshot-service-2:3199 + snapshot-service-2: build: context: ./snapshot-service diff --git a/frontend/admin-web/next.config.ts b/frontend/admin-web/next.config.ts index be5bc529..ac661682 100644 --- a/frontend/admin-web/next.config.ts +++ b/frontend/admin-web/next.config.ts @@ -14,6 +14,15 @@ const nextConfig: NextConfig = { }, ], }, + async rewrites() { + const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3099'; + return [ + { + source: '/api/snapshots/:path*', + destination: `${snapshotServiceUrl}/api/v1/:path*`, + }, + ]; + }, async redirects() { return [ { diff --git a/frontend/admin-web/package.json b/frontend/admin-web/package.json index 6998d710..0de95462 100644 --- a/frontend/admin-web/package.json +++ b/frontend/admin-web/package.json @@ -28,7 +28,6 @@ "react-redux": "^9.2.0", "recharts": "^2.15.0", "redux-persist": "^6.0.0", - "socket.io-client": "^4.7.4", "xlsx": "^0.18.5", "zustand": "^5.0.3" }, diff --git a/frontend/admin-web/src/app/(dashboard)/snapshots/page.tsx b/frontend/admin-web/src/app/(dashboard)/snapshots/page.tsx index a05a2438..73f602e6 100644 --- a/frontend/admin-web/src/app/(dashboard)/snapshots/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/snapshots/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { snapshotApi } from '@/infrastructure/api/snapshot.api'; -import { useSnapshotWebSocket } from '@/hooks/useSnapshotWebSocket'; +import { useSnapshotPolling } from '@/hooks/useSnapshotPolling'; import type { BackupTarget, StorageType, @@ -66,8 +66,8 @@ export default function SnapshotsPage() { // 当前执行中的任务 const [activeTaskId, setActiveTaskId] = useState(null); - const { progresses, isConnected, taskCompleted, totalSize, duration } = - useSnapshotWebSocket(activeTaskId); + const { progresses, taskCompleted, totalSize, duration } = + useSnapshotPolling(activeTaskId); // 历史列表 const [tasks, setTasks] = useState([]); @@ -245,10 +245,7 @@ export default function SnapshotsPage() { {/* 实时进度 */} {activeTaskId && progresses.size > 0 && (
-

- 备份进度 - {isConnected && WebSocket 已连接} -

+

备份进度

{Array.from(progresses.values()).map((p) => ( diff --git a/frontend/admin-web/src/hooks/useSnapshotPolling.ts b/frontend/admin-web/src/hooks/useSnapshotPolling.ts new file mode 100644 index 00000000..18a634b0 --- /dev/null +++ b/frontend/admin-web/src/hooks/useSnapshotPolling.ts @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState, useRef, useCallback } from 'react'; +import { snapshotApi } from '@/infrastructure/api/snapshot.api'; +import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types'; + +interface TargetProgress { + target: BackupTarget; + percent: number; + message: string; + status: 'pending' | 'running' | 'completed' | 'failed'; +} + +interface UseSnapshotPollingReturn { + progresses: Map; + taskCompleted: boolean; + totalSize: string | null; + duration: number | null; + error: string | null; +} + +const POLL_INTERVAL = 2000; // 2 秒轮询 + +export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn { + const [progresses, setProgresses] = useState>(new Map()); + const [taskCompleted, setTaskCompleted] = useState(false); + const [totalSize, setTotalSize] = useState(null); + const [duration, setDuration] = useState(null); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + + const updateFromTask = useCallback((task: SnapshotTask) => { + // 从 task.details 构建进度 Map + const next = new Map(); + for (const d of task.details) { + const statusMap: Record = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + FAILED: 'failed', + }; + next.set(d.target, { + target: d.target, + percent: d.status === 'COMPLETED' ? 100 : d.progress, + message: + d.error + ? d.error + : d.status === 'COMPLETED' + ? '完成' + : d.status === 'RUNNING' + ? `备份中... ${d.progress}%` + : '等待中', + status: statusMap[d.status] || 'pending', + }); + } + setProgresses(next); + + // 任务整体完成/失败 → 停止轮询 + if (task.status === 'COMPLETED' || task.status === 'FAILED') { + setTaskCompleted(true); + setTotalSize(task.totalSize); + if (task.startedAt && task.completedAt) { + setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()); + } + if (task.error) { + setError(task.error); + } + } + }, []); + + useEffect(() => { + if (!taskId) return; + + // 重置状态 + setProgresses(new Map()); + setTaskCompleted(false); + setTotalSize(null); + setDuration(null); + setError(null); + + let stopped = false; + + const poll = async () => { + if (stopped) return; + try { + const task = await snapshotApi.getById(taskId); + if (stopped) return; + updateFromTask(task); + + // 任务结束,停止轮询 + if (task.status === 'COMPLETED' || task.status === 'FAILED') { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + } catch { + // 网络错误静默忽略,下次继续轮询 + } + }; + + // 立即拉一次 + poll(); + // 定时轮询 + timerRef.current = setInterval(poll, POLL_INTERVAL); + + return () => { + stopped = true; + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [taskId, updateFromTask]); + + return { progresses, taskCompleted, totalSize, duration, error }; +} diff --git a/frontend/admin-web/src/hooks/useSnapshotWebSocket.ts b/frontend/admin-web/src/hooks/useSnapshotWebSocket.ts deleted file mode 100644 index aad2947c..00000000 --- a/frontend/admin-web/src/hooks/useSnapshotWebSocket.ts +++ /dev/null @@ -1,117 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef, useCallback } from 'react'; -import { io, Socket } from 'socket.io-client'; -import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types'; - -const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099'; - -interface TargetProgress { - target: BackupTarget; - percent: number; - message: string; - status: 'pending' | 'running' | 'completed' | 'failed'; -} - -interface UseSnapshotWebSocketReturn { - progresses: Map; - isConnected: boolean; - taskCompleted: boolean; - totalSize: string | null; - duration: number | null; - error: string | null; -} - -export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn { - const [progresses, setProgresses] = useState>(new Map()); - const [isConnected, setIsConnected] = useState(false); - const [taskCompleted, setTaskCompleted] = useState(false); - const [totalSize, setTotalSize] = useState(null); - const [duration, setDuration] = useState(null); - const [error, setError] = useState(null); - const socketRef = useRef(null); - - useEffect(() => { - if (!taskId) return; - - // 重置状态 - setProgresses(new Map()); - setTaskCompleted(false); - setTotalSize(null); - setDuration(null); - setError(null); - - const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, { - transports: ['websocket', 'polling'], - }); - socketRef.current = socket; - - socket.on('connect', () => setIsConnected(true)); - socket.on('disconnect', () => setIsConnected(false)); - - socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => { - if (data.taskId !== taskId) return; - const initial = new Map(); - data.targets.forEach((t) => { - initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' }); - }); - setProgresses(initial); - }); - - socket.on('snapshot:progress', (data: SnapshotProgress) => { - if (data.taskId !== taskId) return; - setProgresses((prev) => { - const next = new Map(prev); - next.set(data.target, { - target: data.target, - percent: data.percent, - message: data.message, - status: 'running', - }); - return next; - }); - }); - - socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => { - if (data.taskId !== taskId) return; - setProgresses((prev) => { - const next = new Map(prev); - const existing = next.get(data.target); - if (existing) { - next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' }); - } - return next; - }); - }); - - socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => { - if (data.taskId !== taskId) return; - setTaskCompleted(true); - setTotalSize(data.totalSize); - setDuration(data.duration); - }); - - socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => { - if (data.taskId !== taskId) return; - if (data.target) { - setProgresses((prev) => { - const next = new Map(prev); - const existing = next.get(data.target!); - if (existing) { - next.set(data.target!, { ...existing, message: data.error, status: 'failed' }); - } - return next; - }); - } else { - setError(data.error); - } - }); - - return () => { - socket.disconnect(); - socketRef.current = null; - }; - }, [taskId]); - - return { progresses, isConnected, taskCompleted, totalSize, duration, error }; -} diff --git a/frontend/admin-web/src/infrastructure/api/snapshot.api.ts b/frontend/admin-web/src/infrastructure/api/snapshot.api.ts index 4ed7403b..d99c0fda 100644 --- a/frontend/admin-web/src/infrastructure/api/snapshot.api.ts +++ b/frontend/admin-web/src/infrastructure/api/snapshot.api.ts @@ -1,11 +1,9 @@ import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types'; -const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3099'; - -/** snapshot-service 独立请求(不走通用 apiClient,因为 snapshot 服务有独立端口) */ +/** snapshot-service 请求 — 走 Next.js rewrites 代理 */ async function snapshotFetch(url: string, options?: RequestInit): Promise { const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; - const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, { + const res = await fetch(`/api/snapshots${url}`, { headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -40,5 +38,5 @@ export const snapshotApi = { snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }), getDownloadUrl: (id: string, target: string) => - `${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`, + `/api/snapshots/snapshots/${id}/download/${target}`, }; diff --git a/frontend/mining-admin-web/next.config.js b/frontend/mining-admin-web/next.config.js index 48c051bd..153a9c43 100644 --- a/frontend/mining-admin-web/next.config.js +++ b/frontend/mining-admin-web/next.config.js @@ -10,6 +10,7 @@ const nextConfig = { const miningAdminUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3023'; const tradingServiceUrl = process.env.TRADING_SERVICE_URL || 'http://localhost:3022'; const miningServiceUrl = process.env.MINING_SERVICE_URL || 'http://localhost:3021'; + const snapshotServiceUrl = process.env.SNAPSHOT_SERVICE_URL || 'http://localhost:3199'; // 移除末尾可能存在的路径避免重复 const cleanMiningAdminUrl = miningAdminUrl.replace(/\/api\/v2.*$/, ''); @@ -19,6 +20,10 @@ const nextConfig = { if (apiGatewayUrl) { // 通过 Kong 网关: 所有请求经 Kong 路由分发到各服务 return [ + { + source: '/api/snapshots/:path*', + destination: `${snapshotServiceUrl}/api/v1/:path*`, + }, { source: '/api/trading/:path*', destination: `${apiGatewayUrl}/api/v2/trading/:path*`, @@ -35,6 +40,10 @@ const nextConfig = { } else { // 直连各服务: 前端与后端在同一 Docker 网络内直接通信 return [ + { + source: '/api/snapshots/:path*', + destination: `${snapshotServiceUrl}/api/v1/:path*`, + }, { source: '/api/trading/:path*', destination: `${cleanTradingUrl}/api/v2/:path*`, diff --git a/frontend/mining-admin-web/package.json b/frontend/mining-admin-web/package.json index 6903fe2f..fd2c5901 100644 --- a/frontend/mining-admin-web/package.json +++ b/frontend/mining-admin-web/package.json @@ -39,7 +39,6 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.50.1", "react-redux": "^9.1.0", - "socket.io-client": "^4.7.4", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4", diff --git a/frontend/mining-admin-web/src/app/(dashboard)/snapshots/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/snapshots/page.tsx index d567e92b..e3c0b405 100644 --- a/frontend/mining-admin-web/src/app/(dashboard)/snapshots/page.tsx +++ b/frontend/mining-admin-web/src/app/(dashboard)/snapshots/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { snapshotApi } from '@/lib/api/snapshot.api'; -import { useSnapshotWebSocket } from '@/lib/hooks/useSnapshotWebSocket'; +import { useSnapshotPolling } from '@/lib/hooks/useSnapshotPolling'; import type { BackupTarget, StorageType, @@ -47,8 +47,8 @@ export default function SnapshotsPage() { const [selectedTargets, setSelectedTargets] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [activeTaskId, setActiveTaskId] = useState(null); - const { progresses, isConnected, taskCompleted, totalSize, duration } = - useSnapshotWebSocket(activeTaskId); + const { progresses, taskCompleted, totalSize, duration } = + useSnapshotPolling(activeTaskId); const [tasks, setTasks] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -222,14 +222,7 @@ export default function SnapshotsPage() { {/* 实时进度 */} {activeTaskId && progresses.size > 0 && (
-

- 备份进度 - {isConnected && ( - - WebSocket 已连接 - - )} -

+

备份进度

{Array.from(progresses.values()).map((p) => ( diff --git a/frontend/mining-admin-web/src/lib/api/snapshot.api.ts b/frontend/mining-admin-web/src/lib/api/snapshot.api.ts index 60195f9c..55e7eb26 100644 --- a/frontend/mining-admin-web/src/lib/api/snapshot.api.ts +++ b/frontend/mining-admin-web/src/lib/api/snapshot.api.ts @@ -1,10 +1,8 @@ import type { CreateSnapshotDto, SnapshotTask } from '@/types/snapshot.types'; -const SNAPSHOT_BASE = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199'; - async function snapshotFetch(url: string, options?: RequestInit): Promise { const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null; - const res = await fetch(`${SNAPSHOT_BASE}/api/v1${url}`, { + const res = await fetch(`/api/snapshots${url}`, { headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -39,5 +37,5 @@ export const snapshotApi = { snapshotFetch<{ message: string }>(`/snapshots/${id}`, { method: 'DELETE' }), getDownloadUrl: (id: string, target: string) => - `${SNAPSHOT_BASE}/api/v1/snapshots/${id}/download/${target}`, + `/api/snapshots/snapshots/${id}/download/${target}`, }; diff --git a/frontend/mining-admin-web/src/lib/hooks/useSnapshotPolling.ts b/frontend/mining-admin-web/src/lib/hooks/useSnapshotPolling.ts new file mode 100644 index 00000000..c3faad41 --- /dev/null +++ b/frontend/mining-admin-web/src/lib/hooks/useSnapshotPolling.ts @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useState, useRef, useCallback } from 'react'; +import { snapshotApi } from '@/lib/api/snapshot.api'; +import type { BackupTarget, SnapshotTask } from '@/types/snapshot.types'; + +interface TargetProgress { + target: BackupTarget; + percent: number; + message: string; + status: 'pending' | 'running' | 'completed' | 'failed'; +} + +interface UseSnapshotPollingReturn { + progresses: Map; + taskCompleted: boolean; + totalSize: string | null; + duration: number | null; + error: string | null; +} + +const POLL_INTERVAL = 2000; // 2 秒轮询 + +export function useSnapshotPolling(taskId: string | null): UseSnapshotPollingReturn { + const [progresses, setProgresses] = useState>(new Map()); + const [taskCompleted, setTaskCompleted] = useState(false); + const [totalSize, setTotalSize] = useState(null); + const [duration, setDuration] = useState(null); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + + const updateFromTask = useCallback((task: SnapshotTask) => { + // 从 task.details 构建进度 Map + const next = new Map(); + for (const d of task.details) { + const statusMap: Record = { + PENDING: 'pending', + RUNNING: 'running', + COMPLETED: 'completed', + FAILED: 'failed', + }; + next.set(d.target, { + target: d.target, + percent: d.status === 'COMPLETED' ? 100 : d.progress, + message: + d.error + ? d.error + : d.status === 'COMPLETED' + ? '完成' + : d.status === 'RUNNING' + ? `备份中... ${d.progress}%` + : '等待中', + status: statusMap[d.status] || 'pending', + }); + } + setProgresses(next); + + // 任务整体完成/失败 → 停止轮询 + if (task.status === 'COMPLETED' || task.status === 'FAILED') { + setTaskCompleted(true); + setTotalSize(task.totalSize); + if (task.startedAt && task.completedAt) { + setDuration(new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()); + } + if (task.error) { + setError(task.error); + } + } + }, []); + + useEffect(() => { + if (!taskId) return; + + // 重置状态 + setProgresses(new Map()); + setTaskCompleted(false); + setTotalSize(null); + setDuration(null); + setError(null); + + let stopped = false; + + const poll = async () => { + if (stopped) return; + try { + const task = await snapshotApi.getById(taskId); + if (stopped) return; + updateFromTask(task); + + // 任务结束,停止轮询 + if (task.status === 'COMPLETED' || task.status === 'FAILED') { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + } catch { + // 网络错误静默忽略,下次继续轮询 + } + }; + + // 立即拉一次 + poll(); + // 定时轮询 + timerRef.current = setInterval(poll, POLL_INTERVAL); + + return () => { + stopped = true; + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [taskId, updateFromTask]); + + return { progresses, taskCompleted, totalSize, duration, error }; +} diff --git a/frontend/mining-admin-web/src/lib/hooks/useSnapshotWebSocket.ts b/frontend/mining-admin-web/src/lib/hooks/useSnapshotWebSocket.ts deleted file mode 100644 index c431e028..00000000 --- a/frontend/mining-admin-web/src/lib/hooks/useSnapshotWebSocket.ts +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; - -import { useEffect, useState, useRef } from 'react'; -import { io, Socket } from 'socket.io-client'; -import type { BackupTarget, SnapshotProgress } from '@/types/snapshot.types'; - -const SNAPSHOT_WS_URL = process.env.NEXT_PUBLIC_SNAPSHOT_API_URL || 'http://localhost:3199'; - -interface TargetProgress { - target: BackupTarget; - percent: number; - message: string; - status: 'pending' | 'running' | 'completed' | 'failed'; -} - -interface UseSnapshotWebSocketReturn { - progresses: Map; - isConnected: boolean; - taskCompleted: boolean; - totalSize: string | null; - duration: number | null; - error: string | null; -} - -export function useSnapshotWebSocket(taskId: string | null): UseSnapshotWebSocketReturn { - const [progresses, setProgresses] = useState>(new Map()); - const [isConnected, setIsConnected] = useState(false); - const [taskCompleted, setTaskCompleted] = useState(false); - const [totalSize, setTotalSize] = useState(null); - const [duration, setDuration] = useState(null); - const [error, setError] = useState(null); - const socketRef = useRef(null); - - useEffect(() => { - if (!taskId) return; - - setProgresses(new Map()); - setTaskCompleted(false); - setTotalSize(null); - setDuration(null); - setError(null); - - const socket = io(`${SNAPSHOT_WS_URL}/snapshots`, { - transports: ['websocket', 'polling'], - }); - socketRef.current = socket; - - socket.on('connect', () => setIsConnected(true)); - socket.on('disconnect', () => setIsConnected(false)); - - socket.on('snapshot:started', (data: { taskId: string; targets: BackupTarget[] }) => { - if (data.taskId !== taskId) return; - const initial = new Map(); - data.targets.forEach((t) => { - initial.set(t, { target: t, percent: 0, message: '等待中', status: 'pending' }); - }); - setProgresses(initial); - }); - - socket.on('snapshot:progress', (data: SnapshotProgress) => { - if (data.taskId !== taskId) return; - setProgresses((prev) => { - const next = new Map(prev); - next.set(data.target, { - target: data.target, - percent: data.percent, - message: data.message, - status: 'running', - }); - return next; - }); - }); - - socket.on('snapshot:target-complete', (data: { taskId: string; target: string; fileSize: string }) => { - if (data.taskId !== taskId) return; - setProgresses((prev) => { - const next = new Map(prev); - const existing = next.get(data.target); - if (existing) { - next.set(data.target, { ...existing, percent: 100, message: '完成', status: 'completed' }); - } - return next; - }); - }); - - socket.on('snapshot:complete', (data: { taskId: string; totalSize: string; duration: number }) => { - if (data.taskId !== taskId) return; - setTaskCompleted(true); - setTotalSize(data.totalSize); - setDuration(data.duration); - }); - - socket.on('snapshot:error', (data: { taskId: string; target?: string; error: string }) => { - if (data.taskId !== taskId) return; - if (data.target) { - setProgresses((prev) => { - const next = new Map(prev); - const existing = next.get(data.target!); - if (existing) { - next.set(data.target!, { ...existing, message: data.error, status: 'failed' }); - } - return next; - }); - } else { - setError(data.error); - } - }); - - return () => { - socket.disconnect(); - socketRef.current = null; - }; - }, [taskId]); - - return { progresses, isConnected, taskCompleted, totalSize, duration, error }; -}