1
0
forked from boos/BoosAPI

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
+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 };
}