feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
fetchElementGisProperties,
|
||||
fetchSourceGisFeatureProperties,
|
||||
type GisAttributeGroup
|
||||
} from "../api/gis-properties-client";
|
||||
import { isSourceGisSourceId } from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
|
||||
type GisPropertiesState = {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
groups: GisAttributeGroup[];
|
||||
};
|
||||
|
||||
const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
|
||||
|
||||
export function useElementGisProperties(
|
||||
feature: DetailFeature | null,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature) {
|
||||
setState(INITIAL_STATE);
|
||||
return;
|
||||
}
|
||||
if (feature.layer === "water_quality_monitoring_points") {
|
||||
setState({ status: "ready", groups: [] });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setState({ status: "loading", groups: [] });
|
||||
const sourceLayerKey = feature.properties.source_layer_key;
|
||||
const rawSourceFid = feature.properties.source_fid;
|
||||
const sourceFid = typeof rawSourceFid === "number"
|
||||
? rawSourceFid
|
||||
: Number(rawSourceFid);
|
||||
const request = isSourceGisSourceId(feature.layer)
|
||||
&& typeof sourceLayerKey === "string"
|
||||
&& Number.isSafeInteger(sourceFid)
|
||||
&& sourceFid >= 0
|
||||
? fetchSourceGisFeatureProperties(
|
||||
sourceLayerKey,
|
||||
sourceFid,
|
||||
projectId,
|
||||
getAccessToken,
|
||||
controller.signal
|
||||
)
|
||||
: fetchElementGisProperties(
|
||||
feature.layer,
|
||||
feature.id,
|
||||
projectId,
|
||||
getAccessToken,
|
||||
controller.signal
|
||||
);
|
||||
void request.then((groups) => {
|
||||
if (!controller.signal.aborted) setState({ status: "ready", groups });
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setState({ status: "error", groups: [] });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [feature, getAccessToken, projectId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -33,14 +33,13 @@ describe("map feature interaction state", () => {
|
||||
});
|
||||
|
||||
it("uses transparent hit layers for pointer interactions", () => {
|
||||
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
|
||||
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual(expect.arrayContaining([
|
||||
"pipes-hit",
|
||||
"junctions-hit",
|
||||
"valves-hit",
|
||||
"reservoirs-hit",
|
||||
"pumps-hit",
|
||||
"tanks-hit",
|
||||
"scada-hit"
|
||||
]);
|
||||
"scada-pressure-hit",
|
||||
"scada-flow-hit",
|
||||
"water_quality_monitoring_points-hit",
|
||||
"fx_20200423_pipes-hit"
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { findOwnedWorkbenchProject } from "../api/project-client";
|
||||
import {
|
||||
FrontendActionExecutor,
|
||||
agentBootstrapKey,
|
||||
@@ -62,7 +63,18 @@ export function useWorkbenchAgent({
|
||||
const collapseTimerRef = useRef<number | null>(null);
|
||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
|
||||
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const getProjectId = useCallback(() => {
|
||||
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
|
||||
({ project }) => project.project_id,
|
||||
(error) => {
|
||||
projectIdPromiseRef.current = null;
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
return projectIdPromiseRef.current;
|
||||
}, [getAccessToken]);
|
||||
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId }));
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||
const registryRef = useRef<UIRegistry | null>(null);
|
||||
@@ -82,6 +94,7 @@ export function useWorkbenchAgent({
|
||||
const [resumedStreaming, setResumedStreaming] = useState(false);
|
||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [promptDraft, setPromptDraft] = useState("");
|
||||
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
|
||||
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
|
||||
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
|
||||
@@ -605,6 +618,7 @@ export function useWorkbenchAgent({
|
||||
setPermissionOverrides({});
|
||||
setQuestionOverrides({});
|
||||
setStreamRenderState({});
|
||||
setPromptDraft("");
|
||||
chat.setMessages([]);
|
||||
},
|
||||
[chat, clearSessionId]
|
||||
@@ -821,7 +835,9 @@ export function useWorkbenchAgent({
|
||||
statusLabel,
|
||||
streamRenderState,
|
||||
selectedModel,
|
||||
promptDraft,
|
||||
setApprovalMode,
|
||||
setPromptDraft,
|
||||
setSelectedModel,
|
||||
stopPrompt,
|
||||
streaming,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import type { FeatureCollection, Point } from "geojson";
|
||||
import type { FeatureCollection, Geometry, Point } from "geojson";
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type Map as MapLibreMap,
|
||||
@@ -10,14 +10,14 @@ import maplibregl, {
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
|
||||
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
SIMULATION_SOURCE_IDS,
|
||||
simulationAnnotationLayers,
|
||||
simulationSources
|
||||
} from "../map/annotation-layers";
|
||||
import { fitNetworkBounds } from "../map/camera";
|
||||
import { fitNetworkBounds, type NetworkViewParams } from "../map/camera";
|
||||
import {
|
||||
filterWaterNetworkLayersBySourceIds,
|
||||
waterNetworkBusinessLayers,
|
||||
@@ -66,6 +66,9 @@ type UseWorkbenchMapOptions = {
|
||||
onClearSelection: () => void;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||
allowDemoSimulation: boolean;
|
||||
networkView?: NetworkViewParams | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
@@ -75,7 +78,9 @@ type UseWorkbenchMapResult = {
|
||||
mapError: string | null;
|
||||
sourceStatuses: WorkbenchSourceStatus[];
|
||||
availableSourceIds: WaterNetworkSourceId[];
|
||||
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
|
||||
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||
scadaObservedAt: string | null;
|
||||
refreshGeoServerLayers: () => boolean;
|
||||
fitNetworkBounds: () => void;
|
||||
};
|
||||
@@ -105,10 +110,16 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
),
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
|
||||
};
|
||||
|
||||
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
|
||||
const SCADA_REFRESH_INTERVAL_MS = 15_000;
|
||||
const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
containerRef,
|
||||
@@ -117,16 +128,30 @@ export function useWorkbenchMap({
|
||||
onClearSelection,
|
||||
onSelectFeature,
|
||||
selectedFeature,
|
||||
simulationOverlay,
|
||||
allowDemoSimulation,
|
||||
networkView,
|
||||
getAccessToken
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const simulationOverlayRef = useRef(simulationOverlay);
|
||||
const allowDemoSimulationRef = useRef(allowDemoSimulation);
|
||||
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
|
||||
const fittedNetworkViewRef = useRef<string | null>(null);
|
||||
simulationOverlayRef.current = simulationOverlay;
|
||||
allowDemoSimulationRef.current = allowDemoSimulation;
|
||||
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
const [scadaObservedAt, setScadaObservedAt] = useState<string | null>(null);
|
||||
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
|
||||
GeoServerWaterNetworkSourceId[]
|
||||
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
|
||||
const [sourceFeatureCounts, setSourceFeatureCounts] = useState<
|
||||
Partial<Record<WaterNetworkSourceId, number>>
|
||||
>({});
|
||||
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
|
||||
const geoServerLayerProbeInFlightRef = useRef(false);
|
||||
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
|
||||
@@ -172,6 +197,9 @@ export function useWorkbenchMap({
|
||||
? current
|
||||
: availability.availableSourceIds
|
||||
);
|
||||
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
|
||||
? current
|
||||
: availability.featureCounts);
|
||||
|
||||
if (availability.failedSourceIds.length > 0) {
|
||||
updateSourceStatus(
|
||||
@@ -224,12 +252,8 @@ export function useWorkbenchMap({
|
||||
setMapError(null);
|
||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||
const scadaAbortController = new AbortController();
|
||||
let scadaRefreshTimeoutId: number | undefined;
|
||||
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
||||
const scadaLoadPromise = fetchScadaDeviceCollection(
|
||||
getAccessToken,
|
||||
scadaAbortController.signal
|
||||
);
|
||||
scadaLoadPromiseRef.current = scadaLoadPromise;
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: createBaseStyle(mapboxToken),
|
||||
@@ -254,6 +278,12 @@ export function useWorkbenchMap({
|
||||
});
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
|
||||
syncSimulationSourceData(
|
||||
map,
|
||||
simulationOverlayRef.current,
|
||||
allowDemoSimulationRef.current
|
||||
);
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
const activeScadaBusinessLayers =
|
||||
@@ -305,27 +335,47 @@ export function useWorkbenchMap({
|
||||
: "SCADA 图标不可用,已使用圆点图层降级显示。"
|
||||
);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
fitNetworkBounds(map, networkViewRef.current);
|
||||
fittedNetworkViewRef.current = networkViewKey(networkViewRef.current);
|
||||
});
|
||||
|
||||
void scadaLoadPromise.then((collection) => {
|
||||
scadaCollectionRef.current = collection;
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
"online",
|
||||
`已通过API加载 ${collection.features.length} 个SCADA设备。`
|
||||
const refreshScada = async () => {
|
||||
const snapshotPromise = fetchScadaSnapshot(
|
||||
getAccessToken,
|
||||
scadaAbortController.signal
|
||||
);
|
||||
}).catch((error) => {
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
"offline",
|
||||
error instanceof Error ? error.message : "SCADA API加载失败。"
|
||||
);
|
||||
});
|
||||
scadaLoadPromiseRef.current = snapshotPromise
|
||||
.then((snapshot) => snapshot.collection)
|
||||
.catch(() => scadaCollectionRef.current);
|
||||
|
||||
try {
|
||||
const snapshot = await snapshotPromise;
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
scadaCollectionRef.current = snapshot.collection;
|
||||
setScadaObservedAt(snapshot.observedAt);
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(snapshot.collection);
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
snapshot.telemetryStatus === "ready" ? "online" : "degraded",
|
||||
getScadaStatusMessage(snapshot)
|
||||
);
|
||||
} catch (error) {
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
setScadaObservedAt(null);
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
scadaCollectionRef.current.features.length > 0 ? "degraded" : "offline",
|
||||
error instanceof Error ? error.message : "SCADA API加载失败。"
|
||||
);
|
||||
} finally {
|
||||
if (!scadaAbortController.signal.aborted) {
|
||||
scadaRefreshTimeoutId = window.setTimeout(refreshScada, SCADA_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
void refreshScada();
|
||||
|
||||
let resizeFrame: number | null = null;
|
||||
const resizeMap = () => map.resize();
|
||||
@@ -375,6 +425,9 @@ export function useWorkbenchMap({
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
scadaAbortController.abort();
|
||||
if (scadaRefreshTimeoutId !== undefined) {
|
||||
window.clearTimeout(scadaRefreshTimeoutId);
|
||||
}
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
@@ -387,6 +440,15 @@ export function useWorkbenchMap({
|
||||
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || !networkView) return;
|
||||
const key = networkViewKey(networkView);
|
||||
if (fittedNetworkViewRef.current === key) return;
|
||||
fitNetworkBounds(map, networkView);
|
||||
fittedNetworkViewRef.current = key;
|
||||
}, [mapReady, networkView]);
|
||||
|
||||
useMapInteractions({
|
||||
availableSourceIds: availableGeoServerSourceIds,
|
||||
mapRef,
|
||||
@@ -401,10 +463,16 @@ export function useWorkbenchMap({
|
||||
visible: impactVisible
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
|
||||
}, [allowDemoSimulation, mapReady, simulationOverlay]);
|
||||
|
||||
function fitToNetworkBounds() {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
fitNetworkBounds(map, networkViewRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,12 +486,60 @@ export function useWorkbenchMap({
|
||||
sourceId === "scada" ||
|
||||
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
|
||||
),
|
||||
sourceFeatureCounts,
|
||||
getScadaFeatures,
|
||||
scadaObservedAt,
|
||||
refreshGeoServerLayers,
|
||||
fitNetworkBounds: fitToNetworkBounds
|
||||
};
|
||||
}
|
||||
|
||||
function areFeatureCountsEqual(
|
||||
current: Partial<Record<WaterNetworkSourceId, number>>,
|
||||
next: Partial<Record<WaterNetworkSourceId, number>>
|
||||
) {
|
||||
const currentEntries = Object.entries(current);
|
||||
const nextEntries = Object.entries(next);
|
||||
return currentEntries.length === nextEntries.length
|
||||
&& nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count);
|
||||
}
|
||||
|
||||
function networkViewKey(view: NetworkViewParams) {
|
||||
return view.bbox3857.join(",");
|
||||
}
|
||||
|
||||
function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSnapshot>>) {
|
||||
const deviceSummary = `已通过API加载 ${snapshot.collection.features.length} 个SCADA设备`;
|
||||
if (snapshot.telemetryStatus === "ready") {
|
||||
return `${deviceSummary},其中 ${snapshot.observedDeviceCount} 个带有最近15分钟监测值。`;
|
||||
}
|
||||
if (snapshot.telemetryStatus === "unavailable") {
|
||||
return `${deviceSummary};实时监测值暂不可用,保留设备元数据。`;
|
||||
}
|
||||
return `${deviceSummary};最近15分钟没有监测值,保留设备元数据。`;
|
||||
}
|
||||
|
||||
function syncSimulationSourceData(
|
||||
map: MapLibreMap,
|
||||
backendOverlay: FeatureCollection<Geometry> | null,
|
||||
allowDemoSimulation: boolean
|
||||
) {
|
||||
const useBackendOverlay = Boolean(backendOverlay?.features.length);
|
||||
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
|
||||
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
|
||||
);
|
||||
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
|
||||
useBackendOverlay || !allowDemoSimulation
|
||||
? EMPTY_SIMULATION_COLLECTION
|
||||
: simulationSources.impactArea.data
|
||||
);
|
||||
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
|
||||
useBackendOverlay || !allowDemoSimulation
|
||||
? EMPTY_SIMULATION_COLLECTION
|
||||
: simulationSources.annotations.data
|
||||
);
|
||||
}
|
||||
|
||||
function syncDataDependentGeoServerLayers(
|
||||
map: MapLibreMap,
|
||||
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkbenchProjectPresentation } from "../api/project-client";
|
||||
import { resolveWorkbenchUser } from "./use-workbench-project-presentation";
|
||||
|
||||
const fallbackUser = {
|
||||
name: "调度员",
|
||||
role: "供水运行调度中心"
|
||||
};
|
||||
|
||||
function presentation(
|
||||
overrides: Partial<WorkbenchProjectPresentation> = {}
|
||||
): WorkbenchProjectPresentation {
|
||||
return {
|
||||
projectId: "project-1",
|
||||
projectName: "北区供水项目",
|
||||
username: "north.operator",
|
||||
projectRole: "member",
|
||||
systemRole: "user",
|
||||
isSystemAdmin: false,
|
||||
networkView: null,
|
||||
metadataAvailable: true,
|
||||
accessContextAvailable: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("workbench project presentation", () => {
|
||||
it("uses backend identity and a readable project role", () => {
|
||||
expect(resolveWorkbenchUser(fallbackUser, presentation())).toEqual({
|
||||
name: "north.operator",
|
||||
role: "北区供水项目 · 项目成员"
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the existing user when project context is unavailable", () => {
|
||||
expect(resolveWorkbenchUser(fallbackUser, null)).toBe(fallbackUser);
|
||||
});
|
||||
|
||||
it("can enrich the authenticated fallback name with project metadata", () => {
|
||||
expect(resolveWorkbenchUser(fallbackUser, presentation({
|
||||
username: null,
|
||||
projectRole: "viewer"
|
||||
}))).toEqual({
|
||||
name: "调度员",
|
||||
role: "北区供水项目 · 项目访客"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
fetchWorkbenchProjectPresentation,
|
||||
type WorkbenchProjectPresentation
|
||||
} from "../api/project-client";
|
||||
import type { WorkbenchUser } from "../types";
|
||||
|
||||
const PROJECT_ROLE_LABELS: Record<string, string> = {
|
||||
owner: "项目负责人",
|
||||
member: "项目成员",
|
||||
viewer: "项目访客"
|
||||
};
|
||||
|
||||
export function useWorkbenchProjectPresentation(
|
||||
fallbackUser: WorkbenchUser,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
) {
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted) setPresentation(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setPresentation(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [getAccessToken]);
|
||||
|
||||
const user = useMemo(
|
||||
() => resolveWorkbenchUser(fallbackUser, presentation),
|
||||
[fallbackUser, presentation]
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
projectId: presentation?.projectId ?? null,
|
||||
networkView: presentation?.networkView ?? null,
|
||||
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
|
||||
? "backend" as const
|
||||
: "fallback" as const
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkbenchUser(
|
||||
fallbackUser: WorkbenchUser,
|
||||
presentation: WorkbenchProjectPresentation | null
|
||||
): WorkbenchUser {
|
||||
if (!presentation) return fallbackUser;
|
||||
const role = presentation.projectRole
|
||||
? PROJECT_ROLE_LABELS[presentation.projectRole] ?? `项目角色 ${presentation.projectRole}`
|
||||
: null;
|
||||
const roleParts = [
|
||||
presentation.projectName,
|
||||
role,
|
||||
presentation.isSystemAdmin ? "系统管理员" : null
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return {
|
||||
name: presentation.username ?? fallbackUser.name,
|
||||
role: roleParts.length > 0 ? roleParts.join(" · ") : fallbackUser.role
|
||||
};
|
||||
}
|
||||
@@ -42,9 +42,6 @@ export function useWorkbenchResponsiveLayout({
|
||||
const handleViewportChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
if (mediaQuery.matches) {
|
||||
setMobileSheet(null);
|
||||
}
|
||||
};
|
||||
|
||||
handleViewportChange();
|
||||
@@ -58,6 +55,17 @@ export function useWorkbenchResponsiveLayout({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLargeScreen || mobileSheet === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileSheet === "agent") {
|
||||
expandAgentPanel();
|
||||
}
|
||||
setMobileSheet(null);
|
||||
}, [expandAgentPanel, isLargeScreen, mobileSheet]);
|
||||
|
||||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,15 +1,375 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatHeaderDataTime,
|
||||
getMsUntilNextHeaderDataTimeTick
|
||||
getMsUntilNextHeaderDataTimeTick,
|
||||
resolveWorkbenchOperationalDate,
|
||||
resolveWorkbenchRuntimeData
|
||||
} from "./use-workbench-runtime-data";
|
||||
import type { ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
|
||||
|
||||
const SEPTEMBER_17 = new Date("2026-09-17T02:30:00Z");
|
||||
const SEPTEMBER_18 = new Date("2026-09-18T02:30:00Z");
|
||||
|
||||
const mockCondition: ScheduledConditionRecord = {
|
||||
id: "mock-condition",
|
||||
kind: "condition",
|
||||
taskId: "scada-diagnosis",
|
||||
sessionId: "scheduled-condition-mock",
|
||||
title: "SCADA 数据诊断",
|
||||
summary: "mock",
|
||||
status: "error",
|
||||
riskLevel: "critical",
|
||||
scheduledAt: "2026-09-17T10:00:00+08:00",
|
||||
updatedAt: Date.parse("2026-09-17T10:00:00+08:00")
|
||||
};
|
||||
|
||||
const backendCondition: ScheduledConditionRecord = {
|
||||
...mockCondition,
|
||||
id: "schedule-run-1",
|
||||
taskId: "backend-schedule",
|
||||
sessionId: "scheduled-condition-schedule-run-1",
|
||||
title: "演示 合成低压调查",
|
||||
modelName: "TJWater scheduler"
|
||||
};
|
||||
|
||||
const mockWorkOrder: ScheduledWorkOrderItem = {
|
||||
id: "mock-work-order",
|
||||
kind: "work_order",
|
||||
code: "WO-MOCK",
|
||||
scheduledAt: "2026-09-17T11:00:00+08:00",
|
||||
title: "演示工单",
|
||||
summary: "mock",
|
||||
status: "pending",
|
||||
riskLevel: "normal",
|
||||
updatedAt: Date.parse("2026-09-17T10:00:00+08:00"),
|
||||
detail: "mock",
|
||||
durationMinutes: 30,
|
||||
source: "演示",
|
||||
dispatcher: "调度中心",
|
||||
priority: "normal",
|
||||
subtasks: [{
|
||||
id: "mock-subtask-1",
|
||||
title: "现场复核",
|
||||
summary: "复核设备状态并回传。",
|
||||
status: "pending",
|
||||
scheduledAt: "2026-09-17T11:00:00+08:00",
|
||||
location: "V-1",
|
||||
assignee: "现场班组",
|
||||
durationMinutes: 30,
|
||||
replyWindowMinutes: 15,
|
||||
stages: [{
|
||||
id: "dispatch",
|
||||
sequence: 1,
|
||||
title: "派发",
|
||||
status: "current",
|
||||
owner: "调度中心",
|
||||
timeLabel: "10:45",
|
||||
description: "派发现场复核任务。"
|
||||
}],
|
||||
replyRequirements: ["回传设备状态"]
|
||||
}]
|
||||
};
|
||||
|
||||
describe("workbench runtime data", () => {
|
||||
it("snaps the header time to the current five-minute interval", () => {
|
||||
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
|
||||
});
|
||||
|
||||
it("preserves the backend observation minute", () => {
|
||||
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31), false)).toBe("2026-07-22 15:43");
|
||||
});
|
||||
|
||||
it("schedules the next header refresh on the next interval boundary", () => {
|
||||
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
|
||||
});
|
||||
|
||||
it("keeps mock conditions and shows no work orders when backend storage is not configured", () => {
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
|
||||
availability: "not_configured",
|
||||
source: "none",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "not_configured",
|
||||
workOrderAvailability: "not_configured",
|
||||
alertAvailability: "not_configured",
|
||||
generatedAt: "2026-09-17T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_17 });
|
||||
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(resolved.workbenchAlerts).toHaveLength(1);
|
||||
expect(resolved.scenarioDataSource).toBe("mock");
|
||||
expect(resolved.artifactDataSource).toBe("mock");
|
||||
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",
|
||||
title: "演示 测试待复核",
|
||||
description: "运行失败",
|
||||
time: "10:25"
|
||||
};
|
||||
const backendSnapshot = {
|
||||
availability: "ready" as const,
|
||||
source: "scheduled_tasks" as const,
|
||||
scenarioAvailability: "not_configured" as const,
|
||||
artifactAvailability: "not_configured" as const,
|
||||
scheduleAvailability: "not_configured" as const,
|
||||
workOrderAvailability: "not_configured" as const,
|
||||
alertAvailability: "not_configured" as const,
|
||||
generatedAt: "2026-09-17T02:30:00Z",
|
||||
conditions: [backendCondition],
|
||||
alerts: [backendAlert],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
};
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
|
||||
operationalDate: SEPTEMBER_17,
|
||||
runtimeMode: "history"
|
||||
});
|
||||
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(resolved.workbenchAlerts).not.toEqual([backendAlert]);
|
||||
expect(resolved.workbenchAlerts[0]?.id).toBe("scheduled-condition-alert-mock-condition");
|
||||
expect(resolved.alertDataSource).toBe("mock");
|
||||
expect(resolved.scenarioDataSource).toBe("mock");
|
||||
expect(resolved.workOrderDataSource).toBe("none");
|
||||
|
||||
const mockResolved = resolveWorkbenchRuntimeData([mockCondition], backendSnapshot, {
|
||||
operationalDate: SEPTEMBER_17,
|
||||
runtimeMode: "demo"
|
||||
});
|
||||
expect(mockResolved.runtimeDataSource).toBe("mock");
|
||||
expect(mockResolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(mockResolved.workOrderDataSource).toBe("none");
|
||||
});
|
||||
|
||||
it("anchors history mode to the latest day that contains backend work orders", () => {
|
||||
const olderWorkOrder = {
|
||||
...mockWorkOrder,
|
||||
id: "older-backend-work-order",
|
||||
scheduledAt: "2026-08-03T10:05:00+08:00"
|
||||
};
|
||||
const latestWorkOrder = {
|
||||
...mockWorkOrder,
|
||||
id: "latest-backend-work-order",
|
||||
scheduledAt: "2026-08-17T17:40:00+08:00"
|
||||
};
|
||||
const futureWorkOrder = {
|
||||
...mockWorkOrder,
|
||||
id: "future-backend-work-order",
|
||||
scheduledAt: "2026-09-20T10:00:00+08:00"
|
||||
};
|
||||
const now = new Date("2026-09-18T02:30:00Z");
|
||||
const operationalDate = resolveWorkbenchOperationalDate({
|
||||
availability: "ready",
|
||||
source: "scheduled_tasks",
|
||||
scenarioAvailability: "ready",
|
||||
artifactAvailability: "ready",
|
||||
scheduleAvailability: "ready",
|
||||
workOrderAvailability: "ready",
|
||||
alertAvailability: "ready",
|
||||
generatedAt: "2026-09-18T02:30:00Z",
|
||||
conditions: [backendCondition],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
|
||||
}, { runtimeMode: "history", now });
|
||||
|
||||
expect(operationalDate.toISOString()).toBe("2026-08-17T09:40:00.000Z");
|
||||
expect(resolveWorkbenchOperationalDate({
|
||||
availability: "ready",
|
||||
source: "scheduled_tasks",
|
||||
scenarioAvailability: "ready",
|
||||
artifactAvailability: "ready",
|
||||
scheduleAvailability: "ready",
|
||||
workOrderAvailability: "ready",
|
||||
alertAvailability: "ready",
|
||||
generatedAt: "2026-09-18T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: [olderWorkOrder, latestWorkOrder, futureWorkOrder]
|
||||
}, { runtimeMode: "demo", now }).toISOString()).toBe("2026-08-17T09:40:00.000Z");
|
||||
});
|
||||
|
||||
it("keeps current-day mock conditions when backend schedules are configured", () => {
|
||||
const currentMockCondition = {
|
||||
...mockCondition,
|
||||
id: "mock-current-day",
|
||||
scheduledAt: "2026-09-18T10:00:00+08:00"
|
||||
};
|
||||
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
|
||||
availability: "ready",
|
||||
source: "scheduled_tasks",
|
||||
scenarioAvailability: "ready",
|
||||
artifactAvailability: "ready",
|
||||
scheduleAvailability: "ready",
|
||||
workOrderAvailability: "not_configured",
|
||||
alertAvailability: "not_configured",
|
||||
generatedAt: "2026-09-18T02:30:00Z",
|
||||
conditions: [backendCondition],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_18 });
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
});
|
||||
|
||||
it("ignores current-day backend conditions and keeps demo conditions", () => {
|
||||
const currentMockCondition = {
|
||||
...mockCondition,
|
||||
id: "mock-current-day",
|
||||
scheduledAt: "2026-09-18T10:00:00+08:00"
|
||||
};
|
||||
const currentBackendCondition = {
|
||||
...backendCondition,
|
||||
id: "backend-current-day",
|
||||
scheduledAt: "2026-09-18T10:05:00+08:00"
|
||||
};
|
||||
const resolved = resolveWorkbenchRuntimeData([currentMockCondition], {
|
||||
availability: "ready",
|
||||
source: "scheduled_tasks",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "not_configured",
|
||||
workOrderAvailability: "not_configured",
|
||||
alertAvailability: "not_configured",
|
||||
generatedAt: "2026-09-18T02:30:00Z",
|
||||
conditions: [currentBackendCondition],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_18 });
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([currentMockCondition]);
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
});
|
||||
|
||||
it("treats an empty backend day as authoritative when backend mode is explicit", () => {
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
|
||||
availability: "ready",
|
||||
source: "none",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "not_configured",
|
||||
workOrderAvailability: "ready",
|
||||
alertAvailability: "not_configured",
|
||||
generatedAt: "2026-09-17T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_17, runtimeMode: "backend" });
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
expect(resolved.workOrderDataSource).toBe("backend");
|
||||
});
|
||||
|
||||
it("uses a provisioned alert list while other sections keep their mock fallback", () => {
|
||||
const backendAlert = {
|
||||
id: "backend-alert-alarm-1",
|
||||
title: "泵站通信告警",
|
||||
description: "来自正式告警同步适配器。",
|
||||
severity: "warning" as const,
|
||||
time: "10:29"
|
||||
};
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
|
||||
availability: "ready",
|
||||
source: "none",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "not_configured",
|
||||
workOrderAvailability: "not_configured",
|
||||
alertAvailability: "ready",
|
||||
generatedAt: "2026-09-17T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [backendAlert],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_17 });
|
||||
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition]);
|
||||
expect(resolved.runtimeDataSource).toBe("mock");
|
||||
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
|
||||
});
|
||||
|
||||
it("does not invent demo alerts when the backend alert partition is empty", () => {
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition], {
|
||||
availability: "ready",
|
||||
source: "none",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "ready",
|
||||
workOrderAvailability: "not_configured",
|
||||
alertAvailability: "ready",
|
||||
generatedAt: "2026-09-17T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: []
|
||||
}, { operationalDate: SEPTEMBER_17 });
|
||||
|
||||
expect(resolved.alertDataSource).toBe("backend");
|
||||
expect(resolved.workbenchAlerts).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses valid backend records retained by a degraded partition", () => {
|
||||
const backendWorkOrder = { ...mockWorkOrder, id: "backend-work-order", code: "WO-BACKEND" };
|
||||
const backendAlert = {
|
||||
id: "backend-alert-1",
|
||||
title: "后端任务告警",
|
||||
description: "合法记录与另一条坏记录同时存在。",
|
||||
severity: "warning" as const,
|
||||
time: "09-18 10:29"
|
||||
};
|
||||
const resolved = resolveWorkbenchRuntimeData([mockCondition, mockWorkOrder], {
|
||||
availability: "degraded",
|
||||
source: "none",
|
||||
scenarioAvailability: "not_configured",
|
||||
artifactAvailability: "not_configured",
|
||||
scheduleAvailability: "not_configured",
|
||||
workOrderAvailability: "degraded",
|
||||
alertAvailability: "degraded",
|
||||
generatedAt: "2026-09-18T02:30:00Z",
|
||||
conditions: [],
|
||||
alerts: [backendAlert],
|
||||
scenarios: [],
|
||||
latestAnalysisArtifact: null,
|
||||
simulationOverlay: null,
|
||||
workOrders: [backendWorkOrder]
|
||||
}, { operationalDate: SEPTEMBER_17 });
|
||||
|
||||
expect(resolved.workbenchAlerts).toEqual([backendAlert]);
|
||||
expect(resolved.alertDataSource).toBe("backend");
|
||||
expect(resolved.scheduledConditions).toEqual([mockCondition, backendWorkOrder]);
|
||||
expect(resolved.workOrderDataSource).toBe("backend");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,60 @@
|
||||
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";
|
||||
import {
|
||||
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
|
||||
createOperationalScheduledConditions
|
||||
} from "../data/scheduled-conditions";
|
||||
import { WORKBENCH_SCENARIOS } from "../data/workbench-session";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import { createScheduledConditionAlerts } from "../utils/scheduled-condition-alerts";
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||
const BACKEND_RUNTIME_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
export function formatHeaderDataTime(date: Date) {
|
||||
const snappedMinutes =
|
||||
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
|
||||
export function resolveWorkbenchOperationalDate(
|
||||
backendSnapshot: WorkbenchRuntimeSnapshot | null,
|
||||
options: {
|
||||
now?: Date;
|
||||
runtimeMode?: "auto" | "demo" | "history" | "backend";
|
||||
} = {}
|
||||
) {
|
||||
const now = options.now ?? new Date();
|
||||
if (
|
||||
!["demo", "history"].includes(options.runtimeMode ?? "auto")
|
||||
|| !backendSnapshot
|
||||
) return now;
|
||||
|
||||
const timestamps = backendSnapshot.workOrders
|
||||
.map((workOrder) => Date.parse(workOrder.scheduledAt))
|
||||
.filter(Number.isFinite);
|
||||
const historicalTimestamps = timestamps.filter((timestamp) => timestamp <= now.getTime());
|
||||
const latestTimestamp = historicalTimestamps.length > 0
|
||||
? Math.max(...historicalTimestamps)
|
||||
: timestamps.length > 0
|
||||
? Math.min(...timestamps)
|
||||
: null;
|
||||
|
||||
return latestTimestamp === null ? now : new Date(latestTimestamp);
|
||||
}
|
||||
|
||||
export function formatHeaderDataTime(date: Date, snapToStep = true) {
|
||||
const minutes = snapToStep
|
||||
? Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES
|
||||
: date.getMinutes();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(snappedMinutes).padStart(2, "0");
|
||||
const formattedMinutes = String(minutes).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
return `${year}-${month}-${day} ${hours}:${formattedMinutes}`;
|
||||
}
|
||||
|
||||
export function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
||||
@@ -30,9 +67,74 @@ export function getMsUntilNextHeaderDataTimeTick(date: Date) {
|
||||
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
|
||||
}
|
||||
|
||||
export function useWorkbenchRuntimeData() {
|
||||
export function resolveWorkbenchRuntimeData(
|
||||
mockConditions: ScheduledConditionItem[],
|
||||
backendSnapshot: WorkbenchRuntimeSnapshot | null,
|
||||
options: {
|
||||
operationalDate?: Date;
|
||||
runtimeMode?: "auto" | "demo" | "history" | "backend";
|
||||
} = {}
|
||||
) {
|
||||
const operationalDate = options.operationalDate ?? new Date();
|
||||
const fallbackConditions = mockConditions.filter(
|
||||
(condition) => condition.kind === "condition"
|
||||
&& isSameLocalOperationalDate(condition.scheduledAt, operationalDate)
|
||||
);
|
||||
const backendWorkOrders = (backendSnapshot?.workOrders ?? []).filter(
|
||||
(workOrder) => isSameLocalOperationalDate(workOrder.scheduledAt, operationalDate)
|
||||
);
|
||||
const backendWorkOrdersConfigured = Boolean(
|
||||
backendSnapshot && backendSnapshot.workOrderAvailability !== "not_configured"
|
||||
);
|
||||
const resolvedWorkOrders = backendWorkOrdersConfigured ? backendWorkOrders : [];
|
||||
const backendAlertsConfigured = Boolean(
|
||||
backendSnapshot
|
||||
&& backendSnapshot.alertAvailability !== "not_configured"
|
||||
);
|
||||
const useBackendScenarios = Boolean(
|
||||
backendSnapshot
|
||||
&& backendSnapshot.scenarioAvailability !== "not_configured"
|
||||
&& backendSnapshot.scenarios.length > 0
|
||||
);
|
||||
const useBackendArtifact = Boolean(
|
||||
backendSnapshot
|
||||
&& backendSnapshot.artifactAvailability !== "not_configured"
|
||||
&& backendSnapshot.latestAnalysisArtifact
|
||||
);
|
||||
|
||||
return {
|
||||
scheduledConditions: [...fallbackConditions, ...resolvedWorkOrders],
|
||||
workbenchAlerts: backendAlertsConfigured
|
||||
? backendSnapshot!.alerts
|
||||
: createScheduledConditionAlerts(fallbackConditions),
|
||||
alertDataSource: backendAlertsConfigured ? "backend" as const : "mock" as const,
|
||||
runtimeDataSource: "mock" as const,
|
||||
scenarios: useBackendScenarios ? backendSnapshot!.scenarios : WORKBENCH_SCENARIOS,
|
||||
scenarioDataSource: useBackendScenarios ? "analysis_runs" as const : "mock" as const,
|
||||
latestAnalysisArtifact: useBackendArtifact
|
||||
? backendSnapshot!.latestAnalysisArtifact
|
||||
: null,
|
||||
simulationOverlay: useBackendArtifact ? backendSnapshot!.simulationOverlay : null,
|
||||
artifactDataSource: useBackendArtifact ? "analysis_runs" as const : "mock" as const,
|
||||
workOrderDataSource: backendWorkOrdersConfigured ? "backend" as const : "none" as const
|
||||
};
|
||||
}
|
||||
|
||||
function isSameLocalOperationalDate(value: string, operationalDate: Date) {
|
||||
const candidate = new Date(value);
|
||||
if (Number.isNaN(candidate.getTime()) || Number.isNaN(operationalDate.getTime())) return false;
|
||||
return candidate.getFullYear() === operationalDate.getFullYear()
|
||||
&& candidate.getMonth() === operationalDate.getMonth()
|
||||
&& candidate.getDate() === operationalDate.getDate();
|
||||
}
|
||||
|
||||
export function useWorkbenchRuntimeData(getAccessToken?: AccessTokenProvider) {
|
||||
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
||||
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const [backendSnapshot, setBackendSnapshot] = useState<WorkbenchRuntimeSnapshot | null>(null);
|
||||
const [realtimePipeFlows, setRealtimePipeFlows] = useState<Awaited<
|
||||
ReturnType<typeof fetchRealtimePipeFlows>
|
||||
> | null>(null);
|
||||
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +152,10 @@ export function useWorkbenchRuntimeData() {
|
||||
|
||||
useEffect(() => {
|
||||
const refreshScheduledConditions = () => {
|
||||
setScheduledConditions(createOperationalScheduledConditions(new Date()));
|
||||
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
|
||||
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
|
||||
});
|
||||
setMockConditions(createOperationalScheduledConditions(operationalDate));
|
||||
setScheduledConditionsLoading(false);
|
||||
};
|
||||
|
||||
@@ -61,11 +166,114 @@ export function useWorkbenchRuntimeData() {
|
||||
);
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
|
||||
if (!controller.signal.aborted) {
|
||||
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setRealtimePipeFlows(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
timeoutId = window.setTimeout(
|
||||
refreshRealtimePipeFlows,
|
||||
BACKEND_RUNTIME_REFRESH_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refreshRealtimePipeFlows();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [getAccessToken]);
|
||||
|
||||
const operationalDate = resolveWorkbenchOperationalDate(backendSnapshot, {
|
||||
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
|
||||
});
|
||||
const resolvedRuntimeData = resolveWorkbenchRuntimeData(mockConditions, backendSnapshot, {
|
||||
operationalDate,
|
||||
runtimeMode: env.TJWATER_WORKBENCH_RUNTIME_MODE
|
||||
});
|
||||
const backendDataTime = realtimePipeFlows?.observedAt;
|
||||
const backendRuntimeDataTime = backendSnapshot?.generatedAt;
|
||||
const demoMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "demo";
|
||||
const historyMode = env.TJWATER_WORKBENCH_RUNTIME_MODE === "history";
|
||||
const backendRuntimeActive = resolvedRuntimeData.runtimeDataSource !== "mock";
|
||||
const backendWorkOrdersActive = resolvedRuntimeData.workOrderDataSource === "backend";
|
||||
const backendHistoryActive = historyMode && (backendRuntimeActive || backendWorkOrdersActive);
|
||||
|
||||
return {
|
||||
headerDataTime,
|
||||
scheduledConditions,
|
||||
scheduledConditionsLoading
|
||||
headerDataTime: backendHistoryActive
|
||||
? `历史 ${formatHeaderDataTime(operationalDate, false)}`
|
||||
: demoMode || historyMode
|
||||
? `演示 ${formatHeaderDataTime(operationalDate, false)}`
|
||||
: backendDataTime
|
||||
? formatHeaderDataTime(new Date(backendDataTime), false)
|
||||
: backendRuntimeActive && backendRuntimeDataTime
|
||||
? formatHeaderDataTime(new Date(backendRuntimeDataTime), false)
|
||||
: headerDataTime === INITIAL_HEADER_DATA_TIME
|
||||
? headerDataTime
|
||||
: `演示 ${headerDataTime}`,
|
||||
headerDataSource: backendHistoryActive
|
||||
? "history" as const
|
||||
: demoMode || historyMode
|
||||
? "demo" as const
|
||||
: backendDataTime
|
||||
? "realtime" as const
|
||||
: backendRuntimeActive
|
||||
? "backend" as const
|
||||
: "mock" as const,
|
||||
operationalDate: formatLocalDateKey(operationalDate),
|
||||
operationalCursorTime: operationalDate.toISOString(),
|
||||
scheduledConditionsLoading,
|
||||
realtimePipeFlows,
|
||||
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
|
||||
...resolvedRuntimeData
|
||||
};
|
||||
}
|
||||
|
||||
function formatLocalDateKey(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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" };
|
||||
}
|
||||
Reference in New Issue
Block a user