167 lines
7.6 KiB
TypeScript
167 lines
7.6 KiB
TypeScript
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)}
|
|
</>
|
|
);
|
|
}
|