From 08097f7c29fba4be451aa53ef53d16c12bad0c29 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:12:34 +0800 Subject: [PATCH] refactor: surface BI metric definitions --- src/components/MetricCatalogButton.tsx | 166 +++++++++++++++++++++++++ src/components/ui/surface.tsx | 2 +- src/modules/energy/ElectricModule.tsx | 2 + src/modules/energy/EtcModule.tsx | 2 + src/modules/energy/HydrogenModule.tsx | 2 + src/modules/mileage/MileageModule.tsx | 2 + 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/components/MetricCatalogButton.tsx diff --git a/src/components/MetricCatalogButton.tsx b/src/components/MetricCatalogButton.tsx new file mode 100644 index 0000000..6c7bfd9 --- /dev/null +++ b/src/components/MetricCatalogButton.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { BookOpen, Database, Loader2, X } from 'lucide-react'; +import { AnimatePresence, motion } from 'motion/react'; +import { fetchJson } from '../auth/api-client'; +import type { MetricDefinition, MetricDomain } from '../shared/analytics/catalog'; + +interface MetricCatalogResponse { + catalogVersion: number; + domains: MetricDomain[]; + metrics: MetricDefinition[]; +} + +const AGGREGATION_LABEL: Record = { + sum: '求和', + count: '计数', + derived: '派生计算', + 'weighted-ratio': '加权比率', + 'distinct-count': '去重计数', + latest: '取最新值', +}; + +const TIME_LABEL: Record = { + flow: '区间流量', + snapshot: '时点快照', + freshness: '数据新鲜度', +}; + +export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) { + const [open, setOpen] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const triggerRef = useRef(null); + const closeRef = useRef(null); + const setCloseRef = useCallback((node: HTMLButtonElement | null) => { + closeRef.current = node; + node?.focus(); + }, []); + + useEffect(() => { + if (!open || data) return undefined; + let cancelled = false; + setError(null); + fetchJson(`/api/analytics/metrics?domain=${domain}`) + .then(result => { if (!cancelled) setData(result); }) + .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); + return () => { cancelled = true; }; + }, [open, data, domain]); + + useEffect(() => { + if (!open) return undefined; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + triggerRef.current?.focus(); + }; + }, [open]); + + return ( + <> + + + {createPortal( + {open && ( +
+ setOpen(false)} + className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]" + /> + +
+ + + +
+

指标口径

+

+ {data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'} +

+
+ +
+ +
+ {error ? ( +
{error}
+ ) : !data ? ( +
+ 正在加载指标口径 +
+ ) : ( + data.metrics.map(metric => ( +
+
+
+

{metric.label}

+
{metric.id}
+
+
+ {AGGREGATION_LABEL[metric.aggregation]} + {TIME_LABEL[metric.timeSemantics]} + {metric.unit} +
+
+

{metric.description}

+
+ {metric.formula} +
+
+ + {metric.sources.join(' · ')} +
+
+ {metric.dimensions.map(dimension => ( + {dimension} + ))} +
+
+ )) + )} +
+
+
+ )} +
, document.body)} + + ); +} diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx index 4f2e240..a3d0ded 100644 --- a/src/components/ui/surface.tsx +++ b/src/components/ui/surface.tsx @@ -56,7 +56,7 @@ export function PageFrame({

{title}

- {actions ?
{actions}
: null} + {actions ?
{actions}
: null} {subtitle ? (