chore: initialize clean metapi repository
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)',
|
||||
};
|
||||
Reference in New Issue
Block a user