218 lines
9.7 KiB
TypeScript
218 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { Check, Copy, Target, TriangleAlert, X } from "lucide-react";
|
|
import Image from "next/image";
|
|
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";
|
|
import {
|
|
formatFeaturePropertyValue,
|
|
getFeatureInsightMetrics,
|
|
getLocalizedFeatureProperties
|
|
} from "../utils/feature-properties";
|
|
import { FeaturePropertyList } from "./feature-property-list";
|
|
|
|
type FeatureInsightPanelProps = {
|
|
feature: DetailFeature | null;
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
|
const [copiedScadaUuid, setCopiedScadaUuid] = useState(false);
|
|
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => () => {
|
|
if (copyResetTimeoutRef.current) {
|
|
clearTimeout(copyResetTimeoutRef.current);
|
|
}
|
|
}, []);
|
|
|
|
const metrics = useMemo(() => {
|
|
if (!feature) {
|
|
return [];
|
|
}
|
|
|
|
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
|
}, [feature]);
|
|
|
|
const propertyEntries = useMemo(() => {
|
|
if (!feature) {
|
|
return [];
|
|
}
|
|
|
|
return feature.layer === "scada"
|
|
? []
|
|
: getLocalizedFeatureProperties(feature.properties);
|
|
}, [feature]);
|
|
|
|
const scadaQueryUuid = feature?.layer === "scada"
|
|
? getCopyablePropertyValue(feature.properties.sensor_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 (
|
|
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
|
<div className="border-b border-slate-200/80 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs font-medium tracking-[0.14em] text-slate-500">要素详情</p>
|
|
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
|
{feature ? feature.title : "选择一个管网对象"}
|
|
</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭详情"
|
|
title="关闭详情"
|
|
onClick={onClose}
|
|
className="surface-control grid h-9 w-9 place-items-center rounded border text-slate-500 hover:text-slate-900"
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<p className="mt-2 text-sm text-slate-600">
|
|
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
|
{feature ? (
|
|
<>
|
|
{feature.layer === "scada" ? (
|
|
<h3 className="mb-2 text-sm font-semibold text-slate-900">监测数据</h3>
|
|
) : null}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{metrics.map(({ label, value }, index) => (
|
|
<div
|
|
key={label}
|
|
className={cn(
|
|
"surface-reading rounded border p-3",
|
|
feature.layer === "scada" && index === metrics.length - 1 && "col-span-2"
|
|
)}
|
|
>
|
|
<p className="text-xs text-slate-500">{label}</p>
|
|
<p className={cn(
|
|
"mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums",
|
|
value === "暂无" && "font-medium text-slate-400"
|
|
)}>{value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{feature.layer === "scada" ? (
|
|
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.kind).sectionClassName)}>
|
|
<div className="flex items-center gap-3">
|
|
<Image src={getScadaPresentation(feature.properties.kind).iconPath} alt="" width={42} height={42} />
|
|
<div className="min-w-0">
|
|
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.kind).labelClassName)}>{formatFeaturePropertyValue("sensor_name", feature.properties.sensor_name)}</p>
|
|
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("sensor_id", feature.properties.sensor_id ?? feature.id)}</p>
|
|
</div>
|
|
</div>
|
|
{scadaQueryUuid ? (
|
|
<div className="mt-3 rounded border border-white/70 bg-white/65 px-3 py-2 shadow-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-xs font-semibold text-slate-500">传感器 ID</p>
|
|
<button
|
|
type="button"
|
|
aria-label={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
|
title={copiedScadaUuid ? "传感器 ID 已复制" : "复制传感器 ID"}
|
|
onClick={() => void handleCopyScadaUuid()}
|
|
className="relative grid h-8 w-8 shrink-0 place-items-center rounded border border-slate-200/80 bg-white/80 text-slate-500 transition-[background-color,color,transform] duration-150 hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
|
>
|
|
<Copy size={14} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
|
<Check size={15} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-150", copiedScadaUuid ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
|
</button>
|
|
</div>
|
|
<p className="mt-1 break-all font-mono text-xs font-semibold leading-5 text-slate-900">{scadaQueryUuid}</p>
|
|
<p className="mt-1 text-xs leading-5 text-slate-500">SCADA 时序查询 sensor_id</p>
|
|
</div>
|
|
) : null}
|
|
<p className="mt-2 text-sm leading-6 text-slate-700">设备类型为综合监测点,数据源为 geo_scadas_mat。</p>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="material-tone-warning mt-4 rounded border border-orange-100 p-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
|
<TriangleAlert size={16} aria-hidden="true" />
|
|
Agent 解释
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-slate-700">
|
|
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
|
</p>
|
|
</section>
|
|
|
|
{feature.layer !== "scada" ? (
|
|
<section className="mt-4">
|
|
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
|
<div className="mt-2">
|
|
<FeaturePropertyList entries={propertyEntries} />
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-slate-500">
|
|
<Target size={22} aria-hidden="true" />
|
|
</div>
|
|
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
|
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
|
|
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="sr-only" aria-live="polite">{copiedScadaUuid ? "编号 UUID 已复制" : ""}</span>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
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" };
|
|
if (kind === "integrated_monitoring" || kind === "综合监测点") return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
|
return { iconPath: SCADA_ICON_PATHS.integratedMonitoring, sectionClassName: "material-tone-normal border-cyan-200", labelClassName: "text-cyan-800" };
|
|
}
|