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
+712
View File
@@ -0,0 +1,712 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import { I18nProvider, useI18n } from './i18n.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js'));
const Tokens = lazy(() => import('./pages/Tokens.js'));
const CheckinLog = lazy(() => import('./pages/CheckinLog.js'));
const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js'));
const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js'));
const Settings = lazy(() => import('./pages/Settings.js'));
const ImportExport = lazy(() => import('./pages/ImportExport.js'));
const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js'));
const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
type ThemeMode = 'system' | 'light' | 'dark';
type UserProfile = {
name: string;
avatarSeed: string;
avatarStyle: string;
};
const THEME_MODE_STORAGE_KEY = 'theme_mode';
const USER_PROFILE_STORAGE_KEY = 'user_profile';
const DICEBEAR_STYLES = [
'pixel-art',
'pixel-art-neutral',
'bottts',
'bottts-neutral',
'identicon',
'initials',
'avataaars',
'avataaars-neutral',
'personas',
'lorelei',
'lorelei-neutral',
'fun-emoji',
] as const;
type DicebearStyle = typeof DICEBEAR_STYLES[number];
function resolveStoredThemeMode(): ThemeMode {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
const legacy = localStorage.getItem('theme');
if (legacy === 'light' || legacy === 'dark') return legacy;
return 'system';
}
function createRandomAvatarSeed(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `seed-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function pickDicebearStyle(seed: string): DicebearStyle {
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
return DICEBEAR_STYLES[index];
}
function buildDicebearAvatarUrl(style: string, seed: string): string {
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
? style
: pickDicebearStyle(seed);
const safeSeed = (seed || 'default').trim() || 'default';
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
}
function resolveStoredProfile(): UserProfile {
try {
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
if (!raw) {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
const avatarSeed = typeof parsed?.avatarSeed === 'string'
? parsed.avatarSeed.trim()
: (typeof parsed?.avatar === 'string' ? parsed.avatar.trim() : '');
const resolvedSeed = avatarSeed || createRandomAvatarSeed();
const avatarStyle = typeof parsed?.avatarStyle === 'string'
? parsed.avatarStyle.trim()
: '';
return {
name: name || '管理员',
avatarSeed: resolvedSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(resolvedSeed),
};
} catch {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
}
function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/stats/dashboard', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
onLogin(token);
} else {
setError(t('登录令牌无效'));
setLoading(false);
}
} catch {
setError(t('无法连接到服务器'));
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-bg)' }}>
<div className="animate-scale-in" style={{ background: 'var(--color-bg-card)', padding: 40, borderRadius: 'var(--radius-xl)', boxShadow: 'var(--shadow-lg)', width: 400, border: '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<img src="/logo.png" alt="Metapi" style={{ width: 40, height: 40, borderRadius: 12 }} />
<h1 style={{ fontSize: 24, fontWeight: 700 }}>Metapi</h1>
</div>
<p style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 24 }}>{t('请输入管理员令牌后继续。')}</p>
<input
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: 14, outline: 'none', background: 'var(--color-bg)', color: 'var(--color-text-primary)', marginBottom: 12, transition: 'border-color 0.2s' }}
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
{error}
</div>
)}
<button
onClick={handleLogin}
disabled={loading || !token}
className="btn btn-primary"
style={{ width: '100%', padding: '10px 0', fontSize: 14, fontWeight: 600 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')}
</button>
</div>
</div>
);
}
function UserProfileModal({
open,
profile,
onClose,
onSave,
t,
}: {
open: boolean;
profile: UserProfile;
onClose: () => void;
onSave: (nextProfile: UserProfile) => void;
t: (text: string) => string;
}) {
const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setName(profile.name);
setAvatarSeed(profile.avatarSeed);
setAvatarStyle(profile.avatarStyle);
setError('');
}, [open, profile]);
if (!open) return null;
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
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 handleRandomAvatar = () => {
const nextSeed = createRandomAvatarSeed();
setAvatarSeed(nextSeed);
setAvatarStyle(pickDicebearStyle(nextSeed));
};
const handleSubmit = () => {
const normalizedName = name.trim();
if (!normalizedName) {
setError(t('用户名不能为空'));
return;
}
if (Array.from(normalizedName).length > 24) {
setError(t('用户名最多 24 个字符'));
return;
}
onSave({
name: normalizedName,
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(avatarSeed),
});
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-header">{t('个人信息')}</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
<img
src={avatarUrl}
alt={name.trim() || 'avatar'}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
<input
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
placeholder={t('例如:小王')}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">{t('取消')}</button>
<button onClick={handleSubmit} className="btn btn-primary">{t('保存')}</button>
</div>
</div>
</div>
);
}
const sidebarGroups = [
{
label: '控制台',
items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/accounts', label: '账号', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/tokens', label: '令牌管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/monitor', label: '可用性监控', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M3 5a2 2 0 012-2h14a2 2 0 012 2v11a2 2 0 01-2 2h-5l-2.5 3-2.5-3H5a2 2 0 01-2-2V5z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 10h3l1.5-2.5L14 13l1.5-3H17" /></svg> },
],
},
{
label: '系统',
items: [
{ to: '/settings', label: '设置', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/events', label: '程序日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> },
{ to: '/settings/import-export', label: '导入/导出', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 7h10M7 12h6m-6 5h10M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /></svg> },
{ to: '/settings/notify', label: '通知设置', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> },
],
},
];
const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: '模型操练场', to: '/playground' },
{ label: '关于', to: '/about' },
];
function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return <div key={location.pathname} className="page-enter">{children}</div>;
}
function RouteLoadingFallback() {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
function AppShell() {
const { language, toggleLanguage, t } = useI18n();
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const [showThemeMenu, setShowThemeMenu] = useState(false);
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile());
const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const notifBtnRef = useRef<HTMLButtonElement>(null);
const latestTaskEventIdRef = useRef(0);
const toast = useToast();
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
? (systemPrefersDark ? 'dark' : 'light')
: themeMode;
const rawDisplayName = (userProfile.name || '').trim();
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemPrefersDark(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
if (themeMode === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', themeMode);
}
}, [resolvedTheme, themeMode]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setShowSearch(true);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
if (!authed) return;
let cancelled = false;
const pollEvents = async () => {
try {
const recentEvents = await api.getEvents('limit=30');
if (cancelled) return;
const rows = Array.isArray(recentEvents) ? recentEvents : [];
const unread = rows.filter((r: any) => !r.read).length;
setUnreadCount(unread);
const maxId = rows.reduce((acc: number, row: any) => Math.max(acc, Number(row?.id) || 0), 0);
if (latestTaskEventIdRef.current === 0) {
latestTaskEventIdRef.current = maxId;
return;
}
const newTaskEvents = rows
.filter((row: any) => (
(Number(row?.id) || 0) > latestTaskEventIdRef.current
&& row?.relatedType === 'task'
&& !String(row?.title || '').includes('已开始')
))
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.slice(-3);
for (const event of newTaskEvents) {
const message = event?.message || event?.title || t('任务状态已更新');
if (event?.level === 'error') {
toast.error(message);
} else if (event?.level === 'warning') {
toast.info(message);
} else {
toast.success(message);
}
}
if (maxId > latestTaskEventIdRef.current) {
latestTaskEventIdRef.current = maxId;
}
} catch {
// ignore polling errors
}
};
void pollEvents();
const timer = setInterval(() => { void pollEvents(); }, 15000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
const check = () => {
if (hasValidAuthSession(localStorage)) return;
setAuthed(false);
toast.info(t('会话已过期,请重新登录'));
};
check();
const timer = setInterval(check, 60_000);
return () => clearInterval(timer);
}, [authed, toast]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) {
setShowThemeMenu(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSelectThemeMode = (nextMode: ThemeMode) => {
setThemeMode(nextMode);
setShowThemeMenu(false);
};
const handleSaveProfile = (nextProfile: UserProfile) => {
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
const normalized = {
name: nextProfile.name.trim() || t('管理员'),
avatarSeed: normalizedSeed,
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
? nextProfile.avatarStyle
: pickDicebearStyle(normalizedSeed),
};
setUserProfile(normalized);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
setShowProfileModal(false);
toast.success(t('个人信息已保存'));
};
if (!authed) {
return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}} />;
}
return (
<>
<header className="topbar">
<div className="topbar-logo">
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span className="topbar-logo-text">Metapi</span>
</div>
<nav className="topbar-nav">
{topNavItems.map((item) => (
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
{t(item.label)}
</NavLink>
))}
</nav>
<div className="topbar-right">
<button
className="topbar-icon-btn"
title={language === 'zh' ? 'Switch to English' : '切换到中文'}
onClick={toggleLanguage}
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
>
{language === 'zh' ? 'EN' : '中'}
</button>
<button className="topbar-icon-btn" title={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</button>
<div style={{ position: 'relative' }}>
<button ref={notifBtnRef} className="topbar-icon-btn" title={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<NotificationPanel open={showNotifications} onClose={() => setShowNotifications(false)} anchorRef={notifBtnRef} onUnreadCountChange={setUnreadCount} />
</div>
<div ref={themeMenuRef} style={{ position: 'relative' }}>
<button
className="topbar-icon-btn"
title={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
onClick={() => {
setShowThemeMenu((prev) => !prev);
setShowUserMenu(false);
}}
>
{themeMode === 'system' ? (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5h16v10H4V5zm6 12h4m-7 2h10" /></svg>
) : themeMode === 'light' ? (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
{showThemeMenu && (
<div className="user-dropdown" style={{ right: 0, left: 'auto', minWidth: 168 }}>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('system')}
style={themeMode === 'system' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('跟随系统')}({resolvedThemeLabel})
</button>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('light')}
style={themeMode === 'light' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('浅色模式')}
</button>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('dark')}
style={themeMode === 'dark' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('深色模式')}
</button>
</div>
)}
</div>
<div ref={userMenuRef} style={{ position: 'relative' }}>
<div
className="topbar-avatar"
title={displayName}
onClick={() => {
setShowUserMenu(!showUserMenu);
setShowThemeMenu(false);
}}
>
<img
src={avatarUrl}
alt={displayName}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
{showUserMenu && (
<div className="user-dropdown">
<button
className="user-dropdown-item"
onClick={() => {
setShowProfileModal(true);
setShowUserMenu(false);
}}
>
<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>
{t('个人信息')}
</button>
<button onClick={() => {
clearAuthSession(localStorage);
setAuthed(false);
}} className="user-dropdown-item danger">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
{t('退出登录')}
</button>
</div>
)}
</div>
</div>
</header>
<div className="app-layout">
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
{sidebarGroups.map((group) => (
<div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
title={sidebarCollapsed ? t(item.label) : undefined}
>
{item.icon}
{!sidebarCollapsed && <span>{t(item.label)}</span>}
</NavLink>
))}
</div>
))}
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
</button>
</aside>
<main className="main-content">
<PageTransition>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/tokens" element={<Tokens />} />
<Route path="/checkin" element={<CheckinLog />} />
<Route path="/routes" element={<TokenRoutes />} />
<Route path="/logs" element={<ProxyLogs />} />
<Route path="/monitor" element={<Monitors />} />
<Route path="/settings" element={<Settings />} />
<Route path="/events" element={<ProgramLogs />} />
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/playground" element={<ModelTester />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</PageTransition>
</main>
</div>
<UserProfileModal
open={showProfileModal}
profile={userProfile}
onClose={() => setShowProfileModal(false)}
onSave={handleSaveProfile}
t={t}
/>
<SearchModal open={showSearch} onClose={() => setShowSearch(false)} />
</>
);
}
export default function App() {
return (
<I18nProvider>
<ToastProvider>
<AppShell />
</ToastProvider>
</I18nProvider>
);
}
+247
View File
@@ -0,0 +1,247 @@
import { clearAuthSession, getAuthToken } from './authSession.js';
type RequestOptions = RequestInit & {
timeoutMs?: number;
};
async function request(url: string, options: RequestOptions = {}) {
const { timeoutMs = 30_000, signal: externalSignal, ...fetchOptions } = options;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, timeoutMs);
let cleanupExternalSignal = () => {};
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
const abortHandler = () => controller.abort();
externalSignal.addEventListener('abort', abortHandler, { once: true });
cleanupExternalSignal = () => externalSignal.removeEventListener('abort', abortHandler);
}
}
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`,
};
if (fetchOptions.body) headers['Content-Type'] = 'application/json';
try {
const res = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers: {
...headers,
...fetchOptions.headers as Record<string, string>,
},
});
if (res.status === 401 || res.status === 403) {
const hadToken = !!getAuthToken(localStorage);
clearAuthSession(localStorage);
if (hadToken) window.location.reload();
throw new Error('Session expired');
}
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const text = await res.text();
if (text) {
try {
const json = JSON.parse(text);
if (json?.message && typeof json.message === 'string') {
message = json.message;
} else if (json?.error && typeof json.error === 'string') {
message = json.error;
} else {
message = `${message}: ${text.slice(0, 120)}`;
}
} catch {
message = `${message}: ${text.slice(0, 120)}`;
}
}
} catch {}
throw new Error(message);
}
return res.json();
} catch (error: any) {
if (error?.name === 'AbortError') {
if (externalSignal?.aborted) throw error;
throw new Error(`请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)`);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
cleanupExternalSignal();
}
}
type TestChatRequestPayload = {
model: string;
messages: Array<{ role: string; content: string }>;
targetFormat?: 'openai' | 'claude';
stream?: boolean;
temperature?: number;
top_p?: number;
max_tokens?: number;
frequency_penalty?: number;
presence_penalty?: number;
seed?: number;
};
export const api = {
// Sites
getSites: () => request('/api/sites'),
addSite: (data: any) => request('/api/sites', { method: 'POST', body: JSON.stringify(data) }),
updateSite: (id: number, data: any) => request(`/api/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }),
detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }),
// Accounts
getAccounts: () => request('/api/accounts'),
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
method: 'POST',
body: JSON.stringify(data || {}),
timeoutMs: data?.wait ? 150_000 : 30_000,
}),
// Account tokens
getAccountTokens: (accountId?: number) => request(`/api/account-tokens${accountId ? `?accountId=${accountId}` : ''}`),
addAccountToken: (data: any) => request('/api/account-tokens', { method: 'POST', body: JSON.stringify(data) }),
updateAccountToken: (id: number, data: any) => request(`/api/account-tokens/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAccountToken: (id: number) => request(`/api/account-tokens/${id}`, { method: 'DELETE' }),
getAccountTokenGroups: (accountId: number) => request(`/api/account-tokens/groups/${accountId}`),
setDefaultAccountToken: (id: number) => request(`/api/account-tokens/${id}/default`, { method: 'POST' }),
getAccountTokenValue: (id: number) => request(`/api/account-tokens/${id}/value`),
syncAccountTokens: (accountId: number) => request(`/api/account-tokens/sync/${accountId}`, { method: 'POST', timeoutMs: 45_000 }),
syncAllAccountTokens: (wait = false) => request('/api/account-tokens/sync-all', {
method: 'POST',
body: JSON.stringify(wait ? { wait: true } : {}),
timeoutMs: wait ? 150_000 : 30_000,
}),
// Check-in
triggerCheckinAll: () => request('/api/checkin/trigger', { method: 'POST' }),
triggerCheckin: (id: number) => request(`/api/checkin/trigger/${id}`, { method: 'POST' }),
getCheckinLogs: (params?: string) => request(`/api/checkin/logs${params ? '?' + params : ''}`),
updateCheckinSchedule: (cron: string) => request('/api/checkin/schedule', { method: 'PUT', body: JSON.stringify({ cron }) }),
// Routes
getRoutes: () => request('/api/routes'),
addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }),
updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),
addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }),
updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) =>
request('/api/channels/batch', { method: 'PUT', body: JSON.stringify({ updates }) }),
deleteChannel: (id: number) => request(`/api/channels/${id}`, { method: 'DELETE' }),
rebuildRoutes: (refreshModels = true, wait = false) => request('/api/routes/rebuild', {
method: 'POST',
body: JSON.stringify({ refreshModels, ...(wait ? { wait: true } : {}) }),
timeoutMs: wait ? 150_000 : 30_000,
}),
getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`),
getRouteDecisionsBatch: (models: string[]) => request('/api/routes/decision/batch', {
method: 'POST',
body: JSON.stringify({ models }),
}),
// Stats
getDashboard: () => request('/api/stats/dashboard'),
getProxyLogs: (params?: string) => request(`/api/stats/proxy-logs${params ? '?' + params : ''}`),
checkModels: (accountId: number) => request(`/api/models/check/${accountId}`, { method: 'POST' }),
getSiteDistribution: () => request('/api/stats/site-distribution'),
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
getModelBySite: (siteId?: number, days = 7) =>
request(`/api/stats/model-by-site?${siteId ? `siteId=${siteId}&` : ''}days=${days}`),
// Search
search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }),
// Events
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
getEventCount: () => request('/api/events/count'),
markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }),
markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }),
clearEvents: () => request('/api/events', { method: 'DELETE' }),
getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`),
getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`),
// Auth management
getAuthInfo: () => request('/api/settings/auth/info'),
changeAuthToken: (oldToken: string, newToken: string) => request('/api/settings/auth/change', {
method: 'POST', body: JSON.stringify({ oldToken, newToken }),
}),
getRuntimeSettings: () => request('/api/settings/runtime'),
updateRuntimeSettings: (data: any) => request('/api/settings/runtime', {
method: 'PUT',
body: JSON.stringify(data),
}),
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
importBackup: (data: any) =>
request('/api/settings/backup/import', {
method: 'POST',
body: JSON.stringify({ data }),
}),
clearRuntimeCache: () => request('/api/settings/maintenance/clear-cache', { method: 'POST' }),
clearUsageData: () => request('/api/settings/maintenance/clear-usage', { method: 'POST' }),
testNotification: () => request('/api/settings/notify/test', { method: 'POST' }),
// Monitor embed
getMonitorConfig: () => request('/api/monitor/config'),
updateMonitorConfig: (data: { ldohCookie?: string | null }) => request('/api/monitor/config', {
method: 'PUT',
body: JSON.stringify(data),
}),
initMonitorSession: () => request('/api/monitor/session', { method: 'POST' }),
// Models marketplace
getModelsMarketplace: (options?: { refresh?: boolean; includePricing?: boolean }) => {
const params = new URLSearchParams();
if (options?.refresh) params.set('refresh', '1');
if (options?.includePricing) params.set('includePricing', '1');
const query = params.toString();
return request(`/api/models/marketplace${query ? `?${query}` : ''}`, { timeoutMs: options?.refresh ? 45_000 : 15_000 });
},
getModelTokenCandidates: () => request('/api/models/token-candidates'),
// Simple chat test from admin panel
startTestChatJob: (data: TestChatRequestPayload) =>
request('/api/test/chat/jobs', { method: 'POST', body: JSON.stringify(data) }),
getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`),
deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
testChat: (data: TestChatRequestPayload) =>
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => {
const token = getAuthToken(localStorage);
if (!token) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return fetch('/api/test/chat/stream', {
method: 'POST',
signal,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
},
};
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
AUTH_SESSION_DURATION_MS,
clearAuthSession,
getAuthToken,
hasValidAuthSession,
persistAuthSession,
} from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
describe('authSession', () => {
it('persists token with expiration and reads it before expiry', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-1', 60_000, 1_000);
expect(getAuthToken(storage, 10_000)).toBe('token-1');
expect(hasValidAuthSession(storage, 10_000)).toBe(true);
});
it('clears expired session automatically', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-2', 60_000, 1_000);
expect(getAuthToken(storage, 100_000)).toBeNull();
expect(hasValidAuthSession(storage, 100_000)).toBe(false);
});
it('supports explicit logout', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-3', AUTH_SESSION_DURATION_MS, 1_000);
clearAuthSession(storage);
expect(getAuthToken(storage, 2_000)).toBeNull();
});
});
+69
View File
@@ -0,0 +1,69 @@
const AUTH_TOKEN_STORAGE_KEY = 'auth_token';
const AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY = 'auth_token_expires_at';
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
if (storage) return storage;
if (typeof localStorage !== 'undefined') return localStorage;
return null;
}
export function clearAuthSession(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(AUTH_TOKEN_STORAGE_KEY);
target.removeItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
}
export function persistAuthSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearAuthSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(AUTH_TOKEN_STORAGE_KEY, cleanToken);
target.setItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
}
export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
// Legacy migration: set a default TTL the first time we read an old session.
persistAuthSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearAuthSession(target);
return null;
}
return token;
}
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getAuthToken(storage, nowMs);
}
+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)',
};
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolveDevProxyTarget } from './devProxyTarget.js';
describe('resolveDevProxyTarget', () => {
it('falls back to localhost:4000 when no env is provided', () => {
expect(resolveDevProxyTarget({})).toBe('http://localhost:4000');
});
it('prefers explicit backend target env var', () => {
expect(resolveDevProxyTarget({ DEV_PROXY_TARGET: 'http://127.0.0.1:4567' })).toBe('http://127.0.0.1:4567');
});
it('uses PORT env when provided', () => {
expect(resolveDevProxyTarget({ PORT: '4100' })).toBe('http://localhost:4100');
});
});
+15
View File
@@ -0,0 +1,15 @@
type ProxyEnv = Record<string, string | undefined>;
function normalizeTarget(raw: string): string {
return raw.trim().replace(/\/+$/, '');
}
export function resolveDevProxyTarget(env: ProxyEnv): string {
const explicit = normalizeTarget(env.DEV_PROXY_TARGET || env.VITE_DEV_PROXY_TARGET || '');
if (explicit) return explicit;
const port = (env.PORT || env.VITE_BACKEND_PORT || '').trim();
if (port) return `http://localhost:${port}`;
return 'http://localhost:4000';
}
+782
View File
@@ -0,0 +1,782 @@
// Auto-generated supplemental i18n mappings for strict English mode.
// Generated by tmp-generate-i18n-clean.ts
export const zhToEnSupplemental: Record<string, string> = {
'...等${rows.length}个': '...Waiting for ${rows.length}',
'(已配置)': '(configured)',
'// 暂无数据': '// No data yet',
'+ 添加站点': '+ add site',
'+ 添加账号': '+ add account',
'+ 新增令牌': '+ Add new token',
'☀️ 早上好': '☀️ Good morning',
'🌙 晚上好': '🌙 Good evening',
'🌙 夜深了': '🌙 It’s late at night',
'🌤️ 下午好': '🌤️ Good afternoon',
'👋 中午好': '👋 Good afternoon',
'${account.username || \'ID:\' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}': '${account.username || \'ID:\' + accountId} Insufficient balance: $${balanceInfo.balance.toFixed(2)}',
'${accountLabel} @ ${siteLabel} 的 Token 无效或已过期${detail}': 'The Token of ${accountLabel} @ ${siteLabel} is invalid or expired ${detail}',
'${activeBrand} ${tr(\'路由\')}': '${activeBrand} ${tr(\'路由\')}',
'${existingSite.name} 已禁用,关联账号已全部置为禁用': '${existingSite.name} is disabled, all associated accounts have been set to disabled',
'${existingSite.name} 已启用,关联禁用账号已恢复为活跃': '${existingSite.name} is enabled, the associated disabled account has been restored to active',
'${initialTask.title} 正在执行': '${initialTask.title} is executing',
'${options.title} 已开始执行': '${options.title} has started execution',
'${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, \'请检查账号配置\')}': '${resolveAccountLabel(item)} Synchronization failed: ${resolveSyncMessage(item, \'请检查账号配置\')}',
'${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, \'不满足同步条件\')}': '${resolveAccountLabel(item)} Skipped: ${resolveSyncMessage(item, \'不满足同步条件\')}',
'${site.name}: 测速失败': '${site.name}: Speed ​​test failed',
'${t(\'跟随系统\')} (${resolvedThemeLabel})': '${t(\'跟随系统\')} (${resolvedThemeLabel})',
'${task.title} 失败': '${task.title} failed',
'${task.title} 失败:${errorText}': '${task.title} failed: ${errorText}',
'${task.title} 已开始执行': '${task.title} has started execution',
'${task.title} 已完成': '${task.title} Completed',
'${task.title}已开始': '${task.title} has started',
'${taskTitle}失败:${currentTask.error || \'unknown error\'}': '${taskTitle} failed: ${currentTask.error || \'unknown error\'}',
'${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}': '${taskTitle} done: healthy ${summary.healthy}, abnormal ${summary.unhealthy}, disabled ${summary.disabled}',
'${taskTitle}已完成': '${taskTitle} Completed',
'${token.name}${token.isDefault ? \'(默认)\' : \'\'}': '${token.name}${token.isDefault ? \'(默认)\' : \'\'}',
'${tr(\'查看\')} ${activeBrand} ${tr(\'品牌的所有模型\')}': '${tr(\'查看\')} ${activeBrand} ${tr(\'品牌的所有模型\')}',
'${tr(\'查看\')} ${activeBrand} ${tr(\'品牌路由\')}': '${tr(\'查看\')} ${activeBrand} ${tr(\'品牌路由\')}',
'${tr(\'当前精确路由\')} ${exactRouteCount} ${tr(\'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。\')}': '${tr(\'当前精确路由\')} ${exactRouteCount} ${tr(\'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。\')}',
'${tr(\'来自供应商\')} ${activeSite} ${tr(\'的模型\')}': '${tr(\'来自供应商\')} ${activeSite} ${tr(\'的模型\')}',
'安全设置已保存': 'Security settings saved',
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Automatically select the optimal channel based on cost, delay, and success rate, and automatically transfer failures',
'按模型过滤': 'Filter by model',
'按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)': 'Randomly by weight (W=${weight}, cost=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)}, same-site channel=${siteChannels}, probability≈${(probability * 100).toFixed(1)}%)',
'保存': 'save',
'保存 Cookie': 'Save Cookie',
'保存 Cookie 失败': 'Failed to save cookie',
'保存安全设置': 'Save security settings',
'保存定时任务': 'Save scheduled tasks',
'保存路由': 'Save route',
'保存路由策略': 'Save routing policy',
'保存失败': 'Save failed',
'保存通道优先级失败,已回滚': 'Failed to save channel priority, rolled back',
'保存通知设置': 'Save notification settings',
'保存修改': 'Save changes',
'保存站点': 'save site',
'保存中...': 'Saving...',
'本次未选出通道': 'No channel selected this time',
'编辑站点': 'Edit site',
'标记失败': 'Marking failed',
'标记已读': 'Mark as read',
'表格视图': 'table view',
'不可用': 'Not available',
'不支持的平台: ${row.sites.platform}': 'Unsupported platforms: ${row.sites.platform}',
'不支持的平台: ${site.platform}': 'Unsupported platforms: ${site.platform}',
'操作': 'operate',
'操作失败': 'Operation failed',
'操作已中止': 'Operation aborted',
'测试通知': 'Test notification',
'测试通知发送失败': 'Test notification failed to send',
'测试通知已发送': 'Test notification sent',
'测试通知已发送(成功 ${result.succeeded}/${result.attempted})': 'Test notification sent (successful ${result.succeeded}/${result.attempted})',
'查看未归类品牌路由': 'View uncategorized brand routes',
'查看详细日志后重试': 'Check the detailed log and try again',
'站点未开启签到': 'Check-in is not enabled on this site',
'无需重试(非故障)': 'No retry needed (not a failure)',
'该站点未提供签到端点,账号会被自动跳过。': 'This site does not expose a check-in endpoint, so the account is skipped automatically.',
'婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}': '婵炴垶铸鎸哥粔颢综合极椤曗偓nan炴劖鎷呴悜榯cum殸濡ょ姷草涢崯鑳亹? ${site.platform}',
'超时': 'time out',
'成本权重': 'cost weight',
'成功': 'success',
'成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})',
'成功率': 'success rate',
'程序日志': 'Program log',
'触发 Cloudflare 验证': 'Trigger Cloudflare verification',
'触发测试通知失败': 'Trigger test notification failed',
'触发签到失败': 'Failed to trigger sign-in',
'创建并同步令牌': 'Create and sync tokens',
'创建令牌失败': 'Failed to create token',
'创建路由失败': 'Failed to create route',
'创建任务失败:${message}': 'Failed to create task: ${message}',
'从自定义请求体构建请求失败。': 'Building request from custom request body failed.',
'存在惩罚': 'There is punishment',
'错误': 'mistake',
'打开': 'Open',
'代理': 'acting',
'代理访问 Token': 'Agent access token',
'代理访问 Token 已更新': 'Agent access token has been updated',
'代理访问 Token 至少 6 位': 'Proxy access token must be at least 6 digits',
'下游访问令牌(PROXY_TOKEN)': 'Downstream Access Token (PROXY_TOKEN)',
'用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。': 'Used by downstream sites or clients to access this service proxy API. The sk- prefix is fixed and cannot be edited; only the suffix is required.',
'请输入 sk- 后的令牌内容': 'Enter the token content after sk-.',
'更新下游访问令牌': 'Update downstream access token',
'下游访问令牌已更新': 'Downstream access token updated',
'下游访问令牌必须以 sk- 开头': 'Downstream access token must start with sk-.',
'下游访问令牌至少 6 位(含 sk-)': 'Downstream access token must be at least 6 characters (including sk-).',
'代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。': 'Proxy mode is enabled: If it still cannot be loaded, please check whether the cookie has expired and save it again.',
'代理全部失败': 'All agents failed',
'代理统计: 总计 ${metrics.proxyTotal} | 成功 ${metrics.proxySuccess} | 失败 ${metrics.proxyFailed} | Tokens ${metrics.proxyTotalTokens.toLocaleString()}': 'Agent Statistics: Total ${metrics.proxyTotal} | Success ${metrics.proxySuccess} | Failure ${metrics.proxyFailed} | Tokens ${metrics.proxyTotalTokens.toLocaleString()}',
'单 Docker 容器,内置 SQLite,无外部依赖': 'Single Docker container, built-in SQLite, no external dependencies',
'当前 JSON 结构无法识别': 'The current JSON structure is not recognized',
'当前模型已被筛选:${inputs.model}': 'The current model has been filtered: ${inputs.model}',
'当前请求已尝试': 'The current request has been attempted',
'当前筛选范围内去重后的唯一账号数': 'The number of unique accounts after deduplication within the current filtering range',
'当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。': 'The current site may prohibit iframe embedding, or OAuth cross-site cookies may be restricted. It is recommended to authorize a new window first and then return to this page to refresh.',
'导出成功': 'Export successful',
'导出类型无效,仅支持 all/accounts/preferences': 'Invalid export type, only all/accounts/preferences supported',
'导出失败': 'Export failed',
'导航': 'navigation',
'导入': 'import',
'导入 / 导出': 'Import/Export',
'导入/导出': 'Import/Export',
'导入会覆盖账号/路由或系统设置,确认继续?': 'Importing will overwrite account/routing or system settings. Are you sure to continue?',
'导入失败': 'Import failed',
'导入数据格式错误:必须为 JSON 对象': 'Import data format error: must be a JSON object',
'导入数据格式错误:缺少 timestamp': 'Import data format error: missing timestamp',
'导入数据格式错误:设置数据结构不正确': 'Import data format error: setting data structure is incorrect',
'导入数据格式错误:需要 JSON 对象': 'Import data format error: JSON object required',
'导入数据格式错误:账号数据结构不正确': 'Import data format error: The account data structure is incorrect',
'导入数据中没有可识别的账号或设置数据': 'There is no identifiable account or settings data in the imported data',
'导入完成': 'Import completed',
'导入完成:${sections.length ? sections.join(\'、\') : \'无有效数据\'}': 'Import completed: ${sections.length ? sections.join(\'、\') : \'无有效数据\'}',
'登录': 'Log in',
'登录并添加': 'Log in and add',
'登录令牌无效': 'Invalid login token',
'登录请求失败': 'Login request failed',
'登录失败': 'Login failed',
'低延迟': 'low latency',
'点击“自动重建”可按当前模型可用性生成路由。': 'Click Auto-Rebuild to generate routes based on current model availability.',
'点击关闭签到,全部签到会忽略此账号': 'Click to close sign-in. All sign-ins will ignore this account.',
'点击开启签到': 'Click to open sign-in',
'调用分布': 'call distribution',
'调用趋势': 'Call trend',
'定时签到 + 余额刷新,不再手动操心': 'Regular check-in + balance refresh, no more manual worries',
'定时任务设置已保存': 'Scheduled task settings have been saved',
'豆包': 'bean bag',
'读取文件失败': 'Failed to read file',
'端到端类型安全': 'End-to-end type safety',
'对话已清除。': 'Conversation cleared.',
'多个用英文逗号分隔': 'Multiple separated by commas',
'多渠道告警': 'Multi-channel alarm',
'多站点聚合': 'multisite aggregation',
'额度(正整数)': 'Amount (positive integer)',
'发件人地址': 'Sender address',
'发送被阻止:无效的自定义请求体。': 'Sending blocked: Invalid custom request body.',
'发送测试通知': 'Send test notification',
'发现未完成的请求快照,点击重试继续。': 'Unfinished request snapshot is found, click Retry to continue.',
'发现未完成的任务,正在重新连接...': 'Unfinished tasks found, reconnecting...',
'访问令牌': 'access token',
'访问令牌失效': 'Access token expired',
'访问令牌失效:${params.detail}': 'Access token expired: ${params.detail}',
'访问令牌已过期': 'Access token has expired',
'费用统计: 支出 $${metrics.todaySpend.toFixed(6)} | 奖励 $${metrics.todayReward.toFixed(6)} | 净值 $${net.toFixed(6)}': 'Cost statistics: Expenses $${metrics.todaySpend.toFixed(6)} | Rewards $${metrics.todayReward.toFixed(6)} | Net worth $${net.toFixed(6)}',
'分组计费': 'Group charging',
'分组加载中...': 'Group loading...',
'风险': 'risk',
'复制': 'copy',
'复制模型名': 'Copy model name',
'复制失败,请手动复制。': 'Copy failed, please copy manually.',
'覆盖槽位': 'Cover slot',
'该令牌不支持当前模型': 'The token is not supported by the current model',
'该通道由用户手动添加,而非系统自动生成': 'This channel is added manually by the user and is not automatically generated by the system.',
'该站点需要 LinuxDo OAuth 授权': 'This site requires LinuxDo OAuth authorization',
'该账号当天签到已完成,重复请求会被站点拒绝或跳过。': 'The sign-in for this account has been completed for the day, and repeated requests will be rejected or skipped by the site.',
'该账号所属站点处于禁用状态,任务会自动跳过。': 'The site to which this account belongs is disabled and the task will be automatically skipped.',
'改令牌': 'Change token',
'概率为 0%': 'The probability is 0%',
'高性能 Node.js 后端框架': 'High performance Node.js backend framework',
'告警冷静期必须是大于等于 0 的数字(秒)': 'The alarm cooling off period must be a number greater than or equal to 0 (seconds)',
'个令牌': 'tokens',
'个模型': 'models',
'个人信息': 'personal information',
'个人信息已保存': 'Personal information has been saved',
'个站点': 'sites',
'个账号': 'accounts',
'跟随系统': 'Follow the system',
'更新代理令牌': 'Update proxy token',
'更新令牌失败': 'Update token failed',
'更新失败': 'Update failed',
'供应商': 'supplier',
'共': 'common',
'共 ${models.length} 个模型': 'Total ${models.length} models',
'关闭': 'closure',
'关于': 'about',
'关于 Metapi': 'About metapi',
'管理端 IP 白名单': 'Management IP whitelist',
'管理员': 'administrator',
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
'管理员登录令牌已更新': 'Admin login token updated',
'管理员令牌': 'Admin token',
'过期': 'Expired',
'核心特色': 'Core features',
'忽略非 JSON 的 SSE 数据块 (event=${parsed.event})。': 'Ignore non-JSON SSE data chunks (event=${parsed.event}).',
'缓存清理后重建路由已完成': 'Rebuilding routes after cache cleaning is complete',
'缓存清理后重建失败:${currentTask.error || \'unknown error\'}': 'Rebuild failed after cache cleanup: ${currentTask.error || \'unknown error\'}',
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
'换一个随机头像': 'Change a random avatar',
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
'会话已过期': 'Session has expired',
'会话已过期,请重新登录': 'The session has expired, please log in again',
'获取模型列表失败。': 'Failed to get model list.',
'基础权重因子': 'Basic weight factor',
'基础信息': 'Basic information',
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centralized management New API / One API / OneHub / DoneHub / Veloera, etc.',
'计算中...': 'Calculating...',
'技术栈': 'technology stack',
'加载程序日志失败': 'Loader log failed',
'加载更多': 'load more',
'加载监控配置失败': 'Failed to load monitoring configuration',
'加载令牌失败': 'Failed to load token',
'加载路由配置失败': 'Failed to load routing configuration',
'加载模型列表失败。': 'Failed to load model list.',
'加载签到记录失败': 'Failed to load check-in records',
'加载日志失败': 'Failed to load log',
'加载设置失败': 'Failed to load settings',
'加载通知设置失败': 'Failed to load notification settings',
'加载仪表盘失败': 'Failed to load dashboard',
'加载元数据中...': 'Loading metadata...',
'加载站点列表失败': 'Failed to load site list',
'加载账号列表失败': 'Failed to load account list',
'加载中...': 'loading...',
'价值分因子': 'value factor',
'监控内嵌': 'Monitor embedded',
'检测到平台: ${result.platform}': 'Platform detected: ${result.platform}',
'健康': 'healthy',
'健康检查失败': 'Health check failed',
'降低频率并稍后重试': 'Reduce frequency and try again later',
'降级': 'Downgrade',
'降序 ↓': 'Descending order ↓',
'阶跃星辰': 'Step stars',
'接口能力': 'Interface capability',
'今日已签到': 'Signed in today',
'今天已经签到': 'Already signed in today',
'近期失败': 'recent failure',
'进行中': 'in progress',
'禁用': 'Disable',
'警告': 'warn',
'旧 Token 验证失败': 'Old Token verification failed',
'就绪': 'ready',
'卡片视图': 'card view',
'开启': 'turn on',
'可选种子值': 'Optional seed value',
'可用': 'Available',
'可用性监控': 'Availability monitoring',
'控制台': 'console',
'跨站模型覆盖、定价对比、延迟与成功率实测数据': 'Cross-site model coverage, pricing comparison, latency and success rate measured data',
'拉取分组失败': 'Failed to pull group',
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
'冷却中': 'Cooling down',
'例如 metapi': 'For example metapi',
'例如: admin@example.com': 'For example: admin@example.com',
'例如: smtp.qq.com': 'For example: smtp.qq.com',
'例如: target@example.com': 'For example: target@example.com',
'例如:小王': 'For example: Xiao Wang',
'两次输入的新 Token 不一致': 'The new Token entered twice is inconsistent',
'零配置嵌入式数据库': 'Zero-configuration embedded database',
'零一万物': 'zero one thing',
'令牌': 'token',
'令牌 ID 无效': 'Token ID is invalid',
'令牌不存在': 'Token does not exist',
'令牌不存在或不属于当前账号': 'The token does not exist or does not belong to the current account',
'令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account',
'令牌不可用': 'Token not available',
'令牌不能为空': 'Token cannot be empty',
'令牌管理': 'Token management',
'令牌失效': 'Token expired',
'令牌数': 'Number of tokens',
'令牌同步成功': 'Token synchronization successful',
'令牌同步任务执行中,请稍后查看程序日志': 'Token sync task is running, please check system logs later.',
'令牌同步失败': 'Token sync failed',
'令牌同步跳过': 'Token sync skipped',
'令牌为空,无法复制': 'Token is empty and cannot be copied',
'令牌已禁用': 'Token is disabled',
'令牌已启用': 'Token is enabled',
'令牌已删除': 'Token deleted',
'另有 ${failedRows.length - 3} 个失败账号,请查看日志': 'There are ${failedRows.length - 3} failed accounts, please check the logs',
'另有 ${skippedRows.length - 3} 个跳过账号,请查看日志': 'There are ${skippedRows.length - 3} skipped accounts, please check the logs',
'浏览器先人工签到一次': 'Browser manually sign in once',
'流式': 'streaming',
'流式传输被用户中止。': 'Streaming was aborted by user.',
'流式传输失败:${message}': 'Streaming failed: ${message}',
'流式传输未收到 [DONE] 信号,已在本地完成。': 'Streaming did not receive [DONE] signal and was completed locally.',
'流式传输已成功完成。': 'Streaming completed successfully.',
'流式传输异常中断:${streamError?.message || \'AbortError\'}': 'Streaming aborted abnormally: ${streamError?.message || \'AbortError\'}',
'流式连接中断,请重试。': 'Streaming connection interrupted, please try again.',
'流式请求失败': 'Streaming request failed',
'流式响应体为空': 'Streaming response body is empty',
'路由': 'routing',
'路由不存在': 'Routes',
'路由权重': 'Route weight',
'路由权重已保存': 'Route weight saved',
'路由选择概率已刷新': 'Routes Refresh',
'路由已创建': 'Route created',
'路由已删除': 'Route deleted',
'路由重建任务执行中,请稍后查看程序日志': 'Route rebuild task is running, please check system logs later.',
'轮询 ${pendingJobId} 失败一次:${message}': 'Polling ${pendingJobId} failed once: ${message}',
'没有可复制的文本内容。': 'There is no text content to copy.',
'没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)': 'No channels available (all disabled, site unavailable, cooldown or token unavailable)',
'没有匹配的路由': 'No matching route',
'没有找到匹配结果': 'No matching results found',
'每日总结 ${metrics.localDay}': 'Daily summary ${metrics.localDay}',
'每页条数': 'Number of items per page',
'密码': 'password',
'名称': 'name',
'命中路由:${match.route.modelPattern}': 'Hit route: ${match.route.modelPattern}',
'模糊搜索模型名称': 'Fuzzy search model name',
'模型': 'Model',
'模型=${params.model}, 原因=${params.reason}': 'model=${params.model}, reason=${params.reason}',
'模型操练场': 'Model training ground',
'模型测试': 'Model testing',
'模型广场': 'model square',
'模型广场刷新进行中': 'Model square refresh in progress',
'模型广场刷新失败:${currentTask.error || \'unknown error\'}': 'Model square refresh failed: ${currentTask.error || \'unknown error\'}',
'模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'The model square refresh is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
'模型广场刷新已完成': 'Model square refresh has been completed',
'模型路由': 'model routing',
'模型名称': 'Model name',
'模型匹配(如 gpt-4o、claude-*)': 'Model matching (such as gpt-4o, claude-*)',
'模型已更新': 'Model updated',
'模型映射 JSON(可选)': 'Model mapping JSON (optional)',
'默认': 'default',
'默认令牌': 'default token',
'默认令牌已更新': 'Default token updated',
'目录': 'Table of contents',
'您好,这是一条来自系统设置的连通性测试通知,您的通知相关配置目前工作正常!': 'Hello, this is a connectivity test notification from system settings. Your notification-related configuration is currently working normally!',
'排行榜': 'Ranking list',
'排序保存中': 'Sorting and saving',
'排序方式': 'sort by',
'排序字段': 'sort field',
'配置': 'Configuration',
'匹配 ${filteredModels.length} / ${models.length}': 'Matches ${filteredModels.length} / ${models.length}',
'频率惩罚': 'frequency penalty',
'品牌': 'brand',
'平均延迟': 'average delay',
'平台类型(可自动检测)': 'Platform type (can be automatically detected)',
'其他': 'other',
'其他未归类的模型': 'Other uncategorized models',
'启用': 'enable',
'启用站点后再试': 'Try again after enabling the site',
'签到': 'Sign in',
'签到 Cron': 'Sign in Cron',
'签到 Cron 表达式无效': 'Check-in Cron expression is invalid',
'签到记录': 'Sign-in record',
'签到任务执行中,请稍后查看签到日志': 'Check-in task is running, please check check-in logs later.',
'签到统计: 总计 ${metrics.checkinTotal} | 成功 ${metrics.checkinSuccess} | 跳过 ${metrics.checkinSkipped} | 失败 ${metrics.checkinFailed}': 'Sign-in statistics: Total ${metrics.checkinTotal} | Success ${metrics.checkinSuccess} | Skip ${metrics.checkinSkipped} | Failure ${metrics.checkinFailed}',
'签到完成': 'Sign in completed',
'签到已执行': 'Sign-in has been executed',
'签到中...': 'Checking in...',
'浅色': 'light color',
'浅色模式': 'light mode',
'切换到中文': 'Switch to Chinese',
'切换排序方向': 'Switch sort direction',
'切换签到状态失败': 'Failed to switch sign-in status',
'切换站点状态失败': 'Failed to switch site status',
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
'轻量部署': 'Lightweight deployment',
'清除缓存并重建路由': 'Clear cache and rebuild routes',
'清除占用与使用日志': 'Clear occupancy and usage logs',
'清空': 'Clear',
'清空 Cookie': 'Clear Cookies',
'清空日志': 'Clear log',
'清空失败': 'Clearing failed',
'清理缓存并重建路由': 'Clear cache and rebuild routes',
'清理缓存失败': 'Failed to clear cache',
'清理占用失败': 'Failed to clear occupation',
'请调整品牌筛选、搜索词或排序条件。': 'Please adjust your brand filters, search terms, or sorting criteria.',
'请求超时': 'Request timeout',
'请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)': 'Request timeout (${Math.max(1, Math.round(timeoutMs / 1000))}s)',
'请求触发了防护挑战,建议稍后再试或更换稳定站点。': 'The request triggered a protection challenge. It is recommended to try again later or change the stable site.',
'请求失败': 'Request failed',
'请求体必须是对象': 'The request body must be an object',
'请求在超时时间内未完成,可能是网络波动或站点响应慢。': 'The request was not completed within the timeout period, possibly due to network fluctuations or slow site response.',
'请输入管理员令牌后继续。': 'Please enter your administrator token to continue.',
'请输入新的代理访问 Token': 'Please enter new proxy access token',
'请填写所有字段': 'Please fill in all fields',
'请填写站点名称和 URL': 'Please fill in the site name and URL',
'请先检查站点与账号状态,然后点击刷新。': 'Please check the site and account status first, then click Refresh.',
'请先输入 URL': 'Please enter URL first',
'请先选择或粘贴 JSON 备份内容': 'Please select or paste the JSON backup content first',
'请选择 JSON 格式的备份文件': 'Please select a backup file in JSON format',
'请选择模型': 'Please select a model',
'请选择下游协议': 'Please select a downstream protocol',
'取消': 'Cancel',
'去重账号': 'Remove duplicate accounts',
'全部': 'all',
'全部 (${logs.length})': 'All (${logs.length})',
'全部备份': 'All backup',
'全部测速完成': 'All speed tests completed',
'全部类型': 'All types',
'全部品牌': 'All brands',
'全部签到': 'Sign in all',
'全部同步失败': 'All synchronization failed',
'全部同步失败:${resolveSyncMessage(res, \'请稍后重试\')}': 'All synchronization failed: ${resolveSyncMessage(res, \'请稍后重试\')}',
'全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}': 'All synchronization completed: success ${successRows.length}, skip ${skippedRows.length}, failure ${failedRows.length}',
'全部同步已跳过:${resolveSyncMessage(res, \'没有可同步的账号\')}': 'All syncs skipped: ${resolveSyncMessage(res, \'没有可同步的账号\')}',
'全部已读': 'All read',
'全部账号令牌同步任务已完成': 'All account token synchronization tasks have been completed',
'全部账号令牌同步失败:${currentTask.error || \'unknown error\'}': 'All account token synchronization failed: ${currentTask.error || \'unknown error\'}',
'全部账号令牌同步完成:成功 ${summary.synced},跳过 ${summary.skipped},失败 ${summary.failed}': 'All account tokens are synchronized: successful ${summary.synced}, skipped ${summary.skipped}, failed ${summary.failed}',
'全部账号签到': 'Sign in for all accounts',
'全部账号签到任务失败:${currentTask.error || \'unknown error\'}': 'All account sign-in tasks failed: ${currentTask.error || \'unknown error\'}',
'全部账号签到任务已完成': 'All account sign-in tasks have been completed',
'全部账号签到失败': 'Sign-in failed for all accounts',
'全部账号签到完成:成功 ${summary.success},跳过 ${summary.skipped},失败 ${summary.failed}': 'All account sign-ins completed: successful ${summary.success}, skipped ${summary.skipped}, failed ${summary.failed}',
'全部账号签到已完成': 'Sign-in for all accounts has been completed',
'全部账号签到已完成(成功${summary.success}/跳过${summary.skipped}/失败${summary.failed})': 'Sign-in for all accounts has been completed (successful${summary.success}/skip${summary.skipped}/failure${summary.failed})',
'全部账号同步完成': 'All accounts have been synchronized',
'确认清理模型缓存并重建路由?': 'Are you sure you want to clear the model cache and rebuild the route?',
'确认清理占用统计与使用日志?': 'Are you sure you want to clear the usage statistics and usage logs?',
'确认修改': 'Confirm changes',
'任务 ${pendingJobId} 失败:${message}': 'Task ${pendingJobId} failed: ${message}',
'任务 ${pendingJobId} 已成功。': 'Task ${pendingJobId} was successful.',
'任务 ${pendingJobId} 已取消。': 'Task ${pendingJobId} has been cancelled.',
'任务模式': 'mission mode',
'任务已成功完成。': 'Mission completed successfully.',
'任务状态已更新': 'Task status updated',
'日期: ${metrics.localDay}': 'Date: ${metrics.localDay}',
'日志已清空': 'The log has been cleared',
'筛选': 'filter',
'删除': 'delete',
'删除路由': 'Delete route',
'删除路由失败': 'Failed to delete route',
'删除失败': 'Delete failed',
'上游未提供模型说明。': 'No model description is provided upstream.',
'上游新增模型自动出现在模型列表,零配置路由生成': 'Newly added upstream models automatically appear in the model list, and zero-configuration routes are generated.',
'上游站点错误': 'Upstream site error',
'尚未获取运行健康信息': 'Running health information has not been obtained yet',
'尚未检测': 'Not tested yet',
'稍后重试': 'Try again later',
'稍后重试并检查网络': 'Try again later and check the network',
'稍后重试或联系站点方': 'Try again later or contact the site',
'设默认': 'Set default',
'设置': 'set up',
'深色': 'Dark',
'深色模式': 'dark mode',
'升序 ↑': 'Ascending order ↑',
'生成时间: ${metrics.generatedAtLocal} (${metrics.timeZone})': 'Generation time: ${metrics.generatedAtLocal} (${metrics.timeZone})',
'生成已被用户停止。': 'The build has been stopped by the user.',
'生成已取消。': 'The build was canceled.',
'生成已停止。': 'The build has stopped.',
'生成中...': 'Generating...',
'失败': 'fail',
'失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})',
'失败避让': 'failure avoidance',
'实测': 'Actual measurement',
'实时决策': 'Real-time decision-making',
'使用频次权重': 'Use frequency weight',
'使用日志': 'Usage log',
'收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.',
'收件人地址': 'Recipient address',
'收起': 'close',
'收起侧边栏': 'Collapse sidebar',
'收起高级参数': 'Close advanced parameters',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
'手动增改路由': 'Manually add and modify routes',
'输入当前 Token': 'Enter the current Token',
'输入提示词...(回车发送,Shift+回车换行)': 'Enter the prompt word...(Press Enter to send, Shift+Enter to line feed)',
'输入新 Token (至少 6 位)': 'Enter new Token (at least 6 digits)',
'输入新的 Server酱 Key(留空则不改)': 'Enter the new Server Key (leave it blank and do not change it)',
'输入新的代理访问令牌': 'Enter new proxy access token',
'输入以更改密码...': 'Enter to change password...',
'数据与隐私': 'Data and privacy',
'刷新': 'refresh',
'刷新路由选择概率失败': 'Failed to refresh routing probability',
'刷新模型并重建路由': 'Refresh the model and rebuild the route',
'刷新模型并重建路由失败:${currentTask.error || \'unknown error\'}': 'Refreshing model and rebuilding routes failed: ${currentTask.error || \'unknown error\'}',
'刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Refreshing the model and rebuilding the route is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
'刷新模型并重建路由已完成': 'Refreshing the model and rebuilding the routes is complete',
'刷新模型广场数据': 'Refresh model square data',
'刷新全部账号运行健康状态': 'Refresh the running health status of all accounts',
'刷新选中概率': 'Refresh selection probability',
'刷新账号运行健康状态 #${accountId}': 'Refresh account health status #${accountId}',
'刷新账号状态失败': 'Failed to refresh account status',
'刷新账户状态': 'Refresh account status',
'刷新状态中...': 'Refreshing status...',
'思考中...': 'Thinking...',
'松开以导入文件': 'Release to import file',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
'搜索模型(支持名称片段)': 'Search model (supports name fragments)',
'搜索模型路由...': 'Search model routes...',
'搜索模型名称...': 'Search model name...',
'搜索站点、账号、模型、日志...': 'Search sites, accounts, models, logs...',
'随机种子': 'random seed',
'所有模型 accountCount 累计值,同一账号在多个模型中会重复计数': 'Cumulative value of accountCount in all models. The same account will be counted repeatedly in multiple models.',
'腾讯混元': 'Tencent Hunyuan',
'添加失败': 'Add failed',
'添加通道失败': 'Failed to add channel',
'添加站点': 'Add site',
'添加账号': 'Add account',
'条路由': 'route',
'跳过': 'jump over',
'跳过 (${countBy(\'skipped\')})': 'Skip (${countBy(\'skipped\')})',
'通道': 'aisle',
'通道不存在': 'Channel does not exist',
'通道不存在: ${missingId}': 'Channel does not exist: ${missingId}',
'通道禁用': 'channel disabled',
'通道令牌已更新': 'Channel token updated',
'通道数量': 'Number of channels',
'通道已添加': 'Channel added',
'通道已移除': 'Channel removed',
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is only shown in exact model routing.',
'通义千问': 'Tongyi Qianwen',
'通知': 'notify',
'通知发送失败:${failedResults[0].error}': 'Notification sending failed: ${failedResults[0].error}',
'通知设置': 'Notification settings',
'通知设置已保存': 'Notification settings saved',
'同步令牌失败': 'Sync token failed',
'同步全部账号': 'Sync all accounts',
'同步全部账号令牌': 'Synchronize all account tokens',
'同步全部账号令牌失败': 'Failed to synchronize all account tokens',
'同步全部账号令牌已完成': 'Synchronization of all account tokens has been completed',
'同步全部账号令牌已完成(成功${summary.synced}/跳过${summary.skipped}/失败${summary.failed})': 'Synchronization of all account tokens has been completed (successful${summary.synced}/skip${summary.skipped}/failure${summary.failed})',
'同步失败': 'Sync failed',
'同步失败:${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}': 'Sync failed: ${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}',
'同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}': 'Synchronization completed: new ${res.created || 0}, updated ${res.updated || 0}',
'同步已跳过:${resolveSyncMessage(res, \'账号缺少可用会话令牌\')}': 'Sync skipped: ${resolveSyncMessage(res, \'账号缺少可用会话令牌\')}',
'同步站点令牌': 'Sync site token',
'同步站点令牌失败': 'Failed to sync site token',
'统一代理网关': 'Unified proxy gateway',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear random) · Style:',
'推理过程': 'reasoning process',
'退出登录': 'Log out',
'拖拽 JSON 备份文件到此处': 'Drag and drop the JSON backup file here',
'拖拽调整优先级': 'Drag and drop to adjust priority',
'唯一可用候选': 'The only available candidate',
'未登录': 'Not logged in',
'未登录且未提供 access token': 'Not logged in and no access token provided',
'未关联站点': 'Not associated site',
'未命名': 'Unnamed',
'未匹配到启用的路由': 'No enabled routes were matched',
'未启用任何通知渠道,请先开启并保存至少一种通知方式': 'No notification channels are enabled. Please enable and save at least one notification method first.',
'未设置': 'not set',
'未提供': 'Not provided',
'未选择': 'Not selected',
'未找到': 'not found',
'未找到可重试的用户消息。': 'No user message found to retry.',
'未找到匹配模型': 'No matching model found',
'未知': 'unknown',
'未知错误': 'unknown error',
'未知轮询错误': 'Unknown polling error',
'未知站点': 'unknown site',
'未知账号': 'Unknown account',
'温度': 'temperature',
'文心一言': 'Wenxinyiyan',
'无法连接到服务器': 'Unable to connect to server',
'无法识别平台类型': 'Unable to identify platform type',
'无价模型默认单价必须是大于 0 的数字': 'The default unit price of priceless models must be a number greater than 0',
'无可用通道': 'No channel available',
'无效': 'invalid',
'无需操作': 'No action required',
'无需重复执行': 'No need to repeat',
'系统': 'system',
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Exact model routing will automatically filter accounts and tokens that only support that model. P0 has the highest priority, and the larger the number, the lower the priority. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
'系统设置': 'System settings',
'显示调试': 'show debugging',
'项目链接': 'Project link',
'消耗分布': 'Consumption distribution',
'消耗趋势': 'consumption trend',
'校验': 'check',
'新 Token 至少 6 个字符': 'New Token must be at least 6 characters long',
'新增 ${result.created},更新 ${result.updated},总数 ${result.total}': 'New ${result.created}, updated ${result.updated}, total ${result.total}',
'信息': 'information',
'需要人工验证': 'Requires manual verification',
'选择分组': 'Select group',
'选择令牌(可选)': 'Select token (optional)',
'选择站点': 'Select site',
'选择账号': 'Select account',
'选择账号后同步站点令牌': 'Synchronize site token after selecting account',
'讯飞星火': 'iFlytek Spark',
'延迟': 'Delay',
'验证': 'verify',
'验证 Token': 'Verify Token',
'验证成功: ${result.userInfo?.username || \'未知用户\'}': 'Verification successful: ${result.userInfo?.username || \'未知用户\'}',
'验证失败': 'Authentication failed',
'验证中...': 'Verifying...',
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one entry, compatible with OpenAI/Claude downstream formats',
'仪表盘': 'Dashboard',
'移除通道失败': 'Failed to remove channel',
'已标记全部为已读': 'Marked all as read',
'已触发全部签到': 'All sign-ins have been triggered',
'已创建任务 ${created.jobId}。': 'Task ${created.jobId} has been created.',
'已复制消息 ${message.id}。': 'Message ${message.id} copied.',
'已更新:${changedLabels.join(\'、\')}': 'Updated: ${changedLabels.join(\'、\')}',
'已关闭签到(全部签到会忽略此账号)': 'Sign-in is closed (all sign-ins will ignore this account)',
'已将消息同步到自定义请求体。': 'The message has been synchronized to the custom request body.',
'已将自定义请求体同步到消息。': 'The custom request body has been synchronized to the message.',
'已禁用': 'Disabled',
'已经签到': 'Already signed in',
'已开启签到': 'Sign-in is enabled',
'已开始': 'Started',
'已开始流式请求。': 'Streaming request started.',
'已开始路由重建,请稍后查看程序日志': 'Route rebuild has started, please check system logs later.',
'已开始签到,请稍后查看签到记录': 'Sign-in has started, please check the sign-in record later',
'已开始全部签到,请稍后查看签到日志': 'All sign-ins have started, please check the sign-in log later',
'已开始全部账号令牌同步,请稍后查看程序日志': 'All-account token sync has started, please check system logs later.',
'已开始刷新模型广场': 'Started to refresh the model square',
'已开始刷新账号运行健康状态,请稍后查看账号列表': 'Refreshing account health status has started. Please check the account list later.',
'已开始同步令牌,请稍后查看日志': 'Token synchronization has started, please check the log later',
'已开始重建路由,请稍后查看日志': 'Route reconstruction has started, please check the log later',
'已启用 Cookie 代理模式': 'Cookie proxy mode enabled',
'已清理使用日志 ${deletedProxyLogs} 条,并重置路由与账号占用统计': '${deletedProxyLogs} usage logs have been cleared, and routing and account occupancy statistics have been reset',
'已删除': 'Deleted',
'已删除消息 ${target.id}。': 'Message ${target.id} has been deleted.',
'已添加为 API Key 账号(可用于代理转发)': 'Added as API Key account (can be used for proxy forwarding)',
'已完成': 'Completed',
'已在站点创建并同步令牌': 'The token has been created and synchronized on the site',
'已找到 (': 'Found (',
'异常': 'abnormal',
'隐藏调试': 'Hide debugging',
'隐藏手动模式': 'Hide manual mode',
'用户 ID(数字)': 'User ID (numeric)',
'用户界面库': 'user interface library',
'用户名': 'username',
'用户名不能为空': 'Username cannot be empty',
'用户名已自动识别': 'Username has been automatically recognized',
'用户名最多 24 个字符': 'Username maximum 24 characters',
'优先级 P${priority}:可用 ${rawLayer.length}': 'Priority P${priority}: Available ${rawLayer.length}',
'优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}': 'Priority P${priority}: Available ${rawLayer.length}, avoid ${avoided.length} due to recent failure',
'有限额度令牌必须填写 remainQuota': 'Limited quota token must fill in remainQuota',
'有限额度令牌请填写正整数额度': 'For limited tokens, please fill in the positive integer amount',
'右上角头像实时预览': 'Real-time preview of the avatar in the upper right corner',
'余额': 'Balance',
'余额不足': 'Insufficient balance',
'余额不足提醒': 'Insufficient balance reminder',
'余额权重': 'Balance weight',
'余额刷新 Cron': 'Balance Refresh Cron',
'余额刷新 Cron 表达式无效': 'Balance refresh cron expression is invalid',
'余额已刷新': 'Balance has been refreshed',
'原子化样式框架': 'Atomized style framework',
'运行时设置已更新': 'Runtime settings updated',
'运行所有签到': 'Run all check-ins',
'运行状态波动': 'Operating status fluctuations',
'运行状态正常': 'Running status is normal',
'再次输入新 Token': 'Enter the new Token again',
'在新窗口直接打开目标站点': 'Open the target site directly in a new window',
'暂未识别到明确错误类型,可根据原始信息进一步排查。': 'No clear error type has been identified yet, and further investigation can be based on the original information.',
'暂无标签': 'No tags yet',
'暂无价格元数据': 'No price metadata yet',
'暂无路由': 'No route yet',
'暂无模型': 'No model yet',
'暂无模型数据': 'No model data yet',
'暂无使用日志': 'No usage log yet',
'暂无通知': 'No notification yet',
'粘贴 JSON 数据或通过上面的拖拽区域导入...': 'Paste JSON data or import via the drag area above...',
'粘贴 ld_auth_session 或 ld_auth_session=xxx': 'Paste ld_auth_session or ld_auth_session=xxx',
'粘贴 Session Token 或 API Key&#10;(系统会自动识别 Token 类型)': 'Paste Session Token or API Key (The system will automatically identify the Token type)',
'展开': 'Expand',
'展开高级参数': 'Expand advanced parameters',
'占用统计已清理': 'Occupancy statistics cleared',
'占用统计已清理(日志 ${res.deletedProxyLogs || 0} 条)': 'Occupancy statistics have been cleared (log ${res.deletedProxyLogs || 0} entries)',
'占用统计与使用日志已清理': 'Occupancy statistics and usage logs have been cleared',
'站点': 'site',
'站点 "${payload.name}" 已更新': 'Site "${payload.name}" has been updated',
'站点 "${payload.name}" 已添加': 'Site "${payload.name}" added',
'站点 "${site.name}" 已禁用': 'Site "${site.name}" is disabled',
'站点 "${site.name}" 已启用': 'Site "${site.name}" is enabled',
'站点 "${site.name}" 已删除': 'Site "${site.name}" has been deleted',
'站点 URL (例如 https://api.example.com)': 'Site URL (e.g. https://api.example.com)',
'站点创建令牌失败': 'Site creation token failed',
'站点返回服务端错误,通常需要站点恢复后才可成功。': 'The site returns a server-side error, which usually requires site recovery before success.',
'站点管理': 'Site management',
'站点开启了 Turnstile 人机验证,自动签到无法直接通过。': 'The site has Turnstile human-machine verification turned on, and automatic sign-in cannot pass directly.',
'站点名称': 'Site name',
'站点删除令牌失败,本地未删除': 'Site deletion token failed, not deleted locally',
'站点隧道不可用': 'Site tunnel is not available',
'站点未返回可用令牌': 'Site did not return available tokens',
'站点已禁用': 'site disabled',
'站点已禁用,无法创建令牌': 'Site is disabled, token cannot be created',
'站点已启用': 'Site is enabled',
'站点状态=${row.site.status || \'disabled\'}': 'site status=${row.site.status || \'disabled\'}',
'账号': 'account',
'账号 "${loginForm.username}" 已添加,API Key 已自动获取': 'The account "${loginForm.username}" has been added and the API Key has been automatically obtained.',
'账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)': 'Account "${loginForm.username}" has been added (API Key not found, please set it manually)',
'账号 ID 无效': 'Invalid account ID',
'账号不存在': 'Account does not exist',
'账号访问令牌可能过期或无效,需更新认证信息。': 'The account access token may be expired or invalid, and the authentication information needs to be updated.',
'账号管理': 'Account management',
'账号或站点已禁用': 'Account or site has been disabled',
'账号路由': 'Account routing',
'账号缺少访问令牌,无法创建站点令牌': 'The account is missing an access token and cannot create a site token.',
'账号缺少访问令牌,无法拉取分组': 'The account lacks an access token and cannot pull the group.',
'账号数': 'Number of accounts',
'账号已添加${extra}': 'Account has been added ${extra}',
'账号与路由': 'Account and routing',
'账号运行健康状态刷新进行中,请稍后查看账号列表': 'The account health status is being refreshed. Please check the account list later.',
'账号状态=${row.account.status}': 'Account status=${row.account.status}',
'账号状态刷新任务已提交,完成后会自动更新。': 'The account status refresh task has been submitted and will be automatically updated after completion.',
'账号状态已刷新': 'Account status has been refreshed',
'正在加载价格元数据...': 'Loading price metadata...',
'正在加载模型元数据...': 'Loading model metadata...',
'正在重试待处理的请求。': 'Pending request is being retried.',
'执行成功': 'Executed successfully',
'智能路由引擎': 'Intelligent routing engine',
'智谱 AI': 'Wisdom spectrum AI',
'中': 'ZH',
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'A transit station of transit stations — Aggregate your New API / One API / OneHub and other AI transit stations registered everywhere into a unified gateway. One API Key, one entrance, automatic model discovery, intelligent routing, and cost optimization.',
'重建路由失败': 'Failed to rebuild route',
'重新登录或同步新令牌': 'Log back in or sync new token',
'重新加载当前站点': 'Reload current site',
'重置': 'reset',
'转为系统': 'Convert to system',
'转为助手': 'Turn into assistant',
'状态': 'state',
'自定义模式下输入可选。回车发送提示词并同步到自定义请求体。': 'Input is optional in custom mode. Press Enter to send the prompt word and synchronize it to the custom request body.',
'自定义请求': 'Custom request',
'自定义请求体必须是有效的 JSON 且包含非空消息。': 'The custom request body must be valid JSON and contain a non-empty message.',
'自定义请求体无效或不包含消息。': 'The custom request body is invalid or does not contain a message.',
'自定义请求体中的 JSON 无效': 'Invalid JSON in custom request body',
'自定义请求体中没有有效的消息。': 'There is no valid message in the custom request body.',
'自动': 'automatic',
'自动检测': 'Automatic detection',
'自动检测失败': 'Automatic detection failed',
'自动模型发现': 'Automatic model discovery',
'自动签到': 'Automatic check-in',
'自动重建': 'automatic rebuild',
'自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)',
'最大 Token 数': 'Maximum number of tokens',
'最近健康检查失败': 'Recent health check failed',
'最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)',
'最近一次检查失败': 'The latest check failed',
'最终选择:${selectedLabel}(P${selectedPriority})': 'Final selection: ${selectedLabel} (P${selectedPriority})',
'API Key 已自动获取': 'API Key has been obtained automatically',
'API Key(可选)': 'API Key (optional)',
'Bark 地址': 'address',
'Bark 开关': 'Bark switch',
'Bark 响应状态 ${response.status}': 'Bark response status ${response.status}',
'Cloudflare Tunnel 当前不可达,通常是站点侧网络或隧道进程问题。': 'The Cloudflare Tunnel is currently unreachable, usually due to a site-side network or tunnel process issue.',
'Cookie 格式无效,请填写 ld_auth_session 或其值': 'The cookie format is invalid, please fill in ld_auth_session or its value',
'expiredTime 参数无效': 'expiredTime parameter is invalid',
'https://api.day.app/your_key (可选)': 'https://api.day.app/your_key (optional)',
'https://your-webhook-url (可选)': 'https://your-webhook-url (optional)',
'JSON 解析错误:${formatError?.message || \'无效的 JSON\'}': 'JSON parsing error: ${formatError?.message || \'无效的 JSON\'}',
'LDOH 监控面板': 'LDOH monitoring panel',
'LDOH Cookie 已保存': 'LDOH Cookie Saved',
'LDOH Cookie 已更新': 'LDOH Cookie updated',
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
'metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'metapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
'model 不能为空': 'model cannot be empty',
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
'models 必须是非空数组': 'models must be a non-empty array',
'models 中没有有效模型名称': 'No valid model name in models',
'remainQuota 必须是正整数': 'remainQuota must be a positive integer',
'Server 酱开关': 'Server sauce switch',
'Server 酱密钥': 'Server sauce key',
'Server酱响应状态 ${response.status}': 'Server sauce response status ${response.status}',
'SMTP 安全连接': 'SMTP secure connection',
'SMTP 端口无效': 'Invalid SMTP port',
'SMTP 开关': 'SMTP switch',
'SMTP 密码': 'SMTP password',
'SMTP 用户': 'SMTP user',
'SMTP 用户名': 'SMTP username',
'SMTP 主机': 'SMTP host',
'Token 无效': 'Token is invalid',
'Token 无效或已过期': 'Token is invalid or expired',
'Token 已更新': 'Token has been updated',
'Token 已更新,请使用新 Token 重新登录': 'Token has been updated, please use the new Token to log in again',
'Token 已失效': 'Token has expired',
'turnstile token 为空': 'turnstile token is empty',
'unlimitedQuota 参数无效': 'unlimitedQuota parameter is invalid',
'updates 必须是非空数组': 'updates must be a non-empty array',
'updates[${index}] 必须是对象': 'updates[${index}] must be an object',
'updates[${index}].id 必须大于 0': 'updates[${index}].id must be greater than 0',
'updates[${index}].id 必须是有限数字': 'updates[${index}].id must be a finite number',
'updates[${index}].priority 必须是有限数字': 'updates[${index}].priority must be a finite number',
'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒': 'Webhook / Bark / Server sauce / email, timely reminder of insufficient balance',
'Webhook 地址': 'Webhook address',
'Webhook 开关': 'Webhook switch',
'Webhook 响应状态 ${response.status}': 'Webhook response status ${response.status}',
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { translateText } from './i18n.js';
describe('translateText', () => {
it('keeps zh text unchanged in zh mode', () => {
expect(translateText('模型广场', 'zh')).toBe('模型广场');
});
it('translates exact key in en mode', () => {
expect(translateText('模型广场', 'en')).toBe('Model Marketplace');
});
it('supports phrase replacement for mixed text', () => {
expect(translateText('覆盖槽位 3', 'en')).toBe('Coverage Slots 3');
expect(translateText('共 12 个模型', 'en')).toBe('Total 12 models');
});
it('never returns Chinese characters in strict en mode', () => {
const samples = [
'站点已禁用',
'缓存清理后重建失败:unknown error',
'签到任务执行中,请稍后查看签到日志',
];
for (const sample of samples) {
expect(translateText(sample, 'en')).not.toMatch(/[\u3400-\u9fff]/);
}
});
it('uses concrete english translations instead of fallback for common runtime text', () => {
expect(translateText('切换到中文', 'en')).toBe('Switch to Chinese');
expect(translateText('中', 'en')).toBe('ZH');
const samples = [
'站点已禁用',
'签到任务执行中,请稍后查看签到日志',
'下游访问令牌至少 6 位(含 sk-)',
'路由重建任务执行中,请稍后查看程序日志',
];
for (const sample of samples) {
const translated = translateText(sample, 'en');
expect(translated).not.toBe('Untranslated');
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
}
});
});
+503
View File
@@ -0,0 +1,503 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { zhToEnSupplemental } from './i18n.supplement.js';
export type Language = 'zh' | 'en';
const LANGUAGE_STORAGE_KEY = 'app_language';
const zhToEn: Record<string, string> = {
'管理员': 'Admin',
'登录令牌无效': 'Invalid admin token',
'无法连接到服务器': 'Unable to connect to server',
'请输入管理员令牌后继续。': 'Enter admin token to continue.',
'管理员令牌': 'Admin Token',
'验证中...': 'Verifying...',
'登录': 'Sign In',
'用户名不能为空': 'Username cannot be empty',
'用户名最多 24 个字符': 'Username can be at most 24 characters',
'个人信息': 'Profile',
'右上角头像实时预览': 'Top-right avatar live preview',
'用户名': 'Username',
'例如:小王': 'e.g. Alex',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
'换一个随机头像': 'Randomize Avatar',
'取消': 'Cancel',
'保存': 'Save',
'关闭': 'Close',
'打开': 'Open',
'导航': 'Navigate',
'重置': 'Reset',
'全部': 'All',
'清空': 'Clear',
'全部已读': 'Mark All Read',
'控制台': 'Console',
'仪表盘': 'Dashboard',
'站点': 'Sites',
'账号': 'Accounts',
'未关联站点': 'Unlinked Site',
'余额': 'Balance',
'令牌管理': 'Token Management',
'签到记录': 'Check-in Logs',
'全部签到': 'Check In All',
'签到中...': 'Checking In...',
'刷新状态中...': 'Refreshing...',
'刷新账户状态': 'Refresh Account Status',
'+ 添加账号': '+ Add Account',
'路由': 'Routes',
'模型路由': 'Model Routes',
'使用日志': 'Usage Logs',
'暂无使用日志': 'No Usage Logs',
'可用性监控': 'Availability Monitor',
'系统': 'System',
'设置': 'Settings',
'程序日志': 'System Logs',
'导入/导出': 'Import/Export',
'通知设置': 'Notification Settings',
'暂无通知': 'No Notifications',
'模型广场': 'Model Marketplace',
'模型广场刷新进行中': 'Marketplace refresh in progress',
'已开始刷新模型广场': 'Started refreshing marketplace',
'没有找到匹配结果': 'No matching results',
'搜索站点、账号、模型、日志...': 'Search sites, accounts, models, logs...',
'模型操练场': 'Model Playground',
'模型测试': 'Model Testing',
'关于': 'About',
'关于 Metapi': 'About Metapi',
'任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again',
'个人信息已保存': 'Profile saved',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
'通知': 'Notifications',
'浅色': 'Light',
'深色': 'Dark',
'跟随系统': 'Follow System',
'浅色模式': 'Light Mode',
'深色模式': 'Dark Mode',
'退出登录': 'Sign Out',
'收起侧边栏': 'Collapse Sidebar',
'系统设置': 'System Settings',
'站点管理': 'Site Management',
'账号管理': 'Account Management',
'导入 / 导出': 'Import / Export',
'监控内嵌': 'Embedded Monitor',
'品牌': 'Brands',
'全部品牌': 'All Brands',
'其他': 'Other',
'供应商': 'Providers',
'排序方式': 'Sort By',
'账号数': 'Accounts',
'令牌数': 'Tokens',
'延迟': 'Latency',
'成功率': 'Success Rate',
'名称': 'Name',
'收起': 'Collapse',
'筛选': 'Filter',
'加载元数据中...': 'Loading metadata...',
'卡片视图': 'Card View',
'表格视图': 'Table View',
'模糊搜索模型名称': 'Fuzzy Search Model Name',
'覆盖槽位': 'Coverage Slots',
'去重账号': 'Unique Accounts',
'平均延迟': 'Avg Latency',
'共': 'Total',
'个模型': 'models',
'个账号': 'accounts',
'个令牌': 'tokens',
'个站点': 'sites',
'令牌': 'Token',
'复制': 'Copy',
'复制模型名': 'Copy Model Name',
'展开': 'Expand',
'健康': 'Healthy',
'风险': 'Risk',
'低延迟': 'Low Latency',
'基础信息': 'Basic Info',
'接口能力': 'Endpoint Capabilities',
'分组计费': 'Group Pricing',
'暂无标签': 'No Tags',
'未提供': 'Not Provided',
'暂无价格元数据': 'No Pricing Metadata',
'正在加载价格元数据...': 'Loading pricing metadata...',
'正在加载模型元数据...': 'Loading model metadata...',
'上游未提供模型说明。': 'Upstream did not provide a model description.',
'上游未提供文字说明,但已同步标签、能力或价格信息。': 'Upstream did not provide a text description, but tags, capabilities, or pricing data were synchronized.',
'当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。': 'The upstream returned only model IDs and no description field (common on many sites).',
'暂无模型数据': 'No Model Data',
'请先检查站点与账号状态,然后点击刷新。': 'Check site and account status first, then refresh.',
'模型名称': 'Model Name',
'操作': 'Actions',
'每页条数': 'Rows Per Page',
'查看': 'Viewing',
'来自供应商': 'From Provider',
'品牌的所有模型': 'Brand Models',
'的模型': 'models',
'其他未归类的模型': 'Other uncategorized models',
'所有模型 accountCount 累计值,同一账号在多个模型中会重复计数': 'Cumulative accountCount across all models; same account may be counted repeatedly.',
'当前筛选范围内去重后的唯一账号数': 'Unique deduplicated accounts in current filters.',
'刷新选中概率': 'Refresh Selection Probability',
'自动重建': 'Auto Rebuild',
'手动增改路由': 'Manual Route Edit',
'隐藏手动模式': 'Hide Manual Mode',
'搜索模型路由...': 'Search model routes...',
'通道数量': 'Channel Count',
'排序字段': 'Sort Field',
'切换排序方向': 'Toggle Sort Direction',
'升序 ↑': 'Ascending ↑',
'降序 ↓': 'Descending ↓',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode fits advanced scenarios; auto-routing stays enabled.',
'模型匹配(如 gpt-4o、claude-*)': 'Model pattern (e.g. gpt-4o, claude-*)',
'启用': 'Enabled',
'禁用': 'Disabled',
'通道': 'channels',
'按模型过滤': 'Filter by model',
'排序保存中': 'Saving order',
'删除路由': 'Delete Route',
'选择账号': 'Select Account',
'条路由': 'routes',
'品牌路由': 'Brand Routes',
'查看未归类品牌路由': 'Viewing uncategorized brand routes',
'当前精确路由': 'Current exact routes',
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Exact model routes auto-filter accounts and tokens that support that model. Priority P0 is highest, larger numbers are lower. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
'代理端点': 'Proxy Endpoints',
'路由行为': 'Routing Behavior',
'指标口径': 'Metric Notes',
'metapi 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'Metapi aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
'3. 手动覆盖配置为可选项,且会尽可能保留。': '3. Manual overrides are optional and kept whenever possible.',
'4. 成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': '4. Cost source priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
'5. 同站点多通道会进行概率分摊,避免仅因通道数量导致过度偏置。': '5. Multi-channel routes from the same site share probability to avoid bias from channel count alone.',
'1. 模型广场价格来自上游目录数据,用于展示参考。': '1. Marketplace prices come from upstream catalog data for reference.',
'2. 路由实测成本来自代理真实请求统计,两者不是同一数据源。': '2. Route measured cost comes from real proxy requests; it is not the same data source.',
'3. 覆盖槽位是模型维度累计值;去重账号是唯一账号数。': '3. Coverage slots are model-level cumulative counts; unique accounts are deduplicated account counts.',
'请求超时(': 'Request timed out (',
'未知账号': 'Unknown Account',
'未知站点': 'Unknown Site',
'未知': 'Unknown',
'未设置': 'Not Set',
'成功': 'Success',
'失败': 'Failed',
'警告': 'Warning',
'信息': 'Info',
'异常': 'Error',
'加载中...': 'Loading...',
'刷新': 'Refresh',
'保存中...': 'Saving...',
'保存失败': 'Save failed',
'同步中...': 'Syncing...',
'同步': 'Sync',
'添加': 'Add',
'编辑': 'Edit',
'删除': 'Delete',
'选择站点': 'Select Site',
'选择令牌(可选)': 'Select Token (optional)',
'选择账号后同步站点令牌': 'Select an account to sync site tokens',
'站点名称': 'Site Name',
'站点 URL (例如 https://api.example.com)': 'Site URL (e.g. https://api.example.com)',
'自动检测': 'Auto Detect',
'检测中': 'Detecting',
'保存站点': 'Save Site',
'保存修改': 'Save Changes',
'编辑站点': 'Edit Site',
'添加站点': 'Add Site',
'暂无站点': 'No Sites',
'点击“+ 添加站点”开始使用。': 'Click "+ Add Site" to start.',
'重建中...': 'Rebuilding...',
'发送中...': 'Sending...',
'导入中...': 'Importing...',
'创建中...': 'Creating...',
'更新中...': 'Updating...',
'登录并添加...': 'Logging in and adding...',
'添加中...': 'Adding...',
'同步站点令牌': 'Sync Site Tokens',
'同步全部账号': 'Sync All Accounts',
'+ 新增令牌': '+ New Token',
'保存通知设置': 'Save Notification Settings',
'发送测试通知': 'Send Test Notification',
'通知设置已保存': 'Notification settings saved',
'测试通知已发送': 'Test notification sent',
'操作失败': 'Operation failed',
'操作已中止': 'Operation aborted',
'清空日志': 'Clear Logs',
'加载更多': 'Load More',
'全部类型': 'All Types',
'仅看未读': 'Unread Only',
'时间': 'Time',
'类型': 'Type',
'级别': 'Level',
'标题': 'Title',
'内容': 'Content',
'状态': 'Status',
'已读': 'Read',
'未读': 'Unread',
'标记已读': 'Mark Read',
'标记中...': 'Marking...',
'清空中...': 'Clearing...',
// About page
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'The hub of hubs — aggregate all your New API / One API / OneHub relay sites into one unified gateway. One API Key, one endpoint, with auto model discovery, smart routing, and cost optimization.',
'核心特色': 'Key Features',
'统一代理网关': 'Unified Proxy Gateway',
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
'智能路由引擎': 'Smart Routing Engine',
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Auto-selects the optimal channel by cost, latency, and success rate with automatic failover',
'多站点聚合': 'Multi-Site Aggregation',
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centrally manage New API / One API / OneHub / DoneHub / Veloera and more',
'自动模型发现': 'Auto Model Discovery',
'上游新增模型自动出现在模型列表,零配置路由生成': 'New upstream models appear automatically, with zero-config route generation',
'跨站模型覆盖、定价对比、延迟与成功率实测数据': 'Cross-site model coverage, pricing comparison, latency and success rate metrics',
'自动签到': 'Auto Check-in',
'定时签到 + 余额刷新,不再手动操心': 'Scheduled check-in and balance refresh, never miss one again',
'多渠道告警': 'Multi-Channel Alerts',
'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒': 'Webhook / Bark / ServerChan / Email — get notified when balance is low',
'轻量部署': 'Lightweight Deployment',
'单 Docker 容器,内置 SQLite,无外部依赖': 'Single Docker container with built-in SQLite, no external dependencies',
'技术栈': 'Tech Stack',
'高性能 Node.js 后端框架': 'High-performance Node.js backend framework',
'用户界面库': 'User interface library',
'端到端类型安全': 'End-to-end type safety',
'原子化样式框架': 'Utility-first CSS framework',
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
'零配置嵌入式数据库': 'Zero-config embedded database',
'项目链接': 'Project Links',
'数据与隐私': 'Data & Privacy',
'Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
};
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
if (!zhToEn[source]) {
zhToEn[source] = target;
}
}
const HAS_HAN_RE = /[\u3400-\u9fff]/;
const HAN_BLOCK_RE = /[\u3400-\u9fff]+/g;
const LATIN_OR_DIGIT_RE = /[A-Za-z0-9]/;
const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label'] as const;
const SKIP_PARENT_SELECTOR = 'script, style, code, pre, kbd, samp';
const zhToEnPhrases = Object.entries(zhToEn).sort((a, b) => b[0].length - a[0].length);
const textNodeOriginalMap = new WeakMap<Text, string>();
const elementAttrOriginalMap = new WeakMap<Element, Map<string, string>>();
const CJK_PUNCT_TO_ASCII: Record<string, string> = {
',': ', ',
'。': '. ',
':': ': ',
';': '; ',
'!': '! ',
'?': '? ',
'(': '(',
')': ')',
'【': '[',
'】': ']',
'“': '"',
'”': '"',
'‘': '\'',
'’': '\'',
'、': ', ',
};
function enforceStrictEnglish(text: string): string {
const normalizedPunctuation = text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
const strippedHan = normalizedPunctuation.replace(HAN_BLOCK_RE, ' ');
const compacted = strippedHan.replace(/\s+/g, ' ').trim();
if (!compacted) return 'Untranslated';
if (!LATIN_OR_DIGIT_RE.test(compacted)) return 'Untranslated';
return compacted;
}
function resolveStoredLanguage(): Language {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored === 'zh' || stored === 'en') return stored;
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}
let runtimeLanguage: Language = 'zh';
export function translateText(text: string, language: Language): string {
if (language === 'zh') return text;
if (!text) return text;
if (!HAS_HAN_RE.test(text)) return zhToEn[text] ?? text;
const exact = zhToEn[text];
if (exact) return exact;
let translated = text;
for (const [source, target] of zhToEnPhrases) {
if (!source || source === target) continue;
if (!translated.includes(source)) continue;
translated = translated.split(source).join(target);
}
if (HAS_HAN_RE.test(translated)) return enforceStrictEnglish(translated);
return translated;
}
export function tr(text: string): string {
return translateText(text, runtimeLanguage);
}
type I18nContextValue = {
language: Language;
setLanguage: (next: Language) => void;
toggleLanguage: () => void;
t: (text: string) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const resolved = resolveStoredLanguage();
runtimeLanguage = resolved;
return resolved;
});
useEffect(() => {
runtimeLanguage = language;
document.documentElement.setAttribute('lang', language === 'zh' ? 'zh-CN' : 'en');
}, [language]);
useEffect(() => {
const root = document.body;
if (!root) return;
const shouldTranslateTextNode = (node: Text): boolean => {
const parent = node.parentElement;
if (!parent) return false;
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
if (parent.isContentEditable) return false;
const value = node.nodeValue || '';
if (!value.trim()) return false;
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
return true;
};
const processTextNode = (node: Text) => {
if (!shouldTranslateTextNode(node)) return;
const current = node.nodeValue || '';
const stored = textNodeOriginalMap.get(node);
if (!stored) {
textNodeOriginalMap.set(node, current);
} else {
const expected = translateText(stored, language);
if (current !== expected && current !== stored) {
textNodeOriginalMap.set(node, current);
}
}
const source = textNodeOriginalMap.get(node) || current;
const next = translateText(source, language);
if (next !== current) {
node.nodeValue = next;
}
};
const processElementAttrs = (el: Element) => {
if (el.matches(SKIP_PARENT_SELECTOR)) return;
let attrMap = elementAttrOriginalMap.get(el);
if (!attrMap) {
attrMap = new Map<string, string>();
elementAttrOriginalMap.set(el, attrMap);
}
for (const attr of TRANSLATABLE_ATTRS) {
const current = el.getAttribute(attr);
if (!current || !current.trim()) continue;
const stored = attrMap.get(attr);
if (!stored) {
attrMap.set(attr, current);
} else {
const expected = translateText(stored, language);
if (current !== expected && current !== stored) {
attrMap.set(attr, current);
}
}
const source = attrMap.get(attr) || current;
const next = translateText(source, language);
if (next !== current) {
el.setAttribute(attr, next);
}
}
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
processTextNode(node as Text);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as Element;
processElementAttrs(el);
for (const child of Array.from(el.childNodes)) {
walk(child);
}
};
walk(root);
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'characterData') {
processTextNode(record.target as Text);
continue;
}
if (record.type === 'attributes') {
processElementAttrs(record.target as Element);
continue;
}
if (record.type === 'childList') {
for (const node of Array.from(record.addedNodes)) {
walk(node);
}
}
}
});
observer.observe(root, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: [...TRANSLATABLE_ATTRS],
});
return () => {
observer.disconnect();
};
}, [language]);
const setLanguage = useCallback((next: Language) => {
runtimeLanguage = next;
setLanguageState(next);
localStorage.setItem(LANGUAGE_STORAGE_KEY, next);
document.documentElement.setAttribute('lang', next === 'zh' ? 'zh-CN' : 'en');
}, []);
const toggleLanguage = useCallback(() => {
setLanguage(language === 'zh' ? 'en' : 'zh');
}, [language, setLanguage]);
const t = useCallback((text: string) => translateText(text, language), [language]);
const value = useMemo<I18nContextValue>(() => ({
language,
setLanguage,
toggleLanguage,
t,
}), [language, setLanguage, toggleLanguage, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const value = useContext(I18nContext);
if (!value) {
throw new Error('useI18n must be used within I18nProvider');
}
return value;
}
+2694
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Metapi - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
<title>Metapi</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="min-h-screen">
<script>
(function(){
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.js';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+143
View File
@@ -0,0 +1,143 @@
import { tr } from '../i18n.js';
const VERSION = '1.0.0';
const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
{ icon: '🧠', title: '智能路由引擎', desc: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
{ icon: '📡', title: '多站点聚合', desc: '集中管理 New API / One API / OneHub / DoneHub / Veloera 等' },
{ icon: '🔍', title: '自动模型发现', desc: '上游新增模型自动出现在模型列表,零配置路由生成' },
{ icon: '🏪', title: '模型广场', desc: '跨站模型覆盖、定价对比、延迟与成功率实测数据' },
{ icon: '✅', title: '自动签到', desc: '定时签到 + 余额刷新,不再手动操心' },
{ icon: '🔔', title: '多渠道告警', desc: 'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒' },
{ icon: '📦', title: '轻量部署', desc: '单 Docker 容器,内置 SQLite,无外部依赖' },
];
const TECH_STACK = [
{ name: 'Fastify', desc: '高性能 Node.js 后端框架' },
{ name: 'React', desc: '用户界面库' },
{ name: 'TypeScript', desc: '端到端类型安全' },
{ name: 'Tailwind CSS v4', desc: '原子化样式框架' },
{ name: 'Drizzle ORM', desc: '轻量 TypeScript ORM' },
{ name: 'SQLite', desc: '零配置嵌入式数据库' },
];
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/metapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/metapi', icon: '🐳' },
];
export default function About() {
return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 14 }}>
<h2 className="page-title">{tr('关于 Metapi')}</h2>
</div>
{/* Hero card */}
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
<img
src="/logo.png"
alt="Metapi"
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
/>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>Metapi</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。')}
</div>
</div>
{/* Features */}
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 10,
}}>
{FEATURES.map((f) => (
<div key={f.title} style={{
display: 'flex', gap: 10, padding: '8px 0',
borderBottom: '1px solid var(--color-border-light)',
}}>
<span style={{ fontSize: 18, lineHeight: '24px', flexShrink: 0 }}>{f.icon}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
{tr(f.desc)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
}}>
{TECH_STACK.map((t) => (
<div key={t.name} style={{
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
{tr(t.desc)}
</div>
</div>
))}
</div>
</div>
{/* Links */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{LINKS.map((l) => (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text-primary)',
textDecoration: 'none', fontSize: 13, fontWeight: 500,
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<span>{l.icon}</span>
<span>{l.label}</span>
</a>
))}
</div>
</div>
{/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
</div>
</div>
</div>
);
}
+464
View File
@@ -0,0 +1,464 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { tr } from '../i18n.js';
export default function Accounts() {
const [accounts, setAccounts] = useState<any[]>([]);
const [sites, setSites] = useState<any[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
const [tokenForm, setTokenForm] = useState({ siteId: 0, accessToken: '', platformUserId: '' });
const [verifyResult, setVerifyResult] = useState<any>(null);
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const toast = useToast();
const load = () => {
api.getAccounts().then(setAccounts).catch(() => toast.error('加载账号列表失败'));
api.getSites().then(setSites);
};
useEffect(() => { load(); }, []);
const handleLoginAdd = async () => {
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
setSaving(true);
try {
const result = await api.loginAccount(loginForm);
if (result.success) {
setShowAdd(false);
setLoginForm({ siteId: 0, username: '', password: '' });
const msg = result.apiTokenFound
? `账号 "${loginForm.username}" 已添加,API Key 已自动获取`
: `账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)`;
toast.success(msg);
load();
} else {
toast.error(result.message || '登录失败');
}
} catch (e: any) {
toast.error(e.message || '登录请求失败');
} finally {
setSaving(false);
}
};
const handleVerifyToken = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
setVerifying(true);
setVerifyResult(null);
try {
const result = await api.verifyToken({
siteId: tokenForm.siteId,
accessToken: tokenForm.accessToken,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
});
setVerifyResult(result);
if (result.success) {
toast.success(`验证成功: ${result.userInfo?.username || '未知用户'}`);
} else {
toast.error(result.message || 'Token 无效');
}
} catch (e: any) {
toast.error(e.message || '验证失败');
setVerifyResult({ success: false, message: e.message });
} finally {
setVerifying(false);
}
};
const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
setSaving(true);
try {
const result = await api.addAccount({
siteId: tokenForm.siteId,
accessToken: tokenForm.accessToken,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
});
setShowAdd(false);
setTokenForm({ siteId: 0, accessToken: '', platformUserId: '' });
setVerifyResult(null);
if (result.tokenType === 'apikey') {
toast.success('已添加为 API Key 账号(可用于代理转发)');
} else {
const parts: string[] = [];
if (result.usernameDetected) parts.push('用户名已自动识别');
if (result.apiTokenFound) parts.push('API Key 已自动获取');
const extra = parts.length ? `(${parts.join(',')})` : '';
toast.success(`账号已添加${extra}`);
}
load();
} catch (e: any) {
toast.error(e.message || '添加失败');
} finally {
setSaving(false);
}
};
const withLoading = async (key: string, fn: () => Promise<any>, successMsg?: string) => {
setActionLoading(s => ({ ...s, [key]: true }));
try { await fn(); if (successMsg) toast.success(successMsg); load(); }
catch (e: any) { toast.error(e.message || '操作失败'); }
finally { setActionLoading(s => ({ ...s, [key]: false })); }
};
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 runtimeHealthMap: Record<string, {
label: string;
cls: string;
dotClass: string;
pulse: boolean;
}> = {
healthy: { label: '健康', cls: 'badge-success', dotClass: 'status-dot-success', pulse: true },
unhealthy: { label: '异常', cls: 'badge-error', dotClass: 'status-dot-error', pulse: true },
degraded: { label: '降级', cls: 'badge-warning', dotClass: 'status-dot-pending', pulse: true },
disabled: { label: '已禁用', cls: 'badge-muted', dotClass: 'status-dot-muted', pulse: false },
unknown: { label: '未知', cls: 'badge-muted', dotClass: 'status-dot-pending', pulse: false },
};
const resolveRuntimeHealth = (account: any) => {
const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled'
? 'disabled'
: (account.status === 'expired' ? 'unhealthy' : 'unknown');
const state = account.runtimeHealth?.state || fallbackState;
const cfg = runtimeHealthMap[state] || runtimeHealthMap.unknown;
const reason = account.runtimeHealth?.reason
|| (state === 'disabled'
? '账号或站点已禁用'
: (state === 'unhealthy' ? '最近健康检查失败' : '尚未获取运行健康信息'));
return { state, reason, ...cfg };
};
const handleRefreshRuntimeHealth = async () => {
setActionLoading((s) => ({ ...s, 'health-refresh': true }));
try {
const res = await api.refreshAccountHealth();
if (res?.queued) {
toast.info(res.message || '账号状态刷新任务已提交,完成后会自动更新。');
} else {
toast.success(res?.message || '账号状态已刷新');
}
load();
} catch (e: any) {
toast.error(e.message || '刷新账号状态失败');
} finally {
setActionLoading((s) => ({ ...s, 'health-refresh': false }));
}
};
const handleToggleCheckin = async (account: any) => {
const key = `checkin-toggle-${account.id}`;
const nextEnabled = !account.checkinEnabled;
setActionLoading((s) => ({ ...s, [key]: true }));
try {
await api.updateAccount(account.id, { checkinEnabled: nextEnabled });
toast.success(nextEnabled ? '已开启签到' : '已关闭签到(全部签到会忽略此账号)');
load();
} catch (e: any) {
toast.error(e.message || '切换签到状态失败');
} finally {
setActionLoading((s) => ({ ...s, [key]: false }));
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('账号管理')}</h2>
<div className="page-actions">
<button onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')} disabled={actionLoading['checkin-all']}
className="btn btn-soft-primary">
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
</button>
<button
onClick={handleRefreshRuntimeHealth}
disabled={actionLoading['health-refresh']}
className="btn btn-soft-primary"
>
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button>
<button onClick={() => { setShowAdd(!showAdd); setAddMode('token'); setVerifyResult(null); }} className="btn btn-primary">
{showAdd ? tr('取消') : tr('+ 添加账号')}
</button>
</div>
</div>
{/* Add Panel */}
{showAdd && (
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
{/* Mode tabs */}
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
<button onClick={() => { setAddMode('token'); setVerifyResult(null); }}
style={{
flex: 1, padding: '8px 0', borderRadius: 6, fontSize: 13, fontWeight: 500, border: 'none', cursor: 'pointer', transition: 'all 0.2s',
background: addMode === 'token' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'token' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'token' ? 'var(--shadow-sm)' : 'none'
}}>
Cookie / Token 导入
</button>
<button onClick={() => { setAddMode('login'); setVerifyResult(null); }}
style={{
flex: 1, padding: '8px 0', borderRadius: 6, fontSize: 13, fontWeight: 500, border: 'none', cursor: 'pointer', transition: 'all 0.2s',
background: addMode === 'login' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'login' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'login' ? 'var(--shadow-sm)' : 'none'
}}>
账号密码登录
</button>
</div>
{addMode === 'token' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种 Token 类型,系统自动识别</div>
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
<div><strong>Session Token</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
获取 Session Token: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
</div>
</div>
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId }));
setVerifyResult(null);
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<textarea placeholder="粘贴 Session Token 或 API Key&#10;(系统会自动识别 Token 类型)"
value={tokenForm.accessToken}
onChange={e => { setTokenForm(f => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }} />
{/* Verify results */}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<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={{ fontSize: 12, lineHeight: 1.8 }}>
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
{verifyResult.balance && <div>余额: <strong>${(verifyResult.balance.balance || 0).toFixed(2)}</strong></div>}
<div>API Key: <span style={{ fontWeight: 500, color: verifyResult.apiToken ? 'var(--color-success)' : 'var(--color-text-muted)' }}>
{verifyResult.apiToken ? '已找到 (' + verifyResult.apiToken.substring(0, 8) + '...)' : '未找到'}
</span></div>
</div>
</div>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-info animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
识别为 API Key
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>可用模型: <strong>{verifyResult.modelCount} 个</strong></div>
{verifyResult.models && <div style={{ color: 'var(--color-text-muted)' }}>包含: {verifyResult.models.join(', ')}{verifyResult.modelCount > 10 ? ' ...' : ''}</div>}
</div>
</div>
)}
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">
Token 已识别,但此站点需要提供用户 ID
</div>
<input placeholder="用户 ID(数字)" value={tokenForm.platformUserId}
onChange={e => setTokenForm(f => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') }))}
style={{ ...inputStyle, borderColor: 'color-mix(in srgb, var(--color-warning) 45%, transparent)' }} />
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
<div className="alert alert-error animate-scale-in">
<div className="alert-title">
{verifyResult.message || 'Token 无效或已过期'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>请检查 Token 是否正确</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleVerifyToken} disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button onClick={handleTokenAdd} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-success">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加账号'}
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
输入目标站点的账号密码,将自动登录并获取访问令牌和 API Key
</div>
<ModernSelect
value={String(loginForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setLoginForm((f) => ({ ...f, siteId: nextSiteId }));
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input placeholder="用户名" value={loginForm.username} onChange={e => setLoginForm(f => ({ ...f, username: e.target.value }))} style={inputStyle} />
<input type="password" placeholder="密码" value={loginForm.password} onChange={e => setLoginForm(f => ({ ...f, password: e.target.value }))} onKeyDown={e => e.key === 'Enter' && handleLoginAdd()} style={inputStyle} />
<button onClick={handleLoginAdd} disabled={saving || !loginForm.siteId || !loginForm.username || !loginForm.password}
className="btn btn-success" style={{ alignSelf: 'flex-start' }}>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />登录并添加...</> : '登录并添加'}
</button>
</div>
)}
</div>
)}
{/* Accounts Table */}
<div className="card" style={{ overflowX: 'auto' }}>
{accounts.length > 0 ? (
<table className="data-table">
<thead>
<tr>
<th>用户名</th>
<th>站点</th>
<th>运行健康状态</th>
<th>余额</th>
<th>已用</th>
<th>签到</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{accounts.map((a: any, i: number) => (
<tr key={a.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
<td>
{a.site?.url ? (
<a
href={a.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)}
</td>
<td>
{(() => {
const health = resolveRuntimeHealth(a);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
{health.label}
</span>
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={health.reason}
>
{health.reason}
</span>
</div>
);
})()}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
+{(a.todayReward || 0).toFixed(2)}
</div>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
-{(a.todaySpend || 0).toFixed(2)}
</div>
</td>
<td>
<button
type="button"
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
onClick={() => handleToggleCheckin(a)}
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
title={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
>
{actionLoading[`checkin-toggle-${a.id}`]
? <span className="spinner spinner-sm" />
: (a.checkinEnabled ? '开启' : '关闭')}
</button>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
className="btn btn-link btn-link-primary">
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
</button>
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]}
className="btn btn-link btn-link-warning">
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
</button>
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<div className="empty-state-title">暂无账号</div>
<div className="empty-state-desc">请先添加站点,然后添加账号</div>
</div>
)}
</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
import { tr } from '../i18n.js';
type LogFilter = 'all' | 'success' | 'failed' | 'skipped';
type FailureReason = {
code: string;
category: string;
title: string;
actionHint: string;
detailHint: string;
};
export default function CheckinLog() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
const [filter, setFilter] = useState<LogFilter>('all');
const toast = useToast();
const load = async () => {
setLoading(true);
try {
const data = await api.getCheckinLogs('limit=100');
setLogs(Array.isArray(data) ? data : []);
} catch (e: any) {
toast.error(e.message || '加载签到记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleTriggerAll = async () => {
setTriggering(true);
try {
const res = await api.triggerCheckinAll();
if (res?.queued) {
toast.info(res.message || '已开始签到,请稍后查看签到记录');
} else {
toast.success(res?.message || '签到已执行');
}
await load();
} catch (e: any) {
toast.error(e.message || '触发签到失败');
} finally {
setTriggering(false);
}
};
const getStatus = (log: any): 'success' | 'failed' | 'skipped' => {
const raw = (log.checkin_logs?.status || log.status || 'failed') as string;
if (raw === 'success' || raw === 'skipped') return raw;
return 'failed';
};
const filtered = filter === 'all' ? logs : logs.filter((log) => getStatus(log) === filter);
const countBy = (target: Exclude<LogFilter, 'all'>) => logs.filter((log) => getStatus(log) === target).length;
const statusLabel = (status: 'success' | 'failed' | 'skipped') => {
if (status === 'success') return '成功';
if (status === 'skipped') return '跳过';
return '失败';
};
const statusClass = (status: 'success' | 'failed' | 'skipped') => {
if (status === 'success') return 'badge-success';
if (status === 'skipped') return 'badge-muted';
return 'badge-error';
};
const getFailureReason = (log: any): FailureReason | null => {
const reason = log.failureReason as FailureReason | undefined;
if (!reason || !reason.code) return null;
return reason;
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('签到记录')}</h2>
<button
onClick={handleTriggerAll}
disabled={triggering}
className="btn btn-soft-primary"
>
{triggering ? (
<>
<span className="spinner spinner-sm" />
触发中...
</>
) : (
'运行所有签到'
)}
</button>
</div>
<div className="tabs" style={{ marginBottom: 0 }}>
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
{tab === 'all' && `全部 (${logs.length})`}
{tab === 'success' && `成功 (${countBy('success')})`}
{tab === 'failed' && `失败 (${countBy('failed')})`}
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
</button>
))}
</div>
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
{loading ? (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
{[...Array(5)].map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 16 }}>
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 80, height: 16 }} />
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 70, height: 16 }} />
<div className="skeleton" style={{ flex: 1, height: 16 }} />
<div className="skeleton" style={{ width: 60, height: 16 }} />
</div>
))}
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>账号</th>
<th>站点</th>
<th>状态</th>
<th>分类</th>
<th>信息</th>
<th>建议</th>
<th>奖励</th>
</tr>
</thead>
<tbody>
{filtered.map((log: any) => {
const status = getStatus(log);
const reason = getFailureReason(log);
return (
<tr key={log.checkin_logs?.id || log.id}>
<td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
{formatCheckinLogTime(log.checkin_logs?.createdAt || log.createdAt)}
</td>
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{log.accounts?.username || '未知'}
</td>
<td>
{log.sites?.url ? (
<a
href={log.sites.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{log.sites?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{log.sites?.name || '-'}
</span>
)}
</td>
<td>
<span className={`badge ${statusClass(status)}`}>{statusLabel(status)}</span>
</td>
<td>
{reason ? (
<span className="badge badge-info" title={reason.detailHint}>
{reason.title}
</span>
) : (
<span className="badge badge-muted">-</span>
)}
</td>
<td style={{ maxWidth: 360 }}>
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.checkin_logs?.message || log.message}
</span>
</td>
<td style={{ maxWidth: 220 }}>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--color-text-secondary)',
fontSize: 12,
}}
title={reason?.detailHint || ''}
>
{reason?.actionHint || '-'}
</span>
</td>
<td>{log.checkin_logs?.reward || '-'}</td>
</tr>
);
})}
</tbody>
</table>
)}
{!loading && filtered.length === 0 && (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="empty-state-title">暂无签到记录</div>
<div className="empty-state-desc">点击“运行所有签到”开始执行</div>
</div>
)}
</div>
</div>
);
}
+493
View File
@@ -0,0 +1,493 @@
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js'));
const SiteTrendChart = lazy(() => import('../components/charts/SiteTrendChart.js'));
function getGreeting(): string {
const hour = new Date().getHours();
if (hour < 6) return '🌙 夜深了';
if (hour < 11) return '☀️ 早上好';
if (hour < 13) return '👋 中午好';
if (hour < 18) return '🌤️ 下午好';
return '🌙 晚上好';
}
function safeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function ChartFallback({ height = 280 }: { height?: number }) {
return (
<div className="card" style={{ minHeight: height, padding: 16 }}>
<div className="skeleton" style={{ width: 160, height: 18, marginBottom: 12 }} />
<div className="skeleton" style={{ width: '100%', height: Math.max(120, height - 46), borderRadius: 10 }} />
</div>
);
}
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [siteDistribution, setSiteDistribution] = useState<any[]>([]);
const [siteTrend, setSiteTrend] = useState<any[]>([]);
const [siteLoading, setSiteLoading] = useState(true);
const [sites, setSites] = useState<any[]>([]);
const [trendDays, setTrendDays] = useState(7);
const toast = useToast();
const normalizedAdminName = (adminName || '').trim() || '\u7ba1\u7406\u5458';
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
else setRefreshing(true);
setError(null);
try {
const result = await api.getDashboard();
setData(result);
} catch (err: any) {
const message = err?.message || '加载仪表盘失败';
setError(message);
if (silent) toast.error(message);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [toast]);
const loadSiteStats = useCallback(async () => {
setSiteLoading(true);
try {
const [distRes, trendRes, sitesRes] = await Promise.all([
api.getSiteDistribution(),
api.getSiteTrend(trendDays),
api.getSites(),
]);
setSiteDistribution(distRes.distribution || []);
setSiteTrend(trendRes.trend || []);
const siteRows = Array.isArray(sitesRes) ? sitesRes : (sitesRes?.sites || []);
setSites(siteRows.filter((site: any) => site?.status !== 'disabled'));
} catch (err) {
console.error('Failed to load site stats:', err);
} finally {
setSiteLoading(false);
}
}, [trendDays]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
loadSiteStats();
}, [loadSiteStats]);
useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null;
let disposed = false;
const pollDashboard = async () => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
try {
const next = await api.getDashboard();
if (!disposed) setData(next);
} catch {
// ignore polling errors
}
};
const start = () => {
if (timer) return;
timer = setInterval(() => { void pollDashboard(); }, 30000);
};
const stop = () => {
if (!timer) return;
clearInterval(timer);
timer = null;
};
const handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void pollDashboard();
start();
} else {
stop();
}
};
handleVisibilityChange();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
return () => {
disposed = true;
stop();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
}
};
}, []);
if (loading && !data) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 280, height: 32, marginBottom: 24, borderRadius: 'var(--radius-sm)' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[...Array(4)].map((_, i) => (
<div key={i} className={`stat-card animate-slide-up stagger-${i + 1}`}>
<div className="skeleton" style={{ width: 80, height: 14, marginBottom: 16 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="skeleton" style={{ width: 36, height: 36, borderRadius: '50%' }} />
<div>
<div className="skeleton" style={{ width: 60, height: 10, marginBottom: 6 }} />
<div className="skeleton" style={{ width: 80, height: 20 }} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="skeleton" style={{ width: 36, height: 36, borderRadius: '50%' }} />
<div>
<div className="skeleton" style={{ width: 60, height: 10, marginBottom: 6 }} />
<div className="skeleton" style={{ width: 80, height: 20 }} />
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}
if (error && !data) {
return (
<div className="animate-fade-in">
<h2 className="greeting" style={{ marginBottom: 24 }}>{getGreeting() + '\uFF0C' + normalizedAdminName}</h2>
<div className="card" style={{ padding: 48, textAlign: 'center' }}>
<div style={{ width: 48, height: 48, background: 'var(--color-danger-soft)', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="var(--color-danger)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>加载失败</div>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', marginBottom: 16 }}>{error}</div>
<button onClick={() => load()} className="btn btn-soft-primary">重试</button>
</div>
</div>
);
}
const totalBalance = safeNumber(data?.totalBalance);
const totalUsed = safeNumber(data?.totalUsed || 0);
const todaySpend = safeNumber(data?.todaySpend || 0);
const todayReward = safeNumber(data?.todayReward || 0);
const activeAccounts = safeNumber(data?.activeAccounts);
const totalAccounts = safeNumber(data?.totalAccounts);
const todaySuccess = safeNumber(data?.todayCheckin?.success);
const todayTotal = safeNumber(data?.todayCheckin?.total);
const proxy24hSuccess = safeNumber(data?.proxy24h?.success);
const proxy24hTotal = safeNumber(data?.proxy24h?.total);
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const latencyDot = (ms: number) => {
const color = ms < 200
? 'var(--color-success)'
: ms < 500
? 'var(--color-warning)'
: ms < 1000
? 'color-mix(in srgb, var(--color-warning) 78%, var(--color-danger))'
: 'var(--color-danger)';
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
};
return (
<div className="animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 className="greeting">{getGreeting() + '\uFF0C' + normalizedAdminName}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => { load(true); loadSiteStats(); }} disabled={refreshing} className="topbar-icon-btn" title="刷新">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: refreshing ? 'spin 1s linear infinite' : 'none' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
<div className="stat-card animate-slide-up stagger-1">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
账户数据
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-blue">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<div className="stat-label">当前余额</div>
<div className="stat-value animate-count-up">${totalBalance.toFixed(2)}</div>
<div
style={{
fontSize: 11,
color: todayReward > 0 ? 'var(--color-success)' : 'var(--color-text-muted)',
fontWeight: 500,
marginTop: 2,
}}
>
今日 +{todayReward.toFixed(2)}
</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-green">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
</div>
<div>
<div className="stat-label">累计消耗</div>
<div className="stat-value animate-count-up">${totalUsed.toFixed(2)}</div>
<div
style={{
fontSize: 11,
color: todaySpend > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)',
fontWeight: 500,
marginTop: 2,
}}
>
今日 -{todaySpend.toFixed(2)}
</div>
</div>
</div>
</div>
<div className="stat-card animate-slide-up stagger-2">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
使用统计
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-yellow">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<div>
<div className="stat-label">24h 请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hTotal).toLocaleString()}</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-cyan">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>
</div>
<div>
<div className="stat-label">成功请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hSuccess).toLocaleString()}</div>
</div>
</div>
</div>
<div className="stat-card animate-slide-up stagger-3">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
资源消耗
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-pink">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div>
<div>
<div className="stat-label">活跃账户</div>
<div className="stat-value animate-count-up">{Math.round(activeAccounts)}/{Math.round(totalAccounts)}</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-red">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
</div>
<div>
<div className="stat-label">24h Tokens</div>
<div className="stat-value animate-count-up">{Math.round(totalTokens).toLocaleString()}</div>
</div>
</div>
</div>
<div className="stat-card animate-slide-up stagger-4">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
签到状态
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-purple">
<svg width="16" height="16" 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>
<div className="stat-label">今日签到</div>
<div className="stat-value animate-count-up">{Math.round(todaySuccess)}/{Math.round(todayTotal)}</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-orange">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<div className="stat-label">成功率</div>
<div className="stat-value animate-count-up">
{todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}%
</div>
</div>
</div>
</div>
</div>
{/* 站点级分析 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, marginTop: 8 }}>
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
站点分析
</div>
<div style={{ display: 'flex', gap: 4 }}>
{[7, 30, 90].map(d => (
<button key={d} onClick={() => setTrendDays(d)}
style={{
padding: '4px 12px', borderRadius: 6, fontSize: 12, fontWeight: 500, border: 'none', cursor: 'pointer',
background: trendDays === d ? 'var(--color-primary)' : 'var(--color-bg)',
color: trendDays === d ? 'white' : 'var(--color-text-secondary)',
transition: 'all 0.2s ease',
}}>
{d}天
</button>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div className="chart-panel-enter animate-slide-up stagger-5">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart data={siteDistribution} loading={siteLoading} />
</Suspense>
</div>
<div className="chart-panel-enter animate-slide-up stagger-6">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteTrendChart data={siteTrend} loading={siteLoading} />
</Suspense>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
<div className="chart-container animate-slide-up stagger-7">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
模型数据分析
</div>
</div>
<Suspense fallback={<ChartFallback height={260} />}>
<ModelAnalysisPanel data={data?.modelAnalysis} />
</Suspense>
</div>
<div className="chart-container animate-slide-up stagger-8" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'var(--color-text-primary)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
站点信息
</span>
{sites.length > 0 && (
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '3px 10px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
onClick={async () => {
for (const s of sites) {
const el = document.getElementById(`speed-${s.id}`);
if (el) el.textContent = '...';
}
await Promise.all(sites.map(async (s: any) => {
const el = document.getElementById(`speed-${s.id}`);
try {
const start = performance.now();
await fetch(`${s.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (el) el.innerHTML = latencyDot(ms);
} catch {
if (el) el.textContent = '超时';
}
}));
toast.success('全部测速完成');
}}
>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
一键测速
</button>
)}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 10 }}>
{(sites.length > 0)
? sites.map((site: any, idx: number) => (
<div key={site.id || idx} style={{ padding: '10px 12px', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{site.name}</span>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', border: '1px solid var(--color-border)', borderRadius: 6, display: 'inline-flex', alignItems: 'center', gap: 3 }}
onClick={async () => {
const btn = document.getElementById(`speed-${site.id || idx}`);
if (btn) btn.textContent = '...';
try {
const start = performance.now();
await fetch(`${site.url}/v1/models`, { method: 'GET', mode: 'no-cors' });
const ms = Math.round(performance.now() - start);
if (btn) btn.innerHTML = latencyDot(ms);
toast.success(`${site.name}: ${ms}ms`);
} catch {
if (btn) btn.textContent = '超时';
toast.error(`${site.name}: 测速失败`);
}
}}
>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
<span id={`speed-${site.id || idx}`}>测速</span>
</button>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', border: '1px solid var(--color-border)', borderRadius: 6, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 3 }}
>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
跳转
</a>
</div>
<a href={site.url} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, color: 'var(--color-info)', wordBreak: 'break-all' }}>{site.url}</a>
</div>
))
: (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, padding: 20 }}>
<div style={{ width: 60, height: 60, opacity: 0.25 }}>
<svg fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)" width="60" height="60">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.6} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>代理端点可用</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', textAlign: 'center', lineHeight: 1.6 }}>
使用 <code style={{ background: 'var(--color-bg)', padding: '2px 6px', borderRadius: 4, fontSize: 10 }}>/v1/chat/completions</code> 访问
</div>
</div>
)}
<div style={{ marginTop: 'auto', paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 2 }}>24h 活跃调用</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{proxy24hTotal > 0 ? `${Math.round(proxy24hSuccess)}/${Math.round(proxy24hTotal)}` : '—'}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+418
View File
@@ -0,0 +1,418 @@
import React, { useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type BackupType = 'all' | 'accounts' | 'preferences';
type ParsedSummary = {
valid: boolean;
version: string;
timestampLabel: string;
hasAccounts: boolean;
hasPreferences: boolean;
hasLegacyData: boolean;
sitesCount: number;
accountsCount: number;
tokensCount: number;
routesCount: number;
channelsCount: number;
settingsCount: number;
};
function downloadJsonFile(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function parseImportSummary(raw: string): ParsedSummary | null {
if (!raw.trim()) return null;
try {
const data = JSON.parse(raw) as any;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return {
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
sitesCount: 0,
accountsCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
settingsCount: 0,
};
}
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
? data.accounts
: null;
const preferencesSection = (data.preferences && typeof data.preferences === 'object' && !Array.isArray(data.preferences))
? data.preferences
: null;
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
const legacyPrefs = Boolean(data.data?.preferences);
const hasAccounts = Boolean(
data.type === 'accounts'
|| accountsSection
|| legacyAccounts,
);
const hasPreferences = Boolean(
data.type === 'preferences'
|| preferencesSection
|| legacyPrefs,
);
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
const ts = data.timestamp !== undefined && data.timestamp !== null
? new Date(data.timestamp)
: null;
const timestampLabel = ts && !Number.isNaN(ts.getTime()) ? ts.toLocaleString() : '未知';
return {
valid: hasAccounts || hasPreferences,
version: typeof data.version === 'string' ? data.version : '1.0',
timestampLabel,
hasAccounts,
hasPreferences,
hasLegacyData: legacyAccounts || legacyPrefs,
sitesCount: toCount(accountsSection?.sites),
accountsCount: toCount(accountsSection?.accounts),
tokensCount: toCount(accountsSection?.accountTokens),
routesCount: toCount(accountsSection?.tokenRoutes),
channelsCount: toCount(accountsSection?.routeChannels),
settingsCount: toCount(preferencesSection?.settings),
};
} catch {
return {
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
sitesCount: 0,
accountsCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
settingsCount: 0,
};
}
}
export default function ImportExport() {
const toast = useToast();
const [exportingType, setExportingType] = useState<BackupType | ''>('');
const [importing, setImporting] = useState(false);
const [importData, setImportData] = useState('');
const [selectedFileName, setSelectedFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => parseImportSummary(importData), [importData]);
const handleExport = async (type: BackupType) => {
setExportingType(type);
try {
const data = await api.exportBackup(type);
const date = new Date().toISOString().split('T')[0];
const fileName: Record<BackupType, string> = {
all: `metapi-backup-${date}.json`,
accounts: `metapi-accounts-${date}.json`,
preferences: `metapi-preferences-${date}.json`,
};
downloadJsonFile(data, fileName[type]);
toast.success('导出成功');
} catch (err: any) {
toast.error(err?.message || '导出失败');
} finally {
setExportingType('');
}
};
const readFile = (file: File) => {
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
toast.error('请选择 JSON 格式的备份文件');
return;
}
setSelectedFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
setImportData(String(e.target?.result || ''));
};
reader.onerror = () => toast.error('读取文件失败');
reader.readAsText(file);
};
const handleImportFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
readFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) readFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const handleImport = async () => {
if (!importData.trim()) {
toast.error('请先选择或粘贴 JSON 备份内容');
return;
}
if (!summary?.valid) {
toast.error('当前 JSON 结构无法识别');
return;
}
if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) {
return;
}
setImporting(true);
try {
const parsed = JSON.parse(importData);
const result = await api.importBackup(parsed);
const sections: string[] = [];
if (result?.sections?.accounts) sections.push('账号与路由');
if (result?.sections?.preferences) sections.push('系统设置');
toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`);
setImportData('');
setSelectedFileName('');
} catch (err: any) {
toast.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
return (
<div className="animate-fade-in">
<div className="page-header" style={{ alignItems: 'flex-end', marginBottom: 18 }}>
<div>
<h2 className="page-title" style={{ marginBottom: 6 }}>{tr('导入 / 导出')}</h2>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
支持全量备份、分区备份与手动恢复。
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-muted" style={{ fontSize: 11 }}>Schema v2.0</span>
<span className="badge badge-warning" style={{ fontSize: 11 }}>敏感数据请离线保管</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(360px,1fr))', gap: 14 }}>
{/* ====== 导出区 ====== */}
<div className="card animate-slide-up stagger-1" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导出数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
将数据导出为 JSON 文件进行备份
</div>
<div style={{ display: 'grid', gap: 8 }}>
<button
onClick={() => handleExport('all')}
disabled={!!exportingType}
className="btn btn-primary"
style={{ justifyContent: 'space-between' }}
>
<span>导出全部(账号 + 路由 + 设置)</span>
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
</button>
<button
onClick={() => handleExport('accounts')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出账号与路由</span>
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
</button>
<button
onClick={() => handleExport('preferences')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出系统设置</span>
{exportingType === 'preferences' ? <span className="spinner spinner-sm" /> : null}
</button>
</div>
</div>
{/* ====== 导入区 ====== */}
<div className="card animate-slide-up stagger-2" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导入数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
从备份文件恢复数据
</div>
<div style={{ display: 'grid', gap: 12 }}>
{/* ---- 拖拽上传区 ---- */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
padding: '28px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: dragOver ? 'var(--color-primary-light)' : 'var(--color-bg)',
}}
>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
onChange={handleImportFile}
style={{ display: 'none' }}
/>
{selectedFileName ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{selectedFileName}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
点击重新选择文件
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="36" height="36" fill="none" viewBox="0 0 24 24" stroke={dragOver ? 'var(--color-primary)' : 'var(--color-text-muted)'} style={{ transition: 'stroke 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: dragOver ? 'var(--color-primary)' : 'var(--color-text-secondary)' }}>
{dragOver ? '松开以导入文件' : '拖拽 JSON 备份文件到此处'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
或 <span style={{ color: 'var(--color-primary)', fontWeight: 500 }}>点击选择文件</span>
</div>
</div>
)}
</div>
{/* ---- 数据预览 ---- */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>数据预览</div>
<textarea
value={importData}
onChange={(e) => setImportData(e.target.value)}
placeholder="粘贴 JSON 数据或通过上面的拖拽区域导入..."
style={{
width: '100%',
minHeight: 100,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
fontSize: 12,
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
{/* ---- 解析摘要 ---- */}
{summary ? (
<div
style={{
borderRadius: 'var(--radius-sm)',
border: `1px solid ${summary.valid
? 'color-mix(in srgb, var(--color-success) 35%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 35%, transparent)'}`,
background: summary.valid
? 'color-mix(in srgb, var(--color-success) 10%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 10%, transparent)',
color: summary.valid ? 'var(--color-success)' : 'var(--color-danger)',
padding: '10px 12px',
fontSize: 12,
lineHeight: 1.6,
}}
>
{summary.valid ? (
<>
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
<div>
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}
</div>
) : null}
{summary.hasLegacyData ? <div>检测到兼容结构:将按兼容模式导入。</div> : null}
</>
) : (
<div>JSON 可解析,但结构不受支持。</div>
)}
</div>
) : null}
{/* ---- 操作按钮 ---- */}
<button
onClick={handleImport}
disabled={importing || !summary?.valid}
className="btn btn-primary"
style={{ width: '100%', padding: '10px 0', fontSize: 14, fontWeight: 600, borderRadius: 'var(--radius-sm)' }}
>
{importing ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 导入中...</> : '导入'}
</button>
</div>
</div>
</div>
<div className="card animate-slide-up stagger-3" style={{ marginTop: 14, padding: 16 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>注意事项</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
<div>1. 导入账号分区会覆盖现有站点、账号、令牌和路由配置。</div>
<div>2. 为避免锁死管理界面,管理员登录令牌(`auth_token`)不会从备份导入。</div>
<div>3. 建议先导出一份"全部备份"再执行导入操作。</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+879
View File
@@ -0,0 +1,879 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../api.js';
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
import { tr } from '../i18n.js';
type SortColumn = 'name' | 'accountCount' | 'tokenCount' | 'avgLatency' | 'successRate';
type ViewMode = 'card' | 'table';
interface ModelTokenInfo {
id: number;
name: string;
isDefault: boolean;
}
interface ModelGroupPricing {
quotaType: number;
inputPerMillion?: number;
outputPerMillion?: number;
perCallInput?: number;
perCallOutput?: number;
perCallTotal?: number;
}
interface ModelPricingSource {
siteId: number;
siteName: string;
accountId: number;
username: string | null;
ownerBy: string | null;
enableGroups: string[];
groupPricing: Record<string, ModelGroupPricing>;
}
interface ModelAccountInfo {
id: number;
site: string;
username: string | null;
latency: number | null;
balance: number;
tokens: ModelTokenInfo[];
}
interface ModelRow {
name: string;
accountCount: number;
tokenCount: number;
avgLatency: number;
successRate: number | null;
description: string | null;
tags: string[];
supportedEndpointTypes: string[];
pricingSources: ModelPricingSource[];
accounts: ModelAccountInfo[];
}
interface ModelsMarketplaceResponse {
models: ModelRow[];
meta?: {
refreshRequested?: boolean;
refreshQueued?: boolean;
refreshReused?: boolean;
refreshRunning?: boolean;
refreshJobId?: string | null;
};
}
function getMetricColor(latency: number) {
if (latency > 3000) return 'var(--color-danger)';
if (latency > 1200) return 'var(--color-warning)';
return 'var(--color-success)';
}
function getLatencyBadgeClass(latency: number) {
if (latency > 3000) return 'badge-error';
if (latency > 1200) return 'badge-warning';
return 'badge-success';
}
function getSuccessBadgeClass(rate: number | null) {
if (rate == null) return 'badge-muted';
if (rate >= 90) return 'badge-success';
if (rate >= 60) return 'badge-warning';
return 'badge-error';
}
function resolveMarketplaceDescription(model: ModelRow, metadataHydrating: boolean): string {
if (model.description && model.description.trim().length > 0) return model.description;
if (metadataHydrating) return tr('正在加载模型元数据...');
const hasOtherMetadata = model.tags.length > 0 || model.supportedEndpointTypes.length > 0 || model.pricingSources.length > 0;
if (hasOtherMetadata) return tr('上游未提供文字说明,但已同步标签、能力或价格信息。');
return tr('当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。');
}
function renderGroupPricingValue(pricing: ModelGroupPricing): string {
if (pricing.quotaType === 0) {
return `${pricing.inputPerMillion ?? 0}/${pricing.outputPerMillion ?? 0} USD / 1M`;
}
if (pricing.perCallInput != null || pricing.perCallOutput != null) {
return `${pricing.perCallInput ?? 0}/${pricing.perCallOutput ?? 0} USD / call`;
}
return `${pricing.perCallTotal ?? 0} USD / call`;
}
const PAGE_SIZES = [10, 20, 50];
/* ---- component ---- */
export default function Models() {
const cdn = useIconCdn();
const toast = useToast();
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [sortBy, setSortBy] = useState<SortColumn>('accountCount');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [activeSite, setActiveSite] = useState<string | null>(null);
const [activeBrand, setActiveBrand] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('card');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [copied, setCopied] = useState<string | null>(null);
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [metadataHydrating, setMetadataHydrating] = useState(false);
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
const loadBaseMarketplace = useCallback(async (refresh = false) => {
const requestId = ++latestPrimaryRequestRef.current;
latestMetadataRequestRef.current += 1;
setMetadataHydrating(false);
setLoading(true);
try {
const res = await api.getModelsMarketplace({
refresh,
includePricing: false,
});
if (requestId !== latestPrimaryRequestRef.current) return null;
const next = res as ModelsMarketplaceResponse;
setData(next);
if (refresh && next.meta?.refreshRequested) {
if (next.meta.refreshReused) {
toast.info(tr('模型广场刷新进行中'));
} else if (next.meta.refreshQueued) {
toast.info(tr('已开始刷新模型广场'));
}
}
return next;
} catch {
if (requestId !== latestPrimaryRequestRef.current) return null;
setData({ models: [] });
return null;
} finally {
if (requestId === latestPrimaryRequestRef.current) {
setLoading(false);
}
}
}, [toast]);
const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => {
if (!shouldHydrateMarketplaceMetadata(baseModels)) return;
const metadataRequestId = ++latestMetadataRequestRef.current;
const baseRequestId = latestPrimaryRequestRef.current;
setMetadataHydrating(true);
try {
const res = await api.getModelsMarketplace({
includePricing: true,
});
if (metadataRequestId !== latestMetadataRequestRef.current) return;
if (baseRequestId !== latestPrimaryRequestRef.current) return;
const detailed = res as ModelsMarketplaceResponse;
setData((current) => ({
...current,
models: mergeMarketplaceMetadata(current.models, detailed.models),
meta: detailed.meta ?? current.meta,
}));
} catch {
// Keep the fast base list when metadata fetch fails.
} finally {
if (metadataRequestId === latestMetadataRequestRef.current) {
setMetadataHydrating(false);
}
}
}, []);
useEffect(() => {
let cancelled = false;
const bootstrap = async () => {
const initial = await loadBaseMarketplace(false);
if (!initial || cancelled) return;
void hydrateMarketplaceMetadata(initial.models);
};
void bootstrap();
return () => {
cancelled = true;
latestMetadataRequestRef.current += 1;
};
}, [hydrateMarketplaceMetadata, loadBaseMarketplace]);
const handleRefresh = useCallback(() => {
void (async () => {
const refreshed = await loadBaseMarketplace(true);
if (!refreshed) return;
void hydrateMarketplaceMetadata(refreshed.models);
})();
}, [hydrateMarketplaceMetadata, loadBaseMarketplace]);
useEffect(() => {
const q = new URLSearchParams(location.search).get('q') || '';
setSearch(q);
}, [location.search]);
/* ---- derived: brand list ---- */
const brandList = useMemo(() => {
const m = new Map<string, { count: number; brand: BrandInfo }>();
let otherCount = 0;
for (const model of data.models) {
const brand = getBrand(model.name);
if (brand) {
const existing = m.get(brand.name);
if (existing) existing.count++;
else m.set(brand.name, { count: 1, brand });
} else {
otherCount++;
}
}
const list = [...m.entries()].sort((a, b) => b[1].count - a[1].count);
return { list, otherCount };
}, [data.models]);
/* ---- derived: site list ---- */
const siteMap = useMemo(() => {
const m = new Map<string, number>();
for (const model of data.models) {
for (const a of model.accounts) {
m.set(a.site, (m.get(a.site) || 0) + 1);
}
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}, [data.models]);
/* ---- filtered + sorted ---- */
const filteredModels = useMemo(() => {
let list = data.models;
if (activeBrand) {
if (activeBrand === '__other__') {
list = list.filter(m => !getBrand(m.name));
} else {
list = list.filter(m => getBrand(m.name)?.name === activeBrand);
}
}
if (activeSite) {
list = list.filter(m => m.accounts.some(a => a.site === activeSite));
}
if (search) {
const q = search.toLowerCase();
list = list.filter(m => m.name.toLowerCase().includes(q));
}
return [...list].sort((a, b) => {
if (sortBy === 'name') {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
}
const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0);
const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0);
if (va === vb) return a.name.localeCompare(b.name);
return sortDir === 'desc' ? vb - va : va - vb;
});
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
const safePageVal = Math.min(page, totalPages);
const paged = filteredModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]);
/* ---- stats ---- */
const totalCoverageSlots = filteredModels.reduce((s, m) => s + m.accountCount, 0);
const uniqueAccountCount = (() => {
const ids = new Set<number>();
for (const model of filteredModels) {
for (const account of model.accounts) {
ids.add(account.id);
}
}
return ids.size;
})();
const avgLatency = filteredModels.length
? Math.round(filteredModels.reduce((s, m) => s + m.avgLatency, 0) / filteredModels.length)
: 0;
/* ---- copy ---- */
const copyName = (name: string) => {
navigator.clipboard.writeText(name).catch(() => { });
setCopied(name);
setTimeout(() => setCopied(null), 1500);
};
/* ---- loading skeleton ---- */
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 260, height: 28, marginBottom: 20 }} />
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ width: 240 }}>
{[...Array(6)].map((_, i) => <div key={i} className="skeleton" style={{ height: 28, marginBottom: 8, borderRadius: 8 }} />)}
</div>
<div style={{ flex: 1 }}>
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
</div>
</div>
</div>
);
}
return (
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{/* ====== LEFT: Filter Panel ====== */}
{!filterCollapsed && (
<div className="filter-panel">
{/* Brand filter */}
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('品牌')}
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</button>}
</div>
<div
className={`filter-item ${!activeBrand ? 'active' : ''}`}
onClick={() => setActiveBrand(null)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}>✦</span>
{tr('全部品牌')}
<span className="filter-item-count">{data.models.length}</span>
</div>
{brandList.list.map(([brandName, { count, brand }]) => (
<div
key={brandName}
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brandName}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{brandList.otherCount > 0 && (
<div
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span>
{tr('其他')}
<span className="filter-item-count">{brandList.otherCount}</span>
</div>
)}
</div>
{/* Supplier filter */}
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
{/* Sort */}
<div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div>
{[
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
{ key: 'tokenCount' as SortColumn, label: tr('令牌数') },
{ key: 'avgLatency' as SortColumn, label: tr('延迟') },
{ key: 'successRate' as SortColumn, label: tr('成功率') },
{ key: 'name' as SortColumn, label: tr('名称') },
].map(opt => (
<div
key={opt.key}
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
onClick={() => {
if (sortBy === opt.key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(opt.key);
setSortDir(opt.key === 'name' ? 'asc' : 'desc');
}
}}
>
{opt.label}
{sortBy === opt.key && (
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--color-primary)' }}>
{sortDir === 'desc' ? '↓' : '↑'}
</span>
)}
</div>
))}
</div>
<button
className="btn btn-ghost"
style={{ width: '100%', fontSize: 12, padding: '6px 10px', marginTop: 8, justifyContent: 'center', border: '1px solid var(--color-border)' }}
onClick={() => setFilterCollapsed(true)}
>
{tr('收起')}
</button>
</div>
)}
{/* ====== RIGHT: Content Area ====== */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 16 }}>
<div>
<h2 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{activeBrand || activeSite || tr('模型广场')}
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500 }}>
{tr('共')} {filteredModels.length} {tr('个模型')}
</span>
</h2>
{(activeBrand || activeSite) && (
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '4px 0 0' }}>
{activeBrand && activeBrand !== '__other__' ? `${tr('查看')} ${activeBrand} ${tr('品牌的所有模型')}` : activeSite ? `${tr('来自供应商')} ${activeSite} ${tr('的模型')}` : tr('其他未归类的模型')}
</p>
)}
</div>
<div className="page-actions">
{filterCollapsed && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }} onClick={() => setFilterCollapsed(false)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>
{tr('筛选')}
</button>
)}
<button onClick={handleRefresh} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{metadataHydrating && (
<span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('加载元数据中...')}</span>
)}
<div className="view-toggle">
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} title={tr('卡片视图')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} title={tr('表格视图')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
</button>
</div>
</div>
</div>
{/* Toolbar */}
<div className="toolbar">
<div className="toolbar-search">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={tr('模糊搜索模型名称')}
/>
</div>
{/* Quick stats */}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span title={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖槽位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span title={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
</div>
</div>
{/* Empty */}
{filteredModels.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
<div className="empty-state-title">{tr('暂无模型数据')}</div>
<div className="empty-state-desc">{tr('请先检查站点与账号状态,然后点击刷新。')}</div>
</div>
) : viewMode === 'card' ? (
/* ====== Card View ====== */
<div>
{paged.map(m => (
<div key={m.name} className="model-card" onClick={() => setExpanded(expanded === m.name ? null : m.name)}>
<div className="model-card-header">
<BrandIcon model={m.name} size={44} />
<div className="model-card-info">
<div className="model-card-name">{m.name}</div>
<div className="model-card-meta">
<span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{m.accountCount} {tr('个账号')}
</span>
<span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
{m.tokenCount} {tr('令牌')}
</span>
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
title={tr('平均延迟')}
>
{tr('延迟')} {m.avgLatency}ms
</span>
<span
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
title={tr('成功率')}
>
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '—'}
</span>
</div>
</div>
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" title={tr('复制模型名')} onClick={() => copyName(m.name)}>
{copied === m.name ? (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
)}
</button>
<button
className="model-card-action-btn"
title={expanded === m.name ? tr('收起') : tr('展开')}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: expanded === m.name ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
{/* Tags */}
<div className="model-card-tags">
{getBrand(m.name) && (
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
)}
{m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
<span key={site} className="model-tag model-tag-blue">{site}</span>
))}
{m.successRate != null && m.successRate >= 90 && (
<span className="model-tag model-tag-green">{tr('健康')}</span>
)}
{m.successRate != null && m.successRate < 60 && (
<span className="model-tag model-tag-orange">{tr('风险')}</span>
)}
{m.avgLatency <= 500 && (
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
)}
</div>
{/* Expand: Account Details */}
{expanded === m.name && (
<div className="model-card-expand" onClick={e => e.stopPropagation()}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{resolveMarketplaceDescription(m, metadataHydrating)}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{m.tags.length > 0 ? m.tags.map((tag) => (
<span key={tag} className="badge badge-info">{tag}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}</span>}
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('接口能力')}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => (
<span key={endpoint} className="badge badge-success">{endpoint}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('未提供')}</span>}
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
</div>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ fontWeight: 500 }}>{tr('站点')}</th>
<th style={{ fontWeight: 500 }}>{tr('账号')}</th>
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th>
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th>
<th style={{ fontWeight: 500 }}>{tr('余额')}</th>
</tr>
</thead>
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><span className="badge badge-info">{a.site}</span></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
)) : <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
</td>
<td>
{a.latency != null ? (
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
) : '—'}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
) : (
/* ====== Table View ====== */
<div className="card" style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 44 }} />
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ width: 60 }}>{tr('操作')}</th>
</tr>
</thead>
<tbody>
{paged.map(m => (
<React.Fragment key={m.name}>
<tr onClick={() => setExpanded(expanded === m.name ? null : m.name)} style={{ cursor: 'pointer' }}>
<td>
<BrandIcon model={m.name} size={28} />
</td>
<td>
<code style={{ fontSize: 12, padding: '3px 8px', background: 'var(--color-bg)', borderRadius: 4, border: '1px solid var(--color-border-light)' }}>
{m.name}
</code>
</td>
<td><span className="badge badge-info">{m.accountCount}</span></td>
<td><span className="badge badge-muted">{m.tokenCount}</span></td>
<td>
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
>
{m.avgLatency}ms
</span>
</td>
<td>
<span
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
style={{ fontSize: 12, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
>
{m.successRate != null ? `${m.successRate}%` : '—'}
</span>
</td>
<td onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" title={tr('复制')} onClick={() => copyName(m.name)}>
{copied === m.name ? (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
)}
</button>
</td>
</tr>
{expanded === m.name && (
<tr className="log-detail-row">
<td colSpan={7} style={{ padding: 0 }}>
<div style={{ padding: '12px 16px 12px 54px' }}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{resolveMarketplaceDescription(m, metadataHydrating)}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{m.tags.length > 0 ? m.tags.map((tag) => (
<span key={tag} className="badge badge-info">{tag}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}</span>}
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('接口能力')}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => (
<span key={endpoint} className="badge badge-success">{endpoint}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('未提供')}</span>}
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
</div>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<thead><tr style={{ color: 'var(--color-text-muted)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('站点')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('账号')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('令牌')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('延迟')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('余额')}</th>
</tr></thead>
<tbody>
{m.accounts.map(a => (
<tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}>
<td style={{ padding: 8 }}>{a.site}</td>
<td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-info'}`}>{t.name}</span>
)) : '—'}
</td>
<td style={{ padding: 8, color: a.latency != null ? getMetricColor(a.latency) : 'var(--color-text-muted)' }}>
{a.latency != null ? `${a.latency}ms` : '—'}
</td>
<td style={{ padding: 8 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{filteredModels.length > 0 && (
<div className="pagination">
<button className="pagination-btn" disabled={safePageVal <= 1} onClick={() => setPage(p => p - 1)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let pageNum: number;
if (totalPages <= 7) {
pageNum = i + 1;
} else if (safePageVal <= 4) {
pageNum = i + 1;
} else if (safePageVal >= totalPages - 3) {
pageNum = totalPages - 6 + i;
} else {
pageNum = safePageVal - 3 + i;
}
return (
<button key={pageNum} className={`pagination-btn ${safePageVal === pageNum ? 'active' : ''}`} onClick={() => setPage(pageNum)}>
{pageNum}
</button>
);
})}
<button className="pagination-btn" disabled={safePageVal >= totalPages} onClick={() => setPage(p => p + 1)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
<div className="pagination-size">
{tr('每页条数')}:
<div style={{ minWidth: 86 }}>
<ModernSelect
size="sm"
value={String(pageSize)}
onChange={(nextValue) => setPageSize(Number(nextValue))}
options={PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) }))}
placeholder={String(pageSize)}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type MonitorSite = {
id: string;
name: string;
url: string;
description: string;
requiresLinuxDoOAuth?: boolean;
};
type MonitorConfig = {
ldohCookieConfigured: boolean;
ldohCookieMasked?: string;
};
const MONITOR_SITES: MonitorSite[] = [
{
id: 'check-linux-do',
name: 'check.linux.do',
url: 'https://check.linux.do',
description: 'LinuxDo 可用性监控',
},
{
id: 'ldoh-105117',
name: 'ldoh.105117.xyz',
url: 'https://ldoh.105117.xyz',
description: 'LDOH 监控面板',
requiresLinuxDoOAuth: true,
},
];
export default function Monitors() {
const toast = useToast();
const [activeSiteId, setActiveSiteId] = useState(MONITOR_SITES[0].id);
const [reloadSeed, setReloadSeed] = useState(0);
const [loaded, setLoaded] = useState(false);
const [showFallbackHint, setShowFallbackHint] = useState(false);
const [monitorConfig, setMonitorConfig] = useState<MonitorConfig>({ ldohCookieConfigured: false });
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const activeSite = useMemo(
() => MONITOR_SITES.find((site) => site.id === activeSiteId) || MONITOR_SITES[0],
[activeSiteId],
);
const loadMonitorConfig = async () => {
try {
const res = await api.getMonitorConfig();
setMonitorConfig({
ldohCookieConfigured: !!res?.ldohCookieConfigured,
ldohCookieMasked: typeof res?.ldohCookieMasked === 'string' ? res.ldohCookieMasked : '',
});
} catch (err: any) {
toast.error(err?.message || '加载监控配置失败');
}
};
useEffect(() => {
void loadMonitorConfig();
// Set HttpOnly monitor auth cookie for iframe proxy.
void api.initMonitorSession().catch(() => {});
}, []);
useEffect(() => {
setLoaded(false);
setShowFallbackHint(false);
const timer = window.setTimeout(() => {
setShowFallbackHint(true);
}, 4500);
void api.initMonitorSession().catch(() => {});
return () => window.clearTimeout(timer);
}, [activeSiteId, reloadSeed]);
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
const handleSaveCookie = async () => {
setSavingCookie(true);
try {
await api.updateMonitorConfig({ ldohCookie: cookieInput.trim() || null });
await loadMonitorConfig();
setCookieInput('');
setReloadSeed((prev) => prev + 1);
toast.success('LDOH Cookie 已更新');
} catch (err: any) {
toast.error(err?.message || '保存 Cookie 失败');
} finally {
setSavingCookie(false);
}
};
const fallbackHint = usingCookieProxy
? '代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。'
: '当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。';
return (
<div className="animate-fade-in monitor-page">
<div className="monitor-toolbar page-header">
<div>
<h2 className="page-title">{tr('监控内嵌')}</h2>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
在 metapi 内查看外部站点监控页面。
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setReloadSeed((prev) => prev + 1)}
title="重新加载当前站点"
>
刷新
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
title="在新窗口直接打开目标站点"
>
新窗口打开
</button>
</div>
</div>
<div className="monitor-tabs card">
{MONITOR_SITES.map((site) => (
<button
key={site.id}
type="button"
className={`monitor-tab ${site.id === activeSite.id ? 'active' : ''}`}
onClick={() => setActiveSiteId(site.id)}
>
<span>{site.name}</span>
<span className="monitor-tab-desc">{site.description}</span>
</button>
))}
</div>
{activeSite.requiresLinuxDoOAuth && (
<div className="monitor-oauth-hint card">
<div style={{ fontSize: 13, fontWeight: 600 }}>
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
</div>
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.65 }}>
{!usingCookieProxy && (
<>
1. 先点击“授权登录(新窗口)”完成 LinuxDo 登录。<br />
2. 回来后把 `ld_auth_session` 填到下方,启用 Cookie 代理内嵌。<br />
</>
)}
{usingCookieProxy && (
<>
当前使用服务端代理访问,不依赖跨站第三方 Cookie。<br />
已保存 Cookie:{monitorConfig.ldohCookieMasked || '(已配置)'}<br />
</>
)}
Cookie 过期后请重新粘贴保存。
</div>
<div className="monitor-cookie-row">
<input
value={cookieInput}
onChange={(e) => setCookieInput(e.target.value)}
placeholder="粘贴 ld_auth_session 或 ld_auth_session=xxx"
className="monitor-cookie-input"
/>
<button
type="button"
className="btn btn-primary"
onClick={handleSaveCookie}
disabled={savingCookie}
>
{savingCookie ? '保存中...' : (cookieInput.trim() ? '保存 Cookie' : '清空 Cookie')}
</button>
</div>
<div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open(ldohOauthUrl, '_blank', 'noopener,noreferrer')}
>
授权登录(新窗口)
</button>
{usingCookieProxy && (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open('/monitor-proxy/ldoh/', '_blank', 'noopener,noreferrer')}
>
通过代理打开
</button>
)}
</div>
</div>
)}
<div className="monitor-frame-shell card">
{showFallbackHint && !loaded && (
<div className="monitor-hint">
{fallbackHint}
</div>
)}
<iframe
key={`${activeSite.id}-${reloadSeed}-${usingCookieProxy ? 'proxy' : 'direct'}`}
src={iframeUrl}
title={`monitor-${activeSite.id}`}
className="monitor-iframe"
onLoad={() => setLoaded(true)}
/>
</div>
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type RuntimeSettings = {
webhookUrl: string;
barkUrl: string;
webhookEnabled: boolean;
barkEnabled: boolean;
serverChanEnabled: boolean;
smtpEnabled: boolean;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassMasked?: string;
smtpFrom: string;
smtpTo: string;
serverChanKeyMasked?: string;
notifyCooldownSec: number;
};
export default function NotificationSettings() {
const [runtime, setRuntime] = useState<RuntimeSettings>({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
const [serverChanKey, setServerChanKey] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [loading, setLoading] = useState(true);
const [savingNotify, setSavingNotify] = useState(false);
const [testingNotify, setTestingNotify] = useState(false);
const toast = useToast();
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)',
transition: 'border-color 0.2s',
};
const loadSettings = async () => {
setLoading(true);
try {
const runtimeInfo = await api.getRuntimeSettings();
setRuntime({
webhookUrl: runtimeInfo.webhookUrl || '',
barkUrl: runtimeInfo.barkUrl || '',
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587,
smtpSecure: !!runtimeInfo.smtpSecure,
smtpUser: runtimeInfo.smtpUser || '',
smtpPassMasked: runtimeInfo.smtpPassMasked || '',
smtpFrom: runtimeInfo.smtpFrom || '',
smtpTo: runtimeInfo.smtpTo || '',
serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '',
notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec))
? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec)))
: 300,
});
} catch (err: any) {
toast.error(err?.message || '加载通知设置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSettings();
}, []);
const saveNotify = async () => {
setSavingNotify(true);
try {
const payload: any = {
webhookUrl: runtime.webhookUrl,
barkUrl: runtime.barkUrl,
webhookEnabled: runtime.webhookEnabled,
barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort,
smtpSecure: runtime.smtpSecure,
smtpUser: runtime.smtpUser,
smtpFrom: runtime.smtpFrom,
smtpTo: runtime.smtpTo,
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
};
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
const res = await api.updateRuntimeSettings(payload);
setRuntime((prev) => ({
...prev,
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
}));
setServerChanKey('');
setSmtpPass('');
toast.success('通知设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingNotify(false);
}
};
const testNotify = async () => {
setTestingNotify(true);
try {
const res = await api.testNotification();
toast.success(res?.message || '测试通知已发送');
} catch (err: any) {
toast.error(err?.message || '触发测试通知失败');
} finally {
setTestingNotify(false);
}
};
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
return (
<div className="animate-fade-in" style={{ paddingBottom: 40 }}>
{/* 头部标题与操作 */}
<div className="page-header">
<h2 className="page-title">{tr('通知设置')}</h2>
<div className="page-actions">
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
</button>
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
</button>
</div>
</div>
<div style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
相同告警在冷静期内不会重复推送;冷静期结束后会自动合并重复条数。
</div>
<div style={{ maxWidth: 260 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
冷静期(秒)
</div>
<input
type="number"
min={0}
value={runtime.notifyCooldownSec}
onChange={(e) => setRuntime((prev) => ({
...prev,
notifyCooldownSec: Math.max(0, Math.trunc(Number(e.target.value) || 0)),
}))}
style={inputStyle}
/>
</div>
</div>
{/* 卡片:Webhook & Bark */}
<div className="card animate-slide-up stagger-2" style={{ padding: 24, border: (runtime.webhookEnabled || runtime.barkEnabled) ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送简单消息通知</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.webhookEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Webhook</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.webhookEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookEnabled: e.target.checked }))}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.barkEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Bark</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.barkEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkEnabled: e.target.checked }))}
/>
</label>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ opacity: runtime.webhookEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Webhook URL</div>
<input
value={runtime.webhookUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://your-webhook-url (可选)"
style={inputStyle}
disabled={!runtime.webhookEnabled}
/>
</div>
<div style={{ opacity: runtime.barkEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Bark URL</div>
<input
value={runtime.barkUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkUrl: e.target.value }))}
placeholder="https://api.day.app/your_key (可选)"
style={inputStyle}
disabled={!runtime.barkEnabled}
/>
</div>
</div>
</div>
{/* 卡片:Server酱 */}
<div className="card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Server酱 (SendKey)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>微信推送消息支持</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.serverChanEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Server酱</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.serverChanEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, serverChanEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ opacity: runtime.serverChanEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
当前配置: {runtime.serverChanKeyMasked || '未设置'}
</code>
<input
type="password"
value={serverChanKey}
onChange={(e) => setServerChanKey(e.target.value)}
placeholder="输入新的 Server酱 Key(留空则不改)"
style={inputStyle}
disabled={!runtime.serverChanEnabled}
/>
</div>
</div>
{/* 卡片:SMTP 邮件设置 */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>邮件服务 (SMTP)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过电子邮件推送提醒</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.smtpEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 SMTP</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.smtpEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.smtpEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
{/* Host */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>SMTP 服务器</div>
<input
value={runtime.smtpHost}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpHost: e.target.value }))}
placeholder="例如: smtp.qq.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Port & Secure */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>端口</div>
<input
type="number"
min={1}
value={runtime.smtpPort}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpPort: Number(e.target.value) || 0 }))}
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', paddingBottom: 12 }}>
<input
type="checkbox"
checked={runtime.smtpSecure}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpSecure: e.target.checked }))}
disabled={!runtime.smtpEnabled}
/>
启用 TLS/SSL
</label>
</div>
{/* User */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>账号用户</div>
<input
value={runtime.smtpUser}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpUser: e.target.value }))}
placeholder="SMTP 用户名"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Pass */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
账号密码
{runtime.smtpPassMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="输入以更改密码..."
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* From */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>发件人地址</div>
<input
value={runtime.smtpFrom}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpFrom: e.target.value }))}
placeholder="例如: admin@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* To */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>接收地址</div>
<input
value={runtime.smtpTo}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpTo: e.target.value }))}
placeholder="例如: target@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
</div>
</div>
</div>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { tr } from '../i18n.js';
type ProgramEvent = {
id: number;
type: string;
title: string;
message?: string | null;
level: 'info' | 'warning' | 'error';
read: boolean;
relatedId?: number | null;
relatedType?: string | null;
createdAt?: string | null;
};
const PAGE_SIZE = 80;
const TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'checkin', label: '签到' },
{ value: 'balance', label: '余额' },
{ value: 'token', label: '令牌' },
{ value: 'proxy', label: '代理' },
{ value: 'status', label: '状态' },
];
function levelLabel(level: string) {
if (level === 'error') return { label: '错误', cls: 'badge-error' };
if (level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
function eventStatusLabel(row: ProgramEvent) {
const text = `${row.title || ''} ${row.message || ''}`.toLowerCase();
if (text.includes('失败') || text.includes('failed') || text.includes('error')) {
return { label: '失败', cls: 'badge-error' };
}
if (text.includes('跳过') || text.includes('skipped')) {
return { label: '跳过', cls: 'badge-warning' };
}
if (text.includes('进行中') || text.includes('已开始') || text.includes('running') || text.includes('pending')) {
return { label: '进行中', cls: 'badge-info' };
}
if (text.includes('成功') || text.includes('已完成') || text.includes('completed') || text.includes('finished')) {
return { label: '成功', cls: 'badge-success' };
}
if (row.level === 'error') return { label: '异常', cls: 'badge-error' };
if (row.level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
export default function ProgramLogs() {
const [events, setEvents] = useState<ProgramEvent[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [filterType, setFilterType] = useState('');
const [onlyUnread, setOnlyUnread] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [clearing, setClearing] = useState(false);
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
const toast = useToast();
const load = async (silent = false, append = false) => {
if (append) setLoadingMore(true);
else if (silent) setRefreshing(true);
else setLoading(true);
try {
const nextOffset = append ? offset : 0;
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(nextOffset));
if (filterType) params.set('type', filterType);
if (onlyUnread) params.set('read', 'false');
const rows = await api.getEvents(params.toString());
const safeRows = Array.isArray(rows) ? rows : [];
setEvents((prev) => (append ? [...prev, ...safeRows] : safeRows));
const loaded = append ? nextOffset + safeRows.length : safeRows.length;
setOffset(loaded);
setHasMore(safeRows.length >= PAGE_SIZE);
} catch (e: any) {
toast.error(e.message || '加载程序日志失败');
} finally {
setLoading(false);
setLoadingMore(false);
setRefreshing(false);
}
};
useEffect(() => {
load();
}, [filterType, onlyUnread]);
const visibleRows = useMemo(() => events, [events]);
const withRowLoading = async (id: number, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [id]: true }));
try {
await fn();
} finally {
setRowLoading((prev) => ({ ...prev, [id]: false }));
}
};
const markOneRead = async (id: number) => {
await withRowLoading(id, async () => {
await api.markEventRead(id);
setEvents((prev) => {
if (onlyUnread) return prev.filter((item) => item.id !== id);
return prev.map((item) => (item.id === id ? { ...item, read: true } : item));
});
});
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllEventsRead();
if (onlyUnread) setEvents([]);
else setEvents((prev) => prev.map((item) => ({ ...item, read: true })));
toast.success('已标记全部为已读');
} catch (e: any) {
toast.error(e.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const clearAll = async () => {
setClearing(true);
try {
await api.clearEvents();
setEvents([]);
setOffset(0);
setHasMore(false);
toast.success('日志已清空');
} catch (e: any) {
toast.error(e.message || '清空失败');
} finally {
setClearing(false);
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('程序日志')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
</button>
</div>
</div>
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 20 }}>
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : visibleRows.length > 0 ? (
<table className="data-table program-logs-table">
<colgroup>
<col style={{ width: 170 }} />
<col style={{ width: 90 }} />
<col style={{ width: 90 }} />
<col style={{ width: 260 }} />
<col />
<col style={{ width: 110 }} />
<col style={{ width: 140 }} />
</colgroup>
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>级别</th>
<th>标题</th>
<th>内容</th>
<th>状态</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row, idx) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<tr key={row.id} className={`animate-slide-up stagger-${Math.min(idx + 1, 5)}`}>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatDateTimeLocal(row.createdAt)}
</td>
<td>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{row.type || '-'}
</span>
</td>
<td>
<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>
{level.label}
</span>
</td>
<td className="program-logs-title-cell">
{row.title || '-'}
</td>
<td className="program-logs-content-cell">
{row.message || '-'}
</td>
<td>
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>
{eventStatus.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
{row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
) : (
<span className="badge badge-warning" style={{ fontSize: 11 }}>未读</span>
)}
{!row.read && (
<button
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title">暂无日志</div>
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
</div>
)}
</div>
{!loading && visibleRows.length > 0 && hasMore && (
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => load(false, true)}
disabled={loadingMore}
style={{ border: '1px solid var(--color-border)', padding: '8px 16px' }}
>
{loadingMore ? <><span className="spinner spinner-sm" /> 加载中...</> : '加载更多'}
</button>
</div>
)}
</div>
);
}
+357
View File
@@ -0,0 +1,357 @@
import React, { useEffect, useState, useCallback } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { tr } from '../i18n.js';
type StatusFilter = 'all' | 'success' | 'failed';
interface ProxyLog {
id: number;
createdAt: string;
modelRequested: string;
modelActual: string;
status: string;
latencyMs: number;
totalTokens: number | null;
retryCount: number;
accountId?: number;
username?: string | null;
siteName?: string | null;
siteUrl?: string | null;
errorMessage?: string;
promptTokens?: number;
completionTokens?: number;
estimatedCost?: number;
}
const PAGE_SIZES = [20, 50, 100];
function formatLatency(ms: number) {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`;
}
return `${ms}ms`;
}
function latencyColor(ms: number) {
if (ms > 10000) return 'var(--color-danger)';
if (ms > 3000) return 'var(--color-warning)';
return 'var(--color-success)';
}
function latencyBgColor(ms: number) {
if (ms > 10000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
if (ms > 3000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
export default function ProxyLogs() {
const [logs, setLogs] = useState<ProxyLog[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(100);
const toast = useToast();
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.getProxyLogs('limit=500');
setLogs(data);
} catch (e: any) {
toast.error(e.message || '加载日志失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
/* ---- derived ---- */
const filtered = logs.filter(log => {
if (statusFilter === 'success' && log.status !== 'success') return false;
if (statusFilter === 'failed' && log.status === 'success') return false;
if (search) {
const q = search.toLowerCase();
return (log.modelRequested || '').toLowerCase().includes(q) ||
(log.modelActual || '').toLowerCase().includes(q);
}
return true;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
useEffect(() => { setPage(1); }, [statusFilter, search, pageSize]);
/* ---- stats ---- */
const totalCount = logs.length;
const successCount = logs.filter(l => l.status === 'success').length;
const failedCount = totalCount - successCount;
const totalCost = logs.reduce((s, l) => s + (l.estimatedCost || 0), 0);
const totalTokensAll = logs.reduce((s, l) => s + (l.totalTokens || 0), 0);
return (
<div className="animate-fade-in">
{/* Header + Stat Badges */}
<div className="page-header" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 className="page-title">{tr('使用日志')}</h2>
<span className="kpi-chip kpi-chip-success">
消耗总额 ${totalCost.toFixed(4)}
</span>
<span className="kpi-chip kpi-chip-warning">
{totalTokensAll.toLocaleString()} tokens
</span>
</div>
<button onClick={load} disabled={loading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{loading ? '加载中...' : '刷新'}
</button>
</div>
{/* Toolbar: Filter + Search */}
<div className="toolbar" style={{ marginBottom: 12 }}>
<div className="pill-tabs">
{([
{ key: 'all' as StatusFilter, label: '全部', count: totalCount },
{ key: 'success' as StatusFilter, label: '成功', count: successCount },
{ key: 'failed' as StatusFilter, label: '失败', count: failedCount },
]).map(tab => (
<button
key={tab.key}
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
onClick={() => setStatusFilter(tab.key)}
>
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
</button>
))}
</div>
<div className="toolbar-search" style={{ maxWidth: 280 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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 value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索模型名称..." />
</div>
</div>
{/* Table */}
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
{[...Array(8)].map((_, i) => (
<div key={i} style={{ display: 'flex', gap: 16 }}>
<div className="skeleton" style={{ width: 140, height: 16 }} />
<div className="skeleton" style={{ width: 200, height: 16 }} />
<div className="skeleton" style={{ width: 50, height: 16 }} />
<div className="skeleton" style={{ width: 50, height: 16 }} />
<div className="skeleton" style={{ width: 50, height: 16 }} />
<div className="skeleton" style={{ width: 70, height: 16 }} />
</div>
))}
</div>
) : (
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 28 }} />
<th>时间</th>
<th>模型</th>
<th>状态</th>
<th style={{ textAlign: 'center' }}>用时</th>
<th style={{ textAlign: 'right' }}>输入</th>
<th style={{ textAlign: 'right' }}>输出</th>
<th style={{ textAlign: 'right' }}>花费</th>
<th style={{ textAlign: 'center' }}>重试</th>
</tr>
</thead>
<tbody>
{paged.map(log => (
<React.Fragment key={log.id}>
<tr
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
style={{
cursor: 'pointer',
background: expanded === log.id ? 'var(--color-primary-light)' : undefined,
transition: 'background 0.15s',
}}
>
<td style={{ padding: '8px 4px 8px 12px' }}>
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{
transform: expanded === log.id ? 'rotate(90deg)' : 'none',
transition: 'transform 0.2s',
color: 'var(--color-text-muted)',
}}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</td>
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
{formatDateTimeLocal(log.createdAt)}
</td>
<td>
<ModelBadge model={log.modelRequested} />
</td>
<td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)',
}} />
{log.status === 'success' ? '成功' : '失败'}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums',
fontSize: 12,
fontWeight: 600,
color: latencyColor(log.latencyMs),
background: latencyBgColor(log.latencyMs),
padding: '2px 8px',
borderRadius: 4,
}}>
{formatLatency(log.latencyMs)}
</span>
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
{log.promptTokens?.toLocaleString() || '—'}
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
{log.completionTokens?.toLocaleString() || '—'}
</td>
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '—'}
</td>
<td style={{ textAlign: 'center' }}>
{log.retryCount > 0 ? (
<span className="badge badge-warning" style={{ fontSize: 11 }}>{log.retryCount}</span>
) : (
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>0</span>
)}
</td>
</tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={9} style={{ padding: 0 }}>
<div className="animate-fade-in" style={{
padding: '14px 20px 14px 40px',
borderTop: '1px solid var(--color-border-light)',
borderBottom: '1px solid var(--color-border-light)',
fontSize: 12,
lineHeight: 1.9,
color: 'var(--color-text-secondary)',
}}>
{/* 日志详情 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
<span>
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
{log.modelActual && log.modelActual !== log.modelRequested && (
<> → 实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
)}
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
,用时 <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
</span>
</div>
{/* 计费过程 section */}
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
<span>
输入 {log.promptTokens?.toLocaleString() || 0} tokens
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
{typeof log.estimatedCost === 'number' && (
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
)}
</span>
</div>
{/* 请求路径 */}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>请求路径</span>
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
border: '1px solid var(--color-border-light)',
}}>
/v1/chat/completions
</code>
</div>
{/* 错误信息 */}
{log.errorMessage && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
<span style={{ color: 'var(--color-danger)' }}>{log.errorMessage}</span>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
)}
{!loading && filtered.length === 0 && (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
<div className="empty-state-title">{tr('暂无使用日志')}</div>
<div className="empty-state-desc">当请求通过代理时,日志将显示在这里</div>
</div>
)}
</div>
{/* Pagination */}
{filtered.length > 0 && (
<div className="pagination">
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginRight: 'auto' }}>
显示第 {(safePage - 1) * pageSize + 1} - {Math.min(safePage * pageSize, filtered.length)} 条,共 {filtered.length} 条
</div>
<button className="pagination-btn" disabled={safePage <= 1} onClick={() => setPage(p => p - 1)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
let num: number;
if (totalPages <= 7) num = i + 1;
else if (safePage <= 4) num = i + 1;
else if (safePage >= totalPages - 3) num = totalPages - 6 + i;
else num = safePage - 3 + i;
return (
<button key={num} className={`pagination-btn ${safePage === num ? 'active' : ''}`} onClick={() => setPage(num)}>
{num}
</button>
);
})}
<button className="pagination-btn" disabled={safePage >= totalPages} onClick={() => setPage(p => p + 1)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button>
<div className="pagination-size">
每页条数:
<div style={{ minWidth: 86 }}>
<ModernSelect
size="sm"
value={String(pageSize)}
onChange={(nextValue) => setPageSize(Number(nextValue))}
options={PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) }))}
placeholder={String(pageSize)}
/>
</div>
</div>
</div>
)}
</div>
);
}
+474
View File
@@ -0,0 +1,474 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import {
applyRoutingProfilePreset,
resolveRoutingProfilePreset,
type RoutingWeights,
} from './helpers/routingProfiles.js';
import { clearAuthSession } from '../authSession.js';
import { tr } from '../i18n.js';
const PROXY_TOKEN_PREFIX = 'sk-';
type RuntimeSettings = {
checkinCron: string;
balanceRefreshCron: string;
routingFallbackUnitCost: number;
routingWeights: RoutingWeights;
proxyTokenMasked?: string;
adminIpAllowlist?: string[];
};
const defaultWeights: RoutingWeights = {
baseWeightFactor: 0.5,
valueScoreFactor: 0.5,
costWeight: 0.4,
balanceWeight: 0.3,
usageWeight: 0.3,
};
export default function Settings() {
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
routingFallbackUnitCost: 1,
routingWeights: defaultWeights,
});
const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false);
const [adminIpAllowlistText, setAdminIpAllowlistText] = useState('');
const [clearingCache, setClearingCache] = useState(false);
const [clearingUsage, setClearingUsage] = useState(false);
const [showChangeKey, setShowChangeKey] = useState(false);
const toast = useToast();
const activeRoutingProfile = useMemo(
() => resolveRoutingProfilePreset(runtime.routingWeights),
[runtime.routingWeights],
);
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 loadSettings = async () => {
setLoading(true);
try {
const [authInfo, runtimeInfo] = await Promise.all([
api.getAuthInfo(),
api.getRuntimeSettings(),
]);
setMaskedToken(authInfo.masked || '****');
setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost)
: 1,
routingWeights: {
...defaultWeights,
...(runtimeInfo.routingWeights || {}),
},
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [],
});
setAdminIpAllowlistText(
Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.join('\n')
: '',
);
} catch (err: any) {
toast.error(err?.message || '加载设置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSettings();
}, []);
const normalizeProxyTokenSuffix = (raw: string) => {
const compact = raw.replace(/\s+/g, '');
if (compact.toLowerCase().startsWith(PROXY_TOKEN_PREFIX)) {
return compact.slice(PROXY_TOKEN_PREFIX.length);
}
return compact;
};
const saveSchedule = async () => {
setSavingSchedule(true);
try {
await api.updateRuntimeSettings({
checkinCron: runtime.checkinCron,
balanceRefreshCron: runtime.balanceRefreshCron,
});
toast.success('定时任务设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingSchedule(false);
}
};
const saveProxyToken = async () => {
const suffix = proxyTokenSuffix.trim();
if (!suffix) {
toast.info(tr('请输入 sk- 后的令牌内容'));
return;
}
setSavingToken(true);
try {
const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` });
setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked }));
setProxyTokenSuffix('');
toast.success(tr('下游访问令牌已更新'));
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingToken(false);
}
};
const saveRouting = async () => {
setSavingRouting(true);
try {
await api.updateRuntimeSettings({
routingWeights: runtime.routingWeights,
routingFallbackUnitCost: runtime.routingFallbackUnitCost,
});
toast.success('路由权重已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingRouting(false);
}
};
const applyRoutingPreset = (preset: 'balanced' | 'stable' | 'cost') => {
setRuntime((prev) => ({
...prev,
routingWeights: applyRoutingProfilePreset(preset),
}));
};
const saveSecuritySettings = async () => {
setSavingSecurity(true);
try {
const allowlist = adminIpAllowlistText
.split(/\r?\n|,/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
await api.updateRuntimeSettings({
adminIpAllowlist: allowlist,
});
setRuntime((prev) => ({ ...prev, adminIpAllowlist: allowlist }));
toast.success('安全设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingSecurity(false);
}
};
const handleClearCache = async () => {
if (!window.confirm('确认清理模型缓存并重建路由?')) return;
setClearingCache(true);
try {
const res = await api.clearRuntimeCache();
toast.success(`缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)`);
} catch (err: any) {
toast.error(err?.message || '清理缓存失败');
} finally {
setClearingCache(false);
}
};
const handleClearUsage = async () => {
if (!window.confirm('确认清理占用统计与使用日志?')) return;
setClearingUsage(true);
try {
const res = await api.clearUsageData();
toast.success(`占用统计已清理(日志 ${res.deletedProxyLogs || 0} 条)`);
} catch (err: any) {
toast.error(err?.message || '清理占用失败');
} finally {
setClearingUsage(false);
}
};
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('系统设置')}</h2>
</div>
<div style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>管理员登录令牌</div>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}>
{maskedToken || '****'}
</code>
<button onClick={() => setShowChangeKey(true)} className="btn btn-primary">修改登录令牌</button>
<ChangeKeyModal
open={showChangeKey}
onClose={() => {
setShowChangeKey(false);
api.getAuthInfo().then((r: any) => setMaskedToken(r.masked || '****')).catch(() => { });
}}
/>
</div>
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到 Cron</div>
<input
value={runtime.checkinCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>余额刷新 Cron</div>
<input
value={runtime.balanceRefreshCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, balanceRefreshCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
</div>
<div style={{ marginTop: 12 }}>
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>{tr('下游访问令牌(PROXY_TOKEN)')}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
{tr('用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。')}
</div>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
当前:{runtime.proxyTokenMasked || '未设置'}
</code>
<div
style={{
...inputStyle,
marginBottom: 10,
padding: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
}}
>
<span
style={{
padding: '10px 12px',
borderRight: '1px solid var(--color-border-light)',
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: 'var(--color-text-secondary)',
userSelect: 'none',
}}
>
{PROXY_TOKEN_PREFIX}
</span>
<input
type="password"
value={proxyTokenSuffix}
onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))}
placeholder={tr('请输入 sk- 后的令牌内容')}
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
fontSize: 13,
padding: '10px 12px',
}}
/>
</div>
<button onClick={saveProxyToken} disabled={savingToken} className="btn btn-primary">
{savingToken ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : tr('更新下游访问令牌')}
</button>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
先选择预设策略,只有需要精调时再展开高级参数。
</div>
<div style={{ marginBottom: 12, maxWidth: 280 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
无实测/配置/目录价时默认单价
</div>
<input
type="number"
min={0.000001}
step={0.000001}
value={runtime.routingFallbackUnitCost}
onChange={(e) => {
const nextValue = Number(e.target.value);
setRuntime((prev) => ({
...prev,
routingFallbackUnitCost: Number.isFinite(nextValue) && nextValue > 0 ? nextValue : prev.routingFallbackUnitCost,
}));
}}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button
onClick={() => applyRoutingPreset('balanced')}
className="btn btn-ghost"
style={{
border: activeRoutingProfile === 'balanced' ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
color: activeRoutingProfile === 'balanced' ? 'var(--color-primary)' : undefined,
}}
>
均衡
</button>
<button
onClick={() => applyRoutingPreset('stable')}
className="btn btn-ghost"
style={{
border: activeRoutingProfile === 'stable' ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
color: activeRoutingProfile === 'stable' ? 'var(--color-primary)' : undefined,
}}
>
稳定优先
</button>
<button
onClick={() => applyRoutingPreset('cost')}
className="btn btn-ghost"
style={{
border: activeRoutingProfile === 'cost' ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
color: activeRoutingProfile === 'cost' ? 'var(--color-primary)' : undefined,
}}
>
成本优先
</button>
<button
onClick={() => setShowAdvancedRouting((prev) => !prev)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{showAdvancedRouting ? '收起高级参数' : '展开高级参数'}
</button>
</div>
{showAdvancedRouting && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{([
['baseWeightFactor', '基础权重因子'],
['valueScoreFactor', '价值分因子'],
['costWeight', '成本权重'],
['balanceWeight', '余额权重'],
['usageWeight', '使用频次权重'],
] as Array<[keyof RoutingWeights, string]>).map(([key, label]) => (
<div key={key}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{label}</div>
<input
type="number"
min={0}
step={0.1}
value={runtime.routingWeights[key]}
onChange={(e) => {
const v = Number(e.target.value);
setRuntime((prev) => ({
...prev,
routingWeights: {
...prev.routingWeights,
[key]: Number.isFinite(v) ? v : 0,
},
}));
}}
style={inputStyle}
/>
</div>
))}
</div>
)}
<div style={{ marginTop: 12 }}>
<button onClick={saveRouting} disabled={savingRouting} className="btn btn-primary">
{savingRouting ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存路由策略'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{clearingCache ? <><span className="spinner spinner-sm" /> 清理中...</> : '清除缓存并重建路由'}
</button>
<button onClick={handleClearUsage} disabled={clearingUsage} className="btn btn-link btn-link-warning">
{clearingUsage ? <><span className="spinner spinner-sm" /> 清理中...</> : '清除占用与使用日志'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
</div>
<textarea
value={adminIpAllowlistText}
onChange={(e) => setAdminIpAllowlistText(e.target.value)}
placeholder={'例如:\n127.0.0.1\n192.168.1.10'}
rows={4}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', resize: 'vertical', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={saveSecuritySettings} disabled={savingSecurity} className="btn btn-primary">
{savingSecurity ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存安全设置'}
</button>
<button
onClick={() => {
clearAuthSession(localStorage);
window.location.reload();
}}
className="btn btn-danger"
>
退出登录
</button>
</div>
</div>
</div>
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
import { useEffect, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { tr } from '../i18n.js';
import {
buildSiteSaveAction,
emptySiteForm,
siteFormFromSite,
type SiteEditorState,
type SiteForm,
} from './helpers/sitesEditor.js';
type SiteRow = {
id: number;
name: string;
url: string;
platform?: string;
status?: string;
apiKey?: string;
createdAt?: string;
};
const platformColors: Record<string, string> = {
'new-api': 'badge-info',
'one-api': 'badge-success',
veloera: 'badge-warning',
'one-hub': 'badge-muted',
'done-hub': 'badge-muted',
sub2api: 'badge-muted',
};
export default function Sites() {
const [sites, setSites] = useState<SiteRow[]>([]);
const [editor, setEditor] = useState<SiteEditorState | null>(null);
const [form, setForm] = useState<SiteForm>(emptySiteForm());
const [detecting, setDetecting] = useState(false);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const toast = useToast();
const isEditing = editor?.mode === 'edit';
const isAdding = editor?.mode === 'add';
const load = async () => {
try {
const rows = await api.getSites();
setSites(rows || []);
} catch {
toast.error('加载站点列表失败');
}
};
useEffect(() => {
load();
}, []);
const closeEditor = () => {
setEditor(null);
setForm(emptySiteForm());
};
const openAdd = () => {
if (isAdding) {
closeEditor();
return;
}
setEditor({ mode: 'add' });
setForm(emptySiteForm());
};
const openEdit = (site: SiteRow) => {
setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site));
};
const handleSave = async () => {
if (!editor) return;
const payload = {
name: form.name.trim(),
url: form.url.trim(),
platform: form.platform.trim(),
apiKey: form.apiKey.trim(),
};
if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL');
return;
}
setSaving(true);
try {
const action = buildSiteSaveAction(editor, payload);
if (action.kind === 'add') {
await api.addSite(action.payload);
toast.success(`站点 "${payload.name}" 已添加`);
} else {
await api.updateSite(action.id, action.payload);
toast.success(`站点 "${payload.name}" 已更新`);
}
closeEditor();
await load();
} catch (e: any) {
toast.error(e.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDetect = async () => {
if (!form.url.trim()) {
toast.error('请先输入 URL');
return;
}
setDetecting(true);
try {
const result = await api.detectSite(form.url.trim());
if (result?.platform) {
setForm((prev) => ({ ...prev, platform: result.platform }));
toast.success(`检测到平台: ${result.platform}`);
} else {
toast.error(result?.error || '无法识别平台类型');
}
} catch (e: any) {
toast.error(e.message || '自动检测失败');
} finally {
setDetecting(false);
}
};
const handleDelete = async (site: SiteRow) => {
setDeleting(site.id);
try {
await api.deleteSite(site.id);
toast.success(`站点 "${site.name}" 已删除`);
await load();
} catch (e: any) {
toast.error(e.message || '删除失败');
} finally {
setDeleting(null);
}
};
const handleToggleStatus = async (site: SiteRow) => {
const nextStatus = site.status === 'disabled' ? 'active' : 'disabled';
setTogglingSiteId(site.id);
try {
await api.updateSite(site.id, { status: nextStatus });
toast.success(nextStatus === 'disabled' ? `站点 "${site.name}" 已禁用` : `站点 "${site.name}" 已启用`);
await load();
} catch (e: any) {
toast.error(e.message || '切换站点状态失败');
} finally {
setTogglingSiteId(null);
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('站点管理')}</h2>
<button onClick={openAdd} className="btn btn-primary">
{isAdding ? '取消' : '+ 添加站点'}
</button>
</div>
{editor && (
<div className="card animate-scale-in" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>
{isEditing ? '编辑站点' : '添加站点'}
</div>
<button onClick={closeEditor} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }}>
取消
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
placeholder="站点名称"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
style={{
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)',
}}
/>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="站点 URL (例如 https://api.example.com)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
if (form.url.trim() && !form.platform.trim()) {
handleDetect();
}
}}
style={{
flex: 1,
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)',
}}
/>
<button
onClick={handleDetect}
disabled={detecting || !form.url.trim()}
className="btn btn-ghost"
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
>
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
</button>
</div>
<input
placeholder="平台类型(可自动检测)"
value={form.platform}
onChange={(e) => setForm((prev) => ({ ...prev, platform: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
color: 'var(--color-text-primary)',
transition: 'all 0.2s',
}}
/>
<input
placeholder="API Key(可选)"
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
style={{
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)',
}}
/>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.url.trim()}
className="btn btn-primary"
style={{ alignSelf: 'flex-start' }}
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
</button>
</div>
</div>
)}
<div className="card" style={{ overflowX: 'auto' }}>
{sites.length > 0 ? (
<table className="data-table">
<thead>
<tr>
<th>名称</th>
<th>URL</th>
<th>状态</th>
<th>平台</th>
<th>创建时间</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{sites.map((site, i) => (
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-text-primary)',
textDecoration: 'underline',
}}
>
{site.name}
</a>
</td>
<td style={{ maxWidth: 300 }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
>
{site.url}
</a>
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'none' }}
>
<span className={`badge ${platformColors[site.platform || ''] || 'badge-muted'}`}>
{site.platform || '-'}
</span>
</a>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-text-muted)', textDecoration: 'underline' }}
>
{formatDateTimeLocal(site.createdAt)}
</a>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
编辑
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9"
/>
</svg>
<div className="empty-state-title">暂无站点</div>
<div className="empty-state-desc">点击“+ 添加站点”开始使用。</div>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+567
View File
@@ -0,0 +1,567 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { tr } from '../i18n.js';
type SyncStatus = 'success' | 'skipped' | 'failed';
type AccountTokenSyncResult = {
status?: string;
success?: boolean;
synced?: boolean;
message?: string;
created?: number;
updated?: number;
accountId?: number;
accountName?: string;
account?: {
id?: number;
username?: string;
};
};
const isAccountSyncable = (account: any) =>
account?.status === 'active' && account?.site?.status !== 'disabled';
const resolveSyncStatus = (result: AccountTokenSyncResult | null | undefined): SyncStatus => {
const raw = String(result?.status || '').toLowerCase();
if (raw === 'failed' || raw === 'error') return 'failed';
if (raw === 'skipped' || raw === 'skip') return 'skipped';
if (raw === 'success' || raw === 'ok' || raw === 'succeeded') return 'success';
if (result?.success === false) return 'failed';
if (result?.synced === false) return 'skipped';
return 'success';
};
const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, fallback: string) => {
const message = typeof result?.message === 'string' ? result.message.trim() : '';
return message || fallback;
};
const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => {
const name = typeof result?.accountName === 'string' ? result.accountName.trim() : '';
if (name) return name;
const username = typeof result?.account?.username === 'string' ? result.account.username.trim() : '';
if (username) return username;
const accountId = result?.accountId ?? result?.account?.id;
if (accountId) return `#${accountId}`;
return '未知账号';
};
async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
export default function Tokens() {
const initialCreateForm = {
accountId: 0,
name: '',
group: 'default',
unlimitedQuota: true,
remainQuota: '',
expiredTime: '',
allowIps: '',
};
const [tokens, setTokens] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [syncingAccountId, setSyncingAccountId] = useState(0);
const [syncing, setSyncing] = useState(false);
const [syncingAll, setSyncingAll] = useState(false);
const [saving, setSaving] = useState(false);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [form, setForm] = useState(initialCreateForm);
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
const [groupLoading, setGroupLoading] = useState(false);
const toast = useToast();
const load = async () => {
setLoading(true);
try {
const [tokenRows, accountRows] = await Promise.all([api.getAccountTokens(), api.getAccounts()]);
setTokens(tokenRows || []);
const latestAccounts = accountRows || [];
setAccounts(latestAccounts);
const syncableAccounts = latestAccounts.filter(isAccountSyncable);
const hasCurrentSelected = syncableAccounts.some((account) => account.id === syncingAccountId);
if (!hasCurrentSelected) {
setSyncingAccountId(syncableAccounts[0]?.id || 0);
}
} catch (e: any) {
toast.error(e.message || '加载令牌失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
useEffect(() => {
if (!showAdd || !form.accountId) {
setGroupLoading(false);
setGroupOptions(['default']);
return;
}
let cancelled = false;
setGroupLoading(true);
api.getAccountTokenGroups(form.accountId)
.then((res: any) => {
if (cancelled) return;
const groups = Array.isArray(res?.groups)
? res.groups.map((item: any) => String(item || '').trim()).filter(Boolean)
: [];
const normalized = Array.from(new Set(groups));
const nextOptions = normalized.length > 0 ? normalized : ['default'];
setGroupOptions(nextOptions);
setForm((prev) => {
if (nextOptions.includes(prev.group)) return prev;
return { ...prev, group: nextOptions[0] };
});
})
.catch((error: any) => {
if (cancelled) return;
setGroupOptions(['default']);
setForm((prev) => ({ ...prev, group: 'default' }));
toast.error(error?.message || '拉取分组失败,已回退 default');
})
.finally(() => {
if (cancelled) return;
setGroupLoading(false);
});
return () => {
cancelled = true;
};
}, [showAdd, form.accountId]);
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true }));
try {
await fn();
} finally {
setRowLoading((prev) => ({ ...prev, [key]: false }));
}
};
const handleCopyToken = async (tokenId: number, tokenName: string) => {
await withRowLoading(`token-${tokenId}-copy`, async () => {
const res = await api.getAccountTokenValue(tokenId);
const tokenValue = (res?.token || '').trim();
if (!tokenValue) {
toast.error('令牌为空,无法复制');
return;
}
await copyText(tokenValue);
toast.success(`已复制令牌:${tokenName || `token-${tokenId}`}`);
});
};
const handleAddToken = async () => {
if (!form.accountId) return;
if (!form.unlimitedQuota) {
const remainQuota = Number.parseInt(form.remainQuota, 10);
if (!Number.isFinite(remainQuota) || remainQuota <= 0) {
toast.error('有限额度令牌请填写正整数额度');
return;
}
}
setSaving(true);
try {
const remainQuota = form.unlimitedQuota
? undefined
: Number.parseInt(form.remainQuota, 10);
await api.addAccountToken({
accountId: form.accountId,
name: form.name,
group: form.group || 'default',
unlimitedQuota: form.unlimitedQuota,
remainQuota,
expiredTime: form.expiredTime || undefined,
allowIps: form.allowIps,
});
toast.success('已在站点创建并同步令牌');
setForm(initialCreateForm);
setShowAdd(false);
await load();
} catch (e: any) {
toast.error(e.message || '创建令牌失败');
} finally {
setSaving(false);
}
};
const handleSync = async () => {
if (!syncingAccountId) return;
setSyncing(true);
try {
const res = await api.syncAccountTokens(syncingAccountId) as AccountTokenSyncResult;
const status = resolveSyncStatus(res);
if (status === 'failed') {
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
} else if (status === 'skipped') {
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用会话令牌')}`);
} else {
toast.success(`同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}`);
}
await load();
} catch (e: any) {
toast.error(e.message || '同步令牌失败');
} finally {
setSyncing(false);
}
};
const handleSyncAll = async () => {
setSyncingAll(true);
try {
const res = await api.syncAllAccountTokens();
if (res?.queued) {
toast.info(res.message || '已开始同步令牌,请稍后查看日志');
await load();
return;
}
const syncResults = (
Array.isArray(res?.results) ? res.results
: Array.isArray(res?.items) ? res.items
: Array.isArray(res?.accounts) ? res.accounts
: []
) as AccountTokenSyncResult[];
if (syncResults.length === 0) {
const status = resolveSyncStatus(res as AccountTokenSyncResult);
if (status === 'failed') {
toast.error(`全部同步失败:${resolveSyncMessage(res, '请稍后重试')}`);
} else if (status === 'skipped') {
toast.info(`全部同步已跳过:${resolveSyncMessage(res, '没有可同步的账号')}`);
} else {
toast.success('全部账号同步完成');
}
} else {
const failedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'failed');
const skippedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'skipped');
const successRows = syncResults.filter((item) => resolveSyncStatus(item) === 'success');
toast.success(`全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}`);
failedRows.slice(0, 3).forEach((item) => {
toast.error(`${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, '请检查账号配置')}`);
});
skippedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, '不满足同步条件')}`);
});
if (failedRows.length > 3) {
toast.error(`另有 ${failedRows.length - 3} 个失败账号,请查看日志`);
}
if (skippedRows.length > 3) {
toast.info(`另有 ${skippedRows.length - 3} 个跳过账号,请查看日志`);
}
}
await load();
} catch (e: any) {
toast.error(e.message || '全部同步失败');
} finally {
setSyncingAll(false);
}
};
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)',
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('令牌管理')}</h2>
<div className="page-actions">
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
<button onClick={() => setShowAdd((prev) => !prev)} className="btn btn-primary">
{showAdd ? '取消' : '+ 新增令牌'}
</button>
</div>
</div>
<div className="info-tip" style={{ marginBottom: 12 }}>
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
</div>
{showAdd && (
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
<ModernSelect
value={String(form.accountId || 0)}
onChange={(nextValue) => {
setForm((prev) => ({
...prev,
accountId: Number.parseInt(nextValue, 10) || 0,
group: '',
}));
}}
options={[
{ value: '0', label: '选择账号' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号"
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
<input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如 metapi"
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>分组</div>
<ModernSelect
value={form.group || ''}
onChange={(nextValue) => setForm((prev) => ({ ...prev, group: nextValue }))}
options={(groupOptions.length > 0 ? groupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={groupLoading ? '分组加载中...' : '选择分组'}
disabled={!form.accountId || groupLoading}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={form.unlimitedQuota}
onChange={(e) => setForm((prev) => ({ ...prev, unlimitedQuota: e.target.checked }))}
/>
不限额度
</label>
{!form.unlimitedQuota && (
<input
value={form.remainQuota}
onChange={(e) => setForm((prev) => ({ ...prev, remainQuota: e.target.value.replace(/[^\d]/g, '') }))}
placeholder="额度(正整数)"
style={{ ...inputStyle, maxWidth: 220 }}
/>
)}
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>过期时间(可选)</div>
<input
type="datetime-local"
value={form.expiredTime}
onChange={(e) => setForm((prev) => ({ ...prev, expiredTime: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>IP 白名单(可选)</div>
<input
value={form.allowIps}
onChange={(e) => setForm((prev) => ({ ...prev, allowIps: e.target.value }))}
placeholder="多个用英文逗号分隔"
style={inputStyle}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
将在选中账号所属站点直接创建新密钥
</div>
</div>
<div style={{ marginTop: 12 }}>
<button
onClick={handleAddToken}
disabled={saving || !form.accountId}
className="btn btn-primary"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 创建中...</> : '创建并同步令牌'}
</button>
</div>
</div>
)}
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 20 }}>
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : tokens.length > 0 ? (
<table className="data-table token-table">
<thead>
<tr>
<th>令牌名称</th>
<th>令牌值</th>
<th>来源站点</th>
<th>账号</th>
<th>状态</th>
<th>默认</th>
<th>更新时间</th>
<th className="token-table-actions-col" style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{tokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`;
return (
<tr key={token.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<td style={{ fontWeight: 600 }}>{token.name || '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</td>
<td>
{token.site?.url ? (
<a
href={token.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
)}
</td>
<td>{token.account?.username || `account-${token.accountId}`}</td>
<td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
</td>
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
<td className="token-table-actions-col" style={{ textAlign: 'right' }}>
<div className="token-table-actions">
{!token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
toast.success('默认令牌已更新');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-default`]}
className="btn btn-link btn-link-info token-table-action-btn"
>
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-delete`, async () => {
await api.deleteAccountToken(token.id);
toast.success('令牌已删除');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger token-table-action-btn"
>
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
<div className="empty-state-title">暂无令牌</div>
<div className="empty-state-desc">可先同步站点令牌,或直接在站点创建新令牌。</div>
</div>
)}
</div>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
getAccounts: vi.fn(),
getAccountTokens: vi.fn(),
getRoutes: vi.fn(),
startTestChatJob: vi.fn(),
getTestChatJob: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
describe('Dashboard hook order', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.getRoutes.mockResolvedValue([]);
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
it('keeps hook order stable when switching from loading to loaded render', async () => {
let root: ReturnType<typeof create> | null = null;
let renderedTree = '';
const dashboardData = {
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
modelAnalysis: null,
};
let resolveFirstDashboard: ((value: typeof dashboardData) => void) | null = null;
apiMock.getDashboard.mockImplementation(() => {
if (!resolveFirstDashboard) {
return new Promise((resolve) => {
resolveFirstDashboard = resolve as (value: typeof dashboardData) => void;
});
}
return Promise.resolve(dashboardData);
});
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await act(async () => {
resolveFirstDashboard?.(dashboardData);
await Promise.resolve();
});
renderedTree = JSON.stringify(root?.toJSON() ?? null);
} finally {
root?.unmount();
}
const errorOutput = consoleErrorSpy.mock.calls
.map((args) => args.map((item) => String(item)).join(' '))
.join('\n');
expect(errorOutput).not.toContain('change in the order of Hooks called by Dashboard');
expect(renderedTree).not.toContain('快速上手');
});
});
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { getAccountsAddPanelStyle } from './accountsPanelStyle.js';
describe('getAccountsAddPanelStyle', () => {
it('preserves spacing and raises stacking context so select dropdown is clickable', () => {
const style = getAccountsAddPanelStyle();
expect(style.padding).toBe(20);
expect(style.marginBottom).toBe(16);
expect(style.position).toBe('relative');
expect(style.zIndex).toBeGreaterThan(0);
expect(style.overflow).toBe('visible');
});
});
@@ -0,0 +1,9 @@
export function getAccountsAddPanelStyle() {
return {
padding: 20,
marginBottom: 16,
position: 'relative' as const,
zIndex: 10,
overflow: 'visible' as const,
};
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
formatCheckinLogTime,
formatDateLocal,
formatDateTimeMinuteLocal,
} from './checkinLogTime.js';
describe('formatCheckinLogTime', () => {
it('formats sqlite UTC timestamp as local display time', () => {
const formatted = formatCheckinLogTime('2026-02-25 03:51:58', 'en-US', 'UTC');
expect(formatted).toBe('02/25/2026, 03:51:58');
});
it('keeps original value for invalid timestamp', () => {
expect(formatCheckinLogTime('invalid-time', 'en-US', 'UTC')).toBe('invalid-time');
});
it('formats local date with minute precision helpers', () => {
expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026');
expect(formatDateTimeMinuteLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026, 03:51');
});
});
+89
View File
@@ -0,0 +1,89 @@
function parseServerUtcDate(value: string | null | undefined): { date: Date | null; raw: string } {
if (!value) return { date: null, raw: '' };
const raw = String(value).trim();
if (!raw) return { date: null, raw };
let normalized = raw;
if (!normalized.includes('T') && normalized.includes(' ')) {
normalized = normalized.replace(' ', 'T');
}
const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized);
if (!hasTimeZone) {
normalized = `${normalized}Z`;
}
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return { date: null, raw };
}
return { date, raw };
}
function formatWithParts(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions,
locale = 'zh-CN',
timeZone?: string,
): string {
const { date, raw } = parseServerUtcDate(value);
if (!date) return raw || '-';
return new Intl.DateTimeFormat(locale, {
...options,
...(timeZone ? { timeZone } : {}),
}).format(date);
}
export function formatDateTimeLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}, locale, timeZone);
}
export function formatDateTimeMinuteLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}, locale, timeZone);
}
export function formatDateLocal(
value: string | null | undefined,
locale = 'zh-CN',
timeZone?: string,
): string {
return formatWithParts(value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}, locale, timeZone);
}
export function formatCheckinLogTime(
value: string | null | undefined,
locale?: string,
timeZone?: string,
): string {
return formatDateTimeLocal(value, locale, timeZone);
}
@@ -0,0 +1,264 @@
import { describe, expect, it } from 'vitest';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
DEFAULT_PARAMETER_ENABLED,
MESSAGE_STATUS,
buildApiPayload,
collectModelTesterModelNames,
countConversationTurns,
filterModelTesterModelNames,
parseCustomRequestBody,
parseModelTesterSession,
serializeModelTesterSession,
syncMessagesToCustomRequestBody,
toApiMessages,
type ModelTesterSessionState,
} from './modelTesterSession.js';
describe('modelTesterSession', () => {
it('counts only user messages as turns', () => {
const turns = countConversationTurns([
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
{ id: '2', role: 'assistant', content: 'hi', createAt: 2 },
{ id: '3', role: 'system', content: 'meta', createAt: 3 },
{ id: '4', role: 'user', content: 'again', createAt: 4 },
]);
expect(turns).toBe(2);
});
it('serializes and parses full playground session state', () => {
const state: ModelTesterSessionState = {
input: 'draft',
inputs: {
...DEFAULT_INPUTS,
model: 'gpt-4o-mini',
temperature: 0.6,
top_p: 0.9,
max_tokens: 2048,
frequency_penalty: 0.1,
presence_penalty: 0.2,
seed: 12,
stream: true,
},
parameterEnabled: {
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
seed: true,
},
messages: [
{ id: 'm1', role: 'user', content: 'hello', createAt: 1 },
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
],
pendingPayload: {
model: 'gpt-4o-mini',
stream: true,
temperature: 0.6,
top_p: 0.9,
messages: [{ role: 'user', content: 'hello' }],
},
pendingJobId: 'job-1',
customRequestMode: true,
customRequestBody: '{"model":"gpt-4o-mini","messages":[]}',
showDebugPanel: true,
activeDebugTab: DEBUG_TABS.REQUEST,
};
const serialized = serializeModelTesterSession(state);
const restored = parseModelTesterSession(serialized);
expect(restored).toEqual(state);
});
it('supports parsing legacy session format', () => {
const restored = parseModelTesterSession(JSON.stringify({
model: 'gpt-4o',
temperature: 0.5,
input: 'legacy',
messages: [{ role: 'user', content: 'hello' }],
pendingPayload: null,
}));
expect(restored?.inputs.model).toBe('gpt-4o');
expect(restored?.inputs.temperature).toBe(0.5);
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
});
it('returns null for malformed or missing session payload', () => {
expect(parseModelTesterSession(null)).toBeNull();
expect(parseModelTesterSession('not-json')).toBeNull();
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
});
it('sanitizes invalid message entries and invalid pending payload', () => {
const restored = parseModelTesterSession(JSON.stringify({
inputs: { model: 'gpt-4o', temperature: 99 },
parameterEnabled: {},
input: 123,
messages: [
{ role: 'user', content: 'ok' },
{ role: 'assistant', content: 123 },
{ role: 'unknown', content: 'bad role' },
{ content: 'missing role' },
],
pendingPayload: {
model: 123,
messages: [{ role: 'user', content: 'still here' }],
},
}));
expect(restored?.inputs.model).toBe('gpt-4o');
expect(restored?.input).toBe('');
expect(restored?.pendingPayload).toBeNull();
expect(restored?.messages).toHaveLength(1);
expect(restored?.messages[0]).toMatchObject({ role: 'user', content: 'ok' });
});
it('finalizes loading messages if no pending job remains', () => {
const restored = parseModelTesterSession(JSON.stringify({
inputs: { ...DEFAULT_INPUTS, model: 'gpt-4o' },
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
input: '',
messages: [{
id: 'm-loading',
role: 'assistant',
content: '<think>reasoning...</think>final',
createAt: 1,
status: 'loading',
}],
pendingPayload: null,
pendingJobId: null,
}));
expect(restored?.messages[0].status).toBe(MESSAGE_STATUS.COMPLETE);
expect(restored?.messages[0].content).toBe('final');
});
it('drops loading assistant placeholders when building API payload messages', () => {
const payloadMessages = toApiMessages([
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
{ id: '2', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
{ id: '3', role: 'assistant', content: 'done', createAt: 3, status: MESSAGE_STATUS.COMPLETE },
]);
expect(payloadMessages).toEqual([
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'done' },
]);
});
it('builds payload with parameter enable switches', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
{
...DEFAULT_INPUTS,
model: 'gpt-4o-mini',
temperature: 0.5,
top_p: 0.8,
max_tokens: 200,
frequency_penalty: 0.2,
presence_penalty: 0.3,
seed: 42,
stream: true,
},
{
temperature: true,
top_p: false,
max_tokens: true,
frequency_penalty: true,
presence_penalty: false,
seed: true,
},
);
expect(payload).toEqual({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello' }],
targetFormat: 'openai',
stream: true,
temperature: 0.5,
max_tokens: 200,
frequency_penalty: 0.2,
seed: 42,
});
});
it('parses and syncs custom request body', () => {
const parsed = parseCustomRequestBody('{"model":"gpt-4o","messages":[{"role":"user","content":"x"}],"stream":true}');
expect(parsed?.model).toBe('gpt-4o');
expect(parsed?.stream).toBe(true);
const synced = syncMessagesToCustomRequestBody(
'{"model":"legacy"}',
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-4o' },
);
const syncedJson = JSON.parse(synced);
expect(syncedJson.model).toBe('legacy');
expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]);
});
it('merges marketplace models with exact enabled route models for tester options', () => {
const modelNames = collectModelTesterModelNames(
{
models: [
{ name: 'gpt-4o-mini' },
{ name: 'bge-large-en-v1.5' },
],
},
[
{ modelPattern: 'BAAI/bge-large-en-v1.5', enabled: true },
{ modelPattern: 'claude-*', enabled: true },
{ modelPattern: 'gemini-2.5-pro', enabled: false },
],
);
expect(modelNames).toEqual([
'gpt-4o-mini',
'bge-large-en-v1.5',
'BAAI/bge-large-en-v1.5',
]);
});
it('deduplicates repeated model names when merging model sources', () => {
const modelNames = collectModelTesterModelNames(
{
models: [
{ name: 'gpt-4o-mini' },
{ name: 'gpt-4o-mini' },
],
},
[
{ modelPattern: 'gpt-4o-mini', enabled: true },
],
);
expect(modelNames).toEqual(['gpt-4o-mini']);
});
it('filters models by keyword and keeps best matches first', () => {
const filtered = filterModelTesterModelNames(
[
'BAAI/bge-large-en-v1.5',
'text-embedding-3-large',
'bge-m3',
],
'bge',
);
expect(filtered).toEqual([
'bge-m3',
'BAAI/bge-large-en-v1.5',
]);
});
it('returns all models unchanged when keyword is empty', () => {
const filtered = filterModelTesterModelNames(
['gpt-4o', 'claude-3-5-haiku-20241022'],
' ',
);
expect(filtered).toEqual(['gpt-4o', 'claude-3-5-haiku-20241022']);
});
});
+544
View File
@@ -0,0 +1,544 @@
export const MESSAGE_STATUS = {
LOADING: 'loading',
INCOMPLETE: 'incomplete',
COMPLETE: 'complete',
ERROR: 'error',
} as const;
export const DEBUG_TABS = {
PREVIEW: 'preview',
REQUEST: 'request',
RESPONSE: 'response',
} as const;
type MessageStatus = typeof MESSAGE_STATUS[keyof typeof MESSAGE_STATUS];
export type DebugTab = typeof DEBUG_TABS[keyof typeof DEBUG_TABS];
type ChatRole = 'user' | 'assistant' | 'system';
export type TestTargetFormat = 'openai' | 'claude';
export type ChatMessage = {
id: string;
role: ChatRole;
content: string;
createAt: number;
status?: MessageStatus;
reasoningContent?: string | null;
isReasoningExpanded?: boolean;
isThinkingComplete?: boolean;
hasAutoCollapsed?: boolean;
};
type ApiChatMessage = {
role: ChatRole;
content: string;
};
export type ModelTesterInputs = {
model: string;
targetFormat: TestTargetFormat;
temperature: number;
top_p: number;
max_tokens: number;
frequency_penalty: number;
presence_penalty: number;
seed: number | null;
stream: boolean;
};
export type ParameterEnabled = {
temperature: boolean;
top_p: boolean;
max_tokens: boolean;
frequency_penalty: boolean;
presence_penalty: boolean;
seed: boolean;
};
export type TestChatPayload = {
model: string;
messages: ApiChatMessage[];
targetFormat?: TestTargetFormat;
stream?: boolean;
temperature?: number;
top_p?: number;
max_tokens?: number;
frequency_penalty?: number;
presence_penalty?: number;
seed?: number;
};
export type ModelTesterSessionState = {
input: string;
inputs: ModelTesterInputs;
parameterEnabled: ParameterEnabled;
messages: ChatMessage[];
pendingPayload: TestChatPayload | null;
pendingJobId?: string | null;
customRequestMode: boolean;
customRequestBody: string;
showDebugPanel: boolean;
activeDebugTab: DebugTab;
};
export const MODEL_TESTER_STORAGE_KEY = 'metapi:model-tester:session:v4';
export const DEFAULT_INPUTS: ModelTesterInputs = {
model: '',
targetFormat: 'openai',
temperature: 0.7,
top_p: 1,
max_tokens: 4096,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: false,
};
export const DEFAULT_PARAMETER_ENABLED: ParameterEnabled = {
temperature: true,
top_p: true,
max_tokens: false,
frequency_penalty: true,
presence_penalty: true,
seed: false,
};
const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
const VALID_ROLES: ReadonlySet<string> = new Set(['user', 'assistant', 'system']);
const VALID_STATUS: ReadonlySet<string> = new Set(Object.values(MESSAGE_STATUS));
const VALID_DEBUG_TABS: ReadonlySet<string> = new Set(Object.values(DEBUG_TABS));
let messageCounter = 0;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const toFiniteNumber = (value: unknown, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
const toBoolean = (value: unknown, fallback: boolean): boolean =>
typeof value === 'boolean' ? value : fallback;
const toNullableFiniteNumber = (value: unknown): number | null => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && Number.isFinite(value)) return value;
return null;
};
const isExactModelPattern = (modelPattern: string): boolean => !/[\*\?\[]/.test(modelPattern);
export const collectModelTesterModelNames = (
marketplace: { models?: Array<{ name?: unknown }>; } | null | undefined,
routes: Array<{ modelPattern?: unknown; enabled?: unknown; }> | null | undefined,
): string[] => {
const result: string[] = [];
const seen = new Set<string>();
const appendModel = (rawName: unknown) => {
if (typeof rawName !== 'string') return;
const name = rawName.trim();
if (!name || seen.has(name)) return;
seen.add(name);
result.push(name);
};
for (const item of marketplace?.models || []) {
appendModel(item?.name);
}
for (const route of routes || []) {
if (!route || route.enabled === false) continue;
if (typeof route.modelPattern !== 'string') continue;
const modelPattern = route.modelPattern.trim();
if (!modelPattern || !isExactModelPattern(modelPattern)) continue;
appendModel(modelPattern);
}
return result;
};
export const filterModelTesterModelNames = (models: string[], query: string): string[] => {
const keyword = query.trim().toLowerCase();
if (!keyword) return [...models];
return models
.map((name, index) => {
const matchIndex = name.toLowerCase().indexOf(keyword);
if (matchIndex === -1) return null;
return { name, matchIndex, index };
})
.filter((item): item is { name: string; matchIndex: number; index: number } => item !== null)
.sort((a, b) => {
if (a.matchIndex !== b.matchIndex) return a.matchIndex - b.matchIndex;
if (a.name.length !== b.name.length) return a.name.length - b.name.length;
return a.index - b.index;
})
.map((item) => item.name);
};
const createMessageId = (): string => {
messageCounter += 1;
return `msg-${Date.now()}-${messageCounter}`;
};
export const createMessage = (role: ChatRole, content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id: createMessageId(),
role,
content,
createAt: Date.now(),
...extra,
});
export const createLoadingAssistantMessage = (): ChatMessage =>
createMessage('assistant', '', {
status: MESSAGE_STATUS.LOADING,
reasoningContent: '',
isReasoningExpanded: true,
isThinkingComplete: false,
hasAutoCollapsed: false,
});
const parseMessage = (value: unknown, index: number): ChatMessage | null => {
if (!isRecord(value)) return null;
if (typeof value.role !== 'string' || !VALID_ROLES.has(value.role)) return null;
if (typeof value.content !== 'string') return null;
const parsed: ChatMessage = {
id: typeof value.id === 'string' && value.id.trim().length > 0
? value.id
: `legacy-${index}-${Date.now()}`,
role: value.role as ChatRole,
content: value.content,
createAt: typeof value.createAt === 'number' && Number.isFinite(value.createAt)
? value.createAt
: Date.now(),
};
if (typeof value.status === 'string' && VALID_STATUS.has(value.status)) {
parsed.status = value.status as MessageStatus;
}
if (typeof value.reasoningContent === 'string') {
parsed.reasoningContent = value.reasoningContent;
} else if (value.reasoningContent === null) {
parsed.reasoningContent = null;
}
if (typeof value.isReasoningExpanded === 'boolean') {
parsed.isReasoningExpanded = value.isReasoningExpanded;
}
if (typeof value.isThinkingComplete === 'boolean') {
parsed.isThinkingComplete = value.isThinkingComplete;
}
if (typeof value.hasAutoCollapsed === 'boolean') {
parsed.hasAutoCollapsed = value.hasAutoCollapsed;
}
return parsed;
};
const sanitizeMessages = (value: unknown): ChatMessage[] => {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => parseMessage(item, index))
.filter((item): item is ChatMessage => item !== null);
};
const parseInputs = (value: unknown, fallbackModel = ''): ModelTesterInputs => {
if (!isRecord(value)) {
return {
...DEFAULT_INPUTS,
model: fallbackModel,
};
}
const model = typeof value.model === 'string' && value.model.trim().length > 0
? value.model
: fallbackModel;
return {
model,
targetFormat: value.targetFormat === 'claude' ? 'claude' : DEFAULT_INPUTS.targetFormat,
temperature: toFiniteNumber(value.temperature, DEFAULT_INPUTS.temperature),
top_p: toFiniteNumber(value.top_p, DEFAULT_INPUTS.top_p),
max_tokens: toFiniteNumber(value.max_tokens, DEFAULT_INPUTS.max_tokens),
frequency_penalty: toFiniteNumber(value.frequency_penalty, DEFAULT_INPUTS.frequency_penalty),
presence_penalty: toFiniteNumber(value.presence_penalty, DEFAULT_INPUTS.presence_penalty),
seed: toNullableFiniteNumber(value.seed),
stream: toBoolean(value.stream, DEFAULT_INPUTS.stream),
};
};
const parseParameterEnabled = (value: unknown): ParameterEnabled => {
if (!isRecord(value)) {
return { ...DEFAULT_PARAMETER_ENABLED };
}
return {
temperature: toBoolean(value.temperature, DEFAULT_PARAMETER_ENABLED.temperature),
top_p: toBoolean(value.top_p, DEFAULT_PARAMETER_ENABLED.top_p),
max_tokens: toBoolean(value.max_tokens, DEFAULT_PARAMETER_ENABLED.max_tokens),
frequency_penalty: toBoolean(value.frequency_penalty, DEFAULT_PARAMETER_ENABLED.frequency_penalty),
presence_penalty: toBoolean(value.presence_penalty, DEFAULT_PARAMETER_ENABLED.presence_penalty),
seed: toBoolean(value.seed, DEFAULT_PARAMETER_ENABLED.seed),
};
};
const parsePendingPayload = (value: unknown): TestChatPayload | null => {
if (!isRecord(value)) return null;
if (typeof value.model !== 'string' || value.model.trim().length === 0) return null;
const payloadMessages = sanitizeMessages(value.messages).map((message) => ({
role: message.role,
content: message.content,
}));
if (payloadMessages.length === 0) return null;
const payload: TestChatPayload = {
model: value.model,
messages: payloadMessages,
};
if (value.targetFormat === 'claude' || value.targetFormat === 'openai') {
payload.targetFormat = value.targetFormat;
}
if (typeof value.stream === 'boolean') payload.stream = value.stream;
if (typeof value.temperature === 'number' && Number.isFinite(value.temperature)) payload.temperature = value.temperature;
if (typeof value.top_p === 'number' && Number.isFinite(value.top_p)) payload.top_p = value.top_p;
if (typeof value.max_tokens === 'number' && Number.isFinite(value.max_tokens)) payload.max_tokens = value.max_tokens;
if (typeof value.frequency_penalty === 'number' && Number.isFinite(value.frequency_penalty)) payload.frequency_penalty = value.frequency_penalty;
if (typeof value.presence_penalty === 'number' && Number.isFinite(value.presence_penalty)) payload.presence_penalty = value.presence_penalty;
if (typeof value.seed === 'number' && Number.isFinite(value.seed)) payload.seed = value.seed;
return payload;
};
export const serializeModelTesterSession = (state: ModelTesterSessionState): string =>
JSON.stringify(state);
export const processThinkTags = (content: string, reasoningContent = ''): { content: string; reasoningContent: string } => {
if (!content || !content.includes('<think>')) {
return { content, reasoningContent };
}
const thoughts: string[] = [];
const replyParts: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
THINK_TAG_REGEX.lastIndex = 0;
while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
replyParts.push(content.substring(lastIndex, match.index));
thoughts.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(content.substring(lastIndex));
const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
const thoughtsCombined = thoughts.join('\n\n---\n\n');
return {
content: processedContent,
reasoningContent: reasoningContent && thoughtsCombined
? `${reasoningContent}\n\n---\n\n${thoughtsCombined}`
: (reasoningContent || thoughtsCombined),
};
};
const processIncompleteThinkTags = (content: string, reasoningContent = ''): { content: string; reasoningContent: string } => {
if (!content) return { content: '', reasoningContent };
const lastOpenThinkIndex = content.lastIndexOf('<think>');
if (lastOpenThinkIndex === -1) {
return processThinkTags(content, reasoningContent);
}
const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
const cleanContent = content.substring(0, lastOpenThinkIndex);
const mergedReasoning = unclosedThought
? (reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought)
: reasoningContent;
return processThinkTags(cleanContent, mergedReasoning);
}
return processThinkTags(content, reasoningContent);
};
export const finalizeIncompleteMessage = (message: ChatMessage): ChatMessage => {
if (message.status !== MESSAGE_STATUS.LOADING && message.status !== MESSAGE_STATUS.INCOMPLETE) {
return message;
}
const processed = processIncompleteThinkTags(message.content || '', message.reasoningContent || '');
return {
...message,
content: processed.content || message.content,
reasoningContent: processed.reasoningContent || null,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
};
};
const buildFallbackInputsFromLegacy = (value: Record<string, unknown>): ModelTesterInputs => {
const legacyModel = typeof value.model === 'string' ? value.model : '';
const inputs = parseInputs(value.inputs, legacyModel);
if (!value.inputs) {
inputs.targetFormat = value.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
inputs.temperature = toFiniteNumber(value.temperature, inputs.temperature);
}
return inputs;
};
export const parseModelTesterSession = (raw: string | null): ModelTesterSessionState | null => {
if (typeof raw !== 'string' || raw.trim().length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isRecord(parsed)) return null;
const inputs = buildFallbackInputsFromLegacy(parsed);
if (!inputs.model) return null;
const state: ModelTesterSessionState = {
input: typeof parsed.input === 'string' ? parsed.input : '',
inputs,
parameterEnabled: parseParameterEnabled(parsed.parameterEnabled),
messages: sanitizeMessages(parsed.messages),
pendingPayload: parsePendingPayload(parsed.pendingPayload),
customRequestMode: toBoolean(parsed.customRequestMode, false),
customRequestBody: typeof parsed.customRequestBody === 'string' ? parsed.customRequestBody : '',
showDebugPanel: toBoolean(parsed.showDebugPanel, false),
activeDebugTab: typeof parsed.activeDebugTab === 'string' && VALID_DEBUG_TABS.has(parsed.activeDebugTab)
? parsed.activeDebugTab as DebugTab
: DEBUG_TABS.PREVIEW,
};
if (typeof parsed.pendingJobId === 'string' && parsed.pendingJobId.trim().length > 0) {
state.pendingJobId = parsed.pendingJobId;
} else if (parsed.pendingJobId === null) {
state.pendingJobId = null;
}
if (!state.pendingJobId && state.messages.length > 0) {
state.messages = state.messages.map((message) => finalizeIncompleteMessage(message));
}
return state;
};
export const toApiMessages = (messages: ChatMessage[]): ApiChatMessage[] =>
messages
.filter((message) => {
if (message.role !== 'assistant') return true;
return message.status !== MESSAGE_STATUS.LOADING && message.status !== MESSAGE_STATUS.INCOMPLETE;
})
.map((message) => ({
role: message.role,
content: message.content,
}));
export const buildApiPayload = (
messages: ChatMessage[],
inputs: ModelTesterInputs,
parameterEnabled: ParameterEnabled,
): TestChatPayload => {
const payload: TestChatPayload = {
model: inputs.model,
messages: toApiMessages(messages),
targetFormat: inputs.targetFormat,
stream: inputs.stream,
};
const mapping: Array<{ key: keyof ParameterEnabled; field: keyof ModelTesterInputs }> = [
{ key: 'temperature', field: 'temperature' },
{ key: 'top_p', field: 'top_p' },
{ key: 'max_tokens', field: 'max_tokens' },
{ key: 'frequency_penalty', field: 'frequency_penalty' },
{ key: 'presence_penalty', field: 'presence_penalty' },
{ key: 'seed', field: 'seed' },
];
for (const item of mapping) {
const enabled = parameterEnabled[item.key];
if (!enabled) continue;
const value = inputs[item.field];
if (item.field === 'seed') {
if (typeof value === 'number' && Number.isFinite(value)) {
payload.seed = value;
}
continue;
}
if (typeof value === 'number' && Number.isFinite(value)) {
(payload as any)[item.field] = value;
}
}
return payload;
};
export const parseCustomRequestBody = (raw: string): TestChatPayload | null => {
if (!raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
return parsePendingPayload(parsed);
} catch {
return null;
}
};
export const syncMessagesToCustomRequestBody = (
currentBody: string,
messages: ChatMessage[],
inputs: ModelTesterInputs,
): string => {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(currentBody || '{}');
} catch {
payload = {};
}
payload.model = typeof payload.model === 'string' && payload.model.trim().length > 0
? payload.model
: inputs.model;
payload.targetFormat = payload.targetFormat === 'claude' ? 'claude' : inputs.targetFormat;
payload.stream = payload.stream !== undefined ? payload.stream : inputs.stream;
payload.messages = toApiMessages(messages);
return JSON.stringify(payload, null, 2);
};
export const syncCustomRequestBodyToMessages = (raw: string): ChatMessage[] | null => {
const parsed = parseCustomRequestBody(raw);
if (!parsed?.messages || parsed.messages.length === 0) return null;
return parsed.messages.map((item, index) => createMessage(item.role, item.content, {
id: `custom-${index}-${Date.now()}`,
}));
};
export const findLastLoadingAssistantIndex = (messages: ChatMessage[]): number => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (
message.role === 'assistant' &&
(message.status === MESSAGE_STATUS.LOADING || message.status === MESSAGE_STATUS.INCOMPLETE)
) {
return index;
}
}
return -1;
};
export const countConversationTurns = (messages: ChatMessage[]): number =>
messages.reduce((turns, message) => turns + (message.role === 'user' ? 1 : 0), 0);
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
mergeMarketplaceMetadata,
shouldHydrateMarketplaceMetadata,
type MarketplaceModelMetadataShape,
} from './modelsMarketplaceMetadata.js';
interface TestModel extends MarketplaceModelMetadataShape {
name: string;
accountCount: number;
}
function createModel(overrides: Partial<TestModel>): TestModel {
return {
name: 'gpt-4o',
accountCount: 1,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
...overrides,
};
}
describe('modelsMarketplaceMetadata', () => {
it('identifies when metadata hydration is needed', () => {
const models = [
createModel({ name: 'gpt-4o', description: 'base model' }),
createModel({ name: 'claude-sonnet-4' }),
];
expect(shouldHydrateMarketplaceMetadata(models)).toBe(true);
});
it('identifies when metadata is already complete', () => {
const models = [
createModel({
name: 'gpt-4o',
description: 'base model',
tags: ['chat'],
supportedEndpointTypes: ['chat'],
pricingSources: [{ source: 'site-a' }],
}),
createModel({
name: 'claude-sonnet-4',
description: 'anthropic model',
tags: ['reasoning'],
supportedEndpointTypes: ['chat'],
pricingSources: [{ source: 'site-b' }],
}),
];
expect(shouldHydrateMarketplaceMetadata(models)).toBe(false);
});
it('merges metadata into current models without overriding non-metadata fields', () => {
const base = [
createModel({ name: 'GPT-4O', accountCount: 3 }),
createModel({ name: 'claude-sonnet-4', accountCount: 5 }),
];
const detail = [
createModel({
name: 'gpt-4o',
accountCount: 99,
description: 'OpenAI flagship model',
tags: ['openai'],
supportedEndpointTypes: ['chat', 'responses'],
pricingSources: [{ source: 'site-a' }],
}),
];
const merged = mergeMarketplaceMetadata(base, detail);
expect(merged).toEqual([
{
...base[0],
description: 'OpenAI flagship model',
tags: ['openai'],
supportedEndpointTypes: ['chat', 'responses'],
pricingSources: [{ source: 'site-a' }],
},
base[1],
]);
});
});
@@ -0,0 +1,46 @@
export interface MarketplaceModelMetadataShape {
description: string | null;
tags: string[];
supportedEndpointTypes: string[];
pricingSources: unknown[];
}
interface MarketplaceModelLike extends MarketplaceModelMetadataShape {
name: string;
}
function normalizeModelName(name: string): string {
return name.trim().toLowerCase();
}
function hasMarketplaceMetadata(model: MarketplaceModelMetadataShape): boolean {
if (typeof model.description === 'string' && model.description.trim().length > 0) return true;
if (Array.isArray(model.tags) && model.tags.length > 0) return true;
if (Array.isArray(model.supportedEndpointTypes) && model.supportedEndpointTypes.length > 0) return true;
if (Array.isArray(model.pricingSources) && model.pricingSources.length > 0) return true;
return false;
}
export function shouldHydrateMarketplaceMetadata<T extends MarketplaceModelMetadataShape>(models: T[]): boolean {
return models.some((model) => !hasMarketplaceMetadata(model));
}
export function mergeMarketplaceMetadata<T extends MarketplaceModelLike>(baseModels: T[], detailedModels: T[]): T[] {
const detailedByName = new Map<string, T>();
for (const model of detailedModels) {
detailedByName.set(normalizeModelName(model.name), model);
}
return baseModels.map((model) => {
const detail = detailedByName.get(normalizeModelName(model.name));
if (!detail) return model;
return {
...model,
description: detail.description ?? null,
tags: Array.isArray(detail.tags) ? detail.tags : [],
supportedEndpointTypes: Array.isArray(detail.supportedEndpointTypes) ? detail.supportedEndpointTypes : [],
pricingSources: Array.isArray(detail.pricingSources) ? detail.pricingSources : [],
};
});
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
ROUTING_PROFILE_PRESETS,
applyRoutingProfilePreset,
resolveRoutingProfilePreset,
} from './routingProfiles.js';
describe('routingProfiles', () => {
it('applies stable preset weights', () => {
const stable = applyRoutingProfilePreset('stable');
expect(stable).toEqual(ROUTING_PROFILE_PRESETS.stable);
});
it('resolves known profile by exact weights', () => {
const preset = resolveRoutingProfilePreset(ROUTING_PROFILE_PRESETS.cost);
expect(preset).toBe('cost');
});
it('returns custom when weights do not match presets', () => {
const preset = resolveRoutingProfilePreset({
baseWeightFactor: 0.61,
valueScoreFactor: 0.39,
costWeight: 0.22,
balanceWeight: 0.41,
usageWeight: 0.37,
});
expect(preset).toBe('custom');
});
});
+58
View File
@@ -0,0 +1,58 @@
export type RoutingWeights = {
baseWeightFactor: number;
valueScoreFactor: number;
costWeight: number;
balanceWeight: number;
usageWeight: number;
};
type RoutingProfilePresetId = 'balanced' | 'stable' | 'cost' | 'custom';
export const ROUTING_PROFILE_PRESETS: Record<Exclude<RoutingProfilePresetId, 'custom'>, RoutingWeights> = {
balanced: {
baseWeightFactor: 0.5,
valueScoreFactor: 0.5,
costWeight: 0.4,
balanceWeight: 0.3,
usageWeight: 0.3,
},
stable: {
baseWeightFactor: 0.7,
valueScoreFactor: 0.3,
costWeight: 0.2,
balanceWeight: 0.6,
usageWeight: 0.2,
},
cost: {
baseWeightFactor: 0.35,
valueScoreFactor: 0.65,
costWeight: 0.75,
balanceWeight: 0.15,
usageWeight: 0.1,
},
};
const EPSILON = 1e-6;
export function applyRoutingProfilePreset(presetId: Exclude<RoutingProfilePresetId, 'custom'>): RoutingWeights {
return {
...ROUTING_PROFILE_PRESETS[presetId],
};
}
function weightsAlmostEqual(a: RoutingWeights, b: RoutingWeights): boolean {
return (
Math.abs(a.baseWeightFactor - b.baseWeightFactor) <= EPSILON
&& Math.abs(a.valueScoreFactor - b.valueScoreFactor) <= EPSILON
&& Math.abs(a.costWeight - b.costWeight) <= EPSILON
&& Math.abs(a.balanceWeight - b.balanceWeight) <= EPSILON
&& Math.abs(a.usageWeight - b.usageWeight) <= EPSILON
);
}
export function resolveRoutingProfilePreset(weights: RoutingWeights): RoutingProfilePresetId {
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.balanced)) return 'balanced';
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.stable)) return 'stable';
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.cost)) return 'cost';
return 'custom';
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { buildSiteSaveAction } from './sitesEditor.js';
describe('buildSiteSaveAction', () => {
it('returns add action in add mode', () => {
const action = buildSiteSaveAction(
{ mode: 'add' },
{ name: 'site-a', url: 'https://a.example.com/', platform: 'new-api', apiKey: 'sk-1' },
);
expect(action).toEqual({
kind: 'add',
payload: {
name: 'site-a',
url: 'https://a.example.com/',
platform: 'new-api',
apiKey: 'sk-1',
},
});
});
it('returns update action in edit mode with site id', () => {
const action = buildSiteSaveAction(
{ mode: 'edit', editingSiteId: 12 },
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '' },
);
expect(action).toEqual({
kind: 'update',
id: 12,
payload: {
name: 'site-b',
url: 'https://b.example.com',
platform: 'one-api',
apiKey: '',
},
});
});
it('throws when edit mode has no site id', () => {
expect(() =>
buildSiteSaveAction(
{ mode: 'edit' },
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '' },
),
).toThrow('editingSiteId is required in edit mode');
});
});
+37
View File
@@ -0,0 +1,37 @@
export type SiteForm = {
name: string;
url: string;
platform: string;
apiKey: string;
};
export type SiteEditorState =
| { mode: 'add' }
| { mode: 'edit'; editingSiteId: number };
type SiteSaveAction =
| { kind: 'add'; payload: SiteForm }
| { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): SiteForm {
return { name: '', url: '', platform: '', apiKey: '' };
}
export function siteFormFromSite(site: Partial<SiteForm>): SiteForm {
return {
name: site.name ?? '',
url: site.url ?? '',
platform: site.platform ?? '',
apiKey: site.apiKey ?? '',
};
}
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteForm): SiteSaveAction {
if (editor.mode === 'edit') {
if (!Number.isFinite(editor.editingSiteId)) {
throw new Error('editingSiteId is required in edit mode');
}
return { kind: 'update', id: editor.editingSiteId, payload: form };
}
return { kind: 'add', payload: form };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const chartFiles = [
'src/web/components/charts/SiteDistributionChart.tsx',
'src/web/components/charts/SiteTrendChart.tsx',
'src/web/components/ModelAnalysisPanel.tsx',
];
describe('VChart compatibility guards', () => {
it.each(chartFiles)('%s should not use function formatter syntax', (filePath) => {
const content = readFileSync(resolve(process.cwd(), filePath), 'utf8');
expect(content).not.toMatch(/formatter\s*:\s*\(/);
});
});