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 <noreply@anthropic.com>
This commit is contained in:
liumangmang
2026-05-07 20:50:20 +08:00
parent 30a8d8caa9
commit b97f5debac
5 changed files with 403 additions and 0 deletions
+43
View File
@@ -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 };
}
+149
View File
@@ -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
};
}
+28
View File
@@ -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 };
}
+133
View File
@@ -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
};
}
+50
View File
@@ -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
};
}