diff --git a/MAP_STYLE_GUIDE.md b/MAP_STYLE_GUIDE.md index eefbf9c..e52ce09 100644 --- a/MAP_STYLE_GUIDE.md +++ b/MAP_STYLE_GUIDE.md @@ -41,7 +41,7 @@ Closed、Off、Inactive 只把 core 改为 inactive 灰,不改变几何身份 ## 状态与图层顺序 -状态优先级是 `selected > hover > status > source`。五个业务 vector source 使用 `promoteId: "id"`,hover 与 selected 通过 feature-state 控制,opacity 使用 120ms 过渡且不脉冲。 +状态优先级是 `selected > hover > status > source`。常规业务 vector source 使用 `promoteId: "id"`,SCADA source 使用 `promoteId: "scada_id"`,hover 与 selected 通过 feature-state 控制,opacity 使用 120ms 过渡且不脉冲。 - hover:白色增宽 casing 加 source/status core,selected 时 opacity 为 0。 - selected:保留普通 core,外侧使用白色分隔、操作蓝外框和窄白外缘。 diff --git a/features/workbench/components/feature-insight-panel.tsx b/features/workbench/components/feature-insight-panel.tsx index 838d05c..2a1b4f8 100644 --- a/features/workbench/components/feature-insight-panel.tsx +++ b/features/workbench/components/feature-insight-panel.tsx @@ -1,8 +1,9 @@ "use client"; -import { Target, TriangleAlert, X } from "lucide-react"; +import { Check, Copy, Target, TriangleAlert, X } from "lucide-react"; import Image from "next/image"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { showMapNotice } from "@/features/map/core/components/notice"; import { cn } from "@/lib/utils"; import { SCADA_ICON_PATHS } from "../map/scada"; import type { DetailFeature } from "../types"; @@ -17,6 +18,15 @@ type FeatureInsightPanelProps = { }; export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) { + const [copiedScadaUuid, setCopiedScadaUuid] = useState(false); + const copyResetTimeoutRef = useRef | null>(null); + + useEffect(() => () => { + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + } + }, []); + const metrics = useMemo(() => { if (!feature) { return []; @@ -60,7 +70,7 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro ]; case "scada": return [ - ["设备类型", formatFeaturePropertyValue("device_type_name", feature.properties.device_type_name)], + ["设备类型", formatFeaturePropertyValue("kind", feature.properties.kind)], ["运行状态", formatFeaturePropertyValue("active", feature.properties.active)], ["关联检查井", formatFeaturePropertyValue("junction_id", feature.properties.junction_id)], ["同步时间", formatFeaturePropertyValue("synced_at", feature.properties.synced_at)] @@ -73,9 +83,43 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro return []; } - return getLocalizedFeatureProperties(feature.properties); + const properties = feature.layer === "scada" && feature.properties.scada_id === undefined + ? { scada_id: feature.id, ...feature.properties } + : feature.properties; + return getLocalizedFeatureProperties(properties); }, [feature]); + const scadaQueryUuid = feature?.layer === "scada" + ? getCopyablePropertyValue(feature.properties.scada_id ?? feature.id) + : null; + + const handleCopyScadaUuid = async () => { + if (!scadaQueryUuid) { + return; + } + + try { + if (!navigator.clipboard) { + throw new Error("Clipboard API unavailable"); + } + + await navigator.clipboard.writeText(scadaQueryUuid); + setCopiedScadaUuid(true); + + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + } + + copyResetTimeoutRef.current = setTimeout(() => setCopiedScadaUuid(false), 1500); + } catch { + showMapNotice({ + tone: "error", + title: "无法复制 UUID", + message: "请手动选择查询 UUID 后复制。" + }); + } + }; + return ( ); } -function getScadaPresentation(rawTypeId: unknown) { - const typeId = Number(rawTypeId); - if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" }; - if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" }; - if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" }; +function getCopyablePropertyValue(value: unknown) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + if (typeof value === "number" || typeof value === "bigint") { + return String(value); + } + + return null; +} + +function getScadaPresentation(rawKind: unknown) { + const kind = String(rawKind ?? ""); + if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "material-tone-normal border-teal-200", labelClassName: "text-teal-800" }; + if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "material-tone-info border-blue-200", labelClassName: "text-blue-800" }; + if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "material-tone-warning border-orange-200", labelClassName: "text-orange-800" }; return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "surface-control border-transparent", labelClassName: "text-slate-800" }; } diff --git a/features/workbench/components/feature-popover.tsx b/features/workbench/components/feature-popover.tsx index 9437996..819c621 100644 --- a/features/workbench/components/feature-popover.tsx +++ b/features/workbench/components/feature-popover.tsx @@ -210,11 +210,12 @@ type ScadaFeaturePopoverProps = { function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: ScadaFeaturePopoverProps) { const properties = feature.properties; - const presentation = getScadaPresentation(properties.device_type_id); + const presentation = getScadaPresentation(properties.kind); const active = properties.active === true || String(properties.active).toLowerCase() === "true"; - const pointId = formatFeaturePropertyValue("point_external_id", properties.point_external_id ?? feature.id); + const scadaId = formatFeaturePropertyValue("scada_id", properties.scada_id ?? feature.id); const attributes = [ - ["设备编号", formatFeaturePropertyValue("device_external_id", properties.device_external_id)], + ["站点编号", formatFeaturePropertyValue("site_external_id", properties.site_external_id)], + ["设备编号", formatFeaturePropertyValue("external_id", properties.external_id)], ["关联检查井", formatFeaturePropertyValue("junction_id", properties.junction_id)], ["同步时间", formatFeaturePropertyValue("synced_at", properties.synced_at)] ]; @@ -233,8 +234,8 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad
- - {formatFeaturePropertyValue("device_type_name", properties.device_type_name)} + + {formatFeaturePropertyValue("kind", properties.kind)}

- {formatFeaturePropertyValue("point_name", properties.point_name)} + {formatFeaturePropertyValue("name", properties.name)}

-

测点 {pointId}

+

编号 UUID {scadaId}

{canCopy ? ( @@ -292,10 +293,10 @@ function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: Scad ); } -function getScadaPresentation(rawTypeId: unknown) { - const typeId = Number(rawTypeId); - if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800" }; - if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800" }; - if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50/70", labelClassName: "text-orange-800" }; +function getScadaPresentation(rawKind: unknown) { + const kind = String(rawKind ?? ""); + if (kind === "water_quality") return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800" }; + if (kind === "radar_level") return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800" }; + if (kind === "ultrasonic_flow") return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50/70", labelClassName: "text-orange-800" }; return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-50/70", labelClassName: "text-slate-700" }; } diff --git a/features/workbench/map/feature-adapter.test.ts b/features/workbench/map/feature-adapter.test.ts index 2ca78d2..d1f8f35 100644 --- a/features/workbench/map/feature-adapter.test.ts +++ b/features/workbench/map/feature-adapter.test.ts @@ -61,25 +61,24 @@ describe("drainage feature adapter", () => { ); }); - it("uses the promoted SCADA point id for feature-state interactions", () => { + it("uses the promoted SCADA id for feature-state interactions", () => { const detail = toDetailFeature({ - id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00", + id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", sourceLayer: SOURCE_LAYERS.scada, properties: { - id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", - point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00", - point_external_id: "36455", - point_name: "DY22东市南街环城南路西段交叉口", - device_external_id: "26825171", - device_type_name: "2代水质仪" + scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", + site_external_id: "36455", + name: "DY22东市南街环城南路西段交叉口", + external_id: "26825171", + kind: "water_quality" } } as unknown as MapGeoJSONFeature); expect(detail).toMatchObject({ - id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00", + id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", layer: "scada", title: "DY22东市南街环城南路西段交叉口", - subtitle: "2代水质仪 · 设备 26825171" + subtitle: "water_quality · 设备 26825171" }); }); }); diff --git a/features/workbench/map/feature-adapter.ts b/features/workbench/map/feature-adapter.ts index 9eeed49..550e7e6 100644 --- a/features/workbench/map/feature-adapter.ts +++ b/features/workbench/map/feature-adapter.ts @@ -3,10 +3,12 @@ import type { DetailFeature } from "../types"; import { formatValue } from "../utils/format-value"; import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + export function getFeatureId(feature: MapGeoJSONFeature) { const layer = getWaterNetworkSourceId(feature); const raw = layer === "scada" - ? feature.id ?? feature.properties?.id ?? feature.properties?.point_id ?? feature.properties?.point_external_id + ? feature.properties?.scada_id ?? (typeof feature.id === "string" && UUID_PATTERN.test(feature.id) ? feature.id : undefined) : feature.id ?? feature.properties?.id; return raw === undefined || raw === null ? "" : String(raw); @@ -70,8 +72,8 @@ function getFeaturePresentation( }; case "scada": return { - title: String(properties.point_name || `SCADA 测点 ${displayId}`), - subtitle: `${formatValue(properties.device_type_name)} · 设备 ${formatValue(properties.device_external_id)}` + title: String(properties.name || `SCADA 测点 ${displayId}`), + subtitle: `${formatValue(properties.kind)} · 设备 ${formatValue(properties.external_id)}` }; } } diff --git a/features/workbench/map/scada.test.ts b/features/workbench/map/scada.test.ts index 279aff3..4f2ac1c 100644 --- a/features/workbench/map/scada.test.ts +++ b/features/workbench/map/scada.test.ts @@ -14,10 +14,10 @@ import { waterNetworkLayers } from "./layers"; describe("SCADA map presentation", () => { it("maps the three authoritative device types and falls back to unknown", () => { expect(SCADA_ICON_EXPRESSION).toEqual([ - "match", ["to-number", ["get", "device_type_id"]], - 72, SCADA_IMAGE_IDS.waterQuality, - 58, SCADA_IMAGE_IDS.radarLevel, - 45, SCADA_IMAGE_IDS.ultrasonicFlow, + "match", ["get", "kind"], + "water_quality", SCADA_IMAGE_IDS.waterQuality, + "radar_level", SCADA_IMAGE_IDS.radarLevel, + "ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow, SCADA_IMAGE_IDS.unknown ]); }); diff --git a/features/workbench/map/scada.ts b/features/workbench/map/scada.ts index 441ab93..66e3aa7 100644 --- a/features/workbench/map/scada.ts +++ b/features/workbench/map/scada.ts @@ -27,10 +27,10 @@ export const SCADA_IMAGE_IDS = { } as const; export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ - "match", ["to-number", ["get", "device_type_id"]], - 72, SCADA_IMAGE_IDS.waterQuality, - 58, SCADA_IMAGE_IDS.radarLevel, - 45, SCADA_IMAGE_IDS.ultrasonicFlow, + "match", ["get", "kind"], + "water_quality", SCADA_IMAGE_IDS.waterQuality, + "radar_level", SCADA_IMAGE_IDS.radarLevel, + "ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow, SCADA_IMAGE_IDS.unknown ]; diff --git a/features/workbench/map/sources.test.ts b/features/workbench/map/sources.test.ts index 379dbed..c5f451d 100644 --- a/features/workbench/map/sources.test.ts +++ b/features/workbench/map/sources.test.ts @@ -10,7 +10,7 @@ describe("createWaterNetworkSources", () => { expect(scada.tiles).toEqual([ expect.stringContaining("/lingang:scada_points/point/WebMercatorQuad/") ]); - expect(scada.promoteId).toBe("point_id"); + expect(scada.promoteId).toBe("scada_id"); }); }); diff --git a/features/workbench/map/sources.ts b/features/workbench/map/sources.ts index c2351e2..0ca63c7 100644 --- a/features/workbench/map/sources.ts +++ b/features/workbench/map/sources.ts @@ -64,7 +64,7 @@ export function createWaterNetworkSources() { SOURCE_LAYERS.scada, "point", [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165], - "point_id" + "scada_id" ) }; } diff --git a/features/workbench/utils/feature-properties.test.ts b/features/workbench/utils/feature-properties.test.ts index 6b1c950..e24fb53 100644 --- a/features/workbench/utils/feature-properties.test.ts +++ b/features/workbench/utils/feature-properties.test.ts @@ -71,27 +71,23 @@ describe("feature panel properties", () => { it("shows SCADA identity, device, state, and sync fields", () => { const model = getFeaturePanelModel("scada", { - id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", - point_id: "aaedc54a-e5b4-5791-b729-4b7e838a8d00", - point_external_id: "36455", - point_name: "DY22东市南街环城南路西段交叉口", - device_external_id: "26825171", + scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2", + site_external_id: "36455", + name: "DY22东市南街环城南路西段交叉口", + external_id: "26825171", + kind: "water_quality", active: true, - device_type_id: 72, - device_type_name: "2代水质仪", junction_id: "31011502010001809", synced_at: "Jul 15, 2026, 7:38:45 AM" }); - expect(model.id).toBe("36455"); + expect(model.id).toBe("4bfa834b-cbbb-5f35-9523-7487ac021ed2"); expect(model.badge).toEqual({ label: "在线", tone: "active" }); expect(model.attributes.map((entry) => entry.key)).toEqual([ - "id", - "point_id", - "point_name", - "device_external_id", - "device_type_id", - "device_type_name", + "site_external_id", + "name", + "external_id", + "kind", "junction_id", "synced_at" ]); @@ -99,12 +95,12 @@ describe("feature panel properties", () => { it("omits the internal SCADA clustering size from user-facing properties", () => { const entries = getLocalizedFeatureProperties({ - point_name: "THC雷达液位计(MQTT)", + name: "THC雷达液位计(MQTT)", cluster_size: 29, - device_external_id: "DEVICE-002" + external_id: "DEVICE-002" }); - expect(entries.map((entry) => entry.key)).toEqual(["point_name", "device_external_id"]); + expect(entries.map((entry) => entry.key)).toEqual(["name", "external_id"]); }); it("labels uniformly sampled SCADA locations", () => { diff --git a/features/workbench/utils/feature-properties.ts b/features/workbench/utils/feature-properties.ts index 838b9f2..aa5421d 100644 --- a/features/workbench/utils/feature-properties.ts +++ b/features/workbench/utils/feature-properties.ts @@ -23,6 +23,7 @@ export type FeaturePanelModel = { const PROPERTY_LABELS: Record = { fid: "GeoServer 要素 ID", id: "编号", + scada_id: "编号 UUID", gid: "要素编号", name: "名称", code: "编码", @@ -69,14 +70,18 @@ const PROPERTY_LABELS: Record = { device_type_name: "设备类型", device_icon_code: "设备图标编码", active: "运行状态", + external_id: "设备编号", junction_id: "关联检查井", + kind: "设备类型", location_source: "位置来源", + site_external_id: "站点编号", synced_at: "同步时间" }; const PROPERTY_ORDER: Record = { fid: 9, id: 10, + scada_id: 10, gid: 11, name: 12, code: 13, @@ -136,7 +141,7 @@ const FEATURE_PANEL_PROPERTY_KEYS: Record> = { @@ -159,7 +164,7 @@ export function getFeaturePanelProperties( featureId?: string ): LocalizedFeatureProperty[] { return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => { - const value = key === "id" ? properties[key] ?? featureId : properties[key]; + const value = key === "id" || key === "scada_id" ? properties[key] ?? featureId : properties[key]; return { key, @@ -175,7 +180,7 @@ export function getFeaturePanelModel( featureId?: string ): FeaturePanelModel { const entries = getFeaturePanelProperties(layer, properties, featureId); - const idKey = layer === "scada" ? "point_external_id" : "id"; + const idKey = layer === "scada" ? "scada_id" : "id"; const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无"; const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer]; const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;