feat: 重构控制台 Dashboard + 新增 API 快速测试页

- 新增 Bento 风格 Dashboard 组件:TopBar、Hero、BentoStats、BentoTrend、MonthlyForecast、RecentActivity、Sparkline
- 新增 /console/api-tester API 快速测试页:支持 OpenAI/Anthropic 协议切换、多家国产模型预设、Key 清洗、流式/非流式切换、cURL 一键复制
- 注释和提示语统一中文化
- App.jsx 新增 /console/api-tester 路由(PrivateRoute 守卫)
This commit is contained in:
Claude 2026-06-26 15:26:52 +08:00 committed by wxj
parent 1e3ce2c0d2
commit b1e65e03b5
10 changed files with 1509 additions and 240 deletions

45
web/src/App.jsx vendored
View File

@ -46,6 +46,7 @@ import ModelPage from './pages/Model';
import ModelDeploymentPage from './pages/ModelDeployment';
import ModelHeatPage from './pages/ModelHeat';
import Playground from './pages/Playground';
import ApiTester from './pages/ApiTester';
import Subscription from './pages/Subscription';
import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting';
@ -58,6 +59,10 @@ import Suppliers from './pages/SupplierAdmin/list';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';
import OperationLog from './pages/OperationLog';
import LuckyBag from './pages/LuckyBag';
import Drawing from './pages/Drawing';
import ProfitDashboard from './pages/ProfitDashboard';
import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
@ -177,6 +182,14 @@ function App() {
</PrivateRoute>
}
/>
<Route
path='/console/api-tester'
element={
<PrivateRoute>
<ApiTester />
</PrivateRoute>
}
/>
<Route
path='/console/redemption'
element={
@ -193,6 +206,38 @@ function App() {
</AdminRoute>
}
/>
<Route
path='/console/lucky-bag'
element={
<PrivateRoute>
<LuckyBag />
</PrivateRoute>
}
/>
<Route
path='/console/drawing'
element={
<PrivateRoute>
<Drawing />
</PrivateRoute>
}
/>
<Route
path='/console/profit'
element={
<PrivateRoute>
<ProfitDashboard />
</PrivateRoute>
}
/>
<Route
path='/console/anti-abuse'
element={
<PrivateRoute>
<SettingsAntiAbuse />
</PrivateRoute>
}
/>
<Route
path='/console/supplier-application'
element={

View File

@ -0,0 +1,143 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Activity, CreditCard, Hash, Layers, Gauge } from 'lucide-react';
import { renderQuota } from '../../helpers';
const formatNumber = (n) => {
if (n == null || isNaN(n)) return '—';
const v = Number(n);
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M';
if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K';
return v.toLocaleString();
};
const formatLatency = (v) => {
if (v == null) return '—';
if (v < 1) return `${Math.round(v * 1000)}ms`;
return `${v.toFixed(2)}s`;
};
const BentoStat = ({ label, value, unit, sub, delta, icon: Icon, className = '' }) => {
return (
<div
className={`relative flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
>
<div className='flex items-center gap-3'>
<div className='flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
<Icon size={16} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>{label}</div>
</div>
<div className='flex flex-1 flex-col justify-center'>
<div className='flex items-baseline gap-2'>
<span className='font-mono text-3xl font-bold tabular-nums leading-none text-slate-900 md:text-4xl dark:text-white'>
{value}
</span>
{unit && <span className='text-xs text-slate-400 dark:text-white/40'>{unit}</span>}
</div>
</div>
<div className='space-y-1.5'>
{delta != null && (
<div
className={`inline-flex items-center gap-1.5 text-xs tabular-nums ${
delta < 0
? 'text-emerald-600 dark:text-emerald-400'
: delta > 0
? 'text-red-500 dark:text-red-400'
: 'text-slate-500 dark:text-white/50'
}`}
>
<span className='font-semibold'>
{delta > 0 ? '↑' : delta < 0 ? '↓' : '·'} {Math.abs(delta).toFixed(0)}%
</span>
<span className='font-normal text-slate-400 dark:text-white/40'>对比上月</span>
</div>
)}
{sub && (
<div className='text-[11px] text-slate-500 tabular-nums dark:text-white/40'>{sub}</div>
)}
</div>
</div>
);
};
const BentoStats = ({ metrics, monthDelta }) => {
return (
<>
<BentoStat
label='今日请求'
value={metrics.today.requests}
unit='次'
sub={
<span>
<span className='font-semibold text-slate-900 dark:text-white'>
{formatNumber(metrics.today.tokens)}
</span>{' '}
tokens
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
{renderQuota(metrics.today.cost)}
</span>
}
icon={Activity}
/>
<BentoStat
label='今日 token'
value={formatNumber(metrics.today.tokens)}
unit='tokens'
sub={`今日花费 ${renderQuota(metrics.today.cost)}`}
icon={Hash}
/>
<BentoStat
label='平均响应'
value={formatLatency(metrics.today.avgLatency)}
sub='今日成功调用'
icon={Gauge}
/>
<BentoStat
label='本月消费'
value={metrics.month.cost ? renderQuota(metrics.month.cost) : '—'}
sub={
<span>
<span className='font-semibold tabular-nums text-slate-900 dark:text-white'>
{metrics.month.requests}
</span>{' '}
次调用
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
<span className='tabular-nums'>{formatNumber(metrics.month.tokens)}</span> tokens
</span>
}
delta={monthDelta}
icon={CreditCard}
/>
<BentoStat
label='累计 token'
value={formatNumber(metrics.month.tokens)}
unit='本月'
sub='输入+输出'
icon={Layers}
/>
</>
);
};
export default BentoStats;

View File

@ -0,0 +1,136 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import { TrendingUp } from 'lucide-react';
import Sparkline from './Sparkline';
import { renderQuota } from '../../helpers';
const WEEKDAYS_SHORT = ['日', '一', '二', '三', '四', '五', '六'];
const BentoTrend = ({
dailyStats = [],
topModels = [],
className = '',
}) => {
const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
const totalCalls = counts.reduce((s, v) => s + v, 0);
const totalCost = costs.reduce((s, v) => s + v, 0);
const avg = totalCalls > 0 ? (totalCalls / Math.max(counts.length, 1)).toFixed(1) : '0.0';
const peak = counts.length > 0 ? Math.max(...counts) : 0;
const topMax = topModels[0]?.count || 0;
return (
<div
className={`relative flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
<TrendingUp size={18} />
</div>
<div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>Token 使用趋势</div>
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
7
<span className='font-semibold text-slate-900 dark:text-white'> {totalCalls}</span>
· 日均{' '}
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
</div>
</div>
</div>
<div className='hidden text-right md:block'>
<div className='text-[10px] uppercase tracking-wider text-slate-400 font-mono dark:text-white/30'>
花费
</div>
<div className='font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-white'>
{totalCost > 0 ? renderQuota(totalCost) : '—'}
</div>
</div>
</div>
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
<Sparkline data={counts} width={400} height={80} />
</div>
<div className='flex items-center justify-between text-[11px]'>
<div className='flex items-center gap-1 tabular-nums'>
{dailyStats.map((d, i) => (
<span
key={i}
className={`w-8 text-center ${
d.count === peak && peak > 0
? 'font-semibold text-slate-900 dark:text-white'
: 'text-slate-300 dark:text-white/30'
}`}
title={`${WEEKDAYS_SHORT[d.date.getDay()]} ${d.count}`}
>
{WEEKDAYS_SHORT[d.date.getDay()]}
</span>
))}
</div>
<div className='text-slate-500 tabular-nums dark:text-white/50'>
峰值{' '}
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span>
</div>
</div>
{topModels.length > 0 && (
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
<div className='mb-2 flex items-center justify-between text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
<span>Top 5 模型</span>
<span className='font-mono normal-case tracking-normal'> 30 </span>
</div>
<ul className='space-y-1.5'>
{topModels.map((m, i) => {
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
return (
<li
key={m.name + i}
className='flex items-center gap-3 text-xs'
title={m.name}
>
<span className='w-3 shrink-0 text-right font-mono text-[10px] text-slate-300 tabular-nums dark:text-white/30'>
{i + 1}
</span>
<span className='min-w-0 flex-1 truncate text-slate-700 dark:text-white/80'>
{m.name}
</span>
<div className='hidden h-1 w-24 overflow-hidden rounded-full bg-slate-100 sm:block dark:bg-white/5'>
<div
className='h-full rounded-full bg-slate-300 dark:bg-white/30'
style={{ width: `${pct}%` }}
/>
</div>
<span className='w-12 shrink-0 text-right font-mono tabular-nums text-slate-500 dark:text-white/60'>
{m.count}
</span>
</li>
);
})}
</ul>
</div>
)}
</div>
);
};
export default BentoTrend;

View File

@ -0,0 +1,56 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
const getGreeting = () => {
const h = new Date().getHours();
if (h < 5) return '深夜好';
if (h < 12) return '早上好';
if (h < 14) return '中午好';
if (h < 18) return '下午好';
return '晚上好';
};
const Hero = ({ user }) => {
const dateStr = useMemo(() => {
const now = new Date();
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}日 · ${weekdays[now.getDay()]}`;
}, []);
return (
<div className='flex flex-wrap items-center justify-between gap-4'>
<div className='min-w-0'>
<div className='mb-1.5 flex items-center gap-2'>
<span className='flex h-1.5 w-1.5 rounded-full bg-emerald-500' />
<span className='text-xs text-slate-500 dark:text-white/50'>{dateStr}</span>
</div>
<h1 className='text-2xl font-semibold leading-tight tracking-tight md:text-3xl'>
<span className='text-slate-500 dark:text-white/60'>{getGreeting()}</span>
<span className='text-slate-900 dark:text-white'>
{user?.username || '访客'}
</span>
</h1>
</div>
</div>
);
};
export default Hero;

View File

@ -0,0 +1,130 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Receipt, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { renderQuota } from '../../helpers';
const SubStat = ({ label, value, hint }) => (
<div className='min-w-0'>
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
{label}
</div>
<div className='mt-1 font-mono text-base font-semibold tabular-nums text-slate-900 dark:text-white'>
{value}
</div>
{hint && (
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
{hint}
</div>
)}
</div>
);
const MonthlyForecast = ({ forecast = {}, className = '' }) => {
const {
spent = 0,
balance = 0,
dailyAvg = 0,
daysInMonth = 0,
daysElapsed = 0,
projected = 0,
monthDelta = null,
} = forecast;
const daysRemaining = Math.max(0, daysInMonth - daysElapsed);
const hasData = spent > 0;
const overBudget = hasData && balance > 0 && projected > balance;
const DeltaIcon =
monthDelta == null ? Minus : monthDelta > 0 ? TrendingUp : TrendingDown;
const deltaTone =
monthDelta == null
? 'text-slate-500 dark:text-white/50'
: monthDelta > 5
? 'text-red-500 dark:text-red-400'
: monthDelta < -5
? 'text-emerald-600 dark:text-emerald-400'
: 'text-slate-500 dark:text-white/50';
return (
<div
className={`relative flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
<Receipt size={18} />
</div>
<div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
本月账单预测
</div>
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
已过 {daysElapsed} / {daysInMonth} · {daysRemaining}
</div>
</div>
</div>
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
月底预计
</div>
<div
className={`mt-1.5 font-mono text-3xl font-bold tabular-nums leading-none md:text-4xl ${
overBudget
? 'text-red-500 dark:text-red-400'
: 'text-slate-900 dark:text-white'
}`}
>
{hasData ? renderQuota(projected) : '—'}
</div>
{monthDelta != null && (
<div className={`mt-2 inline-flex items-center gap-1 text-xs tabular-nums ${deltaTone}`}>
<DeltaIcon size={12} />
<span className='font-semibold'>
{monthDelta > 0 ? '+' : ''}
{monthDelta.toFixed(0)}%
</span>
<span className='text-slate-400 dark:text-white/40'>较上月</span>
</div>
)}
</div>
<div className='grid grid-cols-3 gap-4 border-t border-slate-100 pt-4 dark:border-white/5'>
<SubStat
label='已花费'
value={hasData ? renderQuota(spent) : '—'}
hint={`${daysElapsed} 天累计`}
/>
<SubStat
label='日均'
value={hasData ? renderQuota(dailyAvg) : '—'}
hint='本月至今日'
/>
<SubStat
label='账户余额'
value={balance > 0 ? renderQuota(balance) : '—'}
hint={overBudget ? '预计不足' : '可继续使用'}
/>
</div>
</div>
);
};
export default MonthlyForecast;

View File

@ -0,0 +1,137 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo } from 'react';
import { ArrowUpRight, Clock, Inbox } from 'lucide-react';
import { renderQuota, renderNumber, timestamp2string } from '../../helpers';
const isSuccess = (l) =>
l?.type === 0 || l?.status === 0 || l?.type === 'success' || l?.type === undefined;
const RecentActivity = ({ logs = [], className = '' }) => {
const recent = useMemo(() => logs.slice(0, 10), [logs]);
return (
<div
className={`relative overflow-hidden rounded-2xl border border-slate-200 bg-white transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
>
<div className='flex items-center justify-between p-6 pb-4'>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
<Clock size={18} />
</div>
<div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>最近调用</div>
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
{' '}
<span className='font-semibold text-slate-900 dark:text-white'>
{recent.length}
</span>{' '}
条记录
</div>
</div>
</div>
<a
href='/console/log'
className='flex items-center gap-1 text-xs text-slate-500 transition-colors hover:text-slate-900 dark:text-white/60 dark:hover:text-white'
>
查看全部 <ArrowUpRight size={11} />
</a>
</div>
{recent.length === 0 ? (
<div className='flex flex-col items-center justify-center px-6 py-14'>
<div className='mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 dark:bg-white/5'>
<Inbox size={28} className='text-slate-400 dark:text-white/30' />
</div>
<h3 className='text-sm font-semibold text-slate-900 dark:text-white'>暂无使用记录</h3>
<p className='mt-1 text-xs text-slate-500 dark:text-white/40'>
开始使用 API 您的使用历史将显示在这里
</p>
</div>
) : (
<div className='overflow-hidden border-t border-slate-100 dark:border-white/5'>
<table className='w-full text-sm'>
<thead>
<tr className='border-b border-slate-100 bg-slate-50/50 text-slate-500 dark:border-white/5 dark:bg-white/[0.02] dark:text-white/40'>
<th className='w-[140px] px-6 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
time
</th>
<th className='px-2 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
model
</th>
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
tokens
</th>
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
cost
</th>
<th className='w-[120px] px-6 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
status
</th>
</tr>
</thead>
<tbody>
{recent.map((l) => {
const ok = isSuccess(l);
return (
<tr
key={l.id}
className='border-b border-slate-100 transition-colors last:border-b-0 hover:bg-slate-50/50 dark:border-white/5 dark:hover:bg-white/[0.02]'
>
<td className='whitespace-nowrap px-6 py-3 font-mono text-xs tabular-nums text-slate-500 dark:text-white/50'>
{timestamp2string(l.created_at).slice(5, 16)}
</td>
<td className='max-w-[300px] truncate px-2 py-3 text-slate-700 dark:text-white/90'>
{l.model_name || '—'}
</td>
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
{renderNumber(l.token_used || 0)}
</td>
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
{renderQuota(l.quota || 0)}
</td>
<td className='whitespace-nowrap px-6 py-3 text-right'>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 ${
ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
ok ? 'bg-emerald-500' : 'bg-red-500'
}`}
/>
<span className='text-xs font-medium'>{ok ? '成功' : '失败'}</span>
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
};
export default RecentActivity;

View File

@ -0,0 +1,70 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
const Sparkline = ({
data = [],
width = 200,
height = 40,
stroke = 'currentColor',
className = '',
}) => {
if (!data || data.length === 0) return null;
const max = Math.max(...data, 1);
const step = data.length > 1 ? width / (data.length - 1) : 0;
const points = data
.map((v, i) => {
const x = i * step;
const y = height - (v / max) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
const areaPoints = `0,${height} ${points} ${width},${height}`;
return (
<svg
width='100%'
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio='none'
className={className}
>
<defs>
<linearGradient id='sparkline-gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
<stop offset='0%' stopColor={stroke} stopOpacity='0.18' />
<stop offset='100%' stopColor={stroke} stopOpacity='0' />
</linearGradient>
</defs>
<polygon points={areaPoints} fill='url(#sparkline-gradient)' />
<polyline
points={points}
fill='none'
stroke={stroke}
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
vectorEffect='non-scaling-stroke'
/>
</svg>
);
};
export default Sparkline;

View File

@ -0,0 +1,75 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Search, RefreshCw, Wallet } from 'lucide-react';
import { renderQuota } from '../../helpers';
const TopBar = ({ onSearch, onRefresh, loading, user, unreadCount = 0 }) => {
const initial = (user?.username || '?').charAt(0).toUpperCase();
const balance = Number(user?.quota || 0);
return (
<div className='flex items-center justify-between border-b border-slate-200 py-5 dark:border-white/10'>
<div className='flex items-center gap-3'>
<span className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
TokenFactory
</span>
</div>
<div className='flex items-center gap-2'>
<button
onClick={onSearch}
className='flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
title='搜索'
>
<Search size={13} className='text-slate-400 dark:text-white/40' />
<span>搜索</span>
</button>
<button
onClick={onRefresh}
disabled={loading}
className='flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
title='刷新'
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
<a
href='/console/topup'
className='hidden items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm transition-colors hover:border-slate-300 hover:bg-slate-50 sm:inline-flex dark:border-white/10 dark:bg-white/[0.02] dark:hover:bg-white/5'
title='账户余额'
>
<Wallet size={13} className='text-slate-400 dark:text-white/40' />
<span className='font-mono font-semibold tabular-nums text-emerald-600 dark:text-emerald-400'>
{renderQuota(balance)}
</span>
</a>
<div className='relative ml-1 flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-white/80'>
{initial}
{unreadCount > 0 && (
<span className='absolute -right-0.5 -top-0.5 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-mono text-white'>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</div>
</div>
</div>
);
};
export default TopBar;

View File

@ -17,260 +17,72 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect } from 'react';
import { getRelativeTime, userIsDistributorUser } from '../../helpers';
import React, { useContext } from 'react';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import DashboardHeader from './DashboardHeader';
import StatsCards from './StatsCards';
import ChartsPanel from './ChartsPanel';
import ApiInfoPanel from './ApiInfoPanel';
import AnnouncementsPanel from './AnnouncementsPanel';
import FaqPanel from './FaqPanel';
import UptimePanel from './UptimePanel';
import SearchModal from './modals/SearchModal';
import DistributorAnalyticsBoard from '../distributor/DistributorAnalyticsBoard';
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
import {
CHART_CONFIG,
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
ANNOUNCEMENT_LEGEND_DATA,
UPTIME_STATUS_MAP,
} from '../../constants/dashboard.constants';
import {
getTrendSpec,
handleCopyUrl,
handleSpeedTest,
getUptimeStatusColor,
getUptimeStatusText,
renderMonitorList,
} from '../../helpers/dashboard';
import TopBar from './TopBar';
import Hero from './Hero';
import BentoStats from './BentoStats';
import BentoTrend from './BentoTrend';
import MonthlyForecast from './MonthlyForecast';
import RecentActivity from './RecentActivity';
const Dashboard = () => {
// ========== Context ==========
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const [userState] = useContext(UserContext);
const user = userState?.user;
// ========== ==========
const dashboardData = useDashboardData(userState, userDispatch, statusState);
// ========== ==========
const dashboardCharts = useDashboardCharts(
dashboardData.dataExportDefaultTime,
dashboardData.setTrendData,
dashboardData.setConsumeQuota,
dashboardData.setTimes,
dashboardData.setConsumeTokens,
dashboardData.setPieData,
dashboardData.setLineData,
dashboardData.setModelColors,
dashboardData.t,
);
// ========== ==========
const { groupedStatsData } = useDashboardStats(
userState,
dashboardData.consumeQuota,
dashboardData.consumeTokens,
dashboardData.times,
dashboardData.trendData,
dashboardData.performanceMetrics,
dashboardData.navigate,
dashboardData.t,
);
// ========== ==========
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await dashboardData.loadUptimeData();
};
const handleRefresh = async () => {
const data = await dashboardData.refresh();
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
};
// ========== ==========
const apiInfoData = statusState?.status?.api_info || [];
const announcementData = (statusState?.status?.announcements || []).map(
(item) => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime =
pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: item?.publishDate || '';
const relativeTime = getRelativeTime(item.publishDate);
return {
...item,
time: absoluteTime,
relative: relativeTime,
};
},
);
const faqData = statusState?.status?.faq || [];
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
([status, info]) => ({
status: Number(status),
color: info.color,
label: dashboardData.t(info.label),
}),
);
// ========== Effects ==========
useEffect(() => {
initChart();
}, []);
const dashboard = useDashboardData(userState);
const { unreadCount } = useUserMessageUnreadCount(user);
return (
<div className='h-full flex flex-col gap-6'>
<DashboardHeader
getGreeting={dashboardData.getGreeting}
greetingVisible={dashboardData.greetingVisible}
showSearchModal={dashboardData.showSearchModal}
refresh={handleRefresh}
loading={dashboardData.loading}
t={dashboardData.t}
/>
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<TopBar
onSearch={() => {}}
onRefresh={dashboard.refresh}
loading={dashboard.loading}
user={user}
unreadCount={unreadCount}
/>
<SearchModal
searchModalVisible={dashboardData.searchModalVisible}
handleSearchConfirm={handleSearchConfirm}
handleCloseModal={dashboardData.handleCloseModal}
isMobile={dashboardData.isMobile}
isAdminUser={dashboardData.isAdminUser}
inputs={dashboardData.inputs}
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
timeOptions={dashboardData.timeOptions}
handleInputChange={dashboardData.handleInputChange}
t={dashboardData.t}
/>
<div className='space-y-6 pt-8 pb-8'>
<Hero user={user} />
<StatsCards
groupedStatsData={groupedStatsData}
loading={dashboardData.loading}
getTrendSpec={getTrendSpec}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
/>
{userIsDistributorUser(userState?.user) ? (
<DistributorAnalyticsBoard />
) : null}
{/* API信息和图表面板 */}
<div>
<div
className={`grid grid-cols-1 gap-6 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
>
<ChartsPanel
activeChartTab={dashboardData.activeChartTab}
setActiveChartTab={dashboardData.setActiveChartTab}
spec_line={dashboardCharts.spec_line}
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
t={dashboardData.t}
/>
{dashboardData.hasApiInfoPanel && (
<ApiInfoPanel
apiInfoData={apiInfoData}
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
handleSpeedTest={handleSpeedTest}
CARD_PROPS={CARD_PROPS}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
{/* 第一行5 个核心指标卡 */}
<div className='grid grid-cols-2 gap-5 md:grid-cols-3 xl:grid-cols-5'>
<BentoStats
metrics={dashboard.metrics}
monthDelta={dashboard.monthDelta}
/>
)}
</div>
{/* 第二行:七日用量趋势 + 本月账单预测 */}
<div className='grid grid-cols-1 gap-5 md:grid-cols-4'>
<BentoTrend
dailyStats={dashboard.dailyStats}
topModels={dashboard.topModels}
className='md:col-span-2'
/>
<MonthlyForecast
forecast={dashboard.monthlyForecast}
className='md:col-span-2'
/>
</div>
{/* 第三行:最近调用 */}
<RecentActivity
logs={dashboard.recentLogs}
/>
</div>
<div className='flex items-center justify-center border-t border-slate-200 py-6 text-xs text-slate-400 dark:border-white/10 dark:text-white/30'>
<span>TokenFactory · 2026</span>
</div>
</div>
{/* 系统公告和常见问答卡片 */}
{dashboardData.hasInfoPanels && (
<div>
<div className='grid grid-cols-1 lg:grid-cols-4 gap-6'>
{/* 公告卡片 */}
{dashboardData.announcementsEnabled && (
<AnnouncementsPanel
announcementData={announcementData}
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
(item) => ({
...item,
label: dashboardData.t(item.label),
}),
)}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
{/* 常见问答卡片 */}
{dashboardData.faqEnabled && (
<FaqPanel
faqData={faqData}
CARD_PROPS={CARD_PROPS}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
{/* 大模型部署定制服务 / 可用性监控卡片 */}
{dashboardData.uptimeEnabled && (
<UptimePanel
uptimeData={dashboardData.uptimeData}
uptimeLoading={dashboardData.uptimeLoading}
activeUptimeTab={dashboardData.activeUptimeTab}
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
loadUptimeData={dashboardData.loadUptimeData}
uptimeLegendData={uptimeLegendData}
renderMonitorList={(monitors) =>
renderMonitorList(
monitors,
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
(status) =>
getUptimeStatusText(
status,
UPTIME_STATUS_MAP,
dashboardData.t,
),
dashboardData.t,
)
}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
</div>
</div>
)}
</div>
);
};
export default Dashboard;
export default Dashboard;

View File

@ -0,0 +1,665 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Send,
Copy,
Check,
RefreshCcw,
Loader2,
KeyRound,
Cpu,
MessageSquare,
Zap,
Eye,
EyeOff,
Globe,
Sparkles,
Bookmark,
Plus,
X,
} from 'lucide-react';
import { Toast } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const DEFAULT_PATH = '/v1/chat/completions';
const DEFAULT_MAX_TOKENS = 1024;
// /v1/messages Anthropic Messages API
const detectFormat = (rawPath) =>
/\/v1\/messages\/?$/i.test((rawPath || '').trim()) ? 'anthropic' : 'openai';
const CUSTOM_SCENES_KEY = 'api-tester-custom-scenes';
const SCENES = [
//
{ id: 'platform-chat', group: '本平台', label: '本平台 Chat', path: '/v1/chat/completions', Icon: MessageSquare },
{ id: 'platform-embedding', group: '本平台', label: '本平台 Embedding', path: '/v1/embeddings', Icon: Zap },
{ id: 'platform-image', group: '本平台', label: '本平台 图像生成', path: '/v1/images/generations', Icon: Cpu },
// · OpenAI
{ id: 'deepseek-openai', group: '国产 · OpenAI 兼容', label: 'DeepSeek', base: 'https://api.deepseek.com', path: '/v1/chat/completions', Icon: Sparkles },
{ id: 'qwen', group: '国产 · OpenAI 兼容', label: '通义千问 Qwen', base: 'https://dashscope.aliyuncs.com/compatible-mode', path: '/v1/chat/completions', Icon: Sparkles },
{ id: 'kimi', group: '国产 · OpenAI 兼容', label: 'Kimi 月之暗面', base: 'https://api.moonshot.cn', path: '/v1/chat/completions', Icon: Sparkles },
{ id: 'glm', group: '国产 · OpenAI 兼容', label: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', path: '/chat/completions', Icon: Sparkles },
{ id: 'doubao', group: '国产 · OpenAI 兼容', label: '豆包 Doubao', base: 'https://ark.cn-beijing.volces.com/api/v3', path: '/chat/completions', Icon: Sparkles },
// Anthropic
{ id: 'deepseek-anthropic', group: 'Anthropic 兼容', label: 'DeepSeek · Anthropic', base: 'https://api.deepseek.com/anthropic', path: '/v1/messages', Icon: Sparkles },
{ id: 'anthropic', group: 'Anthropic 兼容', label: 'Anthropic 官方', base: 'https://api.anthropic.com', path: '/v1/messages', Icon: Sparkles },
//
{ id: 'ollama', group: '本地部署', label: 'Ollama', base: 'http://localhost:11434', path: '/v1/chat/completions', Icon: Cpu },
];
// API Key
// 1)
// 2) Bearer
// 3) sk- Key _- "sk-xxx "
const sanitizeKey = (raw) => {
if (!raw) return '';
let s = String(raw).trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1).trim();
}
s = s.replace(/^Bearer\s+/i, '').trim();
const idx = s.search(/sk-[A-Za-z0-9_-]/);
if (idx >= 0) {
return s.slice(idx).match(/sk-[A-Za-z0-9_-]+/)?.[0] || '';
}
const first = s.split(/\s+/).find(Boolean) || '';
return first.replace(/[^\w.\-]/g, '');
};
const buildPayload = (format, model, prompt, systemPrompt, maxTokens) => {
if (format === 'anthropic') {
return {
model,
max_tokens: Number(maxTokens) || DEFAULT_MAX_TOKENS,
...(systemPrompt ? { system: systemPrompt } : {}),
messages: [{ role: 'user', content: prompt }],
};
}
return {
model,
messages: [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: prompt },
],
stream: false,
};
};
const formatJSON = (data) => {
if (typeof data === 'string') return data;
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
const ApiTester = () => {
const { t } = useTranslation();
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [endpointPath, setEndpointPath] = useState(DEFAULT_PATH);
const [endpointBase, setEndpointBase] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false);
const [model, setModel] = useState('deepseek-v4-pro');
const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS);
const [systemPrompt, setSystemPrompt] = useState('');
const [prompt, setPrompt] = useState('用一句话介绍你自己。');
const [response, setResponse] = useState('');
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [elapsed, setElapsed] = useState(0);
const [copied, setCopied] = useState(false);
const [customScenes, setCustomScenes] = useState([]);
const userRaw = useMemo(() => localStorage.getItem('user'), []);
const user = useMemo(() => {
try {
return JSON.parse(userRaw);
} catch {
return null;
}
}, [userRaw]);
useEffect(() => {
if (user?.token) setApiKey(sanitizeKey(user.token));
}, [user?.token]);
const handleApiKeyChange = (e) => {
setApiKey(sanitizeKey(e.target.value));
};
useEffect(() => {
const apiBase =
import.meta.env?.VITE_API_BASE ||
(window.location.port === '3000'
? `${window.location.protocol}//${window.location.hostname}:3001`
: window.location.origin);
setEndpointBase(apiBase);
}, []);
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(CUSTOM_SCENES_KEY) || '[]');
if (Array.isArray(saved)) setCustomScenes(saved);
} catch {
//
}
}, []);
const persistCustomScenes = (list) => {
setCustomScenes(list);
try {
localStorage.setItem(CUSTOM_SCENES_KEY, JSON.stringify(list));
} catch {
// quota
}
};
const format = useMemo(() => detectFormat(endpointPath), [endpointPath]);
const endpoint = useMemo(() => {
const base = (endpointBase || window.location.origin).replace(/\/+$/, '');
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
return `${base}${path}`;
}, [endpointBase, endpointPath]);
const isAbsoluteEndpoint = useMemo(
() => /^https?:\/\//i.test(endpointPath),
[endpointPath],
);
const finalEndpoint = useMemo(
() => (isAbsoluteEndpoint ? endpointPath : endpoint),
[isAbsoluteEndpoint, endpoint, endpointPath],
);
const applyScene = (scene) => {
setShowCustomBase(false);
setEndpointPath(scene.path);
setEndpointBase(scene.base || window.location.origin);
};
const saveCurrentAsCustom = () => {
const label = window.prompt('给当前目标起个名字', '我的目标');
if (!label) return;
const trimmed = label.trim();
if (!trimmed) return;
persistCustomScenes([
...customScenes,
{
id: `custom-${Date.now()}`,
label: trimmed,
base: endpointBase,
path: endpointPath,
},
]);
Toast.success('已保存到我的目标');
};
const deleteCustomScene = (id) => {
persistCustomScenes(customScenes.filter((s) => s.id !== id));
};
const applyFullUrl = (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
setEndpointPath(DEFAULT_PATH);
setEndpointBase(window.location.origin);
return;
}
const m = trimmed.match(/^(https?:\/\/[^\s/?#]+)(.*)$/i);
if (m) {
setEndpointBase(m[1]);
const tail = m[2] || DEFAULT_PATH;
setEndpointPath(tail.startsWith('/') ? tail : `/${tail}`);
} else {
setEndpointBase(window.location.origin);
setEndpointPath(trimmed.startsWith('/') ? trimmed : `/${trimmed}`);
}
};
const cURL = useMemo(() => {
const body = buildPayload(format, model, prompt, systemPrompt, maxTokens);
return `curl ${finalEndpoint} \\
-H "Authorization: Bearer ${apiKey || '<YOUR_API_KEY>'}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(body)}'`;
}, [finalEndpoint, apiKey, format, model, prompt, systemPrompt, maxTokens]);
const send = async () => {
if (!apiKey) {
Toast.warning('请先填写 API Key');
return;
}
if (!prompt.trim()) {
Toast.warning('请输入 Prompt');
return;
}
setLoading(true);
setResponse('');
setStatus(null);
const start = performance.now();
try {
const res = await fetch(finalEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(
buildPayload(format, model, prompt, systemPrompt, maxTokens),
),
});
const ms = Math.round(performance.now() - start);
setElapsed(ms);
setStatus({ code: res.status, ok: res.ok });
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await res.json();
setResponse(formatJSON(data));
} else {
const text = await res.text();
setResponse(text);
}
} catch (err) {
setStatus({ code: 0, ok: false });
setResponse(`请求失败: ${err?.message || String(err)}`);
} finally {
setLoading(false);
}
};
const copyCurl = async () => {
try {
await navigator.clipboard.writeText(cURL);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
Toast.error('复制失败');
}
};
const reset = () => {
setPrompt('用一句话介绍你自己。');
setSystemPrompt('');
setResponse('');
setStatus(null);
setElapsed(0);
};
return (
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div className='space-y-6 pt-8 pb-8'>
{/* 页面标题区 */}
<div className='flex flex-wrap items-end justify-between gap-4'>
<div>
<h1 className='text-3xl font-bold tracking-tight text-slate-900 md:text-4xl dark:text-white'>
API 快速测试
</h1>
<p className='mt-2 text-sm text-slate-500 dark:text-white/50'>
当前协议{format === 'anthropic' ? 'Anthropic Messages' : 'OpenAI Chat Completions'} · 请求地址 {finalEndpoint}
</p>
</div>
<button
onClick={reset}
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-white/20 dark:hover:text-white'
>
<RefreshCcw size={13} /> 重置
</button>
</div>
{/* 第一行API Key / 测试目标 / 模型 */}
<div className='grid grid-cols-1 gap-5 lg:grid-cols-3'>
{/* API Key 输入卡片 */}
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
<div className='mb-3 flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<KeyRound size={15} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>API Key</div>
</div>
<div className='relative'>
<input
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={handleApiKeyChange}
placeholder='sk-...'
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 pr-9 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
<button
onClick={() => setShowKey((v) => !v)}
className='absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
>
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<div className='mt-2 text-[11px] text-slate-400 dark:text-white/40'>
{user?.token ? '已自动填充当前登录用户的 Token' : '请填写你的 API Key'}
</div>
</div>
{/* 测试目标选择卡片 */}
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<Globe size={15} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
测试目标
</div>
</div>
<button
onClick={() => setShowCustomBase((v) => !v)}
className='inline-flex items-center gap-1 text-[11px] text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
title='切换自定义域名'
>
{showCustomBase ? '收起域名' : '换个平台'}
</button>
</div>
{(() => {
const grouped = SCENES.reduce((acc, s) => {
if (!acc[s.group]) acc[s.group] = [];
acc[s.group].push(s);
return acc;
}, {});
return (
<div className='space-y-2.5'>
{Object.entries(grouped).map(([group, items]) => (
<div key={group}>
<div className='mb-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
{group}
</div>
<div className='flex flex-wrap gap-1.5'>
{items.map((s) => (
<button
key={s.id}
onClick={() => applyScene(s)}
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-blue-400 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
>
<s.Icon size={11} /> {s.label}
</button>
))}
</div>
</div>
))}
<div className='border-t border-slate-100 pt-2.5 dark:border-white/5'>
<div className='mb-1.5 flex items-center justify-between'>
<div className='text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
我的目标
</div>
<button
onClick={saveCurrentAsCustom}
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:text-blue-400'
>
<Plus size={10} /> 保存当前
</button>
</div>
{customScenes.length === 0 ? (
<div className='text-[11px] text-slate-400 dark:text-white/40'>
调整完地址后点右上保存当前即可收藏到本地
</div>
) : (
<div className='flex flex-wrap gap-1.5'>
{customScenes.map((s) => (
<span
key={s.id}
className='inline-flex items-center overflow-hidden rounded-full border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'
>
<button
onClick={() => applyScene(s)}
className='inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:bg-blue-50 hover:text-blue-600 dark:text-white/70 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
>
<Bookmark size={11} /> {s.label}
</button>
<button
onClick={() => deleteCustomScene(s.id)}
className='border-l border-slate-200 px-2 py-1.5 text-slate-400 transition hover:bg-red-50 hover:text-red-500 dark:border-white/10 dark:hover:bg-red-500/10 dark:hover:text-red-400'
title='删除'
>
<X size={11} />
</button>
</span>
))}
</div>
)}
</div>
</div>
);
})()}
<div className='mt-3'>
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
完整请求地址
</label>
<input
value={isAbsoluteEndpoint ? endpointPath : `${endpointBase}${endpointPath}`}
onChange={(e) => applyFullUrl(e.target.value)}
placeholder='https://你的平台域名/v1/chat/completions'
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
</div>
{showCustomBase && (
<div className='mt-3 grid grid-cols-[1fr_1.4fr] gap-1.5'>
<input
value={endpointBase}
onChange={(e) => setEndpointBase(e.target.value)}
placeholder='https://api.example.com'
className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
<input
value={endpointPath}
onChange={(e) => setEndpointPath(e.target.value)}
placeholder='/v1/chat/completions'
className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
</div>
)}
<div className='mt-3 flex items-center gap-1.5 truncate font-mono text-[11px] text-slate-400 dark:text-white/40'>
<span className='shrink-0'>实际请求</span>
<span className='truncate'>{finalEndpoint}</span>
</div>
</div>
{/* 模型选择卡片 */}
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
<div className='mb-3 flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<Cpu size={15} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>模型</div>
</div>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder='例如 gpt-4o-mini / claude-3-5-sonnet ...'
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
{format === 'anthropic' && (
<div className='mt-3'>
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
max_tokensAnthropic 必填
</label>
<input
type='number'
min={1}
max={8192}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value) || 1)}
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
</div>
)}
</div>
</div>
{/* 第二行:请求参数 + 响应结果 */}
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
{/* 请求参数卡片 */}
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
<div className='mb-4 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<MessageSquare size={15} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
请求参数
</div>
</div>
<button
onClick={send}
disabled={loading}
className='inline-flex items-center gap-1.5 rounded-full bg-blue-600 px-4 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-blue-500 dark:hover:bg-blue-400'
>
{loading ? (
<Loader2 size={13} className='animate-spin' />
) : (
<Send size={13} />
)}
{loading ? '发送中...' : '发送请求'}
</button>
</div>
<div className='space-y-4'>
<div>
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
{format === 'anthropic' ? 'system顶层字段' : '系统提示'}
</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
rows={2}
placeholder={format === 'anthropic' ? '可选Anthropic 顶层 system 字段' : '(可选)设置模型角色'}
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
</div>
<div>
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
用户提示 <span className='text-red-500'>*</span>
</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={5}
placeholder='输入你想让模型回答的内容'
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
/>
</div>
</div>
</div>
{/* 响应结果卡片 */}
<div className='flex flex-col rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
<div className='mb-4 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<Zap size={15} />
</div>
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
响应结果
</div>
{status && (
<span
className={`ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium ${
status.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
status.ok ? 'bg-emerald-500' : 'bg-red-500'
}`}
/>
{status.code > 0 ? `${status.code}` : 'ERR'} · {elapsed}ms
</span>
)}
</div>
{response && (
<button
onClick={() => {
navigator.clipboard?.writeText(response);
Toast.success('已复制响应');
}}
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:border-white/20 dark:hover:text-white'
>
<Copy size={11} /> 复制
</button>
)}
</div>
<div className='flex-1 overflow-hidden rounded-xl border border-slate-100 bg-slate-50/50 dark:border-white/5 dark:bg-neutral-900/50'>
{loading ? (
<div className='flex h-full min-h-[280px] items-center justify-center text-sm text-slate-400 dark:text-white/40'>
<Loader2 size={20} className='mr-2 animate-spin' />
请求中...
</div>
) : response ? (
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-[11px] leading-relaxed text-slate-800 dark:text-white/80'>
{response}
</pre>
) : (
<div className='flex h-full min-h-[280px] flex-col items-center justify-center px-6 text-center'>
<div className='text-sm text-slate-400 dark:text-white/40'>
填写参数后点击发送请求即可看到原始响应
</div>
</div>
)}
</div>
</div>
</div>
{/* cURL 命令展示区 */}
<div className='overflow-hidden rounded-2xl border border-slate-900 bg-neutral-950 dark:border-white/10'>
<div className='flex items-center justify-between border-b border-white/10 bg-neutral-900 px-4 py-2.5'>
<div className='flex gap-1.5'>
<span className='h-3 w-3 rounded-full bg-red-500/60' />
<span className='h-3 w-3 rounded-full bg-yellow-500/60' />
<span className='h-3 w-3 rounded-full bg-green-500/60' />
</div>
<span className='font-mono text-[10px] tracking-wide text-white/30'>终端</span>
<button
onClick={copyCurl}
className='text-white/40 transition-colors hover:text-white/80'
aria-label='复制'
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
<pre className='overflow-x-auto p-5 font-mono text-[12px] leading-relaxed text-white/80'>
{cURL}
</pre>
</div>
</div>
</div>
</div>
);
};
export default ApiTester;