feat: extend supply workbench demo and map navigation

This commit is contained in:
2026-09-23 13:19:30 +08:00
parent bebcf2a06c
commit 1752b9ef84
45 changed files with 2137 additions and 449 deletions
+211 -134
View File
@@ -35,6 +35,12 @@ import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import {
toWaterQualityConditionTask,
toWaterQualityAgentTrigger,
waterQualityTriggerPrompt
} from "./peripheral-sample/work-order-agent-trigger";
import { MapDevPanel } from "./components/map-dev-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover";
@@ -56,9 +62,7 @@ import {
type ToolbarToolId
} from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
WorkbenchMainFrame
} from "./workspace/workbench-main-frame";
import { WorkbenchMainFrame } from "./workspace/workbench-main-frame";
import { useArtifactWorkspace } from "./workspace/use-artifact-workspace";
import {
createInitialWorkspaceContext,
@@ -71,10 +75,7 @@ import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchProjectPresentation } from "./hooks/use-workbench-project-presentation";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import {
formatHeaderDataTime,
useWorkbenchRuntimeData
} from "./hooks/use-workbench-runtime-data";
import { formatHeaderDataTime, useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import {
useWorkbenchMeasurement,
@@ -121,6 +122,8 @@ export function MapWorkbenchPage({
getAccessToken?: AccessTokenProvider;
}) {
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
const hasTiandituToken = Boolean(env.TJWATER_TIANDITU_TOKEN);
const hasOnlineBasemap = hasMapboxToken || hasTiandituToken;
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
const projectPresentation = useWorkbenchProjectPresentation(user, getAccessToken);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
@@ -138,7 +141,7 @@ export function MapWorkbenchPage({
requestId: number;
} | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState(
hasMapboxToken ? "mapbox-light" : "none"
hasMapboxToken ? "mapbox-light" : hasTiandituToken ? "tianditu-vector" : "none"
);
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
@@ -149,7 +152,7 @@ export function MapWorkbenchPage({
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const {
headerDataTime,
scheduledConditions,
scheduledConditions: runtimeScheduledConditions,
scheduledConditionsLoading,
workbenchAlerts,
runtimeDataSource,
@@ -161,13 +164,22 @@ export function MapWorkbenchPage({
operationalDate,
operationalCursorTime
} = useWorkbenchRuntimeData(getAccessToken);
const [eventConditions, setEventConditions] = useState<ScheduledConditionRecord[]>([]);
const scheduledConditions = useMemo(
() => [...eventConditions, ...runtimeScheduledConditions],
[eventConditions, runtimeScheduledConditions]
);
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
...option,
disabled: option.id !== "none" && !hasMapboxToken
disabled: option.id.startsWith("mapbox-")
? !hasMapboxToken
: option.id.startsWith("tianditu-")
? !hasTiandituToken
: false
})),
[hasMapboxToken]
[hasMapboxToken, hasTiandituToken]
);
const handleSelectFeature = useCallback((feature: DetailFeature) => {
@@ -215,19 +227,18 @@ export function MapWorkbenchPage({
expandAgentPanel,
onClearActiveTool: clearActiveTool
});
const currentAgentWidth = leftPanelOpen
? agentPanelWidth
: WORKBENCH_LAYOUT.collapsedAgentWidth;
const currentAgentWidth = leftPanelOpen ? agentPanelWidth : WORKBENCH_LAYOUT.collapsedAgentWidth;
const businessWorkspaceWidth = Math.max(0, viewportWidth - currentAgentWidth);
const artifactWorkspace = useArtifactWorkspace(businessWorkspaceWidth);
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation = artifactWorkspace.activeArtifact === null
|| artifactWorkspace.activeArtifact.origin === "demo";
const allowDemoSimulation =
artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo";
const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some((block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
latestAnalysisArtifact?.blocks.some(
(block) =>
block.kind === "chart" || block.kind === "data-table" || block.kind === "process-flow"
)
);
const [timelineMode, setTimelineMode] = useState<OperationalTimelineMode>("compact");
@@ -278,15 +289,62 @@ export function MapWorkbenchPage({
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) => current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView });
setLayerVisibility((current) =>
current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
openAgentPanelForViewport();
}
async function handlePeripheralWorkOrderAgentTrigger(workOrder: PeripheralWorkOrder) {
const trigger = toWaterQualityAgentTrigger(workOrder);
if (!trigger) {
throw new Error("工单不符合水质投诉事件触发条件。");
}
showMapNotice({
id: "water-quality-agent-trigger",
tone: "loading",
title: "水质投诉事件已接收",
message: "正在建立结构化工况任务。",
duration: Infinity
});
try {
const result = await agent.triggerEvent(trigger, waterQualityTriggerPrompt(workOrder));
const condition = toWaterQualityConditionTask(workOrder, result.sessionId);
setEventConditions((current) => [
condition,
...current.filter((item) => item.id !== condition.id)
]);
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setSelectedConditionId(condition.id);
setConditionFocusRequest((current) => ({
conditionId: condition.id,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "water-quality-agent-trigger",
tone: "success",
title: result.reused ? "已恢复水质工况任务" : "已建立水质工况任务",
message: `关联模拟工单 ${workOrder.work_order_id.slice(0, 8)},可在任务中查看处理流程。`
});
} catch (error) {
showMapNotice({
id: "water-quality-agent-trigger",
tone: "error",
title: "工单已创建,Agent 待触发",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
}
}
function openConditionWorkspaceForViewport() {
openConditionFeedForViewport();
}
@@ -325,10 +383,10 @@ export function MapWorkbenchPage({
networkView: projectPresentation.networkView,
getAccessToken
});
const displayedDataTime = !["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE)
&& scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const displayedDataTime =
!["demo", "history"].includes(env.TJWATER_WORKBENCH_RUNTIME_MODE) && scadaObservedAt
? formatHeaderDataTime(new Date(scadaObservedAt), false)
: headerDataTime;
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
mapReady,
@@ -378,12 +436,12 @@ export function MapWorkbenchPage({
});
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!artifactActive
&& mobileSheet === null
&& !conditionFeedExpanded
&& detailFeature === null
&& taskTickerVisible
&& taskTickerAvailable;
!artifactActive &&
mobileSheet === null &&
!conditionFeedExpanded &&
detailFeature === null &&
taskTickerVisible &&
taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
@@ -436,41 +494,44 @@ export function MapWorkbenchPage({
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []);
const handleSelectOperationalEvent = useCallback((event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
const mapTarget = event.mapTargets[0];
if (mapTarget) {
const handleSelectOperationalEvent = useCallback(
(event: OperationalEvent) => {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
type: "select-operational-event",
eventId: event.id,
cursorTime: event.cursorTime
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
}, [handleConditionExpandedChange, mapController, openConditionFeedForViewport]);
const mapTarget = event.mapTargets[0];
if (mapTarget) {
dispatchWorkspaceContext({
type: "select-spatial-feature",
featureId: mapTarget.featureId,
sourceId: mapTarget.sourceId
});
void mapController.locateAndHighlight(mapTarget).then(() => {
if (mapController.getSnapshot().errorCode) {
showMapNotice({
tone: "warning",
title: "已选择运行事件",
message: "关联要素当前不可定位,事件与工况报告仍可继续查看。"
});
}
});
}
if (event.conditionId) {
setSelectedConditionId(event.conditionId);
setConditionFocusRequest((current) => ({
conditionId: event.conditionId!,
requestId: (current?.requestId ?? 0) + 1
}));
openConditionFeedForViewport();
handleConditionExpandedChange(true);
}
},
[handleConditionExpandedChange, mapController, openConditionFeedForViewport]
);
const handleClearOperationalSelection = useCallback(() => {
dispatchWorkspaceContext({ type: "clear-operational-event" });
@@ -902,16 +963,18 @@ export function MapWorkbenchPage({
onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig
};
const selectedOperationalEvent = operationalEvents.find(
(event) => event.id === workspaceContext.operational.selectedEventId
) ?? null;
const selectedOperationalEvent =
operationalEvents.find((event) => event.id === workspaceContext.operational.selectedEventId) ??
null;
const agentWorkspaceContext = useMemo<AgentWorkspaceContextItem[]>(() => {
const items: AgentWorkspaceContextItem[] = [{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}];
const items: AgentWorkspaceContextItem[] = [
{
id: "operational-time",
label: "运行时点",
value: formatContextClock(workspaceContext.operational.cursorTime),
tone: selectedOperationalEvent?.lane === "exception" ? "warning" : "neutral"
}
];
if (workspaceContext.spatial.featureId) {
items.push({
@@ -980,9 +1043,11 @@ export function MapWorkbenchPage({
>
<WorkbenchTopBar
dataTime={displayedDataTime}
modelName={agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label
|| agent.selectedModel
|| "Agent 后端连接中"}
modelName={
agent.modelOptions.find((option) => option.id === agent.selectedModel)?.label ||
agent.selectedModel ||
"Agent 后端连接中"
}
activeAnalysisArtifact={artifactWorkspace.activeArtifact}
alerts={workbenchAlerts}
alertDataSource={alertDataSource}
@@ -999,8 +1064,14 @@ export function MapWorkbenchPage({
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onToggleAnalysisResultsPanel={() => { setPeripheralSamplePanelOpen(false); setAnalysisResultsPanelOpen((current) => !current); }}
onTogglePeripheralSamplePanel={() => { setAnalysisResultsPanelOpen(false); setPeripheralSamplePanelOpen((current) => !current); }}
onToggleAnalysisResultsPanel={() => {
setPeripheralSamplePanelOpen(false);
setAnalysisResultsPanelOpen((current) => !current);
}}
onTogglePeripheralSamplePanel={() => {
setAnalysisResultsPanelOpen(false);
setPeripheralSamplePanelOpen((current) => !current);
}}
onToggleDevPanel={() => setDevPanelOpen((current) => !current)}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
latestAnalysisAvailable={latestAnalysisAvailable}
@@ -1029,6 +1100,7 @@ export function MapWorkbenchPage({
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
/>
<WorkbenchMainFrame
@@ -1043,56 +1115,58 @@ export function MapWorkbenchPage({
onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode}
timelineBottom={timelineBottom}
timelineContent={(
timelineVisible ? <>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
timelineContent={
timelineVisible ? (
<>
<div className="hidden lg:block">
<OperationalTimeline
date={workspaceContext.operational.date}
cursorTime={workspaceContext.operational.cursorTime}
events={operationalEvents}
mode={timelineMode}
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={setTimelineMode}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
/>
</div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold">运行时间轴</span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
<span className="text-slate-500">执行 {operationalSummary.active}</span>
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
查看全天
</span>
</button>
</div>
</> : null
)}
mapContent={(
<button
type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport}
className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="shrink-0 font-semibold">运行时间轴</span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500">计划 {operationalSummary.planned}</span>
<span className="text-slate-500">执行 {operationalSummary.active}</span>
<span className="text-amber-700">关注 {operationalSummary.attention}</span>
<span className="text-rose-700">异常 {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
查看全天
</span>
</button>
</div>
</>
) : null
}
mapContent={
<div
ref={mapContainerRef}
className="map-grid absolute inset-0"
style={{ position: "absolute", inset: 0 }}
aria-label="供水管网地图"
/>
)}
mapOverlay={(
}
mapOverlay={
<div className="pointer-events-none absolute inset-0">
{!devPanelOpen ? (
<>
@@ -1106,22 +1180,25 @@ export function MapWorkbenchPage({
) : null}
<div
className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties}
style={
{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties
}
>
<div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
<MapZoom
mapRef={mapRef}
mapReady={mapReady}
onHome={fitNetworkBounds}
reducedMotion={prefersReducedMotion ?? false}
/>
</div>
<MapScaleLine
className="hidden lg:inline-flex"
mapRef={mapRef}
mapReady={mapReady}
/>
<MapScaleLine className="hidden lg:inline-flex" mapRef={mapRef} mapReady={mapReady} />
</div>
</div>
)}
}
/>
<WorkbenchAgentPanels
@@ -1307,11 +1384,11 @@ export function MapWorkbenchPage({
{!mapReady && !mapError ? (
<MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" />
) : null}
{!hasMapboxToken ? (
{!hasOnlineBasemap ? (
<MapSourceStatusNotice
sourceName="Mapbox 底图"
sourceName="在线底图"
status="degraded"
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
message="未检测到 Mapbox 或天地图令牌,当前已切换为无底图模式,仅展示业务图层。"
/>
) : null}
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (