feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
@@ -16,6 +16,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
@@ -184,6 +185,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
if (
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
@@ -275,6 +277,7 @@ function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
appendLegacyDataParts(parts, "permission_request", message.permissions);
appendLegacyDataParts(parts, "question_request", message.questions);
appendLegacyDataParts(parts, "todo_update", message.todos);
appendLegacyDataParts(parts, "execution_plan", message.executionPlans);
return {
id: message.id,
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
clearMapFeatureInteractionState,
isPointOccludedByCityBuilding,
setMapFeatureInteractionState,
toMapFeatureReference
} from "./use-map-interactions";
@@ -8,6 +9,7 @@ import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
describe("map feature interaction state", () => {
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
type CityOcclusionMap = Parameters<typeof isPointOccludedByCityBuilding>[0];
it("maps selected business features to promoted vector feature references", () => {
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
@@ -42,4 +44,30 @@ describe("map feature interaction state", () => {
"fx_20200423_pipes-hit"
]));
});
it("blocks pointer interactions where a 3D building is rendered", () => {
const queryRenderedFeatures = vi.fn(() => [{ id: "building-1" }]);
const map = {
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
getPitch: vi.fn(() => 55),
queryRenderedFeatures
};
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(true);
expect(queryRenderedFeatures).toHaveBeenCalledWith([120, 80], {
layers: ["fengxian-city-buildings"]
});
});
it("keeps 2D pointer interactions independent of the optional city scene", () => {
const queryRenderedFeatures = vi.fn();
const map = {
getLayer: vi.fn(() => ({ id: "fengxian-city-buildings" })),
getPitch: vi.fn(() => 0),
queryRenderedFeatures
};
expect(isPointOccludedByCityBuilding(map as unknown as CityOcclusionMap, [120, 80])).toBe(false);
expect(queryRenderedFeatures).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { Map as MapLibreMap, MapLayerMouseEvent, PointLike } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import type { DetailFeature } from "../types";
@@ -8,6 +8,7 @@ import {
toDetailFeature
} from "../map/feature-adapter";
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
import { CITY_BUILDING_LAYER_ID } from "../map/city-scene-layers";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
@@ -46,6 +47,16 @@ export function clearMapFeatureInteractionState(
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type CityOcclusionMap = Pick<
MapLibreMap,
"getLayer" | "getPitch" | "queryRenderedFeatures"
>;
export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: PointLike) {
if (map.getPitch() <= 0 || !map.getLayer(CITY_BUILDING_LAYER_ID)) return false;
return map.queryRenderedFeatures(point, { layers: [CITY_BUILDING_LAYER_ID] }).length > 0;
}
type UseMapInteractionsOptions = {
availableSourceIds: readonly WaterNetworkSourceId[];
mapRef: RefObject<MapLibreMap | null>;
@@ -74,6 +85,11 @@ export function useMapInteractions({
};
const handleMouseMove = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) {
clearHoveredFeature();
map.getCanvas().style.cursor = "";
return;
}
const feature = event.features?.[0];
const source = feature && getWaterNetworkSourceId(feature);
const id = feature && getFeatureId(feature);
@@ -100,6 +116,7 @@ export function useMapInteractions({
};
const handleClick = (event: MapLayerMouseEvent) => {
if (isPointOccludedByCityBuilding(map, event.point)) return;
const feature = event.features?.[0];
if (feature) onSelectFeature(toDetailFeature(feature));
};
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { OperationalTask } from "../api/operational-task-client";
import { toOperationalTaskCondition } from "./use-operational-tasks";
function task(taskType: OperationalTask["task_type"]): OperationalTask {
return {
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType,
workflow: "general",
lifecycle_status: "open",
active_run: {
task_id: "22222222-2222-4222-8222-222222222222",
run_id: "55555555-5555-4555-8555-555555555555",
generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
agent_session_id: null,
status: "queued",
lease_expires_at: null,
heartbeat_at: null,
started_at: null,
finished_at: null,
failure_code: null,
failure_detail: null,
dispatch_attempts: 0,
next_dispatch_at: "2026-09-23T08:00:00Z",
revision: 1,
created_at: "2026-09-23T08:00:00Z",
updated_at: "2026-09-23T08:00:00Z"
},
status: "queued",
priority: "normal",
revision: 2,
execution_generation: 1,
execution_request_id: "33333333-3333-4333-8333-333333333333",
title: "预约维修",
summary: "任务当前说明",
source: { kind: "external_system", external_id: "work-order-1", external_revision: 2, event_kind: "general" },
trigger: { kind: "external_sync", occurred_at: "2026-09-23T08:00:00Z", detail: "初次同步时的旧描述" },
scheduled_for: "2026-09-23T10:00:00Z",
agent_session_id: null,
agent_lease_expires_at: null,
current_plan_revision: null,
current_report_revision: null,
created_at: "2026-09-23T08:00:00Z",
updated_at: "2026-09-23T09:00:00Z",
latest_plan: null,
latest_report: null,
evidence: [],
actions: [],
permissions: {
can_publish_artifacts: false,
can_propose_actions: false,
can_sync_external_feedback: false,
can_decide_work_orders: false,
can_execute_work_orders: false,
can_decide_commands: false,
can_execute_commands: false
},
work_order_snapshot: {
work_order_id: "44444444-4444-4444-8444-444444444444",
revision: 2,
title: "预约维修",
summary: "编辑后的工单说明",
status: "open",
priority: "normal",
assignee: "维修班组",
scheduled_at: "2026-09-23T10:00:00Z",
work_order_type: "general",
location: "测试点",
scenario_code: null,
subtasks: null,
origin: "synthetic",
updated_at: "2026-09-23T09:00:00Z"
}
};
}
describe("operational task condition mapper", () => {
it("uses the latest work-order snapshot instead of the immutable trigger description", () => {
expect(toOperationalTaskCondition(task("external_work_order")).detail).toBe("编辑后的工单说明");
});
it("does not render an open external work order without an Agent run as running", () => {
expect(toOperationalTaskCondition({
...task("external_work_order"),
active_run: null
}).status).toBe("pending");
});
it("falls back to the current task summary when an external work order has no snapshot", () => {
expect(toOperationalTaskCondition({
...task("external_work_order"),
work_order_snapshot: null
}).detail).toBe("任务当前说明");
});
it("keeps the event trigger detail for investigations", () => {
expect(toOperationalTaskCondition(task("event_investigation")).detail).toBe("初次同步时的旧描述");
});
});
@@ -0,0 +1,294 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
acknowledgeOperationalTaskNotification,
createOperationalRequestId,
createOperationalTask,
decideOperationalTaskAction,
executeOperationalTaskAction,
fetchOperationalTask,
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskNotifications,
reconcileOperationalTaskAction,
type OperationalTask,
type OperationalTaskDecision,
type OperationalTaskNotification
} from "../api/operational-task-client";
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
import type { WorkbenchSyncState } from "./use-workbench-sync";
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
export type CreateInvestigationInput = {
kind: ManualInvestigationKind;
title: string;
summary: string;
detail: string;
occurredAt: string;
priority?: OperationalTask["priority"];
source?: OperationalTask["source"];
triggerKind?: "event" | "manual";
};
export function useOperationalTasks(
getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState
) {
const syncEvents = sync.events;
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
const [tasks, setTasks] = useState<OperationalTask[]>([]);
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
const [available, setAvailable] = useState(false);
const activatedRef = useRef(false);
const tasksRef = useRef<OperationalTask[]>([]);
const [error, setError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const forceRefreshRef = useRef(false);
const refresh = useCallback(() => {
forceRefreshRef.current = true;
setRefreshGeneration((current) => current + 1);
}, []);
useEffect(() => {
const controller = new AbortController();
const load = async () => {
const summaries = sync.snapshot?.tasks ?? [];
try {
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
const changed = forceRefreshRef.current
? summaries
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
const details = await Promise.all(changed.map((task) =>
fetchOperationalTask(task.task_id, getAccessToken, controller.signal)
));
if (controller.signal.aborted) return;
const detailMap = new Map(details.map((task) => [task.task_id, task]));
const nextTasks = summaries.flatMap((summary) => {
const detail = detailMap.get(summary.task_id) ?? current.get(summary.task_id);
return detail ? [detail] : [];
});
tasksRef.current = nextTasks;
forceRefreshRef.current = false;
setTasks(nextTasks);
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
setAvailable(true);
activatedRef.current = true;
setError(null);
setLastSyncedAt(new Date().toISOString());
} catch (nextError) {
if (controller.signal.aborted) return;
if (!activatedRef.current) setAvailable(false);
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
if (sync.snapshot) void load();
return () => {
controller.abort();
};
}, [getAccessToken, refreshGeneration, sync.snapshot]);
useEffect(() => {
const events = syncEvents;
if (events.length === 0) return;
const controller = new AbortController();
const lastSequence = events.at(-1)!.event_seq;
const taskIds = new Set<string>();
let notificationsChanged = false;
for (const event of events) {
if (event.topic === "operational.notification.changed") notificationsChanged = true;
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue;
const taskId = typeof event.metadata.task_id === "string"
? event.metadata.task_id
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
if (taskId) taskIds.add(taskId);
}
void Promise.all([
notificationsChanged
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null),
Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal)
))
]).then(([nextNotifications, changedTasks]) => {
if (controller.signal.aborted) return;
if (nextNotifications) setNotifications(nextNotifications);
if (changedTasks.length > 0) {
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
tasksRef.current = [
...changedTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id))
];
setTasks(tasksRef.current);
setLastSyncedAt(new Date().toISOString());
}
acknowledgeEventsThrough(lastSequence);
}).catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
}
});
return () => controller.abort();
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
const task = await createOperationalTask({
request_id: createOperationalRequestId(),
task_type: "event_investigation",
workflow: input.kind,
title: input.title,
summary: input.summary,
priority: input.priority ?? (input.kind === "burst_report" ? "urgent" : "normal"),
source: input.source ?? { kind: "operator", external_id: null, external_revision: null, event_kind: input.kind },
trigger: {
kind: input.triggerKind ?? "manual",
occurred_at: input.occurredAt,
detail: input.detail
}
}, getAccessToken);
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
setTasks(tasksRef.current);
activatedRef.current = true;
setAvailable(true);
setLoading(false);
setError(null);
setLastSyncedAt(new Date().toISOString());
return task;
}, [getAccessToken]);
const decideAction = useCallback(async (
taskId: string,
actionId: string,
expectedVersion: number,
decision: OperationalTaskDecision,
comment?: string
) => {
const action = await decideOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion,
decision,
comment
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const executeAction = useCallback(async (
taskId: string,
actionId: string,
expectedVersion: number
) => {
const action = await executeOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const reconcileAction = useCallback(async (taskId: string, actionId: string, expectedVersion: number) => {
const action = await reconcileOperationalTaskAction(taskId, actionId, {
request_id: createOperationalRequestId(),
expected_version: expectedVersion
}, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? {
...item,
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
} : item));
return action;
}, [getAccessToken]);
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
await acknowledgeOperationalTaskNotification(
notificationId,
version,
createOperationalRequestId(),
getAccessToken
);
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
}, [getAccessToken]);
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
return {
available,
error: error ?? sync.error,
syncStatus: sync.status,
lastSyncedAt,
loading,
tasks,
conditions,
notifications,
refresh,
createInvestigation,
decideAction,
executeAction,
reconcileAction,
acknowledgeNotification,
loadEvidenceSnapshot
};
}
export function toOperationalTaskCondition(task: OperationalTask): ScheduledConditionRecord {
return {
id: operationalTaskConditionId(task.task_id),
kind: "condition",
taskId: "operational-task",
dataSource: "operational_tasks",
sessionId: task.active_run?.agent_session_id ?? task.agent_session_id ?? "",
title: task.title,
summary: task.summary,
status: toConditionStatus(task),
riskLevel: toRiskLevel(task),
scheduledAt: task.scheduled_for ?? task.trigger.occurred_at,
updatedAt: Date.parse(task.updated_at),
detail: task.task_type === "external_work_order"
? task.work_order_snapshot?.summary ?? task.summary
: task.trigger.detail,
recommendation: task.latest_report?.report.current_conclusion || undefined,
evidence: task.evidence.map((item) => item.summary),
modelName: "TJWater operational task",
operationalTask: task
};
}
export function operationalTaskConditionId(taskId: string) {
return `operational-task:${taskId}`;
}
function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
const status = task.lifecycle_status;
if (status === "completed") return "completed";
if (status === "failed") return "error";
if (status === "cancelled" || status === "waiting_human") return "warning";
if (task.task_type === "external_work_order") {
const externalStatus = task.work_order_snapshot?.status;
if (externalStatus === "completed") return "completed";
if (externalStatus === "cancelled" || externalStatus === "awaiting_reply") return "warning";
if (externalStatus === "in_progress") return "running";
return "pending";
}
return task.active_run?.status === "queued" ? "pending" : "running";
}
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
const { priority, lifecycle_status: status } = task;
if (status === "failed" || priority === "urgent") return "critical";
if (status === "waiting_human") return "attention";
return "normal";
}
@@ -27,6 +27,7 @@ import {
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
@@ -87,6 +88,7 @@ export function useWorkbenchAgent({
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -546,6 +548,7 @@ export function useWorkbenchAgent({
}
applySessionId(loadedSession.sessionId);
setSessionPurpose(loadedSession.purpose);
setSessionTitle(loadedSession.title);
setStatusLabel("Agent 历史会话已打开");
setPermissionOverrides({});
@@ -569,6 +572,20 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
}
const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId);
const collaborationSessionId = collaboration.session_id || collaboration.id;
if (!collaborationSessionId) {
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
return collaborationSessionId;
}, [loadHistorySession]);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
const normalizedTitle = title.trim();
@@ -614,6 +631,7 @@ export function useWorkbenchAgent({
(nextStatusLabel = "Agent 新对话已准备") => {
clearSessionId();
setSessionTitle("新对话");
setSessionPurpose("ordinary_chat");
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
setPermissionOverrides({});
@@ -875,6 +893,7 @@ export function useWorkbenchAgent({
}, []);
return {
apiClient: clientRef.current,
approvalMode,
collapsePanel,
expandPanel,
@@ -891,11 +910,13 @@ export function useWorkbenchAgent({
refreshSessionHistory,
startNewSession,
loadHistorySession,
openTaskCollaboration,
renameHistorySession,
deleteHistorySession,
sessionHistory,
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
sessionId,
sessionPurpose,
sessionTitle,
statusLabel,
streamRenderState,
@@ -272,6 +272,7 @@ export function useWorkbenchMap({
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
antialias: true,
preserveDrawingBuffer: true,
attributionControl: false
});
@@ -281,7 +282,6 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
removeCityScene = installCityScene(map);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
@@ -328,6 +328,7 @@ export function useWorkbenchMap({
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
removeCityScene = installCityScene(map);
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);
updateSourceStatus(
@@ -109,6 +109,16 @@ describe("workbench runtime data", () => {
expect(resolved.workOrderDataSource).toBe("none");
});
it("keeps explicit demo work orders visible without treating them as backend fallback", () => {
const demoWorkOrder = { ...mockWorkOrder, dataSource: "mock" as const };
const resolved = resolveWorkbenchRuntimeData([mockCondition, demoWorkOrder], null, {
operationalDate: SEPTEMBER_17
});
expect(resolved.scheduledConditions).toEqual([mockCondition, demoWorkOrder]);
expect(resolved.workOrderDataSource).toBe("none");
});
it("keeps mock mode separate from the selected backend history day", () => {
const backendAlert = {
id: "scheduled-condition-alert-schedule-run-1",
@@ -266,7 +276,7 @@ describe("workbench runtime data", () => {
expect(resolved.runtimeDataSource).toBe("mock");
});
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
it("keeps explicit demo appointments when an explicit backend day has no work orders", () => {
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
availability: "ready",
source: "none",
@@ -284,7 +294,7 @@ describe("workbench runtime data", () => {
workOrders: []
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
expect(resolved.scheduledConditions).toEqual([mockCondition]);
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder]);
expect(resolved.runtimeDataSource).toBe("mock");
expect(resolved.workOrderDataSource).toBe("backend");
});
@@ -369,7 +379,7 @@ describe("workbench runtime data", () => {
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
expect(resolved.alertDataSource).toBe("backend");
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
expect(resolved.scheduledConditions).toEqual([mockCondition, mockWorkOrder, backendWorkOrder]);
expect(resolved.workOrderDataSource).toBe("backend");
});
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
fetchWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "../api/workbench-runtime-client";
import { fetchRealtimePipeFlows } from "../api/realtime-flow-client";
@@ -13,6 +12,7 @@ import {
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
import type { ScheduledConditionItem } from "../types";
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
import type { WorkbenchSyncState } from "./use-workbench-sync";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
@@ -80,6 +80,10 @@ export function resolveWorkbenchRuntimeData(
(condition) => condition.kind === "condition"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const demoWorkOrders = mockConditions.filter(
(condition) => condition.kind === "work_order"
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
);
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
);
@@ -103,7 +107,7 @@ export function resolveWorkbenchRuntimeData(
);
return {
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
scheduledConditions: [...fallbackConditions, ...demoWorkOrders, ...resolvedWorkOrders],
workbenchAlerts: backendAlertsConfigured
? backendSnapshot!.alerts
: createScheduledConditionAlerts(fallbackConditions),
@@ -128,10 +132,13 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
&& candidate.getDate() === operationalDate.getDate();
}
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
export function useWorkbenchRuntimeData(
getAccessToken: AccessTokenProvider | undefined,
sync: WorkbenchSyncState
) {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
ReturnType<typeof fetchRealtimePipeFlows>
> | null>(null);
@@ -168,40 +175,15 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
return () => window.clearInterval(intervalId);
}, [backendSnapshot]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshBackendSnapshot = async () => {
try {
const snapshot = await fetchWorkbenchRuntimeSnapshot(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setBackendSnapshot(snapshot);
}
} catch {
if (!controller.signal.aborted) setBackendSnapshot(null);
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshBackendSnapshot,
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
);
}
}
};
void refreshBackendSnapshot();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
useEffect(() => {
const controller = new AbortController();
let timeoutId: number | undefined;
const refreshRealtimePipeFlows = async () => {
if (document.visibilityState === "hidden") {
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
return;
}
try {
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
@@ -219,9 +201,17 @@ export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
}
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
void refreshRealtimePipeFlows();
};
void refreshRealtimePipeFlows();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
controller.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken]);
@@ -1,66 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchScenarioAnalysis } from "./use-workbench-scenario-analysis";
const projectPage = {
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
};
function analysis(runId: string) {
return {
run_id: runId,
name: "历史爆管定位",
run_type: "burst_location",
run_type_label: "爆管定位",
status: "completed",
status_label: "已完成",
created_by: "alice",
created_at: "2026-09-17T02:20:00Z",
started_at: "2026-09-17T02:10:00Z",
results: [],
map_payload: {
type: "FeatureCollection",
features: [{
type: "Feature",
properties: { label: "历史运行几何" },
geometry: { type: "Point", coordinates: [121.4, 30.8] }
}]
}
};
}
describe("selected workbench scenario analysis", () => {
afterEach(() => vi.unstubAllGlobals());
it("loads geometry for the selected backend run", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(analysis(runId)), { status: 200 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("backend"));
expect(result.current.projection?.runId).toBe(runId);
expect(result.current.projection?.simulationOverlay?.features[0]?.properties).toEqual({
label: "历史运行几何"
});
unmount();
});
it("falls back independently when the selected run cannot be read", async () => {
const runId = "86fd7c19-5b75-434f-999c-ec8fdf67d10b";
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ detail: "unavailable" }), { status: 503 })));
const { result, unmount } = renderHook(() => useWorkbenchScenarioAnalysis(runId));
await waitFor(() => expect(result.current.status).toBe("fallback"));
expect(result.current.projection).toBeNull();
unmount();
});
});
@@ -1,71 +0,0 @@
import { useEffect, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
fetchWorkbenchAnalysisRun,
type WorkbenchAnalysisProjection
} from "../api/workbench-runtime-client";
const SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS = 15_000;
type ScenarioAnalysisState = {
runId: string;
projection: WorkbenchAnalysisProjection | null;
failed: boolean;
};
export type WorkbenchScenarioAnalysis = {
projection: WorkbenchAnalysisProjection | null;
status: "disabled" | "loading" | "backend" | "fallback";
};
export function useWorkbenchScenarioAnalysis(
runId: string | null,
getAccessToken?: AccessTokenProvider
): WorkbenchScenarioAnalysis {
const [state, setState] = useState<ScenarioAnalysisState | null>(null);
useEffect(() => {
if (!runId) {
setState(null);
return;
}
const controller = new AbortController();
let timeoutId: number | undefined;
setState((current) => current?.runId === runId
? current
: { runId, projection: null, failed: false });
const refresh = async () => {
try {
const projection = await fetchWorkbenchAnalysisRun(
runId,
getAccessToken,
controller.signal
);
if (!controller.signal.aborted) {
setState({ runId, projection, failed: false });
}
} catch {
if (!controller.signal.aborted) {
setState({ runId, projection: null, failed: true });
}
} finally {
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(refresh, SCENARIO_ANALYSIS_REFRESH_INTERVAL_MS);
}
}
};
void refresh();
return () => {
controller.abort();
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [getAccessToken, runId]);
if (!runId) return { projection: null, status: "disabled" };
if (!state || state.runId !== runId) return { projection: null, status: "loading" };
if (state.failed) return { projection: null, status: "fallback" };
if (state.projection) return { projection: state.projection, status: "backend" };
return { projection: null, status: "loading" };
}
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
consumeWorkbenchEvents,
fetchWorkbenchSyncSnapshot,
type WorkbenchEvent,
type WorkbenchSyncSnapshot
} from "../api/workbench-sync-client";
const RECONCILE_INTERVAL_MS = 60_000;
const RUNTIME_TOPICS = new Set<WorkbenchEvent["topic"]>([
"workbench.alert.changed",
"workbench.work_order.changed",
"analysis.run.changed"
]);
export type WorkbenchSyncState = {
snapshot: WorkbenchSyncSnapshot | null;
events: WorkbenchEvent[];
status: "connecting" | "live" | "reconnecting";
error: string | null;
acknowledgeEventsThrough: (eventSequence: number) => void;
};
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
const [status, setStatus] = useState<WorkbenchSyncState["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const cursorRef = useRef(0);
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
const runtimeRefreshRef = useRef<number | undefined>(undefined);
const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
}, []);
useEffect(() => {
const controller = new AbortController();
let reconnectTimer: number | undefined;
let reconcileTimer: number | undefined;
let retryMs = 1_000;
let initialized = false;
const loadSnapshot = async () => {
const next = await fetchWorkbenchSyncSnapshot(getAccessToken, controller.signal);
if (controller.signal.aborted) return;
cursorRef.current = next.eventCursor;
setSnapshot(next);
setError(null);
initialized = true;
};
const connect = async () => {
try {
if (!initialized) await loadSnapshot();
if (controller.signal.aborted) return;
statusRef.current = "live";
setStatus("live");
retryMs = 1_000;
await consumeWorkbenchEvents({
after: cursorRef.current,
getAccessToken,
signal: controller.signal,
onReset: loadSnapshot,
onEvents: (nextEvents) => {
const unseen = nextEvents.filter((event) => event.event_seq > cursorRef.current);
if (unseen.length === 0) return;
cursorRef.current = unseen.at(-1)!.event_seq;
setEvents((current) => [...current, ...unseen]);
if (unseen.some((event) => RUNTIME_TOPICS.has(event.topic))) {
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
runtimeRefreshRef.current = window.setTimeout(() => {
void loadSnapshot().catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工作台快照刷新失败");
}
});
}, 250);
}
}
});
if (!controller.signal.aborted) {
statusRef.current = "reconnecting";
setStatus("reconnecting");
}
} catch (nextError) {
if (controller.signal.aborted) return;
statusRef.current = "reconnecting";
setStatus("reconnecting");
setError(nextError instanceof Error ? nextError.message : "工作台事件连接中断");
}
if (!controller.signal.aborted) {
reconnectTimer = window.setTimeout(() => { void connect(); }, retryMs);
retryMs = Math.min(retryMs * 2, 30_000);
}
};
void connect();
reconcileTimer = window.setInterval(() => {
if (statusRef.current !== "live") void loadSnapshot().catch(() => undefined);
}, RECONCILE_INTERVAL_MS);
return () => {
controller.abort();
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
if (reconcileTimer !== undefined) window.clearInterval(reconcileTimer);
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
};
}, [getAccessToken]);
return { snapshot, events, status, error, acknowledgeEventsThrough };
}