feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -1,12 +1,14 @@
import { Check, Copy, Database, X } from "lucide-react";
import type { ComponentType } from "react";
import { useEffect, useRef, useState } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import {
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
showMapNotice
} from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
import {
StatusBadge,
StatusDot,
@@ -14,10 +16,12 @@ import {
} from "@/shared/ui/status";
import type { WaterNetworkSourceId } from "../map/sources";
import type { DetailFeature } from "../types";
import { useElementGisProperties } from "../hooks/use-element-gis-properties";
import {
getFeaturePanelModel,
type FeaturePanelBadgeTone
} from "../utils/feature-properties";
import { presentGisAttributeGroup } from "../utils/gis-attribute-presentation";
import {
PipeFeatureIcon,
JunctionFeatureIcon,
@@ -31,10 +35,12 @@ import { FeaturePropertyList } from "./feature-property-list";
type FeaturePopoverProps = {
feature: DetailFeature | null;
projectId: string | null;
getAccessToken?: AccessTokenProvider;
onClose: () => void;
};
const FEATURE_LAYER_META: Record<
const FEATURE_LAYER_META: Partial<Record<
WaterNetworkSourceId,
{
icon: ComponentType<SupplyFeatureIconProps>;
@@ -43,14 +49,23 @@ const FEATURE_LAYER_META: Record<
labelClassName: string;
iconClassName: string;
}
> = {
>> = {
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" },
scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" },
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }
};
const SOURCE_GIS_LAYER_META = {
icon: JunctionFeatureIcon,
label: "GIS 交付要素",
headerClassName: "bg-slate-50/80",
labelClassName: "text-slate-700",
iconClassName: "bg-slate-600 text-white ring-slate-900/10"
};
const badgeTones = {
@@ -61,9 +76,18 @@ const badgeTones = {
info: "info"
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
export function FeaturePopover({
feature,
projectId,
getAccessToken,
onClose
}: FeaturePopoverProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGisFeatureKey, setExpandedGisFeatureKey] = useState<string | null>(null);
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gisProperties = useElementGisProperties(feature, projectId, getAccessToken);
const featureKey = feature ? `${feature.layer}:${feature.id}` : null;
const gisExpanded = featureKey !== null && expandedGisFeatureKey === featureKey;
useEffect(() => () => {
if (copyResetTimeoutRef.current) {
@@ -76,7 +100,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
}
const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id);
const layerMeta = FEATURE_LAYER_META[feature.layer];
const layerMeta = FEATURE_LAYER_META[feature.layer] ?? SOURCE_GIS_LAYER_META;
const LayerIcon = layerMeta.icon;
const copied = copiedId === panelModel.id;
const canCopy = panelModel.id !== "暂无";
@@ -106,8 +130,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
return (
<aside
aria-label="要素属性"
className={cn(
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden",
"pointer-events-auto absolute left-1/2 top-[92px] z-[60] flex max-h-[calc(100dvh-116px)] -translate-x-1/2 flex-col overflow-hidden transition-[width] duration-200 motion-reduce:transition-none",
gisExpanded
? "w-[min(560px,calc(100vw-24px))]"
: "w-[min(380px,calc(100vw-24px))]",
MAP_FOCUS_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
@@ -148,7 +176,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
</div>
</header>
<div className="px-4 py-3">
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-3">
{panelModel.attributes.length > 0 ? (
<>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
@@ -160,6 +188,93 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
) : (
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
)}
{gisProperties.groups.length > 0 || gisProperties.status === "error" ? (
<Disclosure
key={featureKey}
className="mt-2 border-t border-slate-100 pt-1"
onToggle={(event) => {
if (event.target === event.currentTarget) {
setExpandedGisFeatureKey(event.currentTarget.open ? featureKey : null);
}
}}
label={(
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>GIS 台账属性</span>
<span className="shrink-0 text-[11px] font-normal text-slate-400">
{gisProperties.status === "error"
? "读取失败"
: `${gisProperties.groups.length} 份`}
</span>
</span>
)}
>
<div className="pb-1 pl-6">
{gisProperties.status === "error" ? (
<p className="py-3 text-xs text-rose-700">GIS 台账属性暂时无法读取</p>
) : gisProperties.groups.map((group) => {
const presentation = presentGisAttributeGroup(group);
return (
<Disclosure
key={`${group.layer_key}:${group.source_fid}`}
className="border-b border-slate-100 last:border-b-0"
label={(
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-slate-700">{group.layer_name}</span>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500">
{presentation.datasetRole}
</span>
</span>
<span className="block text-[11px] font-normal text-slate-400 tabular-nums">
{group.source_date} · {presentation.usefulCount} 个有效字段
</span>
</span>
)}
>
<div className="space-y-2 pb-3 pl-6">
<p className="text-[11px] leading-5 text-slate-500">
{presentation.description}
</p>
{presentation.summary.length > 0 ? (
<dl className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{presentation.summary.map((entry) => (
<div key={entry.key} className="min-w-0 rounded-lg border border-slate-100 bg-white/70 px-2.5 py-2">
<dt className="truncate text-[10px] font-medium text-slate-400">{entry.label}</dt>
<dd className="mt-0.5 truncate text-xs font-semibold text-slate-800" title={entry.value}>{entry.value}</dd>
</div>
))}
</dl>
) : null}
{presentation.sections.map((section) => (
<Disclosure
key={section.id}
className="border-t border-slate-100"
label={`${section.label} · ${section.entries.length} 项`}
>
<div className="pb-2 pl-6">
<FeaturePropertyList entries={section.entries} variant="popover" />
</div>
</Disclosure>
))}
<Disclosure
className="border-t border-slate-100"
label={`全部原始字段 · ${presentation.rawCount} 项`}
>
<div className="pb-2 pl-6">
<p className="pb-2 text-[11px] leading-5 text-slate-400">
包含源系统编码、空值和兼容字段,仅用于核对原始数据。
</p>
<FeaturePropertyList entries={presentation.rawEntries} variant="popover" />
</div>
</Disclosure>
</div>
</Disclosure>
);
})}
</div>
</Disclosure>
) : null}
</div>
<span className="sr-only" aria-live="polite">