refactor: surface BI metric definitions
This commit is contained in:
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function PageFrame({
|
||||
</div>
|
||||
<h1 className="mt-0.5 truncate text-base font-black tracking-tight text-slate-950 md:text-lg">{title}</h1>
|
||||
</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 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import ElectricView, { type ElectricSubTab } from './ElectricView';
|
||||
import SubTabs from './SubTabs';
|
||||
import { useHashSubTab } from './useHashSubTab';
|
||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||
import MetricCatalogButton from '../../components/MetricCatalogButton';
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
@@ -21,6 +22,7 @@ export default function ElectricModule() {
|
||||
icon={CalendarDays}
|
||||
eyebrow="ELECTRIC BI"
|
||||
meta="时间单位清晰标注 · 支持日/总览切换"
|
||||
actions={<MetricCatalogButton domain="electric" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Receipt } from 'lucide-react';
|
||||
import ETCView from './ETCView';
|
||||
import { PageFrame } from '../../components/ui/surface';
|
||||
import MetricCatalogButton from '../../components/MetricCatalogButton';
|
||||
|
||||
export default function EtcModule() {
|
||||
return (
|
||||
@@ -10,6 +11,7 @@ export default function EtcModule() {
|
||||
icon={Receipt}
|
||||
eyebrow="ETC BI"
|
||||
meta="真实接入状态 · 指标口径已发布"
|
||||
actions={<MetricCatalogButton domain="etc" />}
|
||||
>
|
||||
<ETCView />
|
||||
</PageFrame>
|
||||
|
||||
@@ -4,6 +4,7 @@ import HydrogenView, { type HydrogenSubTab } from './HydrogenView';
|
||||
import SubTabs from './SubTabs';
|
||||
import { useHashSubTab } from './useHashSubTab';
|
||||
import { FadeIn, PageFrame } from '../../components/ui/surface';
|
||||
import MetricCatalogButton from '../../components/MetricCatalogButton';
|
||||
|
||||
const SUB_TABS = [
|
||||
{ id: 'daily', label: '每日', icon: CalendarDays },
|
||||
@@ -21,6 +22,7 @@ export default function HydrogenModule() {
|
||||
icon={CalendarDays}
|
||||
eyebrow="ENERGY BI"
|
||||
meta="数据单位清晰标注 · 支持日/总览切换"
|
||||
actions={<MetricCatalogButton domain="hydrogen" />}
|
||||
>
|
||||
<SubTabs tabs={SUB_TABS} active={sub} onChange={setSub} />
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
@@ -6,6 +6,7 @@ import DailyReportView from './DailyReportView';
|
||||
import { useHashSubTab } from '../energy/useHashSubTab';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
import { FadeIn, PageFrame, SegmentedNav } from '../../components/ui/surface';
|
||||
import MetricCatalogButton from '../../components/MetricCatalogButton';
|
||||
|
||||
type MileageSubTab = 'monitoring' | 'statistics' | 'report';
|
||||
|
||||
@@ -27,6 +28,7 @@ export default function MileageModule() {
|
||||
icon={LayoutDashboard}
|
||||
eyebrow="MILEAGE BI"
|
||||
meta="实时监控 · 统计报表 · 每日汇报"
|
||||
actions={<MetricCatalogButton domain="mileage" />}
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user