Files
next-tjwater-frontend/src/features/workbench/map-workbench-page.tsx
T

1788 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
getAccessToken?: WorkbenchAccess;
projectSelection?: WorkbenchProjectSelection;
};
export function MapWorkbenchPage(props: MapWorkbenchPageProps) {
return <ReadingWorkspaceProvider><MapWorkbenchContent {...props} /></ReadingWorkspaceProvider>;
}
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<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false);
const [investigationFollow, setInvestigationFollow] = useState(true);
const presentationLayerChangesRef = useRef(new Map<string, boolean>());
const [impactVisible, setImpactVisible] = useState(false);
const [geocodingPickActive, setGeocodingPickActive] = useState(false);
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 [scadaAlertIntensity, setScadaAlertIntensity] = useState<ScadaAlertIntensity>("standard");
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(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<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 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<WorkbenchAlert[]>(() => {
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<OperationalTimelineMode>("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<ScheduledConditionRecord[]>(
() =>
scheduledConditions.reduce<ScheduledConditionRecord[]>((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<MapToolbarItem[]>(
() =>
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<AgentWorkspaceContextItem[]>(() => {
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<typeof AgentCommandPanel>,
"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 (
<main
className="relative isolate h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
data-basemap-tone={basemapTone}
style={
{
...WORKBENCH_LAYOUT_CSS_VARIABLES,
"--workbench-agent-current-width": `${currentAgentWidth}px`,
"--workbench-timeline-height": `${OPERATIONAL_TIMELINE_LAYOUT.overlayReservedHeight}px`
} as CSSProperties
}
>
<ScadaAlertAtmosphere atmosphere={scadaAlertAtmosphere} onDismiss={dismissAtmosphere} onOpen={handleSelectAlert} intensity={scadaAlertIntensity} />
<WorkbenchTopBar
dataTime={displayedDataTime}
modelName={
agent.modelOptions.find((option) => 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 ? (
<div
aria-hidden="true"
data-map-interaction-shield
className="pointer-events-auto absolute inset-x-0 bottom-0 top-14 z-[59] touch-none overscroll-none"
/>
) : null}
<AnalysisResultsPanel
open={analysisResultsPanelOpen}
onClose={() => setAnalysisResultsPanelOpen(false)}
getAccessToken={getAccessToken}
onOpenArtifact={(artifact) => {
artifactWorkspace.openArtifact(artifact);
setAnalysisResultsPanelOpen(false);
}}
/>
<PeripheralSamplePanel
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
mapReady={mapReady}
mapRef={mapRef}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
onOpenInvestigation={(taskId) => {
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
const conditionId = operationalTaskConditionId(taskId);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 }));
operationalTasks.refresh();
}}
/>
<WorkbenchAgentPanels
panelProps={agentPanelProps}
panelOpen={agent.panelOpen}
panelCollapsing={agent.panelCollapsing}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
viewportWidth={viewportWidth}
conditionExpanded={rightPanelExpanded}
onCollapsePanel={agent.collapsePanel}
onExpandPanel={agent.expandPanel}
onWidthCommit={setAgentPanelWidth}
>
<WorkbenchMainFrame
activeArtifact={artifactWorkspace.activeArtifact}
pendingArtifact={artifactWorkspace.pendingArtifact}
surfaceMode={artifactWorkspace.surfaceMode}
mapSplitAvailable={artifactWorkspace.mapSplitAvailable}
readingSplitAvailable={businessWorkspaceWidth >= 960}
onOpenPendingArtifact={artifactWorkspace.openPreview}
onDestroyPendingArtifact={artifactWorkspace.destroyPreview}
onSetArtifactView={artifactWorkspace.setView}
onCollapseArtifact={artifactWorkspace.collapseArtifact}
onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode}
timelineBottom={timelineBottom}
timelineContent={
timelineVisible ? (
<>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold">运行时间轴</span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
<span className="text-slate-500">执行 {operationalSummary.active}</span>
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
查看全天
</span>
</button>
</div>
</>
) : null
}
mapContent={
<div
ref={mapContainerRef}
className="map-grid absolute inset-0"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
}
mapOverlay={
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 flex max-h-[calc(100dvh-9rem-var(--workbench-timeline-height))] max-w-[calc(100%-24px)] flex-col gap-2 overflow-y-auto">
<AnimatePresence initial={false}>
{[
{ scope: "investigation", surface: presentation, task: selectedInvestigationTask, visible: Boolean(selectedInvestigationTask) || presentation.hasHistory },
{ scope: "chat", surface: chatPresentation, task: null, visible: chatPresentation.hasHistory || chatPresentation.charts.length > 0 || Boolean(chatPresentation.overlay) || chatPresentation.highlightLayers.length > 0 },
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
status={scope === "chat" ? "live" : surface.status} error={surface.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
onResumeCamera={surface.resumeCamera} onCloseChart={surface.closeChart} onToggleOverlay={surface.toggleOverlay} onFitOverlay={surface.fitOverlay}
/>)}
</AnimatePresence>
</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={
{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties
}
>
<div className="flex items-end gap-2 lg:pr-2"
onPointerDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}
onKeyDownCapture={() => { if (investigationFollow) presentation.pauseCamera(); }}>
<MapZoom
mapRef={mapRef}
mapReady={mapReady}
onHome={fitNetworkBounds}
reducedMotion={prefersReducedMotion ?? false}
/>
</div>
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
</div>
</div>
}
/>
</WorkbenchAgentPanels>
{conditionFeedMounted ? (
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}>
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
backendLoading={operationalTasks.loading}
backendError={operationalTasks.error}
visible={shouldShowConditionFeed}
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentWorkspaceForViewport}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onViewTaskExecutionHistory={handleViewTaskExecutionHistory}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
onTaskAccessDecision={operationalTasks.decideAccess}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onAnswerTaskQuestion={operationalTasks.answerQuestion}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
</div>
) : null}
{devPanelEnabled && devPanelOpen ? (
<MapDevPanel
commands={mapController}
controllerState={mapControllerState}
detailFeature={detailFeature}
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
onPreviewScadaAlert={previewAtmosphere}
onEndScadaAlertPreview={endAtmospherePreview}
scadaAlertPreviewActive={scadaAlertAtmosphere?.preview === true}
scadaAlertIntensity={scadaAlertIntensity}
onScadaAlertIntensityChange={setScadaAlertIntensity}
investigationTools={{
task: selectedInvestigationTask, mapReady,
charts: previewPresentation.charts.length, overlay: previewPresentation.overlay?.title ?? null,
onSample: previewPresentation.createSample,
onPreview: previewPresentation.runPreview, onClear: previewPresentation.clearPreview
}}
onClose={() => setDevPanelOpen(false)}
/>
) : null}
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{
bottom: mobileOverlayLayout.mapControlBottom,
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
} as CSSProperties}
>
<ToolbarPanel
{...toolbarPanelProps}
panelWidthClassName={activeToolId === "geocoding"
? "w-full max-h-[var(--mobile-tool-available-height)]"
: "w-full max-h-[52dvh] overflow-y-auto"}
/>
</div>
) : null}
<AnimatePresence initial={false}>
{shouldShowTaskTicker ? (
<motion.div
key="agent-task-ticker-shell"
className="fixed left-1/2 right-auto top-24 z-50 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] lg:left-[calc((100%+var(--workbench-agent-current-width))/2)] lg:max-w-[calc(100%-var(--workbench-agent-current-width)-1.5rem)] 2xl:w-[var(--workbench-ticker-width-wide)]"
style={{ transformOrigin: "top center" }}
initial={
prefersReducedMotion
? false
: {
opacity: 0,
scale: 0.96,
y: -8
}
}
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker
className="w-full max-w-full"
conditions={runningTickerConditions}
dataSource={runtimeDataSource}
/>
</motion.div>
) : null}
</AnimatePresence>
<div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? (
<div className="flex items-center justify-center gap-2">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={openAgentPanelForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-violet-700"
>
<AgentPersona className="pointer-events-none h-10 w-10" state={agent.personaState} />
</button>
{artifactWorkspace.surfaceMode !== "focus" ? (
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
/>
) : null}
<button
type="button"
aria-label="打开工况任务"
title="打开工况任务"
onClick={toggleConditionFeedForViewport}
className="acrylic-control grid h-11 w-11 shrink-0 place-items-center rounded-xl border text-blue-700"
>
<CalendarClock size={20} aria-hidden="true" />
</button>
</div>
) : null}
</div>
{mobileSheet ? (
<MobileWorkbenchSheet
className={mobileSheetSuspended ? "hidden" : undefined}
suspended={mobileSheetSuspended}
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
: mobileSheet === "timeline"
? "运行时间轴抽屉"
: "工况任务抽屉"
}
snap={mobileSheetSnap}
onSnapChange={setMobileSheetSnap}
onClose={closeMobileSheet}
>
{mobileSheet === "agent" ? (
<AgentCommandPanel
{...agentPanelProps}
presentation="mobile-sheet"
onCollapse={closeMobileSheet}
/>
) : mobileSheet === "timeline" ? (
<OperationalTimeline
mobile
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
) : (
<ScheduledConditionFeed
presentation="mobile-sheet"
conditions={scheduledConditions}
dataSource={runtimeDataSource}
workOrderDataSource={workOrderDataSource}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
backendLoading={operationalTasks.loading}
backendError={operationalTasks.error}
visible
selectedConditionId={selectedConditionId}
onExpandedChange={handleConditionExpandedChange}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onLocate={handleLocateInvestigationTargets}
onExpandAgent={openAgentPanelForViewport}
onClose={closeMobileSheet}
onLoadHistorySession={handleLoadAgentHistorySession}
onSubmitPrompt={agent.submitPrompt}
onOpenTaskCollaboration={handleOpenTaskCollaboration}
onViewTaskExecutionHistory={handleViewTaskExecutionHistory}
onTaskDecision={operationalTasks.decideAction}
onTaskExecute={operationalTasks.executeAction}
onTaskDraftUpdate={operationalTasks.updateWorkOrderDraft}
onTaskConfirmDispatch={operationalTasks.confirmWorkOrderDispatch}
onTaskAccessDecision={operationalTasks.decideAccess}
onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh}
onLoadTaskReports={operationalTasks.loadReports}
onAnswerTaskQuestion={operationalTasks.answerQuestion}
onReportViewed={operationalTasks.markReportRead}
onRefresh={workbenchSync.refresh}
/>
)}
</MobileWorkbenchSheet>
) : null}
<FeaturePopover
feature={detailFeature}
projectId={projectPresentation.projectId}
getAccessToken={getAccessToken}
onClose={() => setDetailFeature(null)}
/>
{!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
) : null}
{!hasOnlineBasemap ? (
<MapSourceStatusNotice
sourceName="在线底图"
status="degraded"
message="未检测到 Mapbox 或天地图令牌,当前已切换为无底图模式,仅展示业务图层。"
/>
) : null}
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
<MapSourceStatusNotice
key={sourceStatus.id}
id={`map-source-runtime-${sourceStatus.id}`}
sourceName={sourceStatus.sourceName}
status={sourceStatus.status}
message={sourceStatus.message}
/>
))}
{mapError ? <MapErrorNotice message={mapError} /> : null}
</main>
);
}
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));
}