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:
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user