feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
@@ -15,18 +14,20 @@ import {
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
SUPPLY_LAYER_CATALOG,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
|
||||
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
|
||||
import { FeatureLocationPanel } from "./feature-location-panel";
|
||||
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
|
||||
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
|
||||
import { NoticeDevTools } from "./notice-dev-tools";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
commands: WorkbenchMapCommands;
|
||||
@@ -37,6 +38,11 @@ type MapDevPanelProps = {
|
||||
investigationFollow?: boolean;
|
||||
onInvestigationFollowChange?: (enabled: boolean) => void;
|
||||
investigationTools?: InvestigationDevToolsProps;
|
||||
onPreviewScadaAlert?: () => void;
|
||||
onEndScadaAlertPreview?: () => void;
|
||||
scadaAlertPreviewActive?: boolean;
|
||||
scadaAlertIntensity?: ScadaAlertIntensity;
|
||||
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
|
||||
};
|
||||
|
||||
const GROUP_LABELS = Object.fromEntries(
|
||||
@@ -93,13 +99,7 @@ const LABEL_PRESETS: Record<
|
||||
};
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
|
||||
...FEATURE_LOCATION_ERROR_LABELS,
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
@@ -113,13 +113,21 @@ export function MapDevPanel({
|
||||
onClose,
|
||||
investigationFollow = true,
|
||||
onInvestigationFollowChange,
|
||||
investigationTools
|
||||
investigationTools,
|
||||
onPreviewScadaAlert,
|
||||
onEndScadaAlertPreview,
|
||||
scadaAlertPreviewActive = false,
|
||||
scadaAlertIntensity = "standard",
|
||||
onScadaAlertIntensityChange
|
||||
}: MapDevPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
|
||||
sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
|
||||
});
|
||||
const { sourceId, featureId } = locationDraft;
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const [property, setProperty] = useState("diameter");
|
||||
const [flowPipeId, setFlowPipeId] = useState("440681");
|
||||
const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
|
||||
const [flowVelocity, setFlowVelocity] = useState("-1");
|
||||
const [precision, setPrecision] = useState(0);
|
||||
const [unit, setUnit] = useState("mm");
|
||||
@@ -148,10 +156,6 @@ export function MapDevPanel({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const target: FeatureBatchTarget = { sourceId, featureIds };
|
||||
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
|
||||
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
|
||||
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
|
||||
const activeLabel = controllerState.valueLabels[groupId];
|
||||
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
|
||||
@@ -171,14 +175,6 @@ export function MapDevPanel({
|
||||
|
||||
function useSelectedFeature() {
|
||||
if (!detailFeature) return;
|
||||
if (
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
|
||||
detailFeature.layer as AvailableWaterNetworkSourceId
|
||||
)
|
||||
) {
|
||||
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
|
||||
}
|
||||
setFeatureId(detailFeature.id);
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
@@ -246,7 +242,7 @@ export function MapDevPanel({
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
@@ -256,7 +252,7 @@ export function MapDevPanel({
|
||||
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
调查联动验证与地图工具 · 仅当前页面生效
|
||||
调查、通知与地图工具 · 仅当前页面生效
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -272,12 +268,27 @@ export function MapDevPanel({
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
|
||||
<TabsTrigger value="notices">通知测试</TabsTrigger>
|
||||
<TabsTrigger value="map">地图调试</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
|
||||
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
|
||||
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
|
||||
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
|
||||
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
|
||||
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
|
||||
</div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
|
||||
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
|
||||
</div>
|
||||
</section> : null}
|
||||
{onInvestigationFollowChange ? (
|
||||
<section aria-label="调查联动" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -288,6 +299,9 @@ export function MapDevPanel({
|
||||
</section>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
<NoticeDevTools />
|
||||
</TabsContent>
|
||||
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
|
||||
@@ -297,100 +311,14 @@ export function MapDevPanel({
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
status={controllerState.pending ? "执行中" : undefined}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
<FeatureLocationPanel commands={commands} controllerState={controllerState}
|
||||
detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
|
||||
onUseSelectedFeature={useSelectedFeature} showError={false}
|
||||
contentClassName="workbench-shadcn-surface" />
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
|
||||
Reference in New Issue
Block a user