forked from boos/BoosAPI
chore: initialize clean metapi repository
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (系统会自动识别 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user