refactor: surface BI metric definitions

This commit is contained in:
kkfluous
2026-08-07 15:12:34 +08:00
parent 46dbe725de
commit 08097f7c29
6 changed files with 175 additions and 1 deletions
+166
View File
@@ -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<MetricDefinition['aggregation'], string> = {
sum: '求和',
count: '计数',
derived: '派生计算',
'weighted-ratio': '加权比率',
'distinct-count': '去重计数',
latest: '取最新值',
};
const TIME_LABEL: Record<MetricDefinition['timeSemantics'], string> = {
flow: '区间流量',
snapshot: '时点快照',
freshness: '数据新鲜度',
};
export default function MetricCatalogButton({ domain }: { domain: MetricDomain }) {
const [open, setOpen] = useState(false);
const [data, setData] = useState<MetricCatalogResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const setCloseRef = useCallback((node: HTMLButtonElement | null) => {
closeRef.current = node;
node?.focus();
}, []);
useEffect(() => {
if (!open || data) return undefined;
let cancelled = false;
setError(null);
fetchJson<MetricCatalogResponse>(`/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 (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-blue-200 hover:bg-blue-50 hover:text-blue-600"
aria-label="查看指标口径"
title="指标口径"
>
<BookOpen size={16} />
</button>
{createPortal(<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[10020]">
<motion.button
type="button"
aria-label="点击遮罩关闭指标口径"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/35 backdrop-blur-[2px]"
/>
<motion.section
role="dialog"
aria-modal="true"
aria-labelledby={`${domain}-metric-catalog-title`}
initial={{ opacity: 0, x: 32 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 32 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="absolute inset-y-0 right-0 flex w-full max-w-lg flex-col bg-white shadow-2xl"
>
<header className="flex shrink-0 items-center gap-3 border-b border-slate-100 px-4 py-4 md:px-5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<BookOpen size={17} />
</span>
<div className="min-w-0 flex-1">
<h2 id={`${domain}-metric-catalog-title`} className="text-sm font-black text-slate-900"></h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400">
{data ? `目录版本 v${data.catalogVersion} · ${data.metrics.length} 项指标` : '读取已发布指标目录'}
</p>
</div>
<button
ref={setCloseRef}
type="button"
onClick={() => setOpen(false)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 hover:text-slate-800"
aria-label="关闭指标口径"
title="关闭"
>
<X size={16} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-2 md:px-5">
{error ? (
<div className="my-4 rounded-lg border border-rose-100 bg-rose-50 p-3 text-xs font-bold text-rose-700">{error}</div>
) : !data ? (
<div className="flex items-center justify-center gap-2 py-16 text-xs font-bold text-slate-400">
<Loader2 size={16} className="animate-spin" />
</div>
) : (
data.metrics.map(metric => (
<article key={metric.id} className="border-b border-slate-100 py-4 last:border-b-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-black text-slate-900">{metric.label}</h3>
<div className="mt-1 font-mono text-[9px] font-bold text-slate-400">{metric.id}</div>
</div>
<div className="flex flex-wrap gap-1 text-[9px] font-black">
<span className="rounded bg-blue-50 px-1.5 py-1 text-blue-600">{AGGREGATION_LABEL[metric.aggregation]}</span>
<span className="rounded bg-slate-100 px-1.5 py-1 text-slate-600">{TIME_LABEL[metric.timeSemantics]}</span>
<span className="rounded bg-emerald-50 px-1.5 py-1 text-emerald-700">{metric.unit}</span>
</div>
</div>
<p className="mt-3 text-[11px] font-bold leading-relaxed text-slate-600">{metric.description}</p>
<div className="mt-3 rounded-lg bg-slate-950 px-3 py-2 font-mono text-[10px] leading-relaxed text-slate-200 break-words">
{metric.formula}
</div>
<div className="mt-3 flex items-start gap-2 text-[10px] font-bold text-slate-500">
<Database size={12} className="mt-0.5 shrink-0 text-slate-400" />
<span className="break-words">{metric.sources.join(' · ')}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{metric.dimensions.map(dimension => (
<span key={dimension} className="rounded border border-slate-200 px-1.5 py-0.5 text-[9px] font-bold text-slate-500">{dimension}</span>
))}
</div>
</article>
))
)}
</div>
</motion.section>
</div>
)}
</AnimatePresence>, document.body)}
</>
);
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function PageFrame({
</div> </div>
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1> <h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
</div> </div>
{actions ? <div className="hidden shrink-0 items-center gap-2 md:flex">{actions}</div> : null} {actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
{subtitle ? ( {subtitle ? (
<button <button
type="button" type="button"
+2
View File
@@ -4,6 +4,7 @@ import ElectricView, { type ElectricSubTab } from './ElectricView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface'; import { FadeIn, PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -21,6 +22,7 @@ export default function ElectricModule() {
icon={CalendarDays} icon={CalendarDays}
eyebrow="ELECTRIC BI" eyebrow="ELECTRIC BI"
meta="时间单位清晰标注 · 支持日/总览切换" meta="时间单位清晰标注 · 支持日/总览切换"
actions={<MetricCatalogButton domain="electric" />}
> >
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+2
View File
@@ -1,6 +1,7 @@
import { Receipt } from 'lucide-react'; import { Receipt } from 'lucide-react';
import ETCView from './ETCView'; import ETCView from './ETCView';
import { PageFrame } from '../../components/ui/surface'; import { PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton';
export default function EtcModule() { export default function EtcModule() {
return ( return (
@@ -10,6 +11,7 @@ export default function EtcModule() {
icon={Receipt} icon={Receipt}
eyebrow="ETC BI" eyebrow="ETC BI"
meta="真实接入状态 · 指标口径已发布" meta="真实接入状态 · 指标口径已发布"
actions={<MetricCatalogButton domain="etc" />}
> >
<ETCView /> <ETCView />
</PageFrame> </PageFrame>
+2
View File
@@ -4,6 +4,7 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
import SubTabs from './SubTabs'; import SubTabs from './SubTabs';
import { useHashSubTab } from './useHashSubTab'; import { useHashSubTab } from './useHashSubTab';
import { FadeIn, PageFrame } from '../../components/ui/surface'; import { FadeIn, PageFrame } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton';
const SUB_TABS = [ const SUB_TABS = [
{ id: 'daily', label: '每日', icon: CalendarDays }, { id: 'daily', label: '每日', icon: CalendarDays },
@@ -21,6 +22,7 @@ export default function HydrogenModule() {
icon={CalendarDays} icon={CalendarDays}
eyebrow="ENERGY BI" eyebrow="ENERGY BI"
meta="数据单位清晰标注 · 支持日/总览切换" meta="数据单位清晰标注 · 支持日/总览切换"
actions={<MetricCatalogButton domain="hydrogen" />}
> >
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} /> <SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
+2
View File
@@ -6,6 +6,7 @@ import DailyReportView from './DailyReportView';
import { useHashSubTab } from '../energy/useHashSubTab'; import { useHashSubTab } from '../energy/useHashSubTab';
import RotatingFooterHint from '../../components/RotatingFooterHint'; import RotatingFooterHint from '../../components/RotatingFooterHint';
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface'; import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
import MetricCatalogButton from '../../components/MetricCatalogButton';
type MileageSubTab = 'monitoring' | 'statistics' | 'report'; type MileageSubTab = 'monitoring' | 'statistics' | 'report';
@@ -27,6 +28,7 @@ export default function MileageModule() {
icon={LayoutDashboard} icon={LayoutDashboard}
eyebrow="MILEAGE BI" eyebrow="MILEAGE BI"
meta="实时监控 · 统计报表 · 每日汇报" meta="实时监控 · 统计报表 · 每日汇报"
actions={<MetricCatalogButton domain="mileage" />}
compactInfo compactInfo
> >
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6"> <div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">