refactor: organize supply frontend structure

This commit is contained in:
2026-07-22 18:16:43 +08:00
parent 699a0bced4
commit b9bc9c3d39
96 changed files with 917 additions and 745 deletions
@@ -1,9 +1,7 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { cn } from "@/shared/ui/cn";
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
@@ -1,5 +1,3 @@
"use client";
import {
Activity,
CheckCircle2,
@@ -23,7 +21,7 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
@@ -48,7 +46,7 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
@@ -1,10 +1,8 @@
"use client";
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
@@ -1,5 +1,3 @@
"use client";
import {
CheckCircle2,
ChevronDown,
@@ -13,7 +11,7 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import {
agentEase,
@@ -1,5 +1,3 @@
"use client";
import type { Variants } from "motion/react";
export const AGENT_MOTION_DURATION_MS = 180;
@@ -1,5 +1,3 @@
"use client";
import {
ClipboardCheck,
Crosshair,
@@ -8,7 +6,7 @@ import {
Route
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type {
AgentChatMessage
} from "../types";
@@ -1,7 +1,7 @@
import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
const LazyPersona = lazy(async () => {
const module = await import("@/shared/ai-elements/persona");
@@ -1,9 +1,7 @@
"use client";
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
@@ -1,6 +1,4 @@
"use client";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message";
type StreamingTokenResponseProps = {
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
splitSpeechTextIntoChunks,
+42 -5
View File
@@ -1,15 +1,56 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { createAgentApiClient } from "./api/client";
export type {
AgentApiClient,
AgentChatSessionSummary,
AgentLoadedChatSession,
AgentSessionStreamEvent
} from "./api/client";
export {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
fetchAgentSessions,
type AgentBootstrapData
} from "./api/swr";
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
export {
FRONTEND_ACTION_NAMES,
parseFrontendActionRegistry,
parseFrontendActionRequest,
readActionResults,
storeActionResult,
type FrontendActionName,
type FrontendActionRegistry,
type FrontendActionRequest,
type FrontendActionResult
} from "./frontend-action";
export { FrontendActionExecutor } from "./frontend-action/executor";
export {
applyPermissionResponse,
applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry,
toTrustedMapAction,
type TrustedMapAction,
type UIEnvelope,
type UIEnvelopePayload,
type UIRegistry,
type UISurface
} from "./ui-envelope";
export type {
AgentApprovalMode,
AgentChatMessage,
@@ -28,8 +69,4 @@ export type {
AgentTodoUpdate,
AgentUiResult
} from "./types";
export type {
SafeChartData,
SafeChartSeries,
SafeChartSpec
} from "./chart-data";
export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data";
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapPanelSection
@@ -1,8 +1,6 @@
"use client";
import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
@@ -1,6 +1,6 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
Tooltip,
TooltipContent,
@@ -1,5 +1,5 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,5 @@
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem } from "./layer-control";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MapActionRow,
MapModeButton,
@@ -1,8 +1,6 @@
"use client";
import type { ComponentProps } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
@@ -1,8 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -1,5 +1,3 @@
"use client";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
@@ -1,8 +1,6 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
type ScaleLineState = {
+1 -3
View File
@@ -1,7 +1,5 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
Tooltip,
TooltipContent,
+1 -3
View File
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react";
@@ -9,7 +7,7 @@ import {
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
+62
View File
@@ -0,0 +1,62 @@
export {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationPanelProps,
type MapAnnotationShareAction
} from "./components/annotation-panel";
export { BaseLayersControl, type BaseLayerOption } from "./components/base-layers-control";
export {
MapActionChip,
MapActionRow,
MapControlPanel,
MapMetricTile,
MapModeButton,
MapPanelSection,
type MapActionRowProps,
type MapControlPanelProps,
type MapModeButtonProps
} from "./components/control-panel";
export {
MapDrawToolbar,
type MapDrawToolbarProps,
type MapDrawTool
} from "./components/draw-toolbar";
export { MapLayerControl, type MapLayerControlItem } from "./components/layer-control";
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./components/map-control-styles";
export {
MapMeasurePanel,
type MapMeasureAction,
type MapMeasureMode,
type MapMeasurePanelProps,
type MapMeasureResultMetric,
type MapMeasureUnit
} from "./components/measure-panel";
export { dismissMapNotice, showMapNotice } from "./components/notice-actions";
export {
MapErrorNotice,
MapLoadingNotice,
MapSourceStatusNotice,
MapToaster,
type MapNoticeOptions,
type MapNoticePosition,
type MapNoticeTone,
type MapSourceStatus,
type MapSourceStatusNoticeProps
} from "./components/notice";
export { MapScaleLine } from "./components/scale-line";
export { MapToolbar, type MapToolbarItem } from "./components/toolbar";
export { MapZoom } from "./components/zoom";
@@ -1,11 +1,9 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type {
ScheduledConditionRecord
} from "@/features/workbench/types";
} from "../types";
import {
createVisibleTickerCards,
type TickerStackCard
@@ -1,7 +1,7 @@
import type {
ScheduledConditionRiskLevel,
ScheduledConditionStatus
} from "@/features/workbench/types";
} from "../types";
export function getConditionReportRiskLevel(
status: ScheduledConditionStatus,
@@ -1,113 +0,0 @@
import type { ReactNode, SVGProps } from "react";
export type DrainageFeatureIconProps = SVGProps<SVGSVGElement> & {
size?: number;
};
type DrainageIconFrameProps = DrainageFeatureIconProps & {
children: ReactNode;
};
function DrainageIconFrame({ children, size = 20, ...props }: DrainageIconFrameProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{children}
</svg>
);
}
export function ConduitFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="3.5" cy="12" r="1.75" />
<circle cx="20.5" cy="12" r="1.75" />
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
</DrainageIconFrame>
);
}
export function JunctionFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1.75" />
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
</DrainageIconFrame>
);
}
export function ValveFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M3.5 12h4.75M15.75 12h4.75" />
<path d="m8.25 7 7.5 5-7.5 5V7Z" />
<path d="m15.75 7-7.5 5 7.5 5V7Z" />
<path d="M12 7V3.5M9.5 3.5h5" />
</DrainageIconFrame>
);
}
export function ReservoirFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M5 8.5c0-2.2 14-2.2 14 0v7c0 2.2-14 2.2-14 0v-7Z" />
<path d="M5 8.5c0 2.2 14 2.2 14 0" />
<path d="M5 12c0 2.2 14 2.2 14 0" />
<path d="M8 19.5h8" />
</DrainageIconFrame>
);
}
export function ScadaFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="12" cy="7.5" r="1.75" />
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
</DrainageIconFrame>
);
}
export function OrificeFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M2.5 12H9M15 12h6.5" />
<path d="M9.5 4v5.35M9.5 14.65V20M14.5 4v5.35M14.5 14.65V20" />
<circle cx="12" cy="12" r="3" />
</DrainageIconFrame>
);
}
export function PumpFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<circle cx="9.5" cy="13.5" r="6" />
<circle cx="9.5" cy="13.5" r="1.25" />
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
<path d="m17 6 2.5 2.5L22 6" />
</DrainageIconFrame>
);
}
export function OutfallFeatureIcon(props: DrainageFeatureIconProps) {
return (
<DrainageIconFrame {...props}>
<path d="M4 4.5h10v7h5" />
<path d="m16.5 8.75 2.75 2.75-2.75 2.75" />
<path d="M3 18c1.5-1.25 3-1.25 4.5 0s3 1.25 4.5 0 3-1.25 4.5 0 3 1.25 4.5 0M5 21c1.15-.75 2.3-.75 3.45 0s2.3.75 3.45 0 2.3-.75 3.45 0 2.3.75 3.45 0" />
</DrainageIconFrame>
);
}
@@ -1,5 +1,3 @@
"use client";
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
@@ -1,14 +1,12 @@
"use client";
import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/lib/utils";
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
showMapNotice
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import {
@@ -16,14 +14,14 @@ import {
type FeaturePanelBadgeTone
} from "../utils/feature-properties";
import {
ConduitFeatureIcon,
PipeFeatureIcon,
JunctionFeatureIcon,
PumpFeatureIcon,
ReservoirFeatureIcon,
ScadaFeatureIcon,
ValveFeatureIcon,
type DrainageFeatureIconProps
} from "./drainage-feature-icons";
type SupplyFeatureIconProps
} from "./supply-feature-icons";
import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = {
@@ -34,14 +32,14 @@ type FeaturePopoverProps = {
const FEATURE_LAYER_META: Record<
WaterNetworkSourceId,
{
icon: ComponentType<DrainageFeatureIconProps>;
icon: ComponentType<SupplyFeatureIconProps>;
label: string;
headerClassName: string;
labelClassName: string;
iconClassName: string;
}
> = {
pipes: { icon: ConduitFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" },
reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" },
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import type { LocalizedFeatureProperty } from "../utils/feature-properties";
type FeaturePropertyListProps = {
@@ -1,8 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Crosshair, LocateFixed, RotateCcw, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/shared/ui/cn";
import {
LAYER_GROUP_GEOMETRIES,
type LayerGroupId,
@@ -1,5 +1,3 @@
"use client";
import {
AlertTriangle,
Bot,
@@ -20,13 +18,13 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { cn } from "@/shared/ui/cn";
import { showMapNotice } from "@/features/map/core";
import {
getRunningWorkflowDefinition,
getRunningWorkflowState,
getWorkflowStepStatus
} from "@/features/workbench/data/running-workflows";
} from "../data/running-workflows";
import type {
ScheduledConditionAnalysisInsight,
ScheduledConditionItem,
@@ -34,7 +32,7 @@ import type {
ScheduledConditionRecord,
ScheduledConditionRiskLevel,
ScheduledWorkOrderItem
} from "@/features/workbench/types";
} from "../types";
import {
formatDuration,
formatTime,
@@ -10,7 +10,7 @@ import type {
ScheduledConditionStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "@/features/workbench/types";
} from "../types";
export const CONDITION_FILTER_ALL = "__all__";
@@ -1,5 +1,3 @@
"use client";
import {
CalendarClock,
ChevronDown,
@@ -10,12 +8,11 @@ import {
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
import { cn } from "@/lib/utils";
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
import { createConditionConversationPrompt } from "../utils/scheduled-condition-prompts";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
@@ -0,0 +1,93 @@
import type { ReactNode, SVGProps } from "react";
export type SupplyFeatureIconProps = SVGProps<SVGSVGElement> & {
size?: number;
};
type SupplyIconFrameProps = SupplyFeatureIconProps & {
children: ReactNode;
};
function SupplyIconFrame({ children, size = 20, ...props }: SupplyIconFrameProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{children}
</svg>
);
}
export function PipeFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="3.5" cy="12" r="1.75" />
<circle cx="20.5" cy="12" r="1.75" />
<path d="M5.25 9.5h13.5M5.25 14.5h13.5" />
</SupplyIconFrame>
);
}
export function JunctionFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="12" cy="12" r="5" />
<circle cx="12" cy="12" r="1.75" />
<path d="M12 2.5V7M2.5 12H7M17 12h4.5M12 17v4.5" />
</SupplyIconFrame>
);
}
export function ValveFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<path d="M3.5 12h4.75M15.75 12h4.75" />
<path d="m8.25 7 7.5 5-7.5 5V7Z" />
<path d="m15.75 7-7.5 5 7.5 5V7Z" />
<path d="M12 7V3.5M9.5 3.5h5" />
</SupplyIconFrame>
);
}
export function ReservoirFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<path d="M5 8.5c0-2.2 14-2.2 14 0v7c0 2.2-14 2.2-14 0v-7Z" />
<path d="M5 8.5c0 2.2 14 2.2 14 0" />
<path d="M5 12c0 2.2 14 2.2 14 0" />
<path d="M8 19.5h8" />
</SupplyIconFrame>
);
}
export function ScadaFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="12" cy="7.5" r="1.75" />
<path d="M8.25 4.25a5.25 5.25 0 0 0 0 6.5M15.75 4.25a5.25 5.25 0 0 1 0 6.5" />
<path d="M5.25 2.25a9 9 0 0 0 0 10.5M18.75 2.25a9 9 0 0 1 0 10.5" />
<path d="M12 9.25v5.25M3.5 18h4l1.5-3 2.75 6 2.5-5 1.25 2h5" />
</SupplyIconFrame>
);
}
export function PumpFeatureIcon(props: SupplyFeatureIconProps) {
return (
<SupplyIconFrame {...props}>
<circle cx="9.5" cy="13.5" r="6" />
<circle cx="9.5" cy="13.5" r="1.25" />
<path d="M9.5 12.25V8.5M10.75 13.5h3.75M8.65 14.4 6.2 16.85" />
<path d="M9.5 7.5V3.5h10v5M3.5 13.5H1.75" />
<path d="m17 6 2.5 2.5L22 6" />
</SupplyIconFrame>
);
}
@@ -23,21 +23,20 @@ import {
import { useEffect, useMemo, useState } from "react";
import {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationShareAction
} from "@/features/map/core/components/annotation-panel";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import {
MapActionRow,
MapControlPanel,
MapDrawToolbar,
MapLayerPanel,
MapLegendList,
MapMetricTile,
MapPanelSection
} from "@/features/map/core/components/control-panel";
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
MapMeasurePanel,
MapPanelSection,
type BaseLayerOption,
type MapAnnotationItem,
type MapAnnotationShareAction,
type MapLayerControlItem,
type MapLegendItem
} from "@/features/map/core";
import {
type WorkbenchDrawingAnnotation,
type WorkbenchDrawMode
@@ -0,0 +1,118 @@
import { AgentCollapsedRail, AgentCommandPanel, AgentPersona } from "@/features/agent";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { ComponentProps } from "react";
import { WORKBENCH_LAYOUT, getWorkbenchViewportLayout } from "../layout/workbench-layout";
import { useAgentPanelResize } from "../hooks/use-agent-panel-resize";
type AgentPanelProps = Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse">;
type WorkbenchAgentPanelsProps = {
panelProps: AgentPanelProps;
panelOpen: boolean;
panelCollapsing: boolean;
mobileOpen: boolean;
mobilePanelCollapsing: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
viewportWidth: number;
onCollapsePanel: () => void;
onCloseMobilePanel: () => void;
onExpandPanel: () => void;
onOpenMobilePanel: () => void;
};
export function WorkbenchAgentPanels({
panelProps,
panelOpen,
panelCollapsing,
mobileOpen,
mobilePanelCollapsing,
personaState,
statusLabel,
viewportWidth,
onCollapsePanel,
onCloseMobilePanel,
onExpandPanel,
onOpenMobilePanel
}: WorkbenchAgentPanelsProps) {
const resize = useAgentPanelResize(viewportWidth);
const defaultWidth = getWorkbenchViewportLayout(viewportWidth).agentWidth;
return (
<>
<div
ref={resize.panelRef}
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={resize.width === null ? undefined : { width: resize.width }}
>
{panelOpen ? (
<>
<AgentCommandPanel
{...panelProps}
collapsing={panelCollapsing}
onCollapse={onCollapsePanel}
/>
<div
aria-label="调整 Agent 面板宽度"
aria-orientation="vertical"
aria-valuemax={Math.round(viewportWidth * WORKBENCH_LAYOUT.maxAgentViewportRatio)}
aria-valuemin={defaultWidth}
aria-valuenow={Math.round(resize.width ?? defaultWidth)}
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",
resize.resizing && "[&>span]:bg-blue-500 [&>span]:opacity-100"
)}
role="separator"
tabIndex={0}
title="拖拽调整 Agent 面板宽度"
onKeyDown={resize.handleKeyDown}
onPointerDown={resize.handlePointerDown}
>
<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={personaState}
statusLabel={statusLabel}
onExpand={onExpandPanel}
/>
)}
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...panelProps}
collapsing={mobilePanelCollapsing}
onCollapse={onCloseMobilePanel}
/>
</div>
) : null}
</div>
{!mobileOpen ? (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpenMobilePanel}
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>
) : null}
</>
);
}
@@ -1,5 +1,3 @@
"use client";
import {
Activity,
Bell,
@@ -24,8 +22,8 @@ import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import {
Box,
@@ -13,8 +11,8 @@ import {
} from "lucide-react";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
import {
@@ -2,7 +2,7 @@ import type {
ScheduledConditionRecord,
ScheduledConditionStatus,
ScheduledConditionTaskId
} from "@/features/workbench/types";
} from "../types";
export type RunningWorkflowStepDefinition = {
id: string;
@@ -1,21 +1,19 @@
import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
AgentPermissionStatus,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
import {
applyPermissionResponse,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "@/features/agent/session-state";
upsertQuestion,
type AgentChatMessage,
type AgentPermissionReply,
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
} from "@/features/agent";
type AgentUiDataParts = {
progress: Record<string, unknown>;
@@ -0,0 +1,95 @@
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent
} from "react";
import {
WORKBENCH_LAYOUT,
clampAgentPanelWidth,
getWorkbenchViewportLayout
} from "../layout/workbench-layout";
export function useAgentPanelResize(viewportWidth: number) {
const panelRef = useRef<HTMLDivElement | null>(null);
const resizeLeftRef = useRef(0);
const cleanupRef = useRef<(() => void) | null>(null);
const [width, setWidth] = useState<number | null>(null);
const [resizing, setResizing] = useState(false);
useEffect(() => () => cleanupRef.current?.(), []);
useEffect(() => {
setWidth((currentWidth) =>
currentWidth === null ? null : clampAgentPanelWidth(currentWidth, viewportWidth)
);
}, [viewportWidth]);
const resize = useCallback((clientX: number) => {
setWidth(clampAgentPanelWidth(clientX - resizeLeftRef.current, window.innerWidth));
}, []);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !panelRef.current) return;
event.preventDefault();
resizeLeftRef.current = panelRef.current.getBoundingClientRect().left;
setResizing(true);
resize(event.clientX);
cleanupRef.current?.();
const handlePointerMove = (pointerEvent: PointerEvent) => {
pointerEvent.preventDefault();
resize(pointerEvent.clientX);
};
const stopResizing = () => {
cleanupRef.current?.();
setResizing(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResizing);
window.removeEventListener("pointercancel", stopResizing);
cleanupRef.current = null;
};
cleanupRef.current = cleanup;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResizing);
window.addEventListener("pointercancel", stopResizing);
},
[resize]
);
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
const currentWidth =
panelRef.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();
setWidth(clampAgentPanelWidth(nextWidth, window.innerWidth));
}, []);
return {
handleKeyDown,
handlePointerDown,
panelRef,
resizing,
width
};
}
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -7,34 +5,28 @@ import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core/components/notice-actions";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import {
createAgentApiClient,
type AgentSessionStreamEvent
} from "@/features/agent/api/client";
import { showMapNotice } from "@/features/map/core";
import {
FrontendActionExecutor,
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
fetchAgentBootstrap,
fetchAgentSessions
} from "@/features/agent/api/swr";
import {
fetchAgentSessions,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentChatMessage,
type AgentModelOption,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
} from "@/features/agent";
import {
applySessionStreamEvent,
createCompletedStreamRenderState,
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
@@ -1,5 +1,3 @@
"use client";
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
@@ -1,5 +1,3 @@
"use client";
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
formatHeaderDataTime,
getMsUntilNextHeaderDataTimeTick
} from "./use-workbench-runtime-data";
describe("workbench runtime data", () => {
it("snaps the header time to the current five-minute interval", () => {
expect(formatHeaderDataTime(new Date(2026, 6, 22, 15, 43, 31))).toBe("2026-07-22 15:40");
});
it("schedules the next header refresh on the next interval boundary", () => {
expect(getMsUntilNextHeaderDataTimeTick(new Date(2026, 6, 22, 15, 43, 30))).toBe(90_000);
});
});
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "../data/scheduled-conditions";
import type { ScheduledConditionItem } from "../types";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
export 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}`;
}
export 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 useWorkbenchRuntimeData() {
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
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);
}, []);
return {
headerDataTime,
scheduledConditions,
scheduledConditionsLoading
};
}
+35 -293
View File
@@ -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>
);
}
+1 -1
View File
@@ -119,5 +119,5 @@ function getExportLabel(targetLongEdge: number | undefined, scale: number) {
function getDefaultExportFilename(label: string) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `drainage-network-view-${label}-${timestamp}.png`;
return `supply-network-view-${label}-${timestamp}.png`;
}
@@ -0,0 +1,5 @@
import { env } from "@/shared/config/env";
export const MAP_URL = env.TJWATER_MAP_URL.replace(/\/$/, "");
export const GEOSERVER_WORKSPACE = env.TJWATER_GEOSERVER_WORKSPACE.trim().replace(/^\/+|\/+$/g, "");
@@ -1,8 +1,6 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { GEOSERVER_WORKSPACE } from "../../../lib/config";
import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
import { GEOSERVER_WORKSPACE } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
@@ -57,6 +55,18 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(
layerId,
"visibility",
activeBaseLayerId === layerId ? "visible" : "none"
);
}
});
}
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
{
id: "mapbox-light",
@@ -1,5 +1,5 @@
import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
@@ -139,70 +139,6 @@ const facilityIconSize = [
24, 0.56
] as IconSize;
export const DRAINAGE_LAYER_VISUALS = {
conduits: {
minzoom: 9,
casingWidth: [
"interpolate", ["linear"], ["zoom"],
9, 1.7,
13, 2.8,
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
] as LineWidth,
width: [
"interpolate", ["linear"], ["zoom"],
9, 1.1,
13, 2,
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8, 17, 12.5, 22, 15, 24, 17] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 9, 3, 13, 5.2, 17, 9, 22, 11.5, 24, 13] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 9, 1.2, 13, 1.5, 17, 2, 22, 2.5, 24, 3] as LineWidth,
hitWidth: lineHitWidth
},
junctions: {
minzoom: 12,
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 1.2, 14, 1.8, 15, 2.8, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1] as const,
radius: ["interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
hitRadius: pointHitRadius
},
orifices: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
pumps: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 9.5, 22, 11, 24, 12] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7, 17, 12, 22, 14, 24, 16] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4.5, 17, 8.5, 22, 10.5, 24, 12] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
outfalls: {
minzoom: 11,
haloRadius: ["interpolate", ["linear"], ["zoom"], 11, 5, 16, 7, 20, 9, 24, 11] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 11, 6, 16, 8, 20, 11, 24, 13] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
hitRadius: pointHitRadius
}
} as const;
export const SUPPLY_LAYER_VISUALS = {
pipes: {
minzoom: 8.5,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
@@ -0,0 +1,36 @@
import type { ScheduledConditionItem, WorkbenchAlert } from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
export function createScheduledConditionAlerts(
conditions: ScheduledConditionItem[]
): WorkbenchAlert[] {
return conditions
.filter(isAlertCondition)
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
.map((condition) => ({
id: `${SCHEDULED_CONDITION_ALERT_ID_PREFIX}${condition.id}`,
title: `待复核:${condition.title}`,
description: condition.detail ?? condition.summary,
time: formatScheduledConditionTime(condition.scheduledAt)
}));
}
export function getScheduledConditionIdFromAlertId(alertId: string) {
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) return null;
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
}
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;
}
@@ -1,5 +1,5 @@
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
import { getRunningWorkflowDefinition } from "../data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "../types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";