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>
|
</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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user