1788 lines
72 KiB
TypeScript
1788 lines
72 KiB
TypeScript
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));
|
||
}
|