refactor: organize supply frontend structure
This commit is contained in:
@@ -1,6 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -8,39 +5,33 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent
|
||||
type CSSProperties
|
||||
} from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
AgentCollapsedRail,
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
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,
|
||||
MapScaleLine,
|
||||
MapSourceStatusNotice,
|
||||
MapToolbar,
|
||||
MapZoom,
|
||||
showMapNotice,
|
||||
type MapToolbarItem,
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core/components/notice";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
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";
|
||||
} 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 { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
import {
|
||||
ToolbarPanel,
|
||||
type ExportViewPreset,
|
||||
@@ -48,15 +39,12 @@ import {
|
||||
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 { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
|
||||
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
|
||||
import { toMapFeatureReference } from "./hooks/use-map-interactions";
|
||||
import {
|
||||
useWorkbenchMeasurement,
|
||||
@@ -65,20 +53,18 @@ import {
|
||||
} from "./hooks/use-workbench-measurement";
|
||||
import {
|
||||
WORKBENCH_LAYOUT,
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchBasemapTone,
|
||||
getWorkbenchLayoutCssVariables,
|
||||
getWorkbenchViewportLayout
|
||||
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_IDS,
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
MAP_LEGEND_ITEMS,
|
||||
WORKBENCH_LAYER_GROUPS,
|
||||
applyBaseLayerVisibility,
|
||||
createLayerControlItems,
|
||||
getWorkbenchLayerIds
|
||||
} from "./map/map-control-config";
|
||||
@@ -89,49 +75,23 @@ import type {
|
||||
ScheduledConditionRecord,
|
||||
WorkbenchAlert
|
||||
} from "./types";
|
||||
import {
|
||||
createScheduledConditionAlerts,
|
||||
getScheduledConditionIdFromAlertId
|
||||
} from "./utils/scheduled-condition-alerts";
|
||||
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
|
||||
|
||||
const HEADER_DATA_TIME_STEP_MINUTES = 5;
|
||||
const INITIAL_HEADER_DATA_TIME = "同步中";
|
||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||
|
||||
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(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopAgentPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const agentPanelResizeLeftRef = useRef(0);
|
||||
const agentPanelResizeCleanupRef = useRef<(() => void) | 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 | null>(null);
|
||||
const [agentPanelResizing, setAgentPanelResizing] = useState(false);
|
||||
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
|
||||
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
|
||||
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
|
||||
@@ -153,9 +113,8 @@ export function MapWorkbenchPage() {
|
||||
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(INITIAL_HEADER_DATA_TIME);
|
||||
const { headerDataTime, scheduledConditions, scheduledConditionsLoading } =
|
||||
useWorkbenchRuntimeData();
|
||||
const baseLayerOptions = useMemo(
|
||||
() =>
|
||||
BASE_LAYER_OPTIONS.map((option) => ({
|
||||
@@ -174,9 +133,6 @@ export function MapWorkbenchPage() {
|
||||
const handleChange = () => {
|
||||
setIsLargeScreen(mediaQuery.matches);
|
||||
setViewportWidth(window.innerWidth);
|
||||
setAgentPanelWidth((currentWidth) =>
|
||||
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, window.innerWidth)
|
||||
);
|
||||
};
|
||||
|
||||
handleChange();
|
||||
@@ -190,38 +146,6 @@ export function MapWorkbenchPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => agentPanelResizeCleanupRef.current?.(), []);
|
||||
|
||||
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(() => {
|
||||
@@ -751,7 +675,7 @@ export function MapWorkbenchPage() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "drainage-network-map-config.json";
|
||||
link.download = "supply-network-map-config.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -829,69 +753,6 @@ export function MapWorkbenchPage() {
|
||||
onRejectQuestion: agent.rejectQuestion
|
||||
};
|
||||
|
||||
const resizeAgentPanel = useCallback((clientX: number) => {
|
||||
setAgentPanelWidth(
|
||||
clampAgentPanelWidth(clientX - agentPanelResizeLeftRef.current, window.innerWidth)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAgentPanelResizeStart = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !desktopAgentPanelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
agentPanelResizeLeftRef.current = desktopAgentPanelRef.current.getBoundingClientRect().left;
|
||||
setAgentPanelResizing(true);
|
||||
resizeAgentPanel(event.clientX);
|
||||
|
||||
agentPanelResizeCleanupRef.current?.();
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
pointerEvent.preventDefault();
|
||||
resizeAgentPanel(pointerEvent.clientX);
|
||||
};
|
||||
const stopResizing = () => {
|
||||
agentPanelResizeCleanupRef.current?.();
|
||||
setAgentPanelResizing(false);
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResizing);
|
||||
window.removeEventListener("pointercancel", stopResizing);
|
||||
agentPanelResizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
agentPanelResizeCleanupRef.current = cleanup;
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResizing);
|
||||
window.addEventListener("pointercancel", stopResizing);
|
||||
},
|
||||
[resizeAgentPanel]
|
||||
);
|
||||
|
||||
const handleAgentPanelResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const currentWidth =
|
||||
desktopAgentPanelRef.current?.getBoundingClientRect().width ??
|
||||
WORKBENCH_LAYOUT.desktop.agentWidth;
|
||||
let nextWidth = currentWidth;
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
nextWidth -= 16;
|
||||
} else if (event.key === "ArrowRight") {
|
||||
nextWidth += 16;
|
||||
} else if (event.key === "Home") {
|
||||
nextWidth = getWorkbenchViewportLayout(window.innerWidth).agentWidth;
|
||||
} else if (event.key === "End") {
|
||||
nextWidth = window.innerWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setAgentPanelWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
|
||||
@@ -935,47 +796,20 @@ export function MapWorkbenchPage() {
|
||||
onExportConfig={handleExportConfig}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={desktopAgentPanelRef}
|
||||
className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] max-w-[50vw] 2xl:w-[var(--workbench-agent-width-wide)] lg:block"
|
||||
style={agentPanelWidth === null ? undefined : { width: agentPanelWidth }}
|
||||
>
|
||||
{agent.panelOpen ? (
|
||||
<>
|
||||
<AgentCommandPanel
|
||||
{...agentPanelProps}
|
||||
collapsing={agent.panelCollapsing}
|
||||
onCollapse={agent.collapsePanel}
|
||||
/>
|
||||
<div
|
||||
aria-label="调整 Agent 面板宽度"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
|
||||
aria-valuemin={getWorkbenchViewportLayout(viewportWidth).agentWidth}
|
||||
aria-valuenow={Math.round(
|
||||
agentPanelWidth ?? getWorkbenchViewportLayout(viewportWidth).agentWidth
|
||||
)}
|
||||
className={cn(
|
||||
"group pointer-events-auto absolute -right-3 top-4 bottom-4 z-10 flex w-6 cursor-ew-resize touch-none items-center justify-center outline-hidden",
|
||||
agentPanelResizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
|
||||
)}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
title="拖拽调整 Agent 面板宽度"
|
||||
onKeyDown={handleAgentPanelResizeKeyDown}
|
||||
onPointerDown={handleAgentPanelResizeStart}
|
||||
>
|
||||
<span className="h-14 w-1 rounded-full bg-slate-500/60 opacity-50 shadow-xs transition-[height,background-color,opacity] group-hover:h-20 group-hover:bg-blue-500 group-hover:opacity-100 group-focus-visible:h-20 group-focus-visible:bg-blue-500 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
onExpand={agent.expandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<WorkbenchAgentPanels
|
||||
panelProps={agentPanelProps}
|
||||
panelOpen={agent.panelOpen}
|
||||
panelCollapsing={agent.panelCollapsing}
|
||||
mobileOpen={agent.mobileOpen}
|
||||
mobilePanelCollapsing={agent.mobilePanelCollapsing}
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
viewportWidth={viewportWidth}
|
||||
onCollapsePanel={agent.collapsePanel}
|
||||
onCloseMobilePanel={agent.closeMobilePanel}
|
||||
onExpandPanel={agent.expandPanel}
|
||||
onOpenMobilePanel={agent.openMobilePanel}
|
||||
/>
|
||||
|
||||
{!devPanelOpen ? (
|
||||
<div className="absolute right-2 top-24 z-20">
|
||||
@@ -1017,22 +851,6 @@ export function MapWorkbenchPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<MobileAgentToggle personaState={agent.personaState} onOpen={agent.openMobilePanel} />
|
||||
) : null}
|
||||
|
||||
{!agent.mobileOpen ? (
|
||||
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
|
||||
<ToolbarPanel
|
||||
@@ -1118,18 +936,6 @@ function isNoticeSourceStatus(sourceStatus: {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1145,67 +951,3 @@ function getExportPresetLabel(preset: ExportViewPreset) {
|
||||
|
||||
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({
|
||||
personaState,
|
||||
onOpen
|
||||
}: {
|
||||
personaState: ComponentProps<typeof AgentPersona>["state"];
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
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="pointer-events-none h-10 w-10" state={personaState} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user