import { useReadingWorkspace } from "@/shared/ui/reading-workspace-context"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ComponentProps, type CSSProperties } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { CalendarClock, Clock3 } from "lucide-react"; import { ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace"; import { AgentCommandPanel, AgentPersona, toTrustedMapAction, type FrontendActionRequest, type UIEnvelopePayload, type AgentUiResult, type AgentWorkspaceContextItem } from "@/features/agent"; import { MapErrorNotice, MapLoadingNotice, MapScaleLine, MapSourceStatusNotice, MapToolbar, MapZoom, showMapNotice, type MapToolbarItem, type MapSourceStatus } from "@/features/map/core"; import { env } from "@/shared/config/env"; import { getWorkbenchProject, type WorkbenchAccess } from "./api/request-context"; import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-menus"; import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AnalysisResultsPanel } from "./components/analysis-results-panel"; import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel"; import type { PeripheralWorkOrder } from "./peripheral-sample/client"; import { MapDevPanel } from "./components/map-dev-panel"; import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel"; import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import type { InvestigationDiscussion } from "./api/investigation-report"; import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { OperationalTimeline } from "./operational-timeline/operational-timeline"; import { createOperationalEvents, getOperationalEventSummary, OPERATIONAL_TIMELINE_LAYOUT, resolveTimelineScaleLayout, type OperationalEvent, type OperationalTimelineMode } from "./operational-timeline/operational-timeline-model"; import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel"; import { WorkbenchTopBar } from "./components/workbench-top-bar"; import { WorkbenchMainFrame } from "./workspace/workbench-main-frame"; import { useArtifactWorkspace } from "./workspace/use-artifact-workspace"; import { createInitialWorkspaceContext, reduceWorkspaceContext } from "./workspace/workspace-context"; import { WORKBENCH_USER } from "./data/workbench-session"; import { useWorkbenchAgent } from "./hooks/use-workbench-agent"; import { useAgentPromptSuggestions } from "./hooks/use-agent-prompt-suggestions"; import { operationalTaskConditionId, useOperationalTasks } from "./hooks/use-operational-tasks"; import { useWorkbenchSync } from "./hooks/use-workbench-sync"; import { useWorkbenchSyncNotice } from "./hooks/use-workbench-sync-notice"; import { useInvestigationPresentation } from "./hooks/use-investigation-presentation"; import type { InvestigationMapTarget } from "./api/investigation-report"; 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 { useWorkbenchGeocoding } from "./hooks/use-workbench-geocoding"; import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation"; import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout"; import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data"; import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement"; import { WORKBENCH_LAYOUT, getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables, resolveMobileWorkbenchOverlayLayout } from "./layout/workbench-layout"; import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera"; import { exportMapViewImage } from "./map/export-view"; import { setMapLayersVisibility } from "./map/layer-visibility"; import { BASE_LAYER_OPTIONS, INITIAL_LAYER_VISIBILITY, applyBaseLayerVisibility, createLayerControlItems, createVisibleMapLegendItems, getWorkbenchLayerIds } from "./map/map-control-config"; import { waterNetworkToolbarItems } from "./map/toolbar-config"; import type { DetailFeature, ScheduledConditionRecord, WorkbenchAlert, WorkbenchUser } from "./types"; import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts"; import { getOperationalNotificationSeverity } from "./utils/operational-notification-routing"; import { useScadaAlertNotices } from "./hooks/use-scada-alert-notices"; import { useDismissedWorkbenchAlerts } from "./hooks/use-dismissed-workbench-alerts"; import { ScadaAlertAtmosphere, type ScadaAlertIntensity } from "./components/scada-alert-atmosphere"; import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts"; const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables(); const ALERT_TIME_FORMATTER = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); type MapWorkbenchPageProps = { user?: WorkbenchUser; onLogout?: () => Promise; getAccessToken?: WorkbenchAccess; projectSelection?: WorkbenchProjectSelection; }; export function MapWorkbenchPage(props: MapWorkbenchPageProps) { return ; } function MapWorkbenchContent({ user = WORKBENCH_USER, onLogout, getAccessToken, projectSelection }: MapWorkbenchPageProps) { const readingWorkspace = useReadingWorkspace(); const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN); const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN); const hasOnlineBasemap = hasMapboxToken || hasTiandituToken; const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL; const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken); const mapContainerRef = useRef(null); const [detailFeature, setDetailFeature] = useState(null); const [devPanelOpen, setDevPanelOpen] = useState(false); const [investigationFollow, setInvestigationFollow] = useState(true); const presentationLayerChangesRef = useRef(new Map()); const [impactVisible, setImpactVisible] = useState(false); const [geocodingPickActive, setGeocodingPickActive] = useState(false); const [activeToolId, setActiveToolId] = useState(null); const prefersReducedMotion = useReducedMotion(); const [taskTickerVisible, setTaskTickerVisible] = useState(true); const [analysisResultsPanelOpen, setAnalysisResultsPanelOpen] = useState(false); const [peripheralSamplePanelOpen, setPeripheralSamplePanelOpen] = useState(false); const [scadaAlertIntensity, setScadaAlertIntensity] = useState("standard"); const [selectedConditionId, setSelectedConditionId] = useState(null); const [conditionFocusRequest, setConditionFocusRequest] = useState<{ conditionId: string; requestId: number; actionId?: string | null; } | null>(null); const [activeBaseLayerId, setActiveBaseLayerId] = useState( hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none" ); const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId); const [activeMeasureModeId, setActiveMeasureModeId] = useState("distance"); const [activeMeasureUnitId, setActiveMeasureUnitId] = useState("km"); const [activeDrawToolId, setActiveDrawToolId] = useState(null); const [layerVisibility, setLayerVisibility] = useState>(INITIAL_LAYER_VISIBILITY); const [agentUiResults, setAgentUiResults] = useState([]); const workbenchSync = useWorkbenchSync(getAccessToken); useWorkbenchSyncNotice(workbenchSync.status === "reconnecting" && workbenchSync.snapshot !== null); const { headerDataTime, scheduledConditions: runtimeScheduledConditions, scheduledConditionsLoading, workbenchAlerts: runtimeWorkbenchAlerts, runtimeDataSource, alertDataSource, latestAnalysisArtifact, workOrderDataSource, realtimePipeFlows, flowDataSource, operationalDate, operationalCursorTime } = useWorkbenchRuntimeData(getAccessToken, workbenchSync); const operationalTasks = useOperationalTasks(getAccessToken, workbenchSync); const selectedInvestigationTask = operationalTasks.tasks.find((task) => task.task_type === "event_investigation" && operationalTaskConditionId(task.task_id) === selectedConditionId) ?? null; const baseLayerOptions = useMemo( () => BASE_LAYER_OPTIONS.map((option) => ({ ...option, disabled: option.id.startsWith("mapbox-") ? !hasMapboxToken : option.id.startsWith("tianditu-") ? !hasTiandituToken : false })), [hasMapboxToken, hasTiandituToken] ); const handleSelectFeature = useCallback((feature: DetailFeature) => { setDetailFeature(feature); }, []); const handleClearSelection = useCallback(() => { setDetailFeature(null); }, []); const agent = useWorkbenchAgent({ onUiEnvelope: handleAgentUiEnvelope, onFrontendAction: handleFrontendAction, getAccessToken }); const scheduledConditions = useMemo(() => [ ...runtimeScheduledConditions, ...operationalTasks.conditions.filter((condition) => condition.operationalTask?.task_type === "event_investigation" || condition.operationalTask?.task_type === "external_work_order" ) ], [operationalTasks.conditions, runtimeScheduledConditions]); const allWorkbenchAlerts = useMemo(() => { const taskNotifications = operationalTasks.notifications.flatMap((notification) => { const severity = getOperationalNotificationSeverity(notification.kind); return severity ? [{ id: `operational-task-notification:${notification.notification_id}`, notificationVersion: notification.version, conditionId: operationalTaskConditionId(notification.task_id), title: notification.title, description: notification.description, severity, occurredAt: notification.created_at, time: ALERT_TIME_FORMATTER.format(new Date(notification.created_at)) }] : []; }); return [...taskNotifications, ...runtimeWorkbenchAlerts]; }, [operationalTasks.notifications, runtimeWorkbenchAlerts]); const { alerts: workbenchAlerts, dismissAlert } = useDismissedWorkbenchAlerts( allWorkbenchAlerts, projectPresentation.projectId, projectPresentation.user.name ); const handleRemoveAlert = (alert: WorkbenchAlert) => { if (!dismissAlert(alert)) { showMapNotice({ tone: "warning", title: "通知未移除", message: "无法保存移除记录,请确认项目已加载且浏览器允许本地存储后重试。" }); } }; const { atmosphere: scadaAlertAtmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere } = useScadaAlertNotices( projectPresentation.projectId, workbenchSync.snapshot?.runtime.alertAvailability === "ready" ? runtimeWorkbenchAlerts : null, handleSelectAlert, operationalTasks.loading ? workbenchSync.snapshot?.tasks ?? null : operationalTasks.tasks ); const collapseAgentPanel = agent.collapsePanel; const expandAgentPanel = agent.expandPanel; const clearActiveTool = useCallback(() => { setActiveToolId(null); }, []); const { agentPanelWidth, closeMobileSheet, conditionFeedExpanded, conditionFeedMounted, handleConditionExpandedChange, isLargeScreen, leftPanelOpen, mobileSheet, mobileSheetSnap, openAgentPanelForViewport, openConditionFeedForViewport, openTimelineForViewport, rightPanelExpanded, rightPanelOpen, setAgentPanelWidth, setMobileSheetSnap, shouldShowConditionFeed, toggleConditionFeedForViewport, viewportWidth } = useWorkbenchResponsiveLayout({ activeToolOpen: activeToolId !== null, agentPanelOpen: agent.panelOpen, devPanelOpen, collapseAgentPanel, expandAgentPanel, onClearActiveTool: clearActiveTool }); const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth; 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 = (!getWorkbenchProject(getAccessToken) || getWorkbenchProject(getAccessToken)?.gs_workspace === "tjwater_next") && (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("compact"); const [timelineVisible, setTimelineVisible] = useState(true); const [workspaceContext, dispatchWorkspaceContext] = useReducer( reduceWorkspaceContext, undefined, () => 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] ); const operationalSummary = useMemo( () => getOperationalEventSummary(operationalEvents), [operationalEvents] ); const timelineScaleLayout = resolveTimelineScaleLayout( businessWorkspaceWidth, timelineMode, timelineVisible ); const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible); const timelineBottom = isLargeScreen ? timelineScaleLayout.timelineBottom : mobileOverlayLayout.timelineBottom; useEffect(() => { setTimelineMode((current) => { if (artifactWorkspace.surfaceMode === "focus") return "summary"; return current === "summary" ? "compact" : current; }); }, [artifactWorkspace.surfaceMode]); useEffect(() => { dispatchWorkspaceContext({ type: "sync-artifact", artifact: artifactWorkspace.activeArtifact }); }, [artifactWorkspace.activeArtifact]); useEffect(() => { setImpactVisible(activeArtifactHasSpatialView); setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView ? current : { ...current, simulation: activeArtifactHasSpatialView } ); }, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]); function openAgentWorkspaceForViewport() { openAgentPanelForViewport(); } async function handlePeripheralWorkOrderAgentTrigger(workOrder: PeripheralWorkOrder) { const burstReport = workOrder.work_order_type === "burst_report"; if (workOrder.work_order_type !== "water_quality_complaint" && workOrder.work_order_type !== "burst_report") { throw new Error("工单不符合事件调查触发条件。"); } const investigationTitle = burstReport ? "爆管事件调查" : "水质投诉调查"; showMapNotice({ id: "peripheral-event-agent-trigger", tone: "loading", title: `${burstReport ? "爆管上报" : "水质投诉"}已接收`, message: "正在建立结构化工况任务。", duration: Infinity }); try { const task = await operationalTasks.createInvestigation({ kind: burstReport ? "burst_report" : "water_quality_complaint", title: investigationTitle, summary: workOrder.summary, detail: `${workOrder.location ?? "位置待确认"} · 由外围工单事件触发`, occurredAt: workOrder.scheduled_at ?? workOrder.created_at, priority: burstReport || workOrder.priority === "urgent" ? "urgent" : "normal", source: { kind: "external_system", external_id: workOrder.work_order_id, external_revision: workOrder.revision, event_kind: burstReport ? "burst_report" : "water_quality_complaint" }, triggerKind: "event" }); setPeripheralSamplePanelOpen(false); openConditionWorkspaceForViewport(); const conditionId = operationalTaskConditionId(task.task_id); setSelectedConditionId(conditionId); setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 })); showMapNotice({ id: "peripheral-event-agent-trigger", tone: "success", title: `已建立${investigationTitle}`, message: `关联模拟工单 ${workOrder.work_order_id.slice(0, 8)},可在任务中查看处理流程。` }); } catch (error) { showMapNotice({ id: "peripheral-event-agent-trigger", tone: "error", title: "工况任务创建失败", message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。" }); throw error; } } function openConditionWorkspaceForViewport() { openConditionFeedForViewport(); } function toggleConditionWorkspaceForViewport() { toggleConditionFeedForViewport(); } function toggleTimelineVisibility() { setTimelineVisible((current) => { if (current && mobileSheet === "timeline") closeMobileSheet(); return !current; }); } const { mapRef, mapReady, mapError, sourceStatuses, availableSourceIds, sourceFeatureCounts, fitNetworkBounds, getScadaFeatures, scadaObservedAt, refreshGeoServerLayers } = useWorkbenchMap({ featureInteractionsEnabled: !geocodingPickActive, containerRef: mapContainerRef, impactVisible, layerVisibility, onClearSelection: handleClearSelection, onSelectFeature: handleSelectFeature, selectedFeature: detailFeature, simulationOverlay: activeSpatialOverlay, allowDemoSimulation, networkView: projectPresentation.networkView, geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace, 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, geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace, mapReady, leftPanelOpen: false, rightPanelOpen, conditionPanelExpanded: rightPanelExpanded, agentPanelWidth, getScadaFeatures, availableSourceIds }); useEffect(() => { if (!mapReady) return; if (realtimePipeFlows) { void mapController.replacePipeFlowCalculations(realtimePipeFlows.records); return; } void mapController.clearPipeFlowCalculations(); }, [mapController, mapReady, realtimePipeFlows]); const presentation = useInvestigationPresentation({ enabled: investigationFollow, task: selectedInvestigationTask, projectId: projectPresentation.projectId, sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController, onClearLayers: restorePresentationLayers, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 } }); const previewPresentation = useInvestigationPresentation({ scope: "preview", enabled: false, task: selectedInvestigationTask, projectId: projectPresentation.projectId, sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController, onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 } }); const chatPresentation = useInvestigationPresentation({ scope: "chat", scopeId: agent.sessionId, enabled: false, task: null, projectId: projectPresentation.projectId, sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController, onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 } }); const geocoding = useWorkbenchGeocoding({ picking: geocodingPickActive, onPickingChange: (picking) => { setGeocodingPickActive(picking); if (picking) { setActiveDrawToolId(null); measurement.stop(); } }, placeSearch: projectPresentation.placeSearch, active: activeToolId === "geocoding", projectId: projectPresentation.projectId, getAccessToken, mapRef, mapReady, reducedMotion: Boolean(prefersReducedMotion), onLocate: presentation.pauseCamera, getPadding: () => { const map = mapRef.current; if (!map) return { top: 48, right: 48, bottom: 48, left: 48 }; const padding = getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth); return getResponsivePadding(map, isLargeScreen ? { ...padding, right: Math.max(padding.right, 400) } : { ...padding, bottom: Math.max(padding.bottom, 420) }); } }); function handleLocateInvestigationTargets(targets: InvestigationMapTarget[]) { if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only"); return presentation.locateTargets(targets).then(() => true).catch((error) => { showMapNotice({ tone: "warning", title: "关联要素暂不可定位", message: error instanceof Error ? error.message : "地图当前不可用" }); return false; }); } useEffect(() => () => { restorePresentationLayers(); // Layer restoration belongs to the followed task, not each detail refresh. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedInvestigationTask?.task_id, investigationFollow]); const activeAgentUiResults = useMemo( () => agentUiResults.filter((result) => result.sessionId === agent.sessionId), [agent.sessionId, agentUiResults] ); const runningTickerConditions = useMemo( () => scheduledConditions.reduce((runningConditions, condition) => { if (condition.kind === "condition" && condition.status === "running") { runningConditions.push(condition); } return runningConditions; }, []), [scheduledConditions] ); const drawing = useWorkbenchDrawing({ selectionEnabled: !geocodingPickActive, mapRef, mapReady, activeMode: activeDrawToolId, onModeChange: setActiveDrawToolId }); const measurement = useWorkbenchMeasurement({ mapRef, mapReady, mode: activeMeasureModeId, unit: activeMeasureUnitId }); const taskTickerAvailable = runningTickerConditions.length > 0; const shouldShowTaskTicker = !readingWorkspace?.active && (isLargeScreen || activeToolId !== "geocoding") && !artifactActive && mobileSheet === null && !conditionFeedExpanded && detailFeature === null && taskTickerVisible && taskTickerAvailable; const taskTickerEnterState = prefersReducedMotion ? { opacity: 1, scale: 1, y: 0 } : { opacity: 1, scale: 1, y: 0, transition: { type: "spring" as const, stiffness: 420, damping: 30, mass: 0.75 } }; const taskTickerExitState = prefersReducedMotion ? { opacity: 0, scale: 1, y: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.96, y: -8, transition: { duration: 0.18, ease: [0.5, 0, 0.2, 1] as const } }; useEffect(() => { if (!mapReady || !mapRef.current) { return; } applyBaseLayerVisibility(mapRef.current, activeBaseLayerId); }, [activeBaseLayerId, mapReady, mapRef]); const toggleToolbarTool = useCallback((toolId: ToolbarToolId) => { setActiveToolId((current) => { return current === toolId ? null : toolId; }); }, []); const handleConditionFocusRequestHandled = useCallback((requestId: number) => { setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current)); }, []); const handleSelectOperationalEvent = useCallback( (event: OperationalEvent) => { dispatchWorkspaceContext({ type: "select-operational-event", eventId: event.id, cursorTime: event.cursorTime }); const mapTarget = event.mapTargets[0]; if (mapTarget) { dispatchWorkspaceContext({ type: "select-spatial-feature", featureId: mapTarget.featureId, sourceId: mapTarget.sourceId }); void mapController.locateAndHighlight(mapTarget).then(() => { if (mapController.getSnapshot().errorCode) { showMapNotice({ tone: "warning", title: "已选择运行事件", message: "关联要素当前不可定位,事件与工况报告仍可继续查看。" }); } }); } if (event.conditionId) { setSelectedConditionId(event.conditionId); setConditionFocusRequest((current) => ({ conditionId: event.conditionId!, requestId: (current?.requestId ?? 0) + 1 })); openConditionFeedForViewport(); handleConditionExpandedChange(true); } }, [handleConditionExpandedChange, mapController, openConditionFeedForViewport] ); const handleClearOperationalSelection = useCallback(() => { dispatchWorkspaceContext({ type: "clear-operational-event" }); dispatchWorkspaceContext({ type: "clear-spatial-feature" }); mapController.clearHighlight(); }, [mapController]); const handleReturnOperationalNow = useCallback(() => { dispatchWorkspaceContext({ type: "return-operational-now", now: operationalCursorTime }); dispatchWorkspaceContext({ type: "clear-spatial-feature" }); mapController.clearHighlight(); }, [mapController, operationalCursorTime]); const toolbarItems = useMemo( () => waterNetworkToolbarItems.map((item) => ({ ...item, active: activeToolId === item.id, disabled: !mapReady, onClick: () => toggleToolbarTool(item.id as ToolbarToolId) })), [activeToolId, mapReady, toggleToolbarTool] ); const layerControlItems = useMemo( () => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts), [availableSourceIds, layerVisibility, sourceFeatureCounts] ); const visibleLegendItems = useMemo( () => createVisibleMapLegendItems(layerControlItems), [layerControlItems] ); function handleToggleLayer(layerControlId: string, visible: boolean) { presentationLayerChangesRef.current.delete(layerControlId); const map = mapRef.current; setLayerVisibility((current) => ({ ...current, [layerControlId]: visible })); if (layerControlId === "simulation") { setImpactVisible(visible); } if (!map || !mapReady) { return; } setMapLayersVisibility(map, getWorkbenchLayerIds(map, layerControlId), visible); } function handleTogglePresentationLayer(id: string, visible: boolean) { const before = presentationLayerChangesRef.current.get(id) ?? (layerVisibility[id] !== false); handleToggleLayer(id, visible); presentationLayerChangesRef.current.set(id, before); } function restorePresentationLayers() { for (const [id, visible] of presentationLayerChangesRef.current) handleToggleLayer(id, visible); presentationLayerChangesRef.current.clear(); } const presentationLayerIds = new Set([ ...presentation.highlightLayers, ...(presentation.history.selectedId === null ? selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [] : []) ].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id])); const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible })); async function handleExportView(preset: ExportViewPreset) { const map = mapRef.current; if (!map || !mapReady) { showMapNotice({ tone: "warning", message: "地图尚未就绪,无法导出视图。" }); return; } const targetLongEdge = getExportTargetLongEdge(preset); const exportLabel = getExportPresetLabel(preset); showMapNotice({ id: "map-view-export", tone: "loading", title: "正在导出", message: `正在生成${exportLabel}地图截图...`, duration: Infinity }); try { const result = await exportMapViewImage(map, { scale: preset === "current" ? 1 : undefined, targetLongEdge, selectedFeature: toMapFeatureReference(detailFeature) }); showMapNotice({ id: "map-view-export", tone: "success", message: `已导出 ${result.width} × ${result.height} PNG。` }); } catch { showMapNotice({ id: "map-view-export", tone: "error", title: "导出失败", message: "当前底图或瓦片可能限制了画布导出。" }); } } function handleRefreshTiles() { const map = mapRef.current; if (!map || !mapReady) { showMapNotice({ tone: "warning", message: "地图尚未就绪,无法刷新瓦片。" }); return; } if (!refreshGeoServerLayers()) { showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" }); return; } map.triggerRepaint(); showMapNotice({ tone: "success", message: "已开始重新检查业务图层,并请求地图重新渲染。" }); } function handleShowDataStatus() { if (sourceStatuses.length === 0) { showMapNotice({ tone: "info", message: "数据源状态正在初始化。" }); return; } showMapNotice({ tone: sourceStatuses.some((sourceStatus) => sourceStatus.status === "offline") ? "warning" : "info", title: "数据状态", message: sourceStatuses .map((sourceStatus) => `${sourceStatus.sourceName}:${sourceStatus.status}`) .join(" · "), duration: 5000 }); } function handleShowShortcuts() { showMapNotice({ tone: "info", title: "快捷键", message: "双击完成线/面绘制;圆形标注先点圆心,再点半径;Esc 可作为后续快捷键扩展。", duration: 6000 }); } function handleOpenLatestAnalysis() { if (!latestAnalysisArtifact || !latestAnalysisAvailable) return; artifactWorkspace.openArtifact(latestAnalysisArtifact); } async function handleAnalyzeAlertsWithAgent() { if (workbenchAlerts.length === 0) { showMapNotice({ tone: "info", title: "当前无待处理事项", message: "当前没有需要汇总的待办或告警。" }); return; } if (agent.streaming) { showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" }); return; } openAgentWorkspaceForViewport(); try { await agent.submitPrompt( createAlertQueueConversationPrompt({ alerts: workbenchAlerts, conditions: scheduledConditions, dataSource: alertDataSource }) ); showMapNotice({ tone: "success", title: "已发起工况汇总", message: "已将待办与告警发送给 Agent。" }); } catch (error) { showMapNotice({ tone: "error", title: "工况汇总发起失败", message: error instanceof Error ? error.message : "无法将待办与告警发送给 Agent。" }); } } function handleSelectAlert(alert: WorkbenchAlert) { const notificationId = alert.id.startsWith("operational-task-notification:") ? alert.id.slice("operational-task-notification:".length) : null; const notification = operationalTasks.notifications.find((item) => item.notification_id === notificationId); const conditionId = alert.conditionId ?? getScheduledConditionIdFromAlertId(alert.id); if (conditionId) { const condition = scheduledConditions.find((item) => item.id === conditionId); if (!condition) { showMapNotice({ tone: "warning", title: "工况任务未找到", message: `未找到提醒关联的工况任务:${conditionId}。` }); return; } setPeripheralSamplePanelOpen(false); openConditionWorkspaceForViewport(); setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1, actionId: notification?.subject_type === "action" ? notification.subject_id : null })); if (notification?.status === "unread") { void operationalTasks.acknowledgeNotification(notification.notification_id, notification.version); } return; } showMapNotice({ tone: "warning", title: alert.title, message: alert.description }); } async function handleAgentUiEnvelope(payload: UIEnvelopePayload, sessionId: string) { const { envelope } = payload; if (envelope.kind === "map_action") { await handleAgentMapAction( envelope.action, envelope.params, sessionId, envelope.fallbackText ); return; } setAgentUiResults((current) => [ ...current.slice(-5), { id: payload.envelope_id || `agent-ui-${Date.now().toString(36)}`, sessionId, createdAt: payload.created_at, envelope } ]); showMapNotice({ tone: "info", title: envelope.kind === "chart" ? "Agent 图表已渲染" : "Agent 面板已渲染", message: envelope.fallbackText ?? (envelope.kind === "chart" ? "已在 Agent 面板显示可信图表。" : "已在 Agent 面板显示可信组件。") }); } async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) { return chatPresentation.executeCommand(request.command, signal, "chat"); } async function handleStartNewAgentSession() { mapController.clearScadaAnalysis(); await agent.startNewSession(); } async function handleLoadAgentHistorySession(sessionId: string) { const changesSession = sessionId !== agent.sessionId; const loaded = await agent.loadHistorySession(sessionId); if (!loaded) throw new Error("会话打开已取消"); if (changesSession) mapController.clearScadaAnalysis(); } async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) { mapController.clearScadaAnalysis(); const id = await agent.openTaskCollaboration(taskId, discussion); if (discussion?.reference && id) agent.setContentReference(discussion.reference); readingWorkspace?.close(); openAgentPanelForViewport(); } async function handleViewTaskExecutionHistory( taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string ) { mapController.clearScadaAnalysis(); let runId = knownRunId; if (!runId) { const runs = await fetchOperationalTaskRuns(taskId, getAccessToken); runId = runs.find((run) => (target.executionGeneration == null || run.generation === target.executionGeneration) && run.agent_session_id === target.sessionId)?.run_id; } if (!runId) { throw new Error(target.executionGeneration == null ? "所选历史报告没有匹配的执行记录。" : `第 ${target.executionGeneration} 轮调查没有匹配的执行记录。`); } await agent.openTaskExecutionHistory(taskId, runId, target.sessionId); readingWorkspace?.close(); openAgentPanelForViewport(); } async function handleDeleteAgentHistorySession(sessionId: string) { const clearsActiveAnalysis = sessionId === agent.sessionId; const deleted = await agent.deleteHistorySession(sessionId); if (!deleted) throw new Error("会话删除失败,请重试"); if (deleted && clearsActiveAnalysis) { mapController.clearScadaAnalysis(); } } async function handleAgentMapAction( action: string, params: unknown, sessionId: string, fallbackText?: string ) { const trustedAction = toTrustedMapAction(action, params, fallbackText); if (!trustedAction) { showMapNotice({ tone: "warning", title: "地图动作已降级", message: fallbackText ?? "Agent 地图动作参数未通过前端校验。" }); return; } if (trustedAction.type === "zoom_to_map") { const map = mapRef.current; if (!map || !mapReady) { showMapNotice({ tone: "warning", message: "地图尚未就绪,无法执行 Agent 缩放动作。" }); return; } map.easeTo({ center: trustedAction.center, zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14), padding: getResponsiveWorkbenchPadding( map, false, rightPanelOpen, rightPanelExpanded, agentPanelWidth ), duration: trustedAction.durationMs ?? 500 }); showMapNotice({ tone: "info", title: "Agent 地图定位", message: trustedAction.fallbackText ?? "已根据 Agent 建议缩放到目标位置。" }); return; } if (trustedAction.type === "locate_features") { fitNetworkBounds(); showMapNotice({ tone: "info", title: "Agent 资产定位", message: trustedAction.fallbackText ?? (trustedAction.featureIds.length > 0 ? `已接收 ${trustedAction.featureIds.length} 个目标资产,当前先回到全网视图。` : "已根据 Agent 建议回到全网视图。") }); return; } if (trustedAction.type === "apply_layer_style") { if (trustedAction.layerGroupId && typeof trustedAction.visible === "boolean") { handleToggleLayer(trustedAction.layerGroupId, trustedAction.visible); } showMapNotice({ tone: "info", title: "Agent 图层样式", message: trustedAction.fallbackText ?? "已接收 Agent 图层样式建议,当前仅执行受控图层显隐。" }); return; } try { await agent.resolveRenderRef(trustedAction.renderRef, sessionId); showMapNotice({ tone: "info", title: "Agent 节点渲染", message: trustedAction.fallbackText ?? "已验证 render_ref,节点分类渲染器将在后续接入。" }); } catch (error) { showMapNotice({ tone: "error", title: "render_ref 解析失败", message: error instanceof Error ? error.message : "无法读取 Agent 渲染结果。" }); } } function handleClearMapOverlays() { mapController.clearOverlays(); presentation.clearMapOverlays(); chatPresentation.clearMapOverlays(); previewPresentation.clearMapOverlays(); handleClearSelection(); dispatchWorkspaceContext({ type: "clear-spatial-feature" }); measurement.clear(); drawing.clear(); geocoding.clear(); handleToggleLayer("simulation", false); showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" }); } const toolbarPanelProps: ToolbarPanelProps = { geocoding, featureLocation: { commands: mapController, controllerState: mapControllerState, detailFeature, enabled: mapReady }, activeToolId, activeMeasureModeId, activeMeasureUnitId, measuring: measurement.measuring, measureResult: measurement.result, measureEnabled: mapReady, hasMeasurePoints: measurement.hasPoints, activeDrawToolId, drawingEnabled: mapReady, drawingAnnotations: drawing.annotations, canUndoDrawing: drawing.canUndo, canRedoDrawing: drawing.canRedo, canDeleteSelectedDrawing: drawing.canDeleteSelected, hasDrawingFeatures: drawing.hasFeatures, layerItems: layerControlItems, legendItems: visibleLegendItems, baseLayerOptions, activeBaseLayerId, onSelectMeasureMode: setActiveMeasureModeId, onSelectMeasureUnit: setActiveMeasureUnitId, onStartMeasure: measurement.start, onStopMeasure: measurement.stop, onClearMeasure: measurement.clear, onCopyMeasure: measurement.copyResult, onSelectDrawTool: setActiveDrawToolId, onUndoDrawing: drawing.undo, onRedoDrawing: drawing.redo, onDeleteSelectedDrawing: drawing.deleteSelected, onClearDrawing: drawing.clear, onExportAnnotations: drawing.exportGeoJSON, onSelectBaseLayer: setActiveBaseLayerId, onToggleLayer: handleToggleLayer, onExportView: handleExportView, onRefreshTiles: handleRefreshTiles, onShowDataStatus: handleShowDataStatus, onShowShortcuts: handleShowShortcuts, onClearMapOverlays: handleClearMapOverlays }; const selectedOperationalEvent = operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ?? null; const agentWorkspaceContext = useMemo(() => { const items: AgentWorkspaceContextItem[] = [ { id: "operational-time", label: "运行时点", value: formatContextClock(workspaceContext.operational.cursorTime), tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral" } ]; if (workspaceContext.spatial.featureId) { items.push({ id: "spatial-feature", label: "地图要素", value: workspaceContext.spatial.featureId, tone: "info" }); } if (artifactWorkspace.activeArtifact) { items.push({ id: "analytical-artifact", label: "分析成果", value: artifactWorkspace.activeArtifact.title, tone: "info" }); } return items; }, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]); const collaborationTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId ? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId) : undefined; const promptSuggestions = useAgentPromptSuggestions({ enabled: (isLargeScreen ? agent.panelOpen : mobileSheet === "agent") && agent.messages.length === 0 && !agent.streaming && agent.sessionPurpose === "ordinary_chat" && agent.connectionStatus === "online" && !agent.sessionHistoryLoading && !scheduledConditionsLoading && !operationalTasks.loading, model: agent.selectedModel, sessionId: agent.sessionId, request: agent.generatePromptSuggestions, recoveryVersion: agent.connectionRecoveryVersion, alerts: workbenchAlerts, alertDataSource, conditions: scheduledConditions, runtimeDataSource, artifact: latestAnalysisArtifact ?? null, sessions: agent.recentSessions, cacheScope: agent.cacheScope, }); const agentPanelProps: Omit< ComponentProps, "collapsing" | "onCollapse" > = { personaState: agent.personaState, sessionTitle: agent.sessionTitle, sessionHistory: agent.sessionHistory, sessionHistoryLoading: agent.sessionHistoryLoading, topicControls: agent.topicControls, historyTopicId: agent.history.topicId, onHistoryTopicChange: agent.history.setTopicId, onMoveSessionToTopic: agent.moveSessionToTopic, historyQuery: agent.history.query, onHistoryQueryChange: agent.history.setQuery, historyHasMore: agent.history.hasMore, historyLoadingMore: agent.history.loadingMore, historyError: agent.history.error ? "历史记录加载失败,请刷新重试" : undefined, historyPendingUpdate: agent.history.pendingUpdate, onHistoryOpen: agent.history.ensure, onHistoryClose: agent.closeHistory, onLoadMoreHistory: agent.history.loadMore, hasOlderMessages: agent.hasOlderMessages, loadingOlderMessages: agent.loadingOlderMessages, onLoadOlderMessages: agent.loadOlderMessages, readingPosition: agent.readingPosition, onReadingPositionChange: agent.onReadingPositionChange, backgroundStatus: agent.backgroundStatus, promptSuggestions: promptSuggestions.suggestions, promptSuggestionsLoading: promptSuggestions.loading, promptSuggestionsUnavailable: promptSuggestions.unavailable, onRetryPromptSuggestions: promptSuggestions.retry, activeSessionId: agent.sessionId, answerStateScope: agent.answerStateScope, qaOnly: agent.sessionPurpose === "task_collaboration" || agent.sessionPurpose === "task_execution", executionHistory: agent.sessionPurpose === "task_execution", statusLabel: agent.statusLabel, connectionStatus: agent.connectionStatus, streaming: agent.streaming, inputDelivery: agent.inputDelivery, onInputDeliveryChange: agent.setInputDelivery, pendingInputs: agent.pendingInputs, interactionRequests: agent.sessionPurpose === "task_execution" ? undefined : agent.interactionRequests, onPendingInputDeliveryChange: agent.updateInputDelivery, onPendingInputEdit: (id) => agent.withdrawInput(id, true), onPendingInputCancel: (id) => agent.withdrawInput(id, false), onDownloadFile: async (path) => { try { await agent.apiClient.downloadFile(path); } catch (error) { showMapNotice({ tone: "error", title: "文件下载失败", message: error instanceof Error ? error.message : "请稍后重试" }); } }, messages: agent.messages, streamRenderState: agent.streamRenderState, modelOptions: agent.modelOptions, selectedModel: agent.selectedModel, currentProjectName: getWorkbenchProject(getAccessToken)?.name, approvalMode: agent.approvalMode, uiResults: activeAgentUiResults, workspaceContext: agentWorkspaceContext, prompt: agent.promptDraft, contentReference: agent.contentReference, onContentReferenceChange: (value) => { agent.setContentReference(value); if (value) readingWorkspace?.close(); }, promptReference: collaborationTask ? { title: collaborationTask.title, placeholder: agent.sessionPurpose === "task_execution" ? "追加调试问题,发送后进入独立调试会话" : "可询问调查进展、讨论报告或补充信息", detail: agent.sessionPurpose === "task_execution" ? "发送后进入调试分支 · 原始记录保持不变" : [ agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration ? `第 ${agent.taskDiscussionReportGeneration} 轮` : null, agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null, agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null, agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null ].filter(Boolean).join(" · ") } : undefined, onRefreshHistory: agent.refreshSessionHistory, onStartNewSession: handleStartNewAgentSession, onLoadHistorySession: handleLoadAgentHistorySession, onRenameHistorySession: agent.renameHistorySession, onDeleteHistorySession: handleDeleteAgentHistorySession, onSelectModel: agent.setSelectedModel, onApprovalModeChange: agent.setApprovalMode, onPromptChange: agent.setPromptDraft, promptFocusRequest: agent.promptFocusRequest, messageInbox: agent.sessionPurpose === "ordinary_chat" ? { ...agent.agentMessageInbox, onLoadMore: agent.agentMessageInbox.loadMore, onRefresh: agent.agentMessageInbox.refresh } : undefined, onSubmitPrompt: agent.submitPrompt, onLocateAnswerFeatures: async (target) => { if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only"); return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat"); }, onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt, onReplyPermission: agent.sessionPurpose === "task_execution" ? undefined : agent.replyPermission, onReplyQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.replyQuestion, onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion }; const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active); const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen); return (
option.id === agent.selectedModel)?.label || agent.selectedModel || "Agent 后端连接中" } activeAnalysisArtifact={artifactWorkspace.activeArtifact} alerts={workbenchAlerts} alertDataSource={alertDataSource} user={projectPresentation.user} projectSelection={projectSelection} conditionFeedVisible={!conditionFeedSuspended && (isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition")} timelineVisible={timelineVisible} taskTickerAvailable={taskTickerAvailable} taskTickerVisible={taskTickerVisible} analysisResultsPanelOpen={analysisResultsPanelOpen} peripheralSamplePanelOpen={peripheralSamplePanelOpen} devPanelEnabled={devPanelEnabled} devPanelOpen={devPanelOpen} onSelectAlert={handleSelectAlert} onRemoveAlert={handleRemoveAlert} onToggleConditionFeed={toggleConditionWorkspaceForViewport} onToggleTimeline={toggleTimelineVisibility} onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)} onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }} onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }} onToggleDevPanel={() => setDevPanelOpen((current) => !current)} onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent} latestAnalysisAvailable={latestAnalysisAvailable} analysisSplitAvailable={artifactWorkspace.mapSplitAvailable} onOpenLatestAnalysis={handleOpenLatestAnalysis} onSetAnalysisView={artifactWorkspace.setView} onCloseAnalysisView={artifactWorkspace.destroyArtifact} onShowDataStatus={handleShowDataStatus} onRefreshTiles={handleRefreshTiles} onShowShortcuts={handleShowShortcuts} onLogout={onLogout} /> {analysisResultsPanelOpen ? (
); } function isNoticeSourceStatus(sourceStatus: { status: string; }): sourceStatus is { id: string; sourceName: string; status: MapSourceStatus; message: string } { return ( sourceStatus.status === "online" || sourceStatus.status === "degraded" || sourceStatus.status === "offline" ); } function getExportTargetLongEdge(preset: ExportViewPreset) { if (preset === "4k") { return 3840; } return undefined; } function getExportPresetLabel(preset: ExportViewPreset) { if (preset === "4k") { return " 4K "; } return "当前分辨率"; } function formatContextClock(value: string) { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); }