286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
import { Check, Copy, Database, X } from "lucide-react";
|
|
import type { ComponentType } from "react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { WorkbenchAccess } from "../api/request-context";
|
|
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,
|
|
type StatusTone
|
|
} 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,
|
|
PumpFeatureIcon,
|
|
ReservoirFeatureIcon,
|
|
ScadaFeatureIcon,
|
|
ValveFeatureIcon,
|
|
type SupplyFeatureIconProps
|
|
} from "./supply-feature-icons";
|
|
import { FeaturePropertyList } from "./feature-property-list";
|
|
|
|
type FeaturePopoverProps = {
|
|
feature: DetailFeature | null;
|
|
projectId: string | null;
|
|
getAccessToken?: WorkbenchAccess;
|
|
onClose: () => void;
|
|
};
|
|
|
|
const FEATURE_LAYER_META: Partial<Record<
|
|
WaterNetworkSourceId,
|
|
{
|
|
icon: ComponentType<SupplyFeatureIconProps>;
|
|
label: string;
|
|
headerClassName: string;
|
|
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-[var(--action-soft)]/70", labelClassName: "text-[var(--action-blue-pressed)]", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
|
|
valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-[var(--action-soft)]/70", labelClassName: "text-[var(--action-blue-pressed)]", iconClassName: "bg-[var(--action-blue)] text-white ring-[var(--action-blue)]/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-[var(--surface-reading)]/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" },
|
|
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-[var(--surface-reading)]/80",
|
|
labelClassName: "text-slate-700",
|
|
iconClassName: "bg-slate-600 text-white ring-slate-900/10"
|
|
};
|
|
|
|
const badgeTones = {
|
|
active: "success",
|
|
inactive: "neutral",
|
|
warning: "warning",
|
|
critical: "danger",
|
|
info: "info"
|
|
} satisfies Record<FeaturePanelBadgeTone, StatusTone>;
|
|
|
|
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) {
|
|
clearTimeout(copyResetTimeoutRef.current);
|
|
}
|
|
}, []);
|
|
|
|
if (!feature) {
|
|
return null;
|
|
}
|
|
|
|
const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id);
|
|
const layerMeta = FEATURE_LAYER_META[feature.layer] ?? SOURCE_GIS_LAYER_META;
|
|
const LayerIcon = layerMeta.icon;
|
|
const copied = copiedId === panelModel.id;
|
|
const canCopy = panelModel.id !== "暂无";
|
|
|
|
const handleCopyId = async () => {
|
|
try {
|
|
if (!navigator.clipboard) {
|
|
throw new Error("Clipboard API unavailable");
|
|
}
|
|
|
|
await navigator.clipboard.writeText(panelModel.id);
|
|
setCopiedId(panelModel.id);
|
|
|
|
if (copyResetTimeoutRef.current) {
|
|
clearTimeout(copyResetTimeoutRef.current);
|
|
}
|
|
|
|
copyResetTimeoutRef.current = setTimeout(() => setCopiedId(null), 1500);
|
|
} catch {
|
|
showMapNotice({
|
|
tone: "error",
|
|
title: "无法复制编号",
|
|
message: "请手动选择编号后复制。"
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
aria-label="要素属性"
|
|
className={cn(
|
|
"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
|
|
)}
|
|
>
|
|
<header className={cn("px-4 pb-3 pt-4", layerMeta.headerClassName)}>
|
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
|
<span className={cn("grid h-12 w-12 place-items-center rounded-full ring-1", layerMeta.iconClassName)} aria-hidden="true">
|
|
<LayerIcon size={24} />
|
|
</span>
|
|
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
|
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
|
{panelModel.badge ? (
|
|
<StatusBadge
|
|
tone={badgeTones[panelModel.badge.tone]}
|
|
icon={<StatusDot tone={badgeTones[panelModel.badge.tone]} />}
|
|
>
|
|
{panelModel.badge.label}
|
|
</StatusBadge>
|
|
) : null}
|
|
</div>
|
|
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
|
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">编号 {panelModel.id}</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
{canCopy ? (
|
|
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-950">
|
|
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
|
<Check size={16} aria-hidden="true" className={cn("status-tone-success absolute text-[var(--status-foreground)] transition-[opacity,transform] duration-150", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
|
</button>
|
|
) : null}
|
|
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/70 [@media(hover:hover)]:hover:text-slate-950">
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<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-[var(--ink-muted)]">
|
|
<Database size={14} aria-hidden="true" />
|
|
设施属性
|
|
</div>
|
|
<FeaturePropertyList entries={panelModel.attributes} variant="popover" />
|
|
</>
|
|
) : (
|
|
<p className="py-3 text-center text-sm text-[var(--ink-muted)]">暂无可展示属性</p>
|
|
)}
|
|
|
|
{gisProperties.groups.length > 0 || gisProperties.status === "error" ? (
|
|
<Disclosure
|
|
key={featureKey}
|
|
className="mt-2 border-t border-[var(--surface-divider)] 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-[var(--ink-muted)]">
|
|
{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-[var(--surface-divider)] 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-[var(--surface-control)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
|
|
{presentation.datasetRole}
|
|
</span>
|
|
</span>
|
|
<span className="block text-[11px] font-normal text-[var(--ink-muted)] 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-[var(--ink-muted)]">
|
|
{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-[var(--surface-divider)] bg-[var(--surface-reading)]/70 px-2.5 py-2">
|
|
<dt className="truncate text-[10px] font-medium text-[var(--ink-muted)]">{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-[var(--surface-divider)]"
|
|
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-[var(--surface-divider)]"
|
|
label={`全部原始字段 · ${presentation.rawCount} 项`}
|
|
>
|
|
<div className="pb-2 pl-6">
|
|
<p className="pb-2 text-[11px] leading-5 text-[var(--ink-muted)]">
|
|
包含源系统编码、空值和兼容字段,仅用于核对原始数据。
|
|
</p>
|
|
<FeaturePropertyList entries={presentation.rawEntries} variant="popover" />
|
|
</div>
|
|
</Disclosure>
|
|
</div>
|
|
</Disclosure>
|
|
);
|
|
})}
|
|
</div>
|
|
</Disclosure>
|
|
) : null}
|
|
</div>
|
|
|
|
<span className="sr-only" aria-live="polite">
|
|
{copied ? "编号已复制" : ""}
|
|
</span>
|
|
</aside>
|
|
);
|
|
}
|