fix: restore frontend source code (src/web/) that was accidentally deleted

Frontend source is needed for development/building the SPA.
Only Node.js runtime server code should be removed.
This commit is contained in:
boos
2026-05-18 09:33:56 +08:00
parent 5d29f03d50
commit 0eb7c32585
274 changed files with 80446 additions and 0 deletions
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
const vChartSpy = vi.fn();
vi.mock('@visactor/react-vchart', () => ({
VChart: (props: Record<string, unknown>) => {
vChartSpy(props);
return null;
},
}));
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
describe('DownstreamKeyTrendChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
vChartSpy.mockClear();
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('formats trend bucket timestamps with local display time', async () => {
const bucketStart = '2026-04-05T15:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={3600}
buckets={[
{
startUtc: bucketStart,
totalRequests: 2,
totalTokens: 2128425,
totalCost: 2.128425,
successRate: 100,
},
]}
/>,
);
})).resolves.toBeUndefined();
expect(vChartSpy).toHaveBeenCalledTimes(1);
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
data?: Array<{ values?: Array<{ date?: string; tooltipDate?: string }> }>;
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
tooltip?: {
dimension?: {
title?: {
value?: (datum: Record<string, unknown>) => string;
};
};
};
};
const expectedLabel = formatDateTimeMinuteLocal(bucketStart);
expect(spec.data?.[0]?.values?.[0]?.tooltipDate).toBe(expectedLabel);
expect(spec.tooltip?.dimension?.title?.value?.({ tooltipDate: expectedLabel })).toBe(expectedLabel);
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 3600));
renderer.unmount();
});
it('uses day-only axis labels for daily buckets', async () => {
const bucketStart = '2026-04-05T16:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={86400}
buckets={[
{
startUtc: bucketStart,
totalRequests: 3,
totalTokens: 600,
totalCost: 0.06,
successRate: 66.7,
},
]}
/>,
);
})).resolves.toBeUndefined();
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
};
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 86400));
renderer.unmount();
});
});
@@ -0,0 +1,228 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
type Metric = 'tokens' | 'requests' | 'cost';
const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [
{ key: 'tokens', label: 'Tokens' },
{ key: 'requests', label: '请求数' },
{ key: 'cost', label: '成本' },
];
export type DownstreamKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
totalTokens: number;
totalCost: number;
successRate: number | null;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
export default function DownstreamKeyTrendChart({
buckets,
bucketSeconds = 3600,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
bucketSeconds?: number;
loading?: boolean;
height?: number;
}) {
const [metric, setMetric] = useState<Metric>('tokens');
const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const rawDate = (bucket.startUtc || '').trim();
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return {
date: rawDate,
tooltipDate: rawDate ? formatDateTimeMinuteLocal(rawDate) : '',
value,
};
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
type: 'area' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
area: {
style: {
curveType: 'monotone',
fillOpacity: 0.2,
},
},
line: {
style: {
curveType: 'monotone',
lineWidth: 2,
},
},
point: { visible: false },
axes: [
{
orient: 'bottom',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
},
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`;
return value.toLocaleString();
},
},
],
},
},
color: ['var(--color-primary)'],
background: 'transparent',
animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
},
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import SiteDistributionChart from './SiteDistributionChart.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => null,
}));
describe('SiteDistributionChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
let renderer!: WebTestRenderer;
await expect(act(async () => {
renderer = create(
<SiteDistributionChart
data={[
{
siteName: 'Demo Site',
platform: 'demo',
totalBalance: 12.34,
totalSpend: 1.23,
accountCount: 2,
},
]}
/>,
);
})).resolves.toBeUndefined();
renderer.unmount();
});
});
@@ -0,0 +1,287 @@
import { useState, useMemo } from 'react';
import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData {
siteName: string;
platform: string;
totalBalance: number;
totalSpend: number;
accountCount: number;
}
interface SiteDistributionChartProps {
data: SiteDistributionData[];
loading?: boolean;
}
type ViewMode = 'balance' | 'spend';
function coerceDatumRecord(datum: unknown): Record<string, unknown> {
return datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
}
function safeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function SkeletonCircle() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 0',
}}
>
<div
className="skeleton"
style={{
width: 200,
height: 200,
borderRadius: '50%',
}}
/>
<div style={{ marginLeft: 32, display: 'flex', flexDirection: 'column', gap: 12 }}>
{[...Array(4)].map((_, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="skeleton" style={{ width: 12, height: 12, borderRadius: 3 }} />
<div className="skeleton" style={{ width: 80 + i * 10, height: 12 }} />
</div>
))}
</div>
</div>
);
}
function EmptyState() {
return (
<div className="empty-state" style={{ padding: 40 }}>
<div style={{ margin: '0 auto 16px', width: 64, height: 64, opacity: 0.35 }}>
<svg
width="64"
height="64"
fill="none"
viewBox="0 0 24 24"
stroke="var(--color-text-muted)"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
</div>
<div className="empty-state-title" style={{ marginBottom: 4 }}>
暂无站点数据
</div>
<div className="empty-state-desc">添加站点后将自动展示分布图表</div>
</div>
);
}
export default function SiteDistributionChart({ data, loading }: SiteDistributionChartProps) {
const [viewMode, setViewMode] = useState<ViewMode>('balance');
const labelColor = useThemeLabelColor();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item) => ({
siteName: item.siteName,
platform: item.platform,
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
accountCount: safeNumber(item.accountCount),
}));
}, [data, viewMode]);
const hasData = chartData.length > 0 && chartData.some((d) => d.value > 0);
const PIE_COLORS = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
const spec = useMemo(() => {
if (!hasData) return null;
return {
type: 'pie' as const,
data: [{ id: 'siteData', values: chartData }],
valueField: 'value',
categoryField: 'siteName',
outerRadius: 0.8,
innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
legends: { visible: false },
tooltip: {
mark: {
content: [
{
key: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.siteName || '-');
},
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const val = safeNumber(item.value);
return `$${val.toFixed(2)}`;
},
},
{
key: '占比',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const pct = safeNumber(item._percent_);
return `${pct.toFixed(1)}%`;
},
},
{
key: '账户数',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.accountCount || 0);
},
},
] as any,
},
},
color: PIE_COLORS,
animation: true,
background: 'transparent',
};
}, [chartData, hasData, labelColor]);
const formatValue = (value: number): string => {
if (value >= 1000) return `$${value.toFixed(2)}`;
if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`;
};
return (
<div
className="chart-container animate-fade-in"
style={{ padding: 20 }}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
color: 'var(--color-text-primary)',
}}
>
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
站点分布
</div>
{/* Toggle buttons */}
<div
style={{
display: 'flex',
gap: 0,
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
padding: 3,
border: '1px solid var(--color-border-light)',
}}
>
<button
onClick={() => setViewMode('balance')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'balance' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'balance' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'balance' ? 'var(--shadow-sm)' : 'none',
}}
>
余额分布
</button>
<button
onClick={() => setViewMode('spend')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'spend' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'spend' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'spend' ? 'var(--shadow-sm)' : 'none',
}}
>
消耗分布
</button>
</div>
</div>
{/* Content */}
{loading ? (
<SkeletonCircle />
) : !hasData ? (
<EmptyState />
) : (
<div>
<div style={{ width: '100%', height: 300 }}>
{spec && <VChart spec={spec} style={{ width: '100%', height: '100%' }} />}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{chartData.map((d, idx) => (
<span key={d.siteName} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: PIE_COLORS[idx % PIE_COLORS.length], flexShrink: 0 }} />
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.siteName}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{formatValue(d.value)}
</span>
</span>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,253 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SiteTrendData {
date: string;
sites: Record<string, { spend: number; calls: number }>;
}
interface SiteTrendChartProps {
data: SiteTrendData[];
loading?: boolean;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
type Metric = 'spend' | 'calls';
const METRIC_OPTIONS: { key: Metric; label: string }[] = [
{ key: 'spend', label: '消耗趋势' },
{ key: 'calls', label: '调用趋势' },
];
const COLOR_PALETTE = [
'#4f46e5',
'#06b6d4',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
const [metric, setMetric] = useState<Metric>('spend');
/* ---------- data transform ---------- */
const flatData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.flatMap((d) =>
Object.entries(d.sites).map(([site, v]) => ({
date: d.date,
site,
value: metric === 'spend' ? v.spend : v.calls,
})),
);
}, [data, metric]);
/* ---------- loading state ---------- */
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 200, height: 32, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
/* ---------- empty state ---------- */
if (!data || data.length === 0 || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 48 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
</div>
</div>
);
}
/* ---------- vchart spec ---------- */
const spec: Record<string, unknown> = {
type: 'line' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
seriesField: 'site',
point: {
visible: true,
style: { size: 6 },
},
line: {
style: { lineWidth: 2, curveType: 'monotone' },
},
legends: {
visible: true,
orient: 'bottom',
padding: { top: 12 },
item: {
shape: { style: { symbolType: 'circle' } },
label: { style: { fontSize: 12 } },
},
},
tooltip: {
mark: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
dimension: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
},
animation: true,
animationAppear: {
line: { type: 'clipIn', duration: 800, easing: 'cubicOut' },
point: { type: 'fadeIn', duration: 600, delay: 400, easing: 'cubicOut' },
},
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
},
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
color: COLOR_PALETTE,
background: 'transparent',
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
/* ---------- render ---------- */
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height: 320 }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Styles (inline, consistent with project conventions) */
/* ------------------------------------------------------------------ */
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 20,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 16px',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};