1090 lines
35 KiB
TypeScript
1090 lines
35 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ComponentProps,
|
||
type CSSProperties
|
||
} from "react";
|
||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||
import { CalendarClock } from "lucide-react";
|
||
import {
|
||
AgentCommandPanel,
|
||
AgentPersona,
|
||
toTrustedMapAction,
|
||
type FrontendActionRequest,
|
||
type UIEnvelopePayload,
|
||
type AgentUiResult
|
||
} 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 { AgentTaskTicker } from "./components/agent-task-ticker";
|
||
import { MapDevPanel } from "./components/map-dev-panel";
|
||
import {
|
||
MobileWorkbenchSheet,
|
||
type MobileWorkbenchSheetSnap
|
||
} from "./components/mobile-workbench-sheet";
|
||
import { FeaturePopover } from "./components/feature-popover";
|
||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||
import {
|
||
ToolbarPanel,
|
||
type ExportViewPreset,
|
||
type ToolbarPanelProps,
|
||
type ToolbarToolId
|
||
} from "./components/toolbar-panel";
|
||
import { WorkbenchTopBar } from "./components/workbench-top-bar";
|
||
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
|
||
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
|
||
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
|
||
import { useWorkbenchMap } from "./hooks/use-workbench-map";
|
||
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||
import { 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
|
||
} from "./layout/workbench-layout";
|
||
import { getResponsiveWorkbenchPadding } from "./map/camera";
|
||
import { exportMapViewImage } from "./map/export-view";
|
||
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
||
import {
|
||
BASE_LAYER_OPTIONS,
|
||
INITIAL_LAYER_VISIBILITY,
|
||
MAP_LEGEND_ITEMS,
|
||
WORKBENCH_LAYER_GROUPS,
|
||
applyBaseLayerVisibility,
|
||
createLayerControlItems,
|
||
getWorkbenchLayerIds
|
||
} from "./map/map-control-config";
|
||
import { waterNetworkToolbarItems } from "./map/toolbar-config";
|
||
import type {
|
||
DetailFeature,
|
||
ScheduledConditionItem,
|
||
ScheduledConditionRecord,
|
||
WorkbenchAlert
|
||
} from "./types";
|
||
import {
|
||
createScheduledConditionAlerts,
|
||
getScheduledConditionIdFromAlertId
|
||
} from "./utils/scheduled-condition-alerts";
|
||
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
|
||
|
||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||
|
||
export function MapWorkbenchPage() {
|
||
const hasMapboxToken = Boolean(env.DRAINAGE_MAPBOX_ACCESS_TOKEN);
|
||
const devPanelEnabled = env.DRAINAGE_ENABLE_DEV_PANEL;
|
||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
|
||
const [devPanelOpen, setDevPanelOpen] = useState(false);
|
||
const [impactVisible, setImpactVisible] = useState(false);
|
||
const [isLargeScreen, setIsLargeScreen] = useState(false);
|
||
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
|
||
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
|
||
WORKBENCH_LAYOUT.desktop.agentWidth
|
||
);
|
||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
|
||
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
|
||
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
|
||
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
|
||
const prefersReducedMotion = useReducedMotion();
|
||
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
|
||
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
|
||
conditionId: string;
|
||
requestId: number;
|
||
} | null>(null);
|
||
const [activeBaseLayerId, setActiveBaseLayerId] = useState(
|
||
hasMapboxToken ? "mapbox-light" : "none"
|
||
);
|
||
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
|
||
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
|
||
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
|
||
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
|
||
const [activeDrawToolId, setActiveDrawToolId] = useState<WorkbenchDrawMode | null>(null);
|
||
const [layerVisibility, setLayerVisibility] =
|
||
useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
|
||
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
|
||
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
|
||
useWorkbenchRuntimeData();
|
||
const baseLayerOptions = useMemo(
|
||
() =>
|
||
BASE_LAYER_OPTIONS.map((option) => ({
|
||
...option,
|
||
disabled: option.id !== "none" && !hasMapboxToken
|
||
})),
|
||
[hasMapboxToken]
|
||
);
|
||
|
||
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
||
setDetailFeature(feature);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const mediaQuery = window.matchMedia("(min-width: 1024px)");
|
||
const handleChange = () => {
|
||
setIsLargeScreen(mediaQuery.matches);
|
||
setViewportWidth(window.innerWidth);
|
||
if (mediaQuery.matches) {
|
||
setMobileSheet(null);
|
||
}
|
||
};
|
||
|
||
handleChange();
|
||
setConditionFeedVisible(window.innerWidth >= 1280);
|
||
mediaQuery.addEventListener("change", handleChange);
|
||
window.addEventListener("resize", handleChange);
|
||
|
||
return () => {
|
||
mediaQuery.removeEventListener("change", handleChange);
|
||
window.removeEventListener("resize", handleChange);
|
||
};
|
||
}, []);
|
||
|
||
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
|
||
|
||
useEffect(() => {
|
||
if (shouldShowConditionFeed) {
|
||
setConditionFeedMounted(true);
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setConditionFeedMounted(false);
|
||
}, 170);
|
||
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [shouldShowConditionFeed]);
|
||
|
||
const agent = useWorkbenchAgent({
|
||
onUiEnvelope: handleAgentUiEnvelope,
|
||
onFrontendAction: handleFrontendAction
|
||
});
|
||
function openAgentPanelForViewport() {
|
||
if (isLargeScreen) {
|
||
if (conditionFeedExpanded && viewportWidth < 1440) {
|
||
setConditionFeedExpanded(false);
|
||
setAgentCollapsedForCondition(false);
|
||
}
|
||
agent.expandPanel();
|
||
return;
|
||
}
|
||
|
||
setMobileSheet("agent");
|
||
setMobileSheetSnap("half");
|
||
}
|
||
|
||
const closeMobileSheet = useCallback(() => {
|
||
setMobileSheet(null);
|
||
}, []);
|
||
|
||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
|
||
setAgentCollapsedForCondition(agent.panelOpen);
|
||
agent.collapsePanel();
|
||
} else if (!nextExpanded && agentCollapsedForCondition) {
|
||
setAgentCollapsedForCondition(false);
|
||
agent.expandPanel();
|
||
}
|
||
setConditionFeedExpanded(nextExpanded);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
|
||
return;
|
||
}
|
||
|
||
setConditionFeedExpanded(false);
|
||
if (agentCollapsedForCondition) {
|
||
setAgentCollapsedForCondition(false);
|
||
agent.expandPanel();
|
||
}
|
||
}, [
|
||
agent,
|
||
agentCollapsedForCondition,
|
||
conditionFeedExpanded,
|
||
isLargeScreen,
|
||
shouldShowConditionFeed
|
||
]);
|
||
|
||
function toggleConditionFeedForViewport() {
|
||
setActiveToolId(null);
|
||
if (isLargeScreen) {
|
||
setConditionFeedVisible((current) => !current);
|
||
return;
|
||
}
|
||
|
||
if (mobileSheet === "condition") {
|
||
closeMobileSheet();
|
||
return;
|
||
}
|
||
|
||
setConditionFeedExpanded(false);
|
||
setMobileSheetSnap("half");
|
||
setMobileSheet("condition");
|
||
}
|
||
|
||
const leftPanelOpen = isLargeScreen && agent.panelOpen;
|
||
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
|
||
const rightPanelExpanded =
|
||
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
|
||
|
||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||
containerRef: mapContainerRef,
|
||
leftPanelOpen,
|
||
rightPanelOpen,
|
||
impactVisible,
|
||
onSelectFeature: handleSelectFeature,
|
||
selectedFeature: detailFeature
|
||
});
|
||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||
mapRef,
|
||
mapReady,
|
||
leftPanelOpen,
|
||
rightPanelOpen,
|
||
conditionPanelExpanded: rightPanelExpanded,
|
||
agentPanelWidth
|
||
});
|
||
|
||
const activeAgentUiResults = useMemo(
|
||
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
|
||
[agent.sessionId, agentUiResults]
|
||
);
|
||
const conditionAlerts = useMemo(
|
||
() => createScheduledConditionAlerts(scheduledConditions),
|
||
[scheduledConditions]
|
||
);
|
||
const runningTickerConditions = useMemo(
|
||
() =>
|
||
scheduledConditions.filter(
|
||
(condition): condition is ScheduledConditionRecord =>
|
||
condition.kind === "condition" && condition.status === "running"
|
||
),
|
||
[scheduledConditions]
|
||
);
|
||
const workbenchAlerts = conditionAlerts;
|
||
|
||
const drawing = useWorkbenchDrawing({
|
||
mapRef,
|
||
mapReady,
|
||
activeMode: activeDrawToolId,
|
||
onModeChange: setActiveDrawToolId
|
||
});
|
||
|
||
const measurement = useWorkbenchMeasurement({
|
||
mapRef,
|
||
mapReady,
|
||
mode: activeMeasureModeId,
|
||
unit: activeMeasureUnitId
|
||
});
|
||
const taskTickerAvailable = runningTickerConditions.length > 0;
|
||
const shouldShowTaskTicker =
|
||
mobileSheet === null && !conditionFeedExpanded && 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 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),
|
||
[layerVisibility]
|
||
);
|
||
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
||
const map = mapRef.current;
|
||
|
||
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
|
||
if (layerControlId === "simulation") {
|
||
setImpactVisible(visible);
|
||
}
|
||
|
||
if (!map || !mapReady) {
|
||
return;
|
||
}
|
||
|
||
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
|
||
if (map.getLayer(layerId)) {
|
||
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 handleSelectScenario(scenarioId: string) {
|
||
const scenario = WORKBENCH_SCENARIOS.find((item) => item.id === scenarioId);
|
||
setActiveScenarioId(scenarioId);
|
||
showMapNotice({
|
||
tone: "success",
|
||
title: "方案已切换",
|
||
message: `当前方案:${scenario?.name ?? scenarioId}。地图模拟结果暂未自动切换。`
|
||
});
|
||
}
|
||
|
||
function handlePreviewScenario() {
|
||
setImpactVisible(true);
|
||
setLayerVisibility((current) => ({ ...current, simulation: true }));
|
||
|
||
if (mapRef.current && mapReady) {
|
||
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
|
||
if (mapRef.current?.getLayer(layerId)) {
|
||
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
|
||
}
|
||
});
|
||
}
|
||
|
||
showMapNotice({
|
||
tone: "info",
|
||
title: "预览影响范围",
|
||
message: "已打开模拟结果图层,可在右侧图层面板继续调整显示。"
|
||
});
|
||
}
|
||
|
||
function handleCompareScenario() {
|
||
showMapNotice({
|
||
tone: "info",
|
||
title: "方案比较",
|
||
message: "候选方案比较将汇总影响范围、管渠处置与输排风险。"
|
||
});
|
||
}
|
||
|
||
function handleExportScenarioReport() {
|
||
showMapNotice({
|
||
tone: "success",
|
||
title: "场景报告",
|
||
message: "已生成当前场景报告任务,可继续导出地图截图或配置。"
|
||
});
|
||
}
|
||
|
||
async function handleAnalyzeAlertsWithAgent() {
|
||
if (workbenchAlerts.length === 0) {
|
||
showMapNotice({
|
||
tone: "info",
|
||
title: "当前无待复核工况",
|
||
message: "当前没有需要汇总的工况提醒。"
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (agent.streaming) {
|
||
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
||
return;
|
||
}
|
||
|
||
openAgentPanelForViewport();
|
||
|
||
try {
|
||
await agent.submitPrompt(
|
||
createAlertQueueConversationPrompt({
|
||
alerts: workbenchAlerts,
|
||
conditions: scheduledConditions
|
||
})
|
||
);
|
||
|
||
showMapNotice({
|
||
tone: "success",
|
||
title: "已发起工况汇总",
|
||
message: "已将待复核工况发送到 Agent。"
|
||
});
|
||
} catch (error) {
|
||
showMapNotice({
|
||
tone: "error",
|
||
title: "工况汇总发起失败",
|
||
message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。"
|
||
});
|
||
}
|
||
}
|
||
|
||
function handleSelectAlert(alert: WorkbenchAlert) {
|
||
const conditionId = getScheduledConditionIdFromAlertId(alert.id);
|
||
if (conditionId) {
|
||
const condition = scheduledConditions.find((item) => item.id === conditionId);
|
||
if (!condition) {
|
||
showMapNotice({
|
||
tone: "warning",
|
||
title: "工况任务未找到",
|
||
message: `未找到提醒关联的工况任务:${conditionId}。`
|
||
});
|
||
return;
|
||
}
|
||
|
||
setConditionFeedVisible(true);
|
||
setActiveToolId(null);
|
||
if (!isLargeScreen) {
|
||
setMobileSheet("condition");
|
||
setMobileSheetSnap("half");
|
||
}
|
||
setConditionFocusRequest((current) => ({
|
||
conditionId,
|
||
requestId: (current?.requestId ?? 0) + 1
|
||
}));
|
||
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) {
|
||
if (request.name === "render_scada_analysis") {
|
||
const items = parseScadaAnalysisItems(request.params.items);
|
||
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
|
||
const result = await mapController.renderScadaAnalysis(items, signal);
|
||
const counts = result.level_counts;
|
||
showMapNotice({
|
||
tone: "success",
|
||
title: "Agent SCADA 分析已渲染",
|
||
message: `已渲染 ${result.rendered_ids.length} 个,高 ${counts.high}、中 ${counts.medium}、低 ${counts.low}${result.missing_ids.length ? `,缺失 ${result.missing_ids.length} 个` : ""}。`
|
||
});
|
||
return result;
|
||
}
|
||
if (request.name === "clear_scada_analysis") {
|
||
const result = mapController.clearScadaAnalysis();
|
||
showMapNotice({
|
||
tone: "info",
|
||
title: "Agent SCADA 分析已清除",
|
||
message: "地图已移除当前会话的 SCADA 分析结果。"
|
||
});
|
||
return result;
|
||
}
|
||
throw new Error("UNSUPPORTED_FRONTEND_ACTION");
|
||
}
|
||
|
||
async function handleStartNewAgentSession() {
|
||
mapController.clearScadaAnalysis();
|
||
await agent.startNewSession();
|
||
}
|
||
|
||
async function handleLoadAgentHistorySession(sessionId: string) {
|
||
if (sessionId !== agent.sessionId) {
|
||
mapController.clearScadaAnalysis();
|
||
}
|
||
await agent.loadHistorySession(sessionId);
|
||
}
|
||
|
||
async function handleDeleteAgentHistorySession(sessionId: string) {
|
||
const clearsActiveAnalysis = sessionId === agent.sessionId;
|
||
const deleted = await agent.deleteHistorySession(sessionId);
|
||
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, leftPanelOpen, rightPanelOpen),
|
||
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 handleExportConfig() {
|
||
const config = {
|
||
exportedAt: new Date().toISOString(),
|
||
activeBaseLayerId,
|
||
layerVisibility,
|
||
activeToolId,
|
||
activeMeasureModeId,
|
||
activeMeasureUnitId
|
||
};
|
||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = "drainage-network-map-config.json";
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
showMapNotice({ tone: "success", message: "当前地图配置已导出。" });
|
||
}
|
||
|
||
const toolbarPanelProps: ToolbarPanelProps = {
|
||
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: MAP_LEGEND_ITEMS,
|
||
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,
|
||
onExportConfig: handleExportConfig
|
||
};
|
||
const agentPanelProps: Omit<
|
||
ComponentProps<typeof AgentCommandPanel>,
|
||
"collapsing" | "onCollapse"
|
||
> = {
|
||
personaState: agent.personaState,
|
||
sessionTitle: agent.sessionTitle,
|
||
sessionHistory: agent.sessionHistory,
|
||
sessionHistoryLoading: agent.sessionHistoryLoading,
|
||
activeSessionId: agent.sessionId,
|
||
statusLabel: agent.statusLabel,
|
||
connectionStatus: agent.connectionStatus,
|
||
streaming: agent.streaming,
|
||
messages: agent.messages,
|
||
streamRenderState: agent.streamRenderState,
|
||
modelOptions: agent.modelOptions,
|
||
selectedModel: agent.selectedModel,
|
||
approvalMode: agent.approvalMode,
|
||
uiResults: activeAgentUiResults,
|
||
onRefreshHistory: agent.refreshSessionHistory,
|
||
onStartNewSession: handleStartNewAgentSession,
|
||
onLoadHistorySession: handleLoadAgentHistorySession,
|
||
onRenameHistorySession: agent.renameHistorySession,
|
||
onDeleteHistorySession: handleDeleteAgentHistorySession,
|
||
onSelectModel: agent.setSelectedModel,
|
||
onApprovalModeChange: agent.setApprovalMode,
|
||
onSubmitPrompt: agent.submitPrompt,
|
||
onStopPrompt: agent.stopPrompt,
|
||
onReplyPermission: agent.replyPermission,
|
||
onReplyQuestion: agent.replyQuestion,
|
||
onRejectQuestion: agent.rejectQuestion
|
||
};
|
||
|
||
return (
|
||
<main
|
||
className="relative 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": `${
|
||
leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth
|
||
}px`
|
||
} as CSSProperties
|
||
}
|
||
>
|
||
<div
|
||
ref={mapContainerRef}
|
||
className="map-grid"
|
||
style={{ position: "absolute", inset: 0 }}
|
||
aria-label="排水管网地图"
|
||
/>
|
||
|
||
<WorkbenchTopBar
|
||
dataTime={headerDataTime}
|
||
modelName="AquaDispatch v1.0"
|
||
scenarios={WORKBENCH_SCENARIOS}
|
||
activeScenarioId={activeScenarioId}
|
||
alerts={workbenchAlerts}
|
||
user={WORKBENCH_USER}
|
||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||
taskTickerAvailable={taskTickerAvailable}
|
||
taskTickerVisible={taskTickerVisible}
|
||
devPanelEnabled={devPanelEnabled}
|
||
devPanelOpen={devPanelOpen}
|
||
onSelectScenario={handleSelectScenario}
|
||
onSelectAlert={handleSelectAlert}
|
||
onToggleConditionFeed={toggleConditionFeedForViewport}
|
||
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
|
||
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
|
||
onPreviewScenario={handlePreviewScenario}
|
||
onCompareScenario={handleCompareScenario}
|
||
onExportScenarioReport={handleExportScenarioReport}
|
||
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
|
||
onShowDataStatus={handleShowDataStatus}
|
||
onRefreshTiles={handleRefreshTiles}
|
||
onShowShortcuts={handleShowShortcuts}
|
||
onExportConfig={handleExportConfig}
|
||
/>
|
||
|
||
<WorkbenchAgentPanels
|
||
panelProps={agentPanelProps}
|
||
panelOpen={agent.panelOpen}
|
||
panelCollapsing={agent.panelCollapsing}
|
||
conditionExpanded={shouldShowConditionFeed && conditionFeedExpanded}
|
||
personaState={agent.personaState}
|
||
statusLabel={agent.statusLabel}
|
||
viewportWidth={viewportWidth}
|
||
onCollapsePanel={agent.collapsePanel}
|
||
onExpandPanel={agent.expandPanel}
|
||
onWidthCommit={setAgentPanelWidth}
|
||
/>
|
||
|
||
{!devPanelOpen ? (
|
||
<div className="absolute right-2 top-24 z-20 hidden lg:block">
|
||
<MapToolbar items={toolbarItems} className="hidden lg:block" />
|
||
</div>
|
||
) : null}
|
||
|
||
{conditionFeedMounted && !devPanelOpen ? (
|
||
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
|
||
<ScheduledConditionFeed
|
||
presentation="desktop-floating"
|
||
conditions={scheduledConditions}
|
||
expanded={conditionFeedExpanded}
|
||
focusRequest={conditionFocusRequest}
|
||
loading={scheduledConditionsLoading}
|
||
visible={shouldShowConditionFeed}
|
||
selectedConditionId={selectedConditionId}
|
||
onExpandedChange={handleConditionExpandedChange}
|
||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||
onSelectedConditionChange={setSelectedConditionId}
|
||
onExpandAgent={openAgentPanelForViewport}
|
||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||
onSubmitPrompt={agent.submitPrompt}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{!devPanelOpen ? (
|
||
<div className="absolute right-16 top-24 z-20 hidden lg:block">
|
||
<ToolbarPanel {...toolbarPanelProps} />
|
||
</div>
|
||
) : null}
|
||
|
||
{devPanelEnabled && devPanelOpen ? (
|
||
<MapDevPanel
|
||
commands={mapController}
|
||
controllerState={mapControllerState}
|
||
detailFeature={detailFeature}
|
||
onClose={() => setDevPanelOpen(false)}
|
||
/>
|
||
) : null}
|
||
|
||
{mobileSheet === null ? (
|
||
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden">
|
||
<ToolbarPanel
|
||
{...toolbarPanelProps}
|
||
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
|
||
<div className="flex items-end gap-2 pr-2">
|
||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||
</div>
|
||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
||
</div>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{shouldShowTaskTicker ? (
|
||
<motion.div
|
||
key="agent-task-ticker-shell"
|
||
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 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} />
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
|
||
<div className="absolute bottom-3 left-3 right-3 z-30 lg:hidden">
|
||
{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-8 w-8" state={agent.personaState} />
|
||
</button>
|
||
<MapToolbar
|
||
items={toolbarItems}
|
||
orientation="horizontal"
|
||
className="h-11 justify-center"
|
||
/>
|
||
<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
|
||
label={mobileSheet === "agent" ? "Agent 工作台抽屉" : "工况任务抽屉"}
|
||
snap={mobileSheetSnap}
|
||
onSnapChange={setMobileSheetSnap}
|
||
onClose={closeMobileSheet}
|
||
>
|
||
{mobileSheet === "agent" ? (
|
||
<AgentCommandPanel
|
||
{...agentPanelProps}
|
||
presentation="mobile-sheet"
|
||
onCollapse={closeMobileSheet}
|
||
/>
|
||
) : (
|
||
<ScheduledConditionFeed
|
||
presentation="mobile-sheet"
|
||
conditions={scheduledConditions}
|
||
expanded={conditionFeedExpanded}
|
||
focusRequest={conditionFocusRequest}
|
||
loading={scheduledConditionsLoading}
|
||
visible
|
||
selectedConditionId={selectedConditionId}
|
||
onExpandedChange={handleConditionExpandedChange}
|
||
onFocusRequestHandled={handleConditionFocusRequestHandled}
|
||
onSelectedConditionChange={setSelectedConditionId}
|
||
onExpandAgent={openAgentPanelForViewport}
|
||
onClose={closeMobileSheet}
|
||
onLoadHistorySession={handleLoadAgentHistorySession}
|
||
onSubmitPrompt={agent.submitPrompt}
|
||
/>
|
||
)}
|
||
</MobileWorkbenchSheet>
|
||
) : null}
|
||
|
||
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
|
||
|
||
{!mapReady && !mapError ? (
|
||
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
|
||
) : null}
|
||
{!hasMapboxToken ? (
|
||
<MapSourceStatusNotice
|
||
sourceName="Mapbox 底图"
|
||
status="degraded"
|
||
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
|
||
/>
|
||
) : 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 "当前分辨率";
|
||
}
|