feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -127,6 +127,7 @@ export function useInvestigationPresentation(options: Options) {
"connecting" | "live" | "history" | "disconnected" | "error"
>("disconnected");
const [error, setError] = useState<string | null>(null);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0);
const previewRef = useRef<AbortController | null>(null);
@@ -171,7 +172,7 @@ export function useInvestigationPresentation(options: Options) {
[publish, source]
);
const selectHistory = useCallback(
(id: number | null) => {
(id: number | null, pin = false) => {
const store = storeRef.current;
if (store.selectedId === null)
store.update({
@@ -179,7 +180,7 @@ export function useInvestigationPresentation(options: Options) {
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(id === store.currentEntryId ? null : id)) return;
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
@@ -228,8 +229,9 @@ export function useInvestigationPresentation(options: Options) {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
signal.throwIfAborted();
const store = storeRef.current;
const previousView = store.current;
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
if (eventKey && store.entries.some((entry) => entry.key === eventKey && entry.status === "completed"))
return { recorded: true, duplicate: true };
update((view) => ({
...view,
@@ -252,6 +254,7 @@ export function useInvestigationPresentation(options: Options) {
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
);
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
const id = ++activityIdRef.current;
setActivity((current) => [
...current,
@@ -259,6 +262,7 @@ export function useInvestigationPresentation(options: Options) {
]);
let actionSnapshot = store.current;
const apply = (updater: (view: PresentationView) => PresentationView) => {
signal.throwIfAborted();
update(updater);
actionSnapshot = store.current;
};
@@ -285,7 +289,7 @@ export function useInvestigationPresentation(options: Options) {
})
);
signal.throwIfAborted();
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
@@ -300,13 +304,22 @@ export function useInvestigationPresentation(options: Options) {
viewing_history: true
};
} catch (cause) {
// An unfinished display may already have applied a chart or geometry before
// waiting for its frame. Roll back only its own view, never a newer scope.
if (signal.aborted && store.current === actionSnapshot) {
store.update(previousView);
actionSnapshot = previousView;
const map = valuesRef.current.mapRef.current;
if (map && store.selectedId === null && valuesRef.current.mapReady)
renderPresentationSnapshot(scene, map, previousView);
}
const message = signal.aborted
? "展示已取消"
: cause instanceof Error
? cause.message
: "展示失败";
if (historyId !== null)
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
@@ -339,6 +352,8 @@ export function useInvestigationPresentation(options: Options) {
loadHistory: async (cursor, signal) => {
const task = valuesRef.current.task;
if (!task || task.task_id !== taskId) return;
if (task.execution_generation !== cursor.execution_generation)
throw new Error("调查展示代次正在同步,请稍后重试");
const events = await fetchOperationalTaskPresentationHistory(
taskId,
cursor.event_cursor,
@@ -365,6 +380,9 @@ export function useInvestigationPresentation(options: Options) {
})
});
signal.throwIfAborted();
if (valuesRef.current.task?.task_id !== task.task_id ||
valuesRef.current.task.execution_generation !== task.execution_generation)
throw new Error("调查任务已切换,忽略旧展示结果");
storeRef.current.update(next);
publish();
const map = valuesRef.current.mapRef.current;
@@ -514,25 +532,26 @@ export function useInvestigationPresentation(options: Options) {
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
const map = valuesRef.current.mapRef.current;
if (!map) return "";
const task = valuesRef.current.task;
const progress =
task?.latest_progress?.execution_generation === task?.execution_generation
? task?.latest_progress
: null;
const target = progress?.progress.diagram?.node_refs.flatMap(
(node) => node.map_targets ?? []
)[0];
return createInvestigationDevCase(
caseId,
map.getCenter().toArray() as [number, number],
target
);
return createInvestigationDevCase(caseId);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const playing = storeRef.current.playing;
const selectedHistoryId = storeRef.current.selectedId;
useEffect(() => {
if (!playing) return;
const timer = window.setTimeout(() => {
const store = storeRef.current;
store.advancePlayback();
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
}, 1500 / playbackSpeed);
return () => window.clearTimeout(timer);
}, [playing, selectedHistoryId, playbackSpeed, publish, scene]);
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
@@ -552,7 +571,27 @@ export function useInvestigationPresentation(options: Options) {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory
onSelect: selectHistory,
playback: {
playing,
speed: playbackSpeed,
onToggle: () => {
const store = storeRef.current;
if (store.playing) store.pausePlayback();
else {
if (store.selectedId === null)
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
store.startPlayback(taskEntries);
}
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
onSpeedChange: (speed: number) => {
if ([0.5, 1, 2].includes(speed)) setPlaybackSpeed(speed);
},
onSeek: (id: number) => selectHistory(id, true)
}
},
executeCommand,
runPreview,