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:
parent
1e3ce2c0d2
commit
b1e65e03b5
|
|
@ -46,6 +46,7 @@ import ModelPage from './pages/Model';
|
||||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||||
import ModelHeatPage from './pages/ModelHeat';
|
import ModelHeatPage from './pages/ModelHeat';
|
||||||
import Playground from './pages/Playground';
|
import Playground from './pages/Playground';
|
||||||
|
import ApiTester from './pages/ApiTester';
|
||||||
import Subscription from './pages/Subscription';
|
import Subscription from './pages/Subscription';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
import PersonalSetting from './components/settings/PersonalSetting';
|
||||||
|
|
@ -58,6 +59,10 @@ import Suppliers from './pages/SupplierAdmin/list';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupCheck from './components/layout/SetupCheck';
|
import SetupCheck from './components/layout/SetupCheck';
|
||||||
import OperationLog from './pages/OperationLog';
|
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 Home = lazy(() => import('./pages/Home'));
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
|
@ -177,6 +182,14 @@ function App() {
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/api-tester'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<ApiTester />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/console/redemption'
|
path='/console/redemption'
|
||||||
element={
|
element={
|
||||||
|
|
@ -193,6 +206,38 @@ function App() {
|
||||||
</AdminRoute>
|
</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
|
<Route
|
||||||
path='/console/supplier-application'
|
path='/console/supplier-application'
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -17,260 +17,72 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { getRelativeTime, userIsDistributorUser } from '../../helpers';
|
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { StatusContext } from '../../context/Status';
|
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
|
||||||
|
|
||||||
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 { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
|
||||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
|
||||||
|
|
||||||
import {
|
import TopBar from './TopBar';
|
||||||
CHART_CONFIG,
|
import Hero from './Hero';
|
||||||
CARD_PROPS,
|
import BentoStats from './BentoStats';
|
||||||
FLEX_CENTER_GAP2,
|
import BentoTrend from './BentoTrend';
|
||||||
ILLUSTRATION_SIZE,
|
import MonthlyForecast from './MonthlyForecast';
|
||||||
ANNOUNCEMENT_LEGEND_DATA,
|
import RecentActivity from './RecentActivity';
|
||||||
UPTIME_STATUS_MAP,
|
|
||||||
} from '../../constants/dashboard.constants';
|
|
||||||
import {
|
|
||||||
getTrendSpec,
|
|
||||||
handleCopyUrl,
|
|
||||||
handleSpeedTest,
|
|
||||||
getUptimeStatusColor,
|
|
||||||
getUptimeStatusText,
|
|
||||||
renderMonitorList,
|
|
||||||
} from '../../helpers/dashboard';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
// ========== Context ==========
|
const [userState] = useContext(UserContext);
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
const user = userState?.user;
|
||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
|
||||||
|
|
||||||
// ========== 主要数据管理 ==========
|
const dashboard = useDashboardData(userState);
|
||||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
const { unreadCount } = useUserMessageUnreadCount(user);
|
||||||
|
|
||||||
// ========== 图表管理 ==========
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-full flex flex-col gap-6'>
|
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||||
<DashboardHeader
|
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||||
getGreeting={dashboardData.getGreeting}
|
<TopBar
|
||||||
greetingVisible={dashboardData.greetingVisible}
|
onSearch={() => {}}
|
||||||
showSearchModal={dashboardData.showSearchModal}
|
onRefresh={dashboard.refresh}
|
||||||
refresh={handleRefresh}
|
loading={dashboard.loading}
|
||||||
loading={dashboardData.loading}
|
user={user}
|
||||||
t={dashboardData.t}
|
unreadCount={unreadCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchModal
|
<div className='space-y-6 pt-8 pb-8'>
|
||||||
searchModalVisible={dashboardData.searchModalVisible}
|
<Hero user={user} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCards
|
{/* 第一行:5 个核心指标卡 */}
|
||||||
groupedStatsData={groupedStatsData}
|
<div className='grid grid-cols-2 gap-5 md:grid-cols-3 xl:grid-cols-5'>
|
||||||
loading={dashboardData.loading}
|
<BentoStats
|
||||||
getTrendSpec={getTrendSpec}
|
metrics={dashboard.metrics}
|
||||||
CARD_PROPS={CARD_PROPS}
|
monthDelta={dashboard.monthDelta}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|
@ -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_tokens(Anthropic 必填)
|
||||||
|
</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;
|
||||||
Loading…
Reference in New Issue