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

1090 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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 "当前分辨率";
}