feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
+152 -89
View File
@@ -33,6 +33,8 @@ import {
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
@@ -62,13 +64,17 @@ import {
createInitialWorkspaceContext,
reduceWorkspaceContext
} from "./workspace/workspace-context";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import {
formatHeaderDataTime,
useWorkbenchRuntimeData
} from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
useWorkbenchMeasurement,
@@ -87,9 +93,9 @@ import { parseScadaAnalysisItems } from "./map/scada-analysis";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
applyBaseLayerVisibility,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map/map-control-config";
import { waterNetworkToolbarItems } from "./map/toolbar-config";
@@ -100,10 +106,7 @@ import type {
WorkbenchAlert,
WorkbenchUser
} from "./types";
import {
createScheduledConditionAlerts,
getScheduledConditionIdFromAlertId
} from "./utils/scheduled-condition-alerts";
import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
@@ -119,6 +122,7 @@ export function MapWorkbenchPage({
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
@@ -126,6 +130,8 @@ export function MapWorkbenchPage({
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [analysisResultsPanelOpen, setAnalysisResultsPanelOpen] = useState(false);
const [peripheralSamplePanelOpen, setPeripheralSamplePanelOpen] = useState(false);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
@@ -135,15 +141,26 @@ export function MapWorkbenchPage({
hasMapboxToken ? "mapbox-light" : "none"
);
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
const [activeDrawToolId, setActiveDrawToolId] = useState<WorkbenchDrawMode | null>(null);
const [layerVisibility, setLayerVisibility] =
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
useWorkbenchRuntimeData();
const {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading,
workbenchAlerts,
runtimeDataSource,
alertDataSource,
latestAnalysisArtifact,
workOrderDataSource,
realtimePipeFlows,
flowDataSource,
operationalDate,
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken);
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
@@ -204,13 +221,29 @@ export function MapWorkbenchPage({
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation = artifactWorkspace.activeArtifact === null
|| artifactWorkspace.activeArtifact.origin === "demo";
const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some((block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
)
);
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
const [timelineVisible, setTimelineVisible] = useState(true);
const [workspaceContext, dispatchWorkspaceContext] = useReducer(
reduceWorkspaceContext,
undefined,
() => createInitialWorkspaceContext()
() => createInitialWorkspaceContext(new Date(operationalCursorTime))
);
useEffect(() => {
if (workspaceContext.operational.date === operationalDate) return;
dispatchWorkspaceContext({
type: "return-operational-now",
now: operationalCursorTime
});
}, [operationalCursorTime, operationalDate, workspaceContext.operational.date]);
const operationalEvents = useMemo(
() => createOperationalEvents(scheduledConditions),
[scheduledConditions]
@@ -243,6 +276,13 @@ export function MapWorkbenchPage({
});
}, [artifactWorkspace.activeArtifact]);
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView });
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
}
@@ -268,8 +308,10 @@ export function MapWorkbenchPage({
mapError,
sourceStatuses,
availableSourceIds,
sourceFeatureCounts,
fitNetworkBounds,
getScadaFeatures,
scadaObservedAt,
refreshGeoServerLayers
} = useWorkbenchMap({
containerRef: mapContainerRef,
@@ -278,8 +320,15 @@ export function MapWorkbenchPage({
onClearSelection: handleClearSelection,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature,
simulationOverlay: activeSpatialOverlay,
allowDemoSimulation,
networkView: projectPresentation.networkView,
getAccessToken
});
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE)
&& scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
@@ -291,24 +340,29 @@ export function MapWorkbenchPage({
availableSourceIds
});
useEffect(() => {
if (!mapReady) return;
if (realtimePipeFlows) {
void mapController.replacePipeFlowCalculations(realtimePipeFlows.records);
return;
}
void mapController.clearPipeFlowCalculations();
}, [mapController, mapReady, realtimePipeFlows]);
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
);
const conditionAlerts = useMemo(
() => createScheduledConditionAlerts(scheduledConditions),
[scheduledConditions]
);
const runningTickerConditions = useMemo(
const runningTickerConditions = useMemo<ScheduledConditionRecord[]>(
() =>
scheduledConditions.filter(
(condition): condition is ScheduledConditionRecord =>
condition.kind === "condition" && condition.status === "running"
),
scheduledConditions.reduce<ScheduledConditionRecord[]>((runningConditions, condition) => {
if (condition.kind === "condition" && condition.status === "running") {
runningConditions.push(condition);
}
return runningConditions;
}, []),
[scheduledConditions]
);
const workbenchAlerts = conditionAlerts;
const drawing = useWorkbenchDrawing({
mapRef,
mapReady,
@@ -327,6 +381,7 @@ export function MapWorkbenchPage({
!artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& detailFeature === null
&& taskTickerVisible
&& taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
@@ -424,10 +479,10 @@ export function MapWorkbenchPage({
}, [mapController]);
const handleReturnOperationalNow = useCallback(() => {
dispatchWorkspaceContext({ type: "return-operational-now", now: new Date().toISOString() });
dispatchWorkspaceContext({ type: "return-operational-now", now: operationalCursorTime });
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
mapController.clearHighlight();
}, [mapController]);
}, [mapController, operationalCursorTime]);
const toolbarItems = useMemo<MapToolbarItem[]>(
() =>
@@ -441,8 +496,12 @@ export function MapWorkbenchPage({
);
const layerControlItems = useMemo(
() => createLayerControlItems(layerVisibility, availableSourceIds),
[availableSourceIds, layerVisibility]
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts),
[availableSourceIds, layerVisibility, sourceFeatureCounts]
);
const visibleLegendItems = useMemo(
() => createVisibleMapLegendItems(layerControlItems),
[layerControlItems]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
const map = mapRef.current;
@@ -545,48 +604,9 @@ export function MapWorkbenchPage({
});
}
function handleSelectScenario(scenarioId: string) {
const scenario = WORKBENCH_SCENARIOS.find((item) => item.id === scenarioId);
setActiveScenarioId(scenarioId);
showMapNotice({
tone: "success",
title: "方案已切换",
message: `当前方案:${scenario?.name ?? scenarioId}。地图模拟结果暂未自动切换。`
});
}
function handlePreviewScenario() {
setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true }));
const map = mapRef.current;
if (map && mapReady) {
getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
map.setLayoutProperty(layerId, "visibility", "visible");
});
}
showMapNotice({
tone: "info",
title: "预览影响范围",
message: "已打开模拟结果图层,可在右侧图层面板继续调整显示。"
});
}
function handleCompareScenario() {
showMapNotice({
tone: "info",
title: "方案比较",
message: "候选方案比较将汇总影响范围、阀门操作与保供风险。"
});
}
function handleExportScenarioReport() {
showMapNotice({
tone: "success",
title: "场景报告",
message: "已生成当前场景报告任务,可继续导出地图截图或配置。"
});
function handleOpenLatestAnalysis() {
if (!latestAnalysisArtifact || !latestAnalysisAvailable) return;
artifactWorkspace.openArtifact(latestAnalysisArtifact);
}
async function handleAnalyzeAlertsWithAgent() {
@@ -610,7 +630,8 @@ export function MapWorkbenchPage({
await agent.submitPrompt(
createAlertQueueConversationPrompt({
alerts: workbenchAlerts,
conditions: scheduledConditions
conditions: scheduledConditions,
dataSource: alertDataSource
})
);
@@ -629,7 +650,7 @@ export function MapWorkbenchPage({
}
function handleSelectAlert(alert: WorkbenchAlert) {
const conditionId = getScheduledConditionIdFromAlertId(alert.id);
const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id);
if (conditionId) {
const condition = scheduledConditions.find((item) => item.id === conditionId);
if (!condition) {
@@ -858,7 +879,7 @@ export function MapWorkbenchPage({
canDeleteSelectedDrawing: drawing.canDeleteSelected,
hasDrawingFeatures: drawing.hasFeatures,
layerItems: layerControlItems,
legendItems: MAP_LEGEND_ITEMS,
legendItems: visibleLegendItems,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode: setActiveMeasureModeId,
@@ -929,6 +950,7 @@ export function MapWorkbenchPage({
approvalMode: agent.approvalMode,
uiResults: activeAgentUiResults,
workspaceContext: agentWorkspaceContext,
prompt: agent.promptDraft,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: handleStartNewAgentSession,
onLoadHistorySession: handleLoadAgentHistorySession,
@@ -936,6 +958,7 @@ export function MapWorkbenchPage({
onDeleteHistorySession: handleDeleteAgentHistorySession,
onSelectModel: agent.setSelectedModel,
onApprovalModeChange: agent.setApprovalMode,
onPromptChange: agent.setPromptDraft,
onSubmitPrompt: agent.submitPrompt,
onStopPrompt: agent.stopPrompt,
onReplyPermission: agent.replyPermission,
@@ -956,29 +979,35 @@ export function MapWorkbenchPage({
}
>
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="AquaDispatch v1.0"
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
dataTime={displayedDataTime}
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label
|| agent.selectedModel
|| "Agent 后端连接中"}
activeAnalysisArtifact={artifactWorkspace.activeArtifact}
alerts={workbenchAlerts}
user={user}
alertDataSource={alertDataSource}
user={projectPresentation.user}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
analysisResultsPanelOpen={analysisResultsPanelOpen}
peripheralSamplePanelOpen={peripheralSamplePanelOpen}
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }}
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onCreateTestAnalysis={artifactWorkspace.createTestArtifactPreview}
latestAnalysisAvailable={latestAnalysisAvailable}
analysisSplitAvailable={artifactWorkspace.mapSplitAvailable}
onOpenLatestAnalysis={handleOpenLatestAnalysis}
onSetAnalysisView={artifactWorkspace.setView}
onCloseAnalysisView={artifactWorkspace.destroyArtifact}
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
@@ -986,6 +1015,22 @@ export function MapWorkbenchPage({
onLogout={onLogout}
/>
<AnalysisResultsPanel
open={analysisResultsPanelOpen}
onClose={() => setAnalysisResultsPanelOpen(false)}
getAccessToken={getAccessToken}
onOpenArtifact={(artifact) => {
artifactWorkspace.openArtifact(artifact);
setAnalysisResultsPanelOpen(false);
}}
/>
<PeripheralSamplePanel
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
/>
<WorkbenchMainFrame
activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact}
@@ -1049,12 +1094,16 @@ export function MapWorkbenchPage({
)}
mapOverlay={(
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute right-3 top-3 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
{!devPanelOpen ? (
<>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
<MapToolbar items={toolbarItems} />
</div>
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
</>
) : null}
<div
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{
@@ -1092,6 +1141,8 @@ export function MapWorkbenchPage({
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
@@ -1112,6 +1163,7 @@ export function MapWorkbenchPage({
commands={mapController}
controllerState={mapControllerState}
detailFeature={detailFeature}
flowDataSource={flowDataSource}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
@@ -1146,7 +1198,11 @@ export function MapWorkbenchPage({
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker className="w-full max-w-full" conditions={runningTickerConditions} />
<AgentTaskTicker
className="w-full max-w-full"
conditions={runningTickerConditions}
dataSource={runtimeDataSource}
/>
</motion.div>
) : null}
</AnimatePresence>
@@ -1222,6 +1278,8 @@ export function MapWorkbenchPage({
<ScheduledConditionFeed
presentation="mobile-sheet"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
@@ -1239,7 +1297,12 @@ export function MapWorkbenchPage({
</MobileWorkbenchSheet>
) : null}
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
<FeaturePopover
feature={detailFeature}
projectId={projectPresentation.projectId}
getAccessToken={getAccessToken}
onClose={() => setDetailFeature(null)}
/>
{!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />