chore: initialize clean metapi repository

This commit is contained in:
cita
2026-02-28 00:36:28 +08:00
commit 2ecc23730d
199 changed files with 46078 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
const ICON_CDN = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/dark';
const ICON_CDN_LIGHT = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light';
export function useIconCdn() {
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
}
export interface BrandInfo {
name: string;
icon: string;
color: string;
}
const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [
{
prefixes: ['gpt', 'o1', 'o3', 'o4', 'chatgpt', 'dall-e', 'text-embedding', 'text-davinci', 'text-curie', 'text-babbage', 'text-ada', 'whisper', 'tts'],
brand: { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg, #10a37f, #1a7f5a)' }
},
{
prefixes: ['claude'],
brand: { name: 'Anthropic', icon: 'claude.color', color: 'linear-gradient(135deg, #d4a574, #c4956a)' }
},
{
prefixes: ['gemini', 'gemma'],
brand: { name: 'Google', icon: 'gemini.color', color: 'linear-gradient(135deg, #4285f4, #34a853)' }
},
{
prefixes: ['deepseek'],
brand: { name: 'DeepSeek', icon: 'deepseek.color', color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)' }
},
{
prefixes: ['qwen', 'qwq'],
brand: { name: '通义千问', icon: 'qwen.color', color: 'linear-gradient(135deg, #615cf7, #9b8afb)' }
},
{
prefixes: ['glm', 'chatglm', 'codegeex', 'cogview'],
brand: { name: '智谱 AI', icon: 'zhipu.color', color: 'linear-gradient(135deg, #3b6cf5, #6366f1)' }
},
{
prefixes: ['llama', 'code-llama', 'codellama'],
brand: { name: 'Meta', icon: 'meta', color: 'linear-gradient(135deg, #0668E1, #1877f2)' }
},
{
prefixes: ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral'],
brand: { name: 'Mistral', icon: 'mistral.color', color: 'linear-gradient(135deg, #f7d046, #f2a900)' }
},
{
prefixes: ['moonshot', 'kimi'],
brand: { name: 'Moonshot', icon: 'moonshot', color: 'linear-gradient(135deg, #000000, #333333)' }
},
{
prefixes: ['yi-'],
brand: { name: '零一万物', icon: 'yi.color', color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)' }
},
{
prefixes: ['ernie', 'eb-'],
brand: { name: '文心一言', icon: 'wenxin.color', color: 'linear-gradient(135deg, #2932e1, #4468f2)' }
},
{
prefixes: ['spark', 'generalv'],
brand: { name: '讯飞星火', icon: 'spark.color', color: 'linear-gradient(135deg, #0070f3, #00d4ff)' }
},
{
prefixes: ['hunyuan'],
brand: { name: '腾讯混元', icon: 'hunyuan.color', color: 'linear-gradient(135deg, #00b7ff, #0052d9)' }
},
{
prefixes: ['doubao'],
brand: { name: '豆包', icon: 'doubao.color', color: 'linear-gradient(135deg, #3b5bdb, #7048e8)' }
},
{
prefixes: ['minimax', 'abab'],
brand: { name: 'MiniMax', icon: 'minimax.color', color: 'linear-gradient(135deg, #6366f1, #818cf8)' }
},
{
prefixes: ['command', 'embed-'],
brand: { name: 'Cohere', icon: 'cohere.color', color: 'linear-gradient(135deg, #39594d, #5ba77f)' }
},
{
prefixes: ['phi-'],
brand: { name: 'Microsoft', icon: 'azure', color: 'linear-gradient(135deg, #00bcf2, #0078d4)' }
},
{
prefixes: ['grok'],
brand: { name: 'xAI', icon: 'xai', color: 'linear-gradient(135deg, #111, #444)' }
},
{
prefixes: ['step-'],
brand: { name: '阶跃星辰', icon: 'stepfun', color: 'linear-gradient(135deg, #0066ff, #3399ff)' }
},
{
prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'],
brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' }
},
];
export function getBrand(modelName: string): BrandInfo | null {
const lower = modelName.toLowerCase();
for (const entry of BRAND_MAP) {
for (const prefix of entry.prefixes) {
if (lower.startsWith(prefix)) return entry.brand;
}
}
return null;
}
const FALLBACK_COLORS = [
'linear-gradient(135deg, #4f46e5, #818cf8)',
'linear-gradient(135deg, #059669, #34d399)',
'linear-gradient(135deg, #2563eb, #60a5fa)',
'linear-gradient(135deg, #d946ef, #f0abfc)',
'linear-gradient(135deg, #ea580c, #fb923c)',
'linear-gradient(135deg, #0891b2, #22d3ee)',
'linear-gradient(135deg, #7c3aed, #a78bfa)',
'linear-gradient(135deg, #dc2626, #f87171)',
];
export function hashColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
}
function avatarLetters(name: string): string {
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) {
const brand = getBrand(model);
const [imgError, setImgError] = useState(false);
if (brand && !imgError) {
return (
<div style={{
width: size, height: size, borderRadius: 10, background: brand.color,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
padding: 6,
}}>
<img
src={`${ICON_CDN}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brand.name}
style={{ width: size - 14, height: size - 14, objectFit: 'contain', filter: 'brightness(10)' }}
onError={() => setImgError(true)}
loading="lazy"
/>
</div>
);
}
return (
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
{avatarLetters(model)}
</div>
);
}
export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) {
const brand = getBrand(model);
const [imgError, setImgError] = useState(false);
const cdn = useIconCdn();
if (brand && !imgError) {
return (
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brand.name}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
return null;
}
export function ModelBadge({ model, style }: { model: string; style?: React.CSSProperties }) {
const brand = getBrand(model);
// Brand-specific colors for badge background
const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
'OpenAI': { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
'Anthropic': { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
'Google': { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
'DeepSeek': { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'xAI': { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
};
const brandName = brand?.name || '';
const colors = badgeColors[brandName] || {
bg: 'var(--color-primary-light)',
border: 'rgba(79,70,229,0.15)',
text: 'var(--color-primary)',
};
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 10px 2px 6px',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
background: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap',
...style,
}}>
<InlineBrandIcon model={model} size={14} />
{model}
</span>
);
}
+122
View File
@@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const toast = useToast();
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
const handleSubmit = async () => {
setError('');
if (!oldToken || !newToken || !confirmToken) {
setError('请填写所有字段');
return;
}
if (newToken !== confirmToken) {
setError('两次输入的新 Token 不一致');
return;
}
if (newToken.length < 6) {
setError('新 Token 至少 6 个字符');
return;
}
setSaving(true);
try {
const res = await api.changeAuthToken(oldToken, newToken);
if (res.success) {
toast.success('Token 已更新,请使用新 Token 重新登录');
persistAuthSession(localStorage, newToken);
onClose();
setOldToken('');
setNewToken('');
setConfirmToken('');
} else {
setError(res.message || '更新失败');
}
} catch (e: any) {
setError(e.message || '更新失败');
} finally {
setSaving(false);
}
};
if (!open) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
};
const modal = (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">修改管理员 Token</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
interface SpendDistributionItem { model: string; spend: number; calls: number; }
interface SpendTrendItem { day: string; spend: number; }
interface CallsDistributionItem { model: string; calls: number; share: number; }
interface CallRankingItem { model: string; calls: number; successRate: number; avgLatencyMs: number; spend: number; tokens: number; }
interface ModelAnalysisData {
totals?: { spend?: number; calls?: number; tokens?: number };
spendDistribution?: SpendDistributionItem[];
spendTrend?: SpendTrendItem[];
callsDistribution?: CallsDistributionItem[];
callRanking?: CallRankingItem[];
}
interface ModelAnalysisPanelProps {
data?: ModelAnalysisData | null;
}
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
{ key: 'spend', label: '消耗分布', icon: '💰' },
{ key: 'trend', label: '消耗趋势', icon: '📈' },
{ key: 'calls', label: '调用分布', icon: '🔄' },
{ key: 'rank', label: '排行榜', icon: '🏆' },
];
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function formatCurrency(value: number): string {
const n = toSafeNumber(value);
if (n >= 1000) return `$${n.toFixed(2)}`;
if (n >= 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(6)}`;
}
function formatPercent(value: number): string {
return `${toSafeNumber(value).toFixed(1)}%`;
}
function EmptyBlock() {
return (
<div className="empty-state" style={{ padding: 28 }}>
<div className="empty-state-title">暂无模型调用数据</div>
<div className="empty-state-desc">等待代理流量进入后会自动生成统计图表</div>
</div>
);
}
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const [activeTab, setActiveTab] = useState<TabKey>('spend');
/* VChart uses Canvas — CSS variables don't work. Read the computed value. */
const [labelColor, setLabelColor] = useState('#9ca3af');
useEffect(() => {
const read = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-text-secondary').trim();
if (c) setLabelColor(c);
};
read();
const obs = new MutationObserver(read);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
const totals = {
spend: toSafeNumber(data?.totals?.spend),
calls: toSafeNumber(data?.totals?.calls),
tokens: toSafeNumber(data?.totals?.tokens),
};
const spendDistribution = (data?.spendDistribution || []).slice(0, 10);
const spendTrend = data?.spendTrend || [];
const callsDistribution = (data?.callsDistribution || []).slice(0, 10);
const callRanking = (data?.callRanking || []).slice(0, 10);
const hasData = totals.calls > 0
|| spendDistribution.length > 0
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
const spendBarSpec = useMemo(() => ({
type: 'bar' as const,
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
xField: 'value', yField: 'model', direction: 'horizontal' as const,
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
animation: true, background: 'transparent',
}), [spendDistribution, labelColor]);
const trendSpec = useMemo(() => ({
type: 'area' as const,
data: [{ id: 'data', values: spendTrend.map(d => ({ day: d.day, spend: toSafeNumber(d.spend) })) }],
xField: 'day', yField: 'spend',
line: { style: { lineWidth: 2.5, curveType: 'monotone' as const, stroke: '#4f46e5' } },
area: { style: { fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 0, y1: 1, stops: [{ offset: 0, color: 'rgba(79,70,229,0.25)' }, { offset: 1, color: 'rgba(79,70,229,0.02)' }] }, curveType: 'monotone' as const } },
point: { visible: true, style: { size: 7, fill: '#4f46e5', stroke: '#fff', lineWidth: 2 } },
axes: [{ orient: 'bottom' as const, label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'left' as const, label: { style: { fontSize: 11, fill: labelColor } } }],
tooltip: { mark: { content: [{ key: () => '消耗', value: (datum: any) => formatCurrency(datum?.spend ?? 0) }] } },
animation: true, background: 'transparent',
}), [spendTrend, labelColor]);
const callsPieSpec = useMemo(() => ({
type: 'pie' as const,
data: [{ id: 'data', values: callsDistribution.map(d => ({ model: d.model, calls: toSafeNumber(d.calls) })) }],
valueField: 'calls', categoryField: 'model',
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 },
animation: true,
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
background: 'transparent',
}), [callsDistribution, labelColor]);
if (!hasData) return <EmptyBlock />;
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
<div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label">总消耗</div>
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
</div>
<div className="stat-summary-card stat-summary-blue">
<div className="stat-summary-card-label">总调用</div>
<div className="stat-summary-card-value">{Math.round(totals.calls).toLocaleString()}</div>
</div>
<div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">总 Tokens</div>
<div className="stat-summary-card-value">{Math.round(totals.tokens).toLocaleString()}</div>
</div>
</div>
{/* Pill Tabs */}
<div style={{ marginBottom: 16 }}>
<div className="pill-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
</div>
{/* Chart Content */}
{activeTab === 'spend' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={spendBarSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{spendDistribution.map(d => (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
</span>
))}
</div>
</div>
)}
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
</span>
);
})}
</div>
</div>
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latColor = latMs > 10000 ? '#ef4444' : latMs > 3000 ? '#f59e0b' : '#22c55e';
const latBg = latMs > 10000 ? 'rgba(239,68,68,0.08)' : latMs > 3000 ? 'rgba(245,158,11,0.08)' : 'rgba(34,197,94,0.08)';
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latMs >= 10000 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useRef, useState } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
};
export default function ModernSelect({
value,
onChange,
options,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
useEffect(() => {
if (!open) return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
return (
<div
ref={rootRef}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected?.label || placeholder}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{options.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
options.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { useI18n } from '../i18n.js';
const levelColors: Record<string, string> = {
info: 'var(--color-info)',
warning: 'var(--color-warning)',
error: 'var(--color-danger)',
};
const typeLabels: Record<string, string> = {
checkin: '签到',
balance: '余额',
token: '令牌',
proxy: '代理',
status: '状态',
};
export default function NotificationPanel({
open,
onClose,
anchorRef,
onUnreadCountChange,
}: {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
onUnreadCountChange?: (count: number) => void;
}) {
const { t: tr } = useI18n();
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>('');
const panelRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const params = filter ? `type=${filter}` : '';
const data = await api.getEvents(params);
setEvents(data);
// Auto mark all as read on open
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
if (hasUnread) {
api.markAllEventsRead().catch(() => {});
onUnreadCountChange?.(0);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [filter, onUnreadCountChange]);
useEffect(() => {
if (open) load();
}, [open, load]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
panelRef.current && !panelRef.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onClose, anchorRef]);
const clearAll = async () => {
await api.clearEvents();
setEvents([]);
onUnreadCountChange?.(0);
};
if (!open) return null;
return (
<div ref={panelRef} className="user-dropdown" style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
<button onClick={clearAll} className="btn btn-link">
{tr('清空')}
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
cursor: 'pointer',
}}>
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
</button>
))}
</div>
{/* Events list */}
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
{!loading && events.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{tr('暂无通知')}
</div>
)}
{events.map((ev: any) => (
<div key={ev.id} style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: levelColors[ev.level] || 'var(--color-info)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 500, fontSize: 13 }}>{ev.title}</span>
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 8, background: 'var(--color-bg)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-light)' }}>
{tr(typeLabels[ev.type] || ev.type)}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{ev.message}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { useI18n } from '../i18n.js';
interface SiteResult {
id: number;
name: string;
url: string;
}
interface AccountResult {
id: number;
username: string | null;
balance?: number | null;
site?: { name: string } | null;
}
interface CheckinLogResult {
id: number;
accountId: number;
message?: string | null;
createdAt?: string | null;
account?: { username?: string | null } | null;
}
interface ProxyLogResult {
id: number;
modelRequested?: string | null;
status?: string | null;
latencyMs?: number | null;
createdAt?: string | null;
}
interface ModelSearchResult {
name: string;
accountCount: number;
tokenCount: number;
siteCount: number;
}
interface SearchResult {
accounts: AccountResult[];
sites: SiteResult[];
checkinLogs: CheckinLogResult[];
proxyLogs: ProxyLogResult[];
models: ModelSearchResult[];
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const timerRef = useRef<number>();
useEffect(() => {
if (open) {
setQuery('');
setResults(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults(null);
return;
}
setLoading(true);
try {
const res = await api.search(q);
setResults({
models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
sites: Array.isArray(res?.sites) ? res.sites : [],
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
});
} catch {
// ignore search errors in modal
} finally {
setLoading(false);
}
}, []);
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => doSearch(val), 300);
};
const goTo = (path: string) => {
onClose();
navigate(path);
};
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
const hasResults = results && (
results.models.length
|| results.accounts.length
|| results.sites.length
|| results.checkinLogs.length
|| results.proxyLogs.length
);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
value={query}
onChange={e => handleInput(e.target.value)}
placeholder={t('搜索站点、账号、模型、日志...')}
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
/>
{loading && <span className="spinner spinner-sm" />}
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
</div>
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
{query && !loading && !hasResults && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{t('没有找到匹配结果')}
</div>
)}
{results?.models.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
{results.models.map((m) => (
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{m.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{m.accountCount} {t('个账号')} · {m.tokenCount} {t('个令牌')} · {m.siteCount} {t('个站点')}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.sites.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
{results.sites.map((s) => (
<button key={s.id} className="search-result-item" onClick={() => goTo('/sites')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{s.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
</div>
</button>
))}
</div>
) : null}
{results?.accounts.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
{results.accounts.map((a) => (
<button key={a.id} className="search-result-item" onClick={() => goTo('/accounts')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{a.username || `ID:${a.id}`}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{a.site?.name || t('未关联站点')} · {t('余额')} ${(a.balance || 0).toFixed(2)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.checkinLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
{results.checkinLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{l.account?.username || `ID:${l.accountId}`}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.message || '-'} · {formatDateLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.proxyLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
{results.proxyLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{l.modelRequested || '-'}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.status || '-'} · {l.latencyMs || 0}ms · {formatDateTimeMinuteLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
type: ToastType;
message: string;
exiting?: boolean;
}
interface ToastContextValue {
toast: (type: ToastType, message: string) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
const icons: Record<ToastType, React.ReactNode> = {
success: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /></svg>,
error: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>,
info: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 250);
}, []);
const addToast = useCallback((type: ToastType, message: string) => {
const id = ++idRef.current;
setToasts(prev => [...prev, { id, type, message }]);
setTimeout(() => removeToast(id), 3200);
}, [removeToast]);
const value: ToastContextValue = {
toast: addToast,
success: useCallback((msg: string) => addToast('success', msg), [addToast]),
error: useCallback((msg: string) => addToast('error', msg), [addToast]),
info: useCallback((msg: string) => addToast('info', msg), [addToast]),
};
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div
key={t.id}
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
onClick={() => removeToast(t.id)}
style={{ cursor: 'pointer' }}
>
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
<div className="toast-progress" />
</div>
))}
</div>
</ToastContext.Provider>
);
}
@@ -0,0 +1,286 @@
import React, { useState, useMemo } from 'react';
import { VChart } from '@visactor/react-vchart';
interface SiteDistributionData {
siteName: string;
platform: string;
totalBalance: number;
totalSpend: number;
accountCount: number;
}
interface SiteDistributionChartProps {
data: SiteDistributionData[];
loading?: boolean;
}
type ViewMode = 'balance' | 'spend';
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 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);
/* VChart uses Canvas — CSS variables don't work for label colors. Read the computed value. */
const [labelColor, setLabelColor] = useState('#9ca3af');
React.useEffect(() => {
const read = () => {
const c = getComputedStyle(document.documentElement).getPropertyValue('--color-text-secondary').trim();
if (c) setLabelColor(c);
};
read();
const obs = new MutationObserver(read);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
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: Record<string, unknown>) => datum.siteName as string,
value: (datum: Record<string, unknown>) => {
const val = safeNumber(datum.value);
return `$${val.toFixed(2)}`;
},
},
{
key: '占比',
value: (datum: Record<string, unknown>) => {
const pct = datum._percent_ as number;
return `${safeNumber(pct).toFixed(1)}%`;
},
},
{
key: '账户数',
value: (datum: Record<string, unknown>) => `${datum.accountCount}`,
},
],
},
},
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)',
};