feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
+979
View File
@@ -0,0 +1,979 @@
"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 {
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 { 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
} 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
});
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope
});
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(layerGroupId: string, visible: boolean) {
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerGroupId]: visible }));
if (layerGroupId === "simulation") {
setImpactVisible(visible);
}
if (!map || !mapReady) {
return;
}
WORKBENCH_LAYER_GROUPS[layerGroupId]?.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
});
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 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>
);
}