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