From b97f5debac535d22a62ca647f0a284ffa7d66e88 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Thu, 7 May 2026 20:50:20 +0800 Subject: [PATCH] feat: add 5 custom hooks for exception center state management - useExceptionSummary: fetch and refresh exception overview - useExceptionList: paginated advanced list + metadata queue - useExceptionDetail: single exception detail with abort safety - useRepairTask: WebSocket repair task tracking + execution state per exception - useWizardState: 5-step wizard flow state + action/params management Co-Authored-By: Claude Opus 4.6 --- frontend/src/hooks/useExceptionDetail.js | 43 +++++++ frontend/src/hooks/useExceptionList.js | 149 ++++++++++++++++++++++ frontend/src/hooks/useExceptionSummary.js | 28 ++++ frontend/src/hooks/useRepairTask.js | 133 +++++++++++++++++++ frontend/src/hooks/useWizardState.js | 50 ++++++++ 5 files changed, 403 insertions(+) create mode 100644 frontend/src/hooks/useExceptionDetail.js create mode 100644 frontend/src/hooks/useExceptionList.js create mode 100644 frontend/src/hooks/useExceptionSummary.js create mode 100644 frontend/src/hooks/useRepairTask.js create mode 100644 frontend/src/hooks/useWizardState.js diff --git a/frontend/src/hooks/useExceptionDetail.js b/frontend/src/hooks/useExceptionDetail.js new file mode 100644 index 0000000..759dcfa --- /dev/null +++ b/frontend/src/hooks/useExceptionDetail.js @@ -0,0 +1,43 @@ +// frontend/src/hooks/useExceptionDetail.js +import { useState, useEffect, useCallback } from 'react'; +import { fetchExceptionItem } from '../api/exceptions'; + +export default function useExceptionDetail(exceptionId) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => { + if (!exceptionId) { setDetail(null); return; } + setLoading(true); + setError(''); + fetchExceptionItem(exceptionId) + .then(setDetail) + .catch((err) => { + setDetail(null); + setError(err.message || '详情加载失败'); + }) + .finally(() => setLoading(false)); + }, [exceptionId]); + + useEffect(() => { + if (!exceptionId) { setDetail(null); return; } + const controller = new AbortController(); + setLoading(true); + setError(''); + fetchExceptionItem(exceptionId, { signal: controller.signal }) + .then(setDetail) + .catch((err) => { + if (err.name !== 'AbortError') { + setDetail(null); + setError(err.message || '详情加载失败'); + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [exceptionId]); + + return { detail, loading, error, refresh }; +} diff --git a/frontend/src/hooks/useExceptionList.js b/frontend/src/hooks/useExceptionList.js new file mode 100644 index 0000000..f5f379e --- /dev/null +++ b/frontend/src/hooks/useExceptionList.js @@ -0,0 +1,149 @@ +// frontend/src/hooks/useExceptionList.js +import { useState, useEffect, useCallback } from 'react'; +import { fetchExceptionItems } from '../api/exceptions'; +import { + METADATA_QUEUE_TYPES, METADATA_QUEUE_PAGE_SIZE, + ITEMS_PER_PAGE, compareTimestampDesc +} from '../utils/exceptions'; + +export default function useExceptionList({ viewMode, activeFilter, resolutionFilter, currentPage }) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [metadataQueue, setMetadataQueue] = useState([]); + const [metadataTotal, setMetadataTotal] = useState(0); + const [isListLoading, setIsListLoading] = useState(true); + const [isMetadataQueueLoading, setIsMetadataQueueLoading] = useState(true); + const [listError, setListError] = useState(''); + const [metadataQueueError, setMetadataQueueError] = useState(''); + + const refreshList = useCallback(() => { + if (viewMode !== 'advanced') return; + setIsListLoading(true); + setListError(''); + fetchExceptionItems({ + type: activeFilter, + resolutionStatus: resolutionFilter, + page: currentPage, + pageSize: ITEMS_PER_PAGE + }) + .then((payload) => { + setItems(payload.items); + setTotal(payload.total); + }) + .catch((err) => { + setItems([]); + setTotal(0); + setListError(err.message || '异常列表加载失败'); + }) + .finally(() => setIsListLoading(false)); + }, [viewMode, activeFilter, resolutionFilter, currentPage]); + + const refreshMetadataQueue = useCallback(() => { + setIsMetadataQueueLoading(true); + setMetadataQueueError(''); + Promise.all( + METADATA_QUEUE_TYPES.map((type) => + fetchExceptionItems({ + type, + resolutionStatus: 'open', + page: 1, + pageSize: METADATA_QUEUE_PAGE_SIZE + }) + ) + ) + .then((payloads) => { + const queue = payloads + .flatMap((p) => p.items) + .sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); + setMetadataQueue(queue); + setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); + }) + .catch((err) => { + setMetadataQueue([]); + setMetadataTotal(0); + setMetadataQueueError(err.message || '元数据异常队列加载失败'); + }) + .finally(() => setIsMetadataQueueLoading(false)); + }, []); + + // Advanced list: fetch on filter/page change + useEffect(() => { + const controller = new AbortController(); + if (viewMode !== 'advanced') { + setIsListLoading(false); + return undefined; + } + setIsListLoading(true); + setListError(''); + fetchExceptionItems( + { + type: activeFilter, + resolutionStatus: resolutionFilter, + page: currentPage, + pageSize: ITEMS_PER_PAGE + }, + { signal: controller.signal } + ) + .then((payload) => { + setItems(payload.items); + setTotal(payload.total); + }) + .catch((err) => { + if (err.name !== 'AbortError') { + setItems([]); + setTotal(0); + setListError(err.message || '列表加载失败'); + } + }) + .finally(() => { + if (!controller.signal.aborted) setIsListLoading(false); + }); + return () => controller.abort(); + }, [activeFilter, resolutionFilter, currentPage, viewMode]); + + // Metadata queue: fetch on mount and viewMode change + useEffect(() => { + const controller = new AbortController(); + setIsMetadataQueueLoading(true); + setMetadataQueueError(''); + Promise.all( + METADATA_QUEUE_TYPES.map((type) => + fetchExceptionItems( + { + type, + resolutionStatus: 'open', + page: 1, + pageSize: METADATA_QUEUE_PAGE_SIZE + }, + { signal: controller.signal } + ) + ) + ) + .then((payloads) => { + const queue = payloads + .flatMap((p) => p.items) + .sort((a, b) => compareTimestampDesc(a.captured_at, b.captured_at)); + setMetadataQueue(queue); + setMetadataTotal(payloads.reduce((sum, p) => sum + p.total, 0)); + }) + .catch((err) => { + if (err.name !== 'AbortError') { + setMetadataQueue([]); + setMetadataTotal(0); + setMetadataQueueError(err.message || '队列加载失败'); + } + }) + .finally(() => { + if (!controller.signal.aborted) setIsMetadataQueueLoading(false); + }); + return () => controller.abort(); + }, [viewMode]); + + return { + items, total, metadataQueue, metadataTotal, + isListLoading, isMetadataQueueLoading, + listError, metadataQueueError, + setItems, setMetadataQueue, + refreshList, refreshMetadataQueue + }; +} diff --git a/frontend/src/hooks/useExceptionSummary.js b/frontend/src/hooks/useExceptionSummary.js new file mode 100644 index 0000000..cf12de6 --- /dev/null +++ b/frontend/src/hooks/useExceptionSummary.js @@ -0,0 +1,28 @@ +// frontend/src/hooks/useExceptionSummary.js +import { useState, useEffect, useCallback } from 'react'; +import { fetchExceptionSummary } from '../api/exceptions'; + +export default function useExceptionSummary() { + const [summary, setSummary] = useState(null); + const [error, setError] = useState(''); + + const refresh = useCallback(() => { + fetchExceptionSummary() + .then(setSummary) + .catch((err) => setError(err.message || '异常概览加载失败')); + }, []); + + useEffect(() => { + const controller = new AbortController(); + fetchExceptionSummary({ signal: controller.signal }) + .then(setSummary) + .catch((err) => { + if (err.name !== 'AbortError') { + setError(err.message || '异常概览加载失败'); + } + }); + return () => controller.abort(); + }, []); + + return { summary, error, refresh }; +} diff --git a/frontend/src/hooks/useRepairTask.js b/frontend/src/hooks/useRepairTask.js new file mode 100644 index 0000000..9df4905 --- /dev/null +++ b/frontend/src/hooks/useRepairTask.js @@ -0,0 +1,133 @@ +// frontend/src/hooks/useRepairTask.js +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + createRepairTaskStream, + fetchCurrentRepairTask, + fetchRepairTask, + fetchRepairTaskLogs +} from '../api/repairs'; +import { isTerminalRepairStatus } from '../utils/exceptions'; + +export default function useRepairTask() { + const [repairTask, setRepairTask] = useState(null); + const [repairLogs, setRepairLogs] = useState([]); + const [executionStateByExceptionId, setExecutionStateByExceptionId] = useState({}); + const completedRefreshRef = useRef(new Set()); + + // Load current repair task on mount + useEffect(() => { + let cancelled = false; + fetchCurrentRepairTask() + .then((payload) => { + if (cancelled || !payload.task) return; + setRepairTask(payload.task); + return fetchRepairTaskLogs(payload.task.task_id, 1, 20).then((lp) => { + if (!cancelled) setRepairLogs(lp.logs); + }); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + // WebSocket for repair task updates + useEffect(() => { + if (!repairTask?.task_id) return; + const socket = createRepairTaskStream(repairTask.task_id); + let cancelled = false; + + socket.onmessage = async (event) => { + if (cancelled) return; + const payload = JSON.parse(event.data); + if (payload.type === 'task.snapshot') { + setRepairTask(payload.data.task); + setRepairLogs(payload.data.recent_logs || []); + return; + } + try { + const tp = await fetchRepairTask(repairTask.task_id); + const lp = await fetchRepairTaskLogs(repairTask.task_id, 1, 20); + if (!cancelled) { + setRepairTask(tp.task); + setRepairLogs(lp.logs); + } + } catch (err) { + console.error('Repair task refresh error', err); + } + }; + + return () => { + cancelled = true; + socket.close(); + }; + }, [repairTask?.task_id]); + + // Track execution state per exception + useEffect(() => { + if (!repairTask?.task_id) return; + setExecutionStateByExceptionId((prev) => { + let changed = false; + const next = { ...prev }; + Object.entries(prev).forEach(([eid, state]) => { + if (!state || state.repairTaskId !== repairTask.task_id) return; + const nextStatus = + repairTask.status === 'completed' ? 'completed' + : repairTask.status === 'failed' ? 'failed' + : repairTask.status === 'running' ? 'running' + : 'accepted'; + const nextError = repairTask.status === 'failed' + ? (repairTask.error_message || '执行失败') + : ''; + if (state.status !== nextStatus || state.error !== nextError) { + next[eid] = { ...state, status: nextStatus, error: nextError }; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [repairTask]); + + const registerExecution = useCallback((exceptionId, action, repairTaskId, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, + status: 'accepted', repairTaskId, + submittedAt: new Date().toISOString(), + error: '', previewPayload + } + })); + }, []); + + const setExecuting = useCallback((exceptionId, action, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, + status: 'submitting', repairTaskId: null, + submittedAt: new Date().toISOString(), + error: '', + previewPayload: previewPayload || prev[exceptionId]?.previewPayload + } + })); + }, []); + + const setExecutionFailed = useCallback((exceptionId, action, errorMessage, previewPayload) => { + setExecutionStateByExceptionId((prev) => ({ + ...prev, + [exceptionId]: { + exceptionId, action, + status: 'failed', + error: errorMessage, + previewPayload: previewPayload || prev[exceptionId]?.previewPayload + } + })); + }, []); + + return { + repairTask, repairLogs, + executionStateByExceptionId, + registerExecution, setExecuting, setExecutionFailed, + completedRefreshRef, + setRepairTask, setRepairLogs + }; +} diff --git a/frontend/src/hooks/useWizardState.js b/frontend/src/hooks/useWizardState.js new file mode 100644 index 0000000..cfd26a1 --- /dev/null +++ b/frontend/src/hooks/useWizardState.js @@ -0,0 +1,50 @@ +// frontend/src/hooks/useWizardState.js +import { useState, useCallback } from 'react'; +import { + buildDefaultParams, inferDefaultAction +} from '../utils/exceptions'; + +export default function useWizardState(initialStep = 'select') { + const [wizardStep, setWizardStep] = useState(initialStep); + const [selectedAction, setSelectedAction] = useState(''); + const [actionParams, setActionParams] = useState({}); + const [previewState, setPreviewState] = useState({ + loading: false, payload: null, error: '', action: '' + }); + const [executeError, setExecuteError] = useState(''); + + const initForDetail = useCallback((detailRecord, viewMode) => { + if (!detailRecord) { + setSelectedAction(''); + setActionParams({}); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + return; + } + const isMetadataWizard = + viewMode === 'wizard' && + ['missing_tags', 'match_failed', 'low_score'].includes(detailRecord.exception_type); + const nextAction = isMetadataWizard + ? 'save_and_organize' + : inferDefaultAction(detailRecord); + setSelectedAction(nextAction); + setActionParams(buildDefaultParams(nextAction, detailRecord)); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + }, []); + + const focusAction = useCallback((action) => { + setSelectedAction(action); + setPreviewState({ loading: false, payload: null, error: '', action: '' }); + setExecuteError(''); + }, []); + + return { + wizardStep, setWizardStep, + selectedAction, setSelectedAction, + actionParams, setActionParams, + previewState, setPreviewState, + executeError, setExecuteError, + initForDetail, focusAction + }; +}