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

1002 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AgentCollapsedRail,
AgentCommandPanel,
AgentPersona,
type AgentUiResult
} from "@/features/agent";
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import {
MapErrorNotice,
MapLoadingNotice,
MapSourceStatusNotice,
showMapNotice,
type MapSourceStatus
} from "@/features/map/core/components/notice";
import { MapScaleLine } from "@/features/map/core/components/scale-line";
import { MapToolbar, type MapToolbarItem } from "@/features/map/core/components/toolbar";
import { MapZoom } from "@/features/map/core/components/zoom";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "./data/scheduled-conditions";
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 { toMapFeatureReference } from "./hooks/use-map-interactions";
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
import { exportMapViewImage } from "./map/export-view";
import {
BASE_LAYER_IDS,
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
createLayerControlItems,
getWorkbenchLayerIds
} from "./map/map-control-config";
import { waterNetworkToolbarItems } from "./map/toolbar-config";
import type { DetailFeature, ScheduledConditionItem, ScheduledConditionRecord, WorkbenchAlert } from "./types";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
function formatHeaderDataTime(date: Date) {
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function getMsUntilNextHeaderDataTimeTick(date: Date) {
const nextTick = new Date(date);
const nextMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
HEADER_DATA_TIME_STEP_MINUTES;
nextTick.setMinutes(nextMinutes, 0, 0);
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function MapWorkbenchPage() {
const hasMapboxToken = Boolean(process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
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 [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 [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
const [headerDataTime, setHeaderDataTime] = useState(() => formatHeaderDataTime(new Date()));
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);
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
useEffect(() => {
let timeoutId: number;
const refreshHeaderDataTime = () => {
const now = new Date();
setHeaderDataTime(formatHeaderDataTime(now));
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
};
refreshHeaderDataTime();
return () => window.clearTimeout(timeoutId);
}, []);
useEffect(() => {
const refreshScheduledConditions = () => {
setScheduledConditions(createOperationalScheduledConditions(new Date()));
setScheduledConditionsLoading(false);
};
refreshScheduledConditions();
const intervalId = window.setInterval(refreshScheduledConditions, SCHEDULED_CONDITION_REFRESH_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, 170);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
const leftPanelOpen = isLargeScreen;
const rightPanelOpen = false;
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
});
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
});
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 prefersReducedMotion = useReducedMotion();
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)"
}
: {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: {
type: "spring" as const,
stiffness: 420,
damping: 30,
mass: 0.75
}
};
const taskTickerExitState = prefersReducedMotion
? {
opacity: 0,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0 }
}
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(4px)",
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;
}
agent.expandPanel();
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);
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) {
const map = mapRef.current;
if (request.name === "zoom_to_map") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
}
if (request.name === "locate_features") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
}
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
}
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),
duration: 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,
streaming: agent.streaming,
messages: agent.messages,
streamRenderState: agent.streamRenderState,
modelOptions: agent.modelOptions,
selectedModel: agent.selectedModel,
uiResults: activeAgentUiResults,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: agent.startNewSession,
onLoadHistorySession: agent.loadHistorySession,
onRenameHistorySession: agent.renameHistorySession,
onDeleteHistorySession: agent.deleteHistorySession,
onSelectModel: agent.setSelectedModel,
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">
<div
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="排水管网地图"
/>
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="DrainFlow 1.0.0"
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={WORKBENCH_USER}
conditionFeedVisible={shouldShowConditionFeed}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={() => {
setConditionFeedVisible((current) => !current);
setActiveToolId(null);
}}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
onExportConfig={handleExportConfig}
/>
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
{agent.panelOpen ? (
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.panelCollapsing}
onCollapse={agent.collapsePanel}
/>
) : (
<AgentCollapsedRail
personaState={agent.personaState}
statusLabel={agent.statusLabel}
onExpand={agent.expandPanel}
/>
)}
</div>
<div className="absolute right-2 top-24 z-20">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
{conditionFeedMounted ? (
<div
className={cn(
"absolute right-[62px] top-24 z-20 hidden lg:block",
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
)}
>
<ScheduledConditionFeed
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
selectedConditionId={selectedConditionId}
onExpandedChange={setConditionFeedExpanded}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={agent.expandPanel}
onLoadHistorySession={agent.loadHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{agent.mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.mobilePanelCollapsing}
onCollapse={agent.closeMobilePanel}
/>
</div>
) : null}
</div>
<MobileAgentToggle
open={agent.mobileOpen}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
streaming={agent.streaming}
onOpen={agent.openMobilePanel}
onClose={agent.closeMobilePanel}
/>
{!agent.mobileOpen ? (
<div className="absolute bottom-20 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-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
style={{ transformOrigin: "top center" }}
initial={
prefersReducedMotion
? false
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(6px)"
}
}
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker
className="w-full"
conditions={runningTickerConditions}
/>
</motion.div>
) : null}
</AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
{!agent.mobileOpen ? (
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full justify-center" />
) : null}
</div>
<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 applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", activeBaseLayerId === layerId ? "visible" : "none");
}
});
}
function getExportTargetLongEdge(preset: ExportViewPreset) {
if (preset === "4k") {
return 3840;
}
return undefined;
}
function getExportPresetLabel(preset: ExportViewPreset) {
if (preset === "4k") {
return " 4K ";
}
return "当前分辨率";
}
function createScheduledConditionAlerts(conditions: ScheduledConditionItem[]): WorkbenchAlert[] {
return conditions
.filter(isAlertCondition)
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
.map((condition) => {
return {
id: `scheduled-condition-alert-${condition.id}`,
title: `待复核:${condition.title}`,
description: condition.detail ?? condition.summary,
time: formatScheduledConditionTime(condition.scheduledAt)
};
});
}
function isAlertCondition(condition: ScheduledConditionItem) {
return condition.kind === "condition" && condition.status === "error";
}
function getScheduledConditionTimestamp(condition: ScheduledConditionItem) {
const scheduledAt = Date.parse(condition.scheduledAt);
return Number.isNaN(scheduledAt) ? condition.updatedAt : scheduledAt;
}
function formatScheduledConditionTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
function getScheduledConditionIdFromAlertId(alertId: string) {
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
return null;
}
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
}
function MobileAgentToggle({
open,
personaState,
statusLabel,
streaming,
onOpen,
onClose
}: {
open: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
streaming: boolean;
onOpen: () => void;
onClose: () => void;
}) {
if (open) {
return (
<div className="absolute bottom-20 right-3 z-40 lg:hidden">
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onClose}
className="agent-panel-icon-button grid h-10 w-10 place-items-center rounded-2xl text-slate-700 transition"
>
<X size={18} aria-hidden="true" />
</button>
</div>
);
}
return (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpen}
className={cn(
"grid h-12 w-12 place-items-center text-violet-700",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
</button>
</div>
);
}