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:
wangxiaoji 2026-06-26 16:16:30 +08:00
parent 1e3ce2c0d2
commit 3fb6326f4a
38 changed files with 3127 additions and 2445 deletions

45
web/src/App.jsx vendored
View File

@ -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={

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,258 +17,70 @@ 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>
); );
}; };

View File

@ -28,6 +28,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit'; import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
import SettingsDistributor from '../../pages/Setting/Operation/SettingsDistributor'; import SettingsDistributor from '../../pages/Setting/Operation/SettingsDistributor';
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
import SettingsAntiAbuse from '../../pages/Setting/Operation/SettingsAntiAbuse';
import { API, showError, toBoolean } from '../../helpers'; import { API, showError, toBoolean } from '../../helpers';
const OperationSetting = () => { const OperationSetting = () => {
@ -169,6 +170,10 @@ const OperationSetting = () => {
<Card style={{ marginTop: '10px' }}> <Card style={{ marginTop: '10px' }}>
<SettingsCheckin options={inputs} refresh={onRefresh} /> <SettingsCheckin options={inputs} refresh={onRefresh} />
</Card> </Card>
{/* 风控设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAntiAbuse />
</Card>
</Spin> </Spin>
</> </>
); );

View File

@ -33,7 +33,7 @@ import {
} from '../../helpers'; } from '../../helpers';
import { normalizeSmsVerificationEnabled } from '../../helpers/data'; import { normalizeSmsVerificationEnabled } from '../../helpers/data';
import { UserContext } from '../../context/User'; import { UserContext } from '../../context/User';
import { Modal, Card, Button, Typography } from '@douyinfe/semi-ui'; import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// //
@ -555,26 +555,24 @@ const PersonalSetting = () => {
return ( return (
<div className='mt-[60px]'> <div className='mx-auto mt-[60px] max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
<div className='flex justify-center'> {/* 顶部用户信息区域 */}
<div className='w-full max-w-7xl mx-auto px-2'> <UserInfoHeader t={t} userState={userState} />
{/* 顶部用户信息区域 */}
<UserInfoHeader t={t} userState={userState} />
{/* 签到日历 - 仅在启用时显示 */} {/* 签到日历 - 仅在启用时显示 */}
{status?.checkin_enabled && ( {status?.checkin_enabled && (
<div className='mt-4 md:mt-6'> <div className='mt-4 md:mt-6'>
<CheckinCalendar <CheckinCalendar
t={t} t={t}
status={status} status={status}
turnstileEnabled={turnstileEnabled} turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey} turnstileSiteKey={turnstileSiteKey}
/> />
</div> </div>
)} )}
{/* 账户管理和其他设置 */} {/* 账户管理和其他设置 */}
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'> <div className='mt-4 grid grid-cols-1 items-start gap-4 md:mt-6 md:gap-6 xl:grid-cols-2'>
{/* 左侧:账户管理设置 */} {/* 左侧:账户管理设置 */}
<div className='flex flex-col gap-4 md:gap-6'> <div className='flex flex-col gap-4 md:gap-6'>
<AccountManagement <AccountManagement
@ -609,8 +607,6 @@ const PersonalSetting = () => {
handleNotificationSettingChange={handleNotificationSettingChange} handleNotificationSettingChange={handleNotificationSettingChange}
saveNotificationSettings={saveNotificationSettings} saveNotificationSettings={saveNotificationSettings}
/> />
</div>
</div>
</div> </div>
{/* 模态框组件 */} {/* 模态框组件 */}

View File

@ -19,18 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { import {
Card,
Calendar, Calendar,
Button, Button,
Typography,
Avatar,
Spin, Spin,
Tooltip, Tooltip,
Collapsible, Collapsible,
Modal, Modal,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
CalendarCheck, Sparkles,
Gift, Gift,
Check, Check,
ChevronDown, ChevronDown,
@ -213,7 +210,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
}; };
return ( return (
<Card className='!rounded-2xl'> <div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
<Modal <Modal
title='Security Check' title='Security Check'
visible={turnstileModalVisible} visible={turnstileModalVisible}
@ -241,24 +238,24 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
{/* 卡片头部 */} {/* 卡片头部 */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div <div
className='flex items-center flex-1 cursor-pointer' className='flex flex-1 cursor-pointer items-center gap-3'
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
> >
<Avatar size='small' color='green' className='mr-3 shadow-md'> <div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
<CalendarCheck size={16} /> <Sparkles size={18} />
</Avatar> </div>
<div className='flex-1'> <div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Typography.Text className='text-lg font-medium'> <span className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
{t('每日签到')} {t('每日签到')}
</Typography.Text> </span>
{isCollapsed ? ( {isCollapsed ? (
<ChevronDown size={16} className='text-gray-400' /> <ChevronDown size={16} className='text-slate-400 dark:text-white/40' />
) : ( ) : (
<ChevronUp size={16} className='text-gray-400' /> <ChevronUp size={16} className='text-slate-400 dark:text-white/40' />
)} )}
</div> </div>
<div className='text-xs text-gray-500 dark:text-gray-400'> <div className='text-xs text-slate-400 dark:text-white/40'>
{!initialLoaded {!initialLoaded
? t('正在加载签到状态...') ? t('正在加载签到状态...')
: checkinData.stats?.checked_in_today : checkinData.stats?.checked_in_today
@ -276,7 +273,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
onClick={() => doCheckin()} onClick={() => doCheckin()}
loading={checkinLoading || !initialLoaded} loading={checkinLoading || !initialLoaded}
disabled={!initialLoaded || checkinData.stats?.checked_in_today} disabled={!initialLoaded || checkinData.stats?.checked_in_today}
className='!bg-green-600 hover:!bg-green-700' className='!rounded-lg'
> >
{!initialLoaded {!initialLoaded
? t('加载中...') ? t('加载中...')
@ -289,30 +286,36 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
{/* 可折叠内容 */} {/* 可折叠内容 */}
<Collapsible isOpen={isCollapsed === false} keepDOM> <Collapsible isOpen={isCollapsed === false} keepDOM>
{/* 签到统计 */} {/* 签到统计 */}
<div className='grid grid-cols-3 gap-3 mb-4 mt-4'> <div className='mt-4 mb-4 grid grid-cols-3 gap-3'>
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'> <div className='rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 text-center dark:border-white/5 dark:bg-white/[0.02]'>
<div className='text-xl font-bold text-green-600'> <div className='text-xl font-bold tabular-nums text-emerald-600 dark:text-emerald-400'>
{checkinData.stats?.total_checkins || 0} {checkinData.stats?.total_checkins || 0}
</div> </div>
<div className='text-xs text-gray-500'>{t('累计签到')}</div> <div className='text-xs text-slate-400 dark:text-white/40'>
{t('累计签到')}
</div>
</div> </div>
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'> <div className='rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 text-center dark:border-white/5 dark:bg-white/[0.02]'>
<div className='text-xl font-bold text-orange-600'> <div className='text-xl font-bold tabular-nums text-amber-600 dark:text-amber-400'>
{renderQuota(monthlyQuota)} {renderQuota(monthlyQuota)}
</div> </div>
<div className='text-xs text-gray-500'>{t('本月获得')}</div> <div className='text-xs text-slate-400 dark:text-white/40'>
{t('本月获得')}
</div>
</div> </div>
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'> <div className='rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 text-center dark:border-white/5 dark:bg-white/[0.02]'>
<div className='text-xl font-bold text-blue-600'> <div className='text-xl font-bold tabular-nums text-blue-600 dark:text-blue-400'>
{renderQuota(checkinData.stats?.total_quota || 0)} {renderQuota(checkinData.stats?.total_quota || 0)}
</div> </div>
<div className='text-xs text-gray-500'>{t('累计获得')}</div> <div className='text-xs text-slate-400 dark:text-white/40'>
{t('累计获得')}
</div>
</div> </div>
</div> </div>
{/* 签到日历 - 使用更紧凑的样式 */} {/* 签到日历 - 使用更紧凑的样式 */}
<Spin spinning={loading}> <Spin spinning={loading}>
<div className='border rounded-lg overflow-hidden checkin-calendar'> <div className='checkin-calendar overflow-hidden rounded-xl border border-slate-200 dark:border-white/5'>
<style>{` <style>{`
.checkin-calendar .semi-calendar { .checkin-calendar .semi-calendar {
font-size: 13px; font-size: 13px;
@ -367,17 +370,15 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
</Spin> </Spin>
{/* 签到说明 */} {/* 签到说明 */}
<div className='mt-3 p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'> <div className='mt-3 rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 dark:border-white/5 dark:bg-white/[0.02]'>
<Typography.Text type='tertiary' className='text-xs'> <ul className='list-disc space-y-0.5 pl-4 text-xs text-slate-400 dark:text-white/40'>
<ul className='list-disc list-inside space-y-0.5'> <li>{t('每日签到可获得随机额度奖励')}</li>
<li>{t('每日签到可获得随机额度奖励')}</li> <li>{t('签到奖励将直接添加到您的账户余额')}</li>
<li>{t('签到奖励将直接添加到您的账户余额')}</li> <li>{t('每日仅可签到一次,请勿重复签到')}</li>
<li>{t('每日仅可签到一次,请勿重复签到')}</li> </ul>
</ul>
</Typography.Text>
</div> </div>
</Collapsible> </Collapsible>
</Card> </div>
); );
}; };

View File

@ -21,8 +21,6 @@ import React, { useRef, useEffect, useState, useContext } from 'react';
import { import {
Button, Button,
Typography, Typography,
Card,
Avatar,
Form, Form,
Radio, Radio,
Toast, Toast,
@ -33,7 +31,13 @@ import {
Col, Col,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons'; import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react'; import {
BellRing,
Coins,
EyeOff,
LayoutPanelLeft,
Wrench,
} from 'lucide-react';
import { import {
renderQuotaWithPrompt, renderQuotaWithPrompt,
API, API,
@ -317,48 +321,16 @@ const NotificationSettings = ({
}; };
return ( return (
<Card <div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
className='!rounded-2xl shadow-sm border-0' <div className='mb-5 flex items-center gap-3'>
footer={ <div className='flex h-10 w-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
<div className='flex justify-end gap-3'> <Wrench size={18} />
{activeTabKey === 'sidebar' ? (
//
<>
<Button
type='tertiary'
onClick={resetSidebarModules}
className='!rounded-lg'
>
{t('重置为默认')}
</Button>
<Button
type='primary'
onClick={saveSidebarSettings}
loading={sidebarLoading}
className='!rounded-lg'
>
{t('保存设置')}
</Button>
</>
) : (
//
<Button type='primary' onClick={handleSubmit}>
{t('保存设置')}
</Button>
)}
</div> </div>
}
>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
<Bell size={16} />
</Avatar>
<div> <div>
<Typography.Text className='text-lg font-medium'> <div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
{t('其他设置')} {t('其他设置')}
</Typography.Text> </div>
<div className='text-xs text-gray-600'> <div className='text-xs text-slate-400 dark:text-white/40'>
{t('通知、价格和隐私相关设置')} {t('通知、价格和隐私相关设置')}
</div> </div>
</div> </div>
@ -379,7 +351,7 @@ const NotificationSettings = ({
<TabPane <TabPane
tab={ tab={
<div className='flex items-center'> <div className='flex items-center'>
<Bell size={16} className='mr-2' /> <BellRing size={14} className='mr-2' />
{t('通知配置')} {t('通知配置')}
</div> </div>
} }
@ -719,7 +691,7 @@ const NotificationSettings = ({
<TabPane <TabPane
tab={ tab={
<div className='flex items-center'> <div className='flex items-center'>
<DollarSign size={16} className='mr-2' /> <Coins size={14} className='mr-2' />
{t('价格设置')} {t('价格设置')}
</div> </div>
} }
@ -745,7 +717,7 @@ const NotificationSettings = ({
<TabPane <TabPane
tab={ tab={
<div className='flex items-center'> <div className='flex items-center'>
<ShieldCheck size={16} className='mr-2' /> <EyeOff size={14} className='mr-2' />
{t('隐私设置')} {t('隐私设置')}
</div> </div>
} }
@ -770,7 +742,7 @@ const NotificationSettings = ({
<TabPane <TabPane
tab={ tab={
<div className='flex items-center'> <div className='flex items-center'>
<Settings size={16} className='mr-2' /> <LayoutPanelLeft size={14} className='mr-2' />
{t('边栏设置')} {t('边栏设置')}
</div> </div>
} }
@ -791,36 +763,19 @@ const NotificationSettings = ({
</Typography.Text> </Typography.Text>
</div> </div>
{/* 边栏设置功能区域容器 */} {/* 边栏设置功能区域容器 */}
<div <div className='rounded-2xl border border-slate-200 bg-slate-50/50 p-4 dark:border-white/5 dark:bg-white/[0.02]'>
className='border rounded-xl p-4'
style={{
borderColor: 'var(--semi-color-border)',
backgroundColor: 'var(--semi-color-bg-1)',
}}
>
{sectionConfigs.map((section) => ( {sectionConfigs.map((section) => (
<div key={section.key} className='mb-6'> <div key={section.key} className='mb-6 last:mb-0'>
{/* 区域标题和总开关 */} {/* 区域标题和总开关 */}
<div <div className='mb-4 flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4 dark:border-white/5 dark:bg-white/[0.02]'>
className='flex justify-between items-center mb-4 p-4 rounded-lg' <div className='min-w-0'>
style={{ <div className='mb-1 text-base font-semibold text-slate-900 dark:text-white'>
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border-light)',
borderColor: 'var(--semi-color-fill-1)',
}}
>
<div>
<div className='font-semibold text-base text-gray-900 mb-1'>
{section.title} {section.title}
</div> </div>
<Typography.Text <Typography.Text
type='secondary' type='secondary'
size='small' size='small'
style={{ className='text-xs text-slate-400 dark:text-white/40'
fontSize: '12px',
lineHeight: '1.5',
color: 'var(--semi-color-text-2)',
}}
> >
{section.description} {section.description}
</Typography.Text> </Typography.Text>
@ -845,30 +800,23 @@ const NotificationSettings = ({
lg={8} lg={8}
xl={8} xl={8}
> >
<Card <div
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${ className={`rounded-xl border border-slate-200 bg-white p-4 transition-colors hover:border-blue-300 dark:border-white/5 dark:bg-white/[0.02] dark:hover:border-blue-400/40 ${
sidebarModulesUser[section.key]?.enabled !== sidebarModulesUser[section.key]?.enabled !==
false false
? '' ? ''
: 'opacity-50' : 'opacity-50'
}`} }`}
bodyStyle={{ padding: '16px' }}
> >
<div className='flex justify-between items-center h-full'> <div className='flex h-full items-center justify-between'>
<div className='flex-1 text-left'> <div className='min-w-0 flex-1 text-left'>
<div className='font-semibold text-sm text-gray-900 mb-1'> <div className='mb-1 text-sm font-semibold text-slate-900 dark:text-white'>
{module.title} {module.title}
</div> </div>
<Typography.Text <Typography.Text
type='secondary' type='secondary'
size='small' size='small'
className='block' className='mt-1 block text-xs text-slate-400 dark:text-white/40'
style={{
fontSize: '12px',
lineHeight: '1.5',
color: 'var(--semi-color-text-2)',
marginTop: '4px',
}}
> >
{module.description} {module.description}
</Typography.Text> </Typography.Text>
@ -892,7 +840,7 @@ const NotificationSettings = ({
/> />
</div> </div>
</div> </div>
</Card> </div>
</Col> </Col>
))} ))}
</Row> </Row>
@ -906,7 +854,41 @@ const NotificationSettings = ({
</Tabs> </Tabs>
)} )}
</Form> </Form>
</Card>
{/* Footer: 保存按钮 */}
<div className='mt-6 flex justify-end gap-3 border-t border-slate-100 pt-4 dark:border-white/5'>
{activeTabKey === 'sidebar' ? (
<>
<Button
type='tertiary'
theme='light'
onClick={resetSidebarModules}
className='!rounded-lg'
>
{t('重置为默认')}
</Button>
<Button
type='primary'
theme='solid'
onClick={saveSidebarSettings}
loading={sidebarLoading}
className='!rounded-lg'
>
{t('保存设置')}
</Button>
</>
) : (
<Button
type='primary'
theme='solid'
onClick={handleSubmit}
className='!rounded-lg'
>
{t('保存设置')}
</Button>
)}
</div>
</div>
); );
}; };

View File

@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useState, useEffect, useContext } from 'react'; import React, { useState, useEffect, useContext } from 'react';
import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui'; import { Select } from '@douyinfe/semi-ui';
import { Languages } from 'lucide-react'; import { SlidersHorizontal, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { API, showSuccess, showError } from '../../../../helpers'; import { API, showSuccess, showError } from '../../../../helpers';
import { UserContext } from '../../../../context/User'; import { UserContext } from '../../../../context/User';
@ -124,62 +124,55 @@ const PreferencesSettings = ({ t }) => {
}; };
return ( return (
<Card className='!rounded-2xl shadow-sm border-0'> <div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
{/* Card Header */} {/* Card Header */}
<div className='flex items-center mb-4'> <div className='mb-5 flex items-center gap-3'>
<Avatar size='small' color='violet' className='mr-3 shadow-md'> <div className='flex h-10 w-10 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
<Languages size={16} /> <SlidersHorizontal size={18} />
</Avatar> </div>
<div> <div>
<Typography.Text className='text-lg font-medium'> <div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
{t('偏好设置')} {t('偏好设置')}
</Typography.Text> </div>
<div className='text-xs text-gray-600 dark:text-gray-400'> <div className='text-xs text-slate-400 dark:text-white/40'>
{t('界面语言和其他个人偏好')} {t('界面语言和其他个人偏好')}
</div> </div>
</div> </div>
</div> </div>
{/* Language Setting Card */} {/* Language Setting Card */}
<Card className='!rounded-xl border dark:border-gray-700'> <div className='flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between dark:border-white/5 dark:bg-white/[0.02]'>
<div className='flex flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-4'> <div className='flex w-full items-start gap-3 sm:w-auto'>
<div className='flex items-start w-full sm:w-auto'> <div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
<div className='w-12 h-12 rounded-full bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center mr-4 flex-shrink-0'> <Globe size={18} />
<Languages </div>
size={20} <div className='min-w-0'>
className='text-violet-600 dark:text-violet-400' <div className='text-sm font-semibold text-slate-900 dark:text-white'>
/> {t('语言偏好')}
</div> </div>
<div> <div className='mt-0.5 text-xs text-slate-400 dark:text-white/40'>
<Typography.Title heading={6} className='mb-1'> {t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
{t('语言偏好')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
</Typography.Text>
</div> </div>
</div> </div>
<Select
value={currentLanguage}
onChange={handleLanguagePreferenceChange}
style={{ width: 180 }}
loading={loading}
optionList={languageOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
/>
</div> </div>
</Card> <Select
value={currentLanguage}
onChange={handleLanguagePreferenceChange}
style={{ width: 180 }}
loading={loading}
optionList={languageOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
/>
</div>
{/* Additional info */} {/* Additional info */}
<div className='mt-4 text-xs text-gray-500 dark:text-gray-400'> <div className='mt-4 text-xs text-slate-400 dark:text-white/40'>
<Typography.Text type='tertiary'> {t(
{t( '提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。',
'提示语言偏好会同步到您登录的所有设备并影响API返回的错误消息语言。', )}
)}
</Typography.Text>
</div> </div>
</Card> </div>
); );
}; };

View File

@ -32,18 +32,18 @@ import {
Badge, Badge,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconShield,
IconAlertTriangle, IconAlertTriangle,
IconRefresh, IconRefresh,
IconCopy, IconCopy,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { ScanFace } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
const { Text, Paragraph } = Typography; const { Text, Paragraph } = Typography;
const TwoFASetting = ({ t }) => { const TwoFASetting = ({ t, compact = false }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [status, setStatus] = useState({ const [status, setStatus] = useState({
enabled: false, enabled: false,
@ -366,20 +366,23 @@ const TwoFASetting = ({ t }) => {
return ( return (
<> <>
<Card className='!rounded-xl w-full'> <div
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'> className={
<div className='flex items-start w-full sm:w-auto'> compact
<div className='w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0'> ? ''
<IconShield : 'rounded-2xl border border-slate-200 bg-white p-5 dark:border-white/5 dark:bg-white/[0.02]'
size='large' }
className='text-slate-600 dark:text-slate-300' >
/> <div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex min-w-0 items-start gap-3'>
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
<ScanFace size={18} />
</div> </div>
<div className='flex-1'> <div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 mb-1'> <div className='mb-1 flex items-center gap-2'>
<Typography.Title heading={6} className='mb-0'> <span className='text-sm font-semibold text-slate-900 dark:text-white'>
{t('两步验证设置')} {t('两步验证设置')}
</Typography.Title> </span>
{status.enabled ? ( {status.enabled ? (
<Tag color='green' shape='circle' size='small'> <Tag color='green' shape='circle' size='small'>
{t('已启用')} {t('已启用')}
@ -395,23 +398,24 @@ const TwoFASetting = ({ t }) => {
</Tag> </Tag>
)} )}
</div> </div>
<Typography.Text type='tertiary' className='text-sm'> <Typography.Text
type='tertiary'
className='text-xs text-slate-400 dark:text-white/40'
>
{t( {t(
'两步验证2FA为您的账户提供额外的安全保护。启用后登录时需要输入密码和验证器应用生成的验证码。', '两步验证2FA为您的账户提供额外的安全保护。启用后登录时需要输入密码和验证器应用生成的验证码。',
)} )}
</Typography.Text> </Typography.Text>
{status.enabled && ( {status.enabled && (
<div className='mt-2'> <div className='mt-2 text-xs text-slate-400 dark:text-white/40'>
<Text size='small' type='secondary'> {t('剩余备用码:')}
{t('剩余备用码:')} {status.backup_codes_remaining || 0}
{status.backup_codes_remaining || 0} {t('个')}
{t('个')}
</Text>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className='flex flex-col space-y-2 w-full sm:w-auto'> <div className='flex w-full flex-col gap-2 sm:w-auto sm:self-center'>
{!status.enabled ? ( {!status.enabled ? (
<Button <Button
type='primary' type='primary'
@ -419,19 +423,19 @@ const TwoFASetting = ({ t }) => {
size='default' size='default'
onClick={handleSetup2FA} onClick={handleSetup2FA}
loading={loading} loading={loading}
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700' className='!rounded-lg'
icon={<IconShield />} icon={<ScanFace />}
> >
{t('启用验证')} {t('启用验证')}
</Button> </Button>
) : ( ) : (
<div className='flex flex-col space-y-2'> <div className='flex flex-col gap-2'>
<Button <Button
type='danger' type='danger'
theme='solid' theme='solid'
size='default' size='default'
onClick={() => setDisableModalVisible(true)} onClick={() => setDisableModalVisible(true)}
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600' className='!rounded-lg'
icon={<IconAlertTriangle />} icon={<IconAlertTriangle />}
> >
{t('禁用两步验证')} {t('禁用两步验证')}
@ -450,7 +454,7 @@ const TwoFASetting = ({ t }) => {
)} )}
</div> </div>
</div> </div>
</Card> </div>
{/* 2FA设置模态框 */} {/* 2FA设置模态框 */}
<Modal <Modal

View File

@ -18,211 +18,154 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { Avatar, Card, Tag, Divider, Typography } from '@douyinfe/semi-ui'; import { Tag } from '@douyinfe/semi-ui';
import {
Coins,
BarChart2,
Users,
Crown,
ShieldCheck,
User as UserIcon,
Store,
} from 'lucide-react';
import { import {
isRoot, isRoot,
isAdmin, isAdmin,
isDistributor, isDistributor,
renderQuota, renderQuota,
stringToColor,
} from '../../../../helpers'; } from '../../../../helpers';
import { Coins, BarChart2, Users } from 'lucide-react';
const iconWrap =
'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';
const roleAccent = {
root: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
admin:
'bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400',
distributor:
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
user: 'bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70',
};
const roleLabelKey = {
root: '超级管理员',
admin: '管理员',
distributor: '代理',
user: '普通用户',
};
const UserInfoHeader = ({ t, userState }) => { const UserInfoHeader = ({ t, userState }) => {
const getUsername = () => { const username = userState.user?.username || 'null';
if (userState.user) { const roleKey = isRoot()
return userState.user.username; ? 'root'
} else { : isAdmin()
return 'null'; ? 'admin'
} : isDistributor()
}; ? 'distributor'
: 'user';
const RoleIcon =
roleKey === 'root'
? Crown
: roleKey === 'admin'
? ShieldCheck
: roleKey === 'distributor'
? Store
: UserIcon;
const getAvatarText = () => { const stats = [
const username = getUsername(); {
if (username && username.length > 0) { key: 'quota',
return username.slice(0, 2).toUpperCase(); label: t('当前余额'),
} value: renderQuota(userState?.user?.quota),
return 'NA'; accent:
}; 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
icon: Coins,
},
{
key: 'used',
label: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
accent:
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
icon: BarChart2,
},
{
key: 'group',
label: t('用户分组'),
value: userState?.user?.group || t('默认'),
accent:
'bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400',
icon: Users,
},
];
return ( return (
<Card <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'>
className='!rounded-2xl overflow-hidden' {/* 头部:头像 + 用户名 + 角色 */}
cover={ <div className='flex items-center gap-4'>
<div <div
className='relative h-32' className={`flex h-14 w-14 items-center justify-center rounded-2xl text-base font-semibold ${roleAccent[roleKey]}`}
style={{
'--palette-primary-darkerChannel': '0 75 80',
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
> >
{/* 用户信息内容 */} {username.slice(0, 2).toUpperCase()}
<div className='relative z-10 h-full flex flex-col justify-end p-6'> </div>
<div className='flex items-center'> <div className='min-w-0 flex-1'>
<div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'> <div className='flex flex-wrap items-center gap-2'>
<Avatar size='large' color={stringToColor(getUsername())}> <h1 className='truncate text-xl font-semibold tracking-tight text-slate-900 dark:text-white'>
{getAvatarText()} {username}
</Avatar> </h1>
<div className='flex-1 min-w-0 flex flex-col justify-between'> <Tag
<div shape='circle'
className='text-3xl font-bold truncate' size='small'
style={{ color: 'white' }} className='!rounded-full'
> style={{
{getUsername()} color: 'inherit',
</div> backgroundColor: 'transparent',
<div className='flex flex-wrap items-center gap-2'> border: 'none',
{isRoot() ? ( padding: 0,
<Tag }}
size='large' >
shape='circle' <span
style={{ color: 'white' }} className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${roleAccent[roleKey]}`}
> >
{t('超级管理员')} <RoleIcon size={12} />
</Tag> {t(roleLabelKey[roleKey])}
) : isAdmin() ? ( </span>
<Tag </Tag>
size='large' <span className='text-xs text-slate-400 dark:text-white/40 tabular-nums'>
shape='circle' ID: {userState?.user?.id}
style={{ color: 'white' }} </span>
> </div>
{t('管理员')} <p className='mt-1 text-xs text-slate-400 dark:text-white/40'>
</Tag> {t('账户概览与使用统计')}
) : isDistributor() ? ( </p>
<Tag </div>
size='large' </div>
shape='circle'
style={{ color: 'white' }} {/* 数据三宫格 */}
> <div className='mt-6 grid grid-cols-1 gap-3 border-t border-slate-100 pt-6 sm:grid-cols-3 dark:border-white/5'>
{t('代理')} {stats.map((s) => {
</Tag> const Icon = s.icon;
) : ( return (
<Tag <div
size='large' key={s.key}
shape='circle' className='flex items-center gap-3 rounded-xl border border-slate-100 bg-white px-4 py-3 dark:border-white/5 dark:bg-white/[0.02]'
style={{ color: 'white' }} >
> <div
{t('普通用户')} className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${s.accent}`}
</Tag> >
)} <Icon size={18} />
<Tag size='large' shape='circle' style={{ color: 'white' }}> </div>
ID: {userState?.user?.id} <div className='min-w-0'>
</Tag> <div className='text-xs text-slate-400 dark:text-white/40'>
</div> {s.label}
</div>
<div className='truncate text-base font-semibold tabular-nums text-slate-900 dark:text-white'>
{s.value}
</div> </div>
</div> </div>
</div> </div>
</div> );
</div> })}
}
>
{/* 当前余额和桌面版统计信息(勿用 Badge 展示长文案,会遮挡数字并挤压布局) */}
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6'>
{/* 当前余额:标签在上、数字在下,避免与右侧统计区重叠 */}
<div className='min-w-0 flex-1'>
<Typography.Text
type='tertiary'
size='small'
className='!block mb-1.5'
>
{t('当前余额')}
</Typography.Text>
<div className='text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide tabular-nums break-words text-[var(--semi-color-text-0)]'>
{renderQuota(userState?.user?.quota)}
</div>
</div>
{/* 桌面版统计信息Semi UI 卡片) */}
<div className='hidden min-w-0 shrink-0 lg:block'>
<Card
size='small'
className='!rounded-xl max-w-full'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='flex flex-wrap items-center gap-x-4 gap-y-2'>
<div className='flex items-center gap-2'>
<Coins size={16} />
<Typography.Text size='small' type='tertiary'>
{t('历史消耗')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{renderQuota(userState?.user?.used_quota)}
</Typography.Text>
</div>
<Divider layout='vertical' />
<div className='flex items-center gap-2'>
<BarChart2 size={16} />
<Typography.Text size='small' type='tertiary'>
{t('请求次数')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState.user?.request_count || 0}
</Typography.Text>
</div>
<Divider layout='vertical' />
<div className='flex items-center gap-2'>
<Users size={16} />
<Typography.Text size='small' type='tertiary'>
{t('用户分组')}
</Typography.Text>
<Typography.Text size='small' type='tertiary' strong>
{userState?.user?.group || t('默认')}
</Typography.Text>
</div>
</div>
</Card>
</div>
</div> </div>
</div>
{/* 移动端和中等屏幕统计信息卡片 */}
<div className='lg:hidden mt-2'>
<Card
size='small'
className='!rounded-xl'
bodyStyle={{ padding: '12px 16px' }}
>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Coins size={16} />
<Typography.Text size='small' type='tertiary'>
{t('历史消耗')}
</Typography.Text>
</div>
<Typography.Text size='small' type='tertiary' strong>
{renderQuota(userState?.user?.used_quota)}
</Typography.Text>
</div>
<Divider margin='8px' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<BarChart2 size={16} />
<Typography.Text size='small' type='tertiary'>
{t('请求次数')}
</Typography.Text>
</div>
<Typography.Text size='small' type='tertiary' strong>
{userState.user?.request_count || 0}
</Typography.Text>
</div>
<Divider margin='8px' />
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Users size={16} />
<Typography.Text size='small' type='tertiary'>
{t('用户分组')}
</Typography.Text>
</div>
<Typography.Text size='small' type='tertiary' strong>
{userState?.user?.group || t('默认')}
</Typography.Text>
</div>
</div>
</Card>
</div>
</Card>
); );
}; };

View File

@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import PricingGroups from '../filter/PricingGroups'; import PricingGroups from '../filter/PricingGroups';
import PricingQuotaTypes from '../filter/PricingQuotaTypes'; import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../filter/PricingEndpointTypes'; import PricingEndpointTypes from '../filter/PricingEndpointTypes';
@ -99,17 +98,22 @@ const PricingSidebar = ({
}); });
return ( return (
<div className='p-2'> <div className='px-4 py-5'>
<div className='flex items-center justify-between mb-6'> <div className='mb-5 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-white/5'>
<div className='text-lg font-semibold text-gray-800'>{t('筛选')}</div> <div>
<Button <div className='text-sm font-semibold tracking-tight text-slate-900 dark:text-white'>
theme='outline' {t('筛选')}
type='tertiary' </div>
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
调整左侧条件实时刷新结果
</div>
</div>
<button
onClick={handleResetFilters} onClick={handleResetFilters}
className='text-gray-500 hover:text-gray-700' className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-500 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'
> >
{t('重置')} {t('重置')}
</Button> </button>
</div> </div>
<PricingVendors <PricingVendors

View File

@ -18,49 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react'; import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
import { import { Tooltip, Modal } from '@douyinfe/semi-ui';
Card, import { Layers } from 'lucide-react';
Tag,
Avatar,
Typography,
Tooltip,
Modal,
} from '@douyinfe/semi-ui';
import { getLobeHubIcon } from '../../../../../helpers'; import { getLobeHubIcon } from '../../../../../helpers';
import SearchActions from './SearchActions'; import SearchActions from './SearchActions';
const { Paragraph } = Typography;
const CONFIG = { const CONFIG = {
CAROUSEL_INTERVAL: 2000, CAROUSEL_INTERVAL: 2000,
ICON_SIZE: 40, ICON_SIZE: 32,
UNKNOWN_VENDOR: 'unknown', UNKNOWN_VENDOR: 'unknown',
}; };
const THEME_COLORS = { const THEME = {
allVendors: { allVendors: {
primary: '37 99 235', iconWrap:
background: 'rgba(59, 130, 246, 0.08)', 'bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70',
accent: 'text-slate-600 dark:text-slate-300',
badge: 'bg-slate-100 text-slate-700 dark:bg-white/5 dark:text-slate-300',
}, },
specific: { specific: {
primary: '16 185 129', iconWrap:
background: 'rgba(16, 185, 129, 0.1)', 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
accent: 'text-emerald-600 dark:text-emerald-400',
badge: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300',
}, },
}; };
const COMPONENT_STYLES = {
tag: {
backgroundColor: 'rgba(255,255,255,0.95)',
color: '#1f2937',
border: '1px solid rgba(255,255,255,0.8)',
fontWeight: '500',
},
avatarContainer:
'w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center',
titleText: { color: 'white' },
descriptionText: { color: 'rgba(255,255,255,0.9)' },
};
const CONTENT_TEXTS = { const CONTENT_TEXTS = {
unknown: { unknown: {
displayName: (t) => t('未知模型类型'), displayName: (t) => t('未知模型类型'),
@ -85,35 +68,32 @@ const getVendorDisplayName = (vendorName, t) => {
}; };
const createDefaultAvatar = () => ( const createDefaultAvatar = () => (
<div className={COMPONENT_STYLES.avatarContainer}> <div className='flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 text-slate-500 dark:bg-white/5 dark:text-white/60'>
<Avatar size='large' color='transparent'> <Layers size={22} />
AI
</Avatar>
</div> </div>
); );
const getAvatarBackgroundColor = (isAllVendors) =>
isAllVendors
? THEME_COLORS.allVendors.background
: THEME_COLORS.specific.background;
const getAvatarText = (vendorName) => const getAvatarText = (vendorName) =>
vendorName === CONFIG.UNKNOWN_VENDOR vendorName === CONFIG.UNKNOWN_VENDOR
? '?' ? '?'
: (vendorName || '').charAt(0).toUpperCase() || '?'; : (vendorName || '').charAt(0).toUpperCase() || '?';
const createAvatarContent = (vendor, isAllVendors) => { const createAvatarContent = (vendor, iconWrapClass) => {
if (vendor.icon) { if (vendor.icon) {
return getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE); return (
<div
className={`flex h-12 w-12 items-center justify-center rounded-xl ${iconWrapClass}`}
>
{getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE)}
</div>
);
} }
return ( return (
<Avatar <div
size='large' className={`flex h-12 w-12 items-center justify-center rounded-xl font-mono text-lg font-semibold ${iconWrapClass}`}
style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }}
> >
{getAvatarText(vendor.name)} {getAvatarText(vendor.name)}
</Avatar> </div>
); );
}; };
@ -123,11 +103,14 @@ const renderVendorAvatar = (vendor, t, isAllVendors = false) => {
} }
const displayName = getVendorDisplayName(vendor.name, t); const displayName = getVendorDisplayName(vendor.name, t);
const avatarContent = createAvatarContent(vendor, isAllVendors); const iconWrapClass = isAllVendors
? THEME.allVendors.iconWrap
: THEME.specific.iconWrap;
const avatarContent = createAvatarContent(vendor, iconWrapClass);
return ( return (
<Tooltip content={displayName} position='top'> <Tooltip content={displayName} position='top'>
<div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div> {avatarContent}
</Tooltip> </Tooltip>
); );
}; };
@ -259,17 +242,6 @@ const PricingVendorIntro = memo(
[vendorInfo, t], [vendorInfo, t],
); );
const createCoverStyle = useCallback(
(primaryColor) => ({
'--palette-primary-darkerChannel': primaryColor,
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}),
[],
);
const renderSearchActions = useCallback( const renderSearchActions = useCallback(
() => ( () => (
<SearchActions <SearchActions
@ -318,51 +290,41 @@ const PricingVendorIntro = memo(
); );
const renderHeaderCard = useCallback( const renderHeaderCard = useCallback(
({ title, count, description, rightContent, primaryDarkerChannel }) => ( ({ title, count, description, rightContent, theme }) => (
<Card <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'>
className='!rounded-2xl shadow-sm border-0' <div className='flex items-start gap-4'>
cover={ <div className='min-w-0 flex-1'>
<div <div className='mb-1.5 flex flex-wrap items-center gap-2'>
className='relative h-full' <h2 className='truncate text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white'>
style={createCoverStyle(primaryDarkerChannel)} {title}
> </h2>
<div className='relative z-10 h-full flex items-center justify-between p-4'> <span
<div className='flex-1 min-w-0 mr-4'> className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium ${theme.badge}`}
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'> >
<h2 {t('共 {{count}} 个模型', { count })}
className='text-lg sm:text-xl font-bold truncate' </span>
style={COMPONENT_STYLES.titleText}
>
{title}
</h2>
<Tag
style={COMPONENT_STYLES.tag}
shape='circle'
size='small'
className='self-center'
>
{t('共 {{count}} 个模型', { count })}
</Tag>
</div>
<Paragraph
className='text-xs sm:text-sm leading-relaxed !mb-0 cursor-pointer'
style={COMPONENT_STYLES.descriptionText}
ellipsis={{ rows: 2 }}
onClick={() => handleOpenDescModal(description)}
>
{description}
</Paragraph>
</div>
<div className='flex-shrink-0'>{rightContent}</div>
</div> </div>
<p
className='cursor-pointer text-xs leading-relaxed text-slate-500 sm:text-sm dark:text-white/50'
onClick={() => handleOpenDescModal(description)}
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{description}
</p>
</div> </div>
} <div className='flex shrink-0 items-center'>{rightContent}</div>
> </div>
{renderSearchActions()} <div className='mt-5 border-t border-slate-100 pt-4 dark:border-white/5'>
</Card> {renderSearchActions()}
</div>
</div>
), ),
[renderSearchActions, createCoverStyle, handleOpenDescModal, t], [renderSearchActions, handleOpenDescModal, t],
); );
const renderAllVendorsAvatar = useCallback(() => { const renderAllVendorsAvatar = useCallback(() => {
@ -379,7 +341,7 @@ const PricingVendorIntro = memo(
count: currentModelCount, count: currentModelCount,
description: getVendorDescription('all'), description: getVendorDescription('all'),
rightContent: renderAllVendorsAvatar(), rightContent: renderAllVendorsAvatar(),
primaryDarkerChannel: THEME_COLORS.allVendors.primary, theme: THEME.allVendors,
}); });
return ( return (
<> <>
@ -402,7 +364,7 @@ const PricingVendorIntro = memo(
description: description:
currentVendor.description || getVendorDescription(currentVendor.name), currentVendor.description || getVendorDescription(currentVendor.name),
rightContent: renderVendorAvatar(currentVendor, t, false), rightContent: renderVendorAvatar(currentVendor, t, false),
primaryDarkerChannel: THEME_COLORS.specific.primary, theme: THEME.specific,
}); });
return ( return (

View File

@ -65,8 +65,8 @@ const SearchActions = memo(
}, [tokenUnit, setTokenUnit]); }, [tokenUnit, setTokenUnit]);
return ( return (
<div className='flex items-center gap-2 w-full'> <div className='flex flex-wrap items-center gap-2 w-full'>
<div className='flex-1'> <div className='min-w-[180px] flex-1'>
<Input <Input
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')} placeholder={t('模糊搜索模型名称')}
@ -80,11 +80,10 @@ const SearchActions = memo(
<Button <Button
theme='outline' theme='outline'
type='primary' type='tertiary'
icon={<IconCopy />} icon={<IconCopy />}
onClick={handleCopyClick} onClick={handleCopyClick}
disabled={selectedRowKeys.length === 0} disabled={selectedRowKeys.length === 0}
className='!bg-blue-500 hover:!bg-blue-600 !text-white disabled:!bg-gray-300 disabled:!text-gray-500'
> >
{t('复制')} {t('复制')}
</Button> </Button>
@ -96,7 +95,7 @@ const SearchActions = memo(
{/* 充值价格显示开关 */} {/* 充值价格显示开关 */}
{supportsCurrencyDisplay && ( {supportsCurrencyDisplay && (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'> <span className='text-sm text-slate-500 dark:text-white/50'>
{t('充值价格显示')} {t('充值价格显示')}
</span> </span>
<Switch <Switch
@ -121,7 +120,7 @@ const SearchActions = memo(
{/* 显示倍率开关 */} {/* 显示倍率开关 */}
{/* <div className='flex items-center gap-2'> {/* <div className='flex items-center gap-2'>
<span className='text-sm text-gray-600'>{t('倍率')}</span> <span className='text-sm text-slate-500 dark:text-white/50'>{t('倍率')}</span>
<Switch checked={showRatio} onChange={setShowRatio} /> <Switch checked={showRatio} onChange={setShowRatio} />
</div> */} </div> */}

View File

@ -18,11 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Card, Table, Empty } from '@douyinfe/semi-ui'; import { Card, Table } from '@douyinfe/semi-ui';
import { import { SearchX } from 'lucide-react';
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { getPricingTableColumns } from './PricingTableColumns'; import { getPricingTableColumns } from './PricingTableColumns';
const PricingTable = ({ const PricingTable = ({
@ -114,16 +111,17 @@ const PricingTable = ({
style: { cursor: 'pointer' }, style: { cursor: 'pointer' },
})} })}
empty={ empty={
<Empty <div className='flex flex-col items-center justify-center px-6 py-16 text-center'>
image={ <div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-400 dark:bg-white/5 dark:text-white/40'>
<IllustrationNoResult style={{ width: 150, height: 150 }} /> <SearchX size={22} strokeWidth={1.5} />
} </div>
darkModeImage={ <div className='text-sm font-medium text-slate-700 dark:text-white/80'>
<IllustrationNoResultDark style={{ width: 150, height: 150 }} /> {t('搜索无结果')}
} </div>
description={t('搜索无结果')} <div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
style={{ padding: 30 }} {t('试试调整筛选条件或换个关键词')}
/> </div>
</div>
} }
pagination={{ pagination={{
defaultPageSize: 20, defaultPageSize: 20,

View File

@ -184,8 +184,12 @@ const AddUserModal = (props) => {
field='password' field='password'
label={t('密码')} label={t('密码')}
type='password' type='password'
placeholder={t('请输入密码')} placeholder={t('请输入密码8-20位')}
rules={[{ required: true, message: t('请输入密码') }]} rules={[
{ required: true, message: t('请输入密码') },
{ min: 8, message: t('密码长度至少为8个字符') },
{ max: 20, message: t('密码长度不能超过20个字符') },
]}
showClear showClear
/> />
</Col> </Col>

View File

@ -271,8 +271,12 @@ const EditUserModal = (props) => {
<Form.Input <Form.Input
field='password' field='password'
label={t('密码')} label={t('密码')}
placeholder={t('请输入新的密码,最短 8 位')} placeholder={t('请输入新的密码8-20位')}
mode='password' mode='password'
rules={[
{ min: 8, message: t('密码长度至少为8个字符') },
{ max: 20, message: t('密码长度不能超过20个字符') },
]}
showClear showClear
/> />
</Col> </Col>

View File

@ -105,108 +105,74 @@ const RechargeCard = ({
}, [shouldShowSubscription, activeTab]); }, [shouldShowSubscription, activeTab]);
const topupContent = ( const topupContent = (
<Space vertical style={{ width: '100%' }}> <Space vertical style={{ width: '100%' }}>
{/* 统计数据 */} {/* 账户统计 */}
<Card <div className='rounded-3xl border border-slate-200 bg-white p-10 transition-shadow hover:shadow-lg dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/30'>
className='!rounded-xl w-full' <div className='mb-8 flex items-center justify-between'>
cover={ <div className='flex items-center gap-4'>
<div <div className='flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
className='relative h-30' <BarChart2 size={24} />
style={{ </div>
'--palette-primary-darkerChannel': '37 99 235', <div>
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`, <div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
backgroundSize: 'cover', {t('账户统计')}
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='relative z-10 h-full flex flex-col justify-between p-4'>
<div className='flex justify-between items-center'>
<Text strong style={{ color: 'white', fontSize: '16px' }}>
{t('账户统计')}
</Text>
</div> </div>
<div className='mt-1 text-xs text-slate-400 tabular-nums dark:text-white/40'>
{/* 统计数据 */} 实时余额与消费概览
<div className='grid grid-cols-3 gap-6 mt-4'>
{/* 当前余额 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{renderQuota(userState?.user?.quota)}
</div>
<div className='flex items-center justify-center text-sm'>
<Wallet
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('当前余额')}
</Text>
</div>
</div>
{/* 历史消耗 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{renderQuota(userState?.user?.used_quota)}
</div>
<div className='flex items-center justify-center text-sm'>
<TrendingUp
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('历史消耗')}
</Text>
</div>
</div>
{/* 请求次数 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{userState?.user?.request_count || 0}
</div>
<div className='flex items-center justify-center text-sm'>
<BarChart2
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('请求次数')}
</Text>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
} </div>
> <div className='grid grid-cols-1 gap-8 border-t border-slate-100 pt-8 dark:border-white/5 md:grid-cols-[1.8fr_1fr_1fr] md:items-end'>
{/* 当前余额 —— Hero 数字 */}
<div className='min-w-0'>
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400'>
<Wallet size={14} />
{t('当前余额')}
</div>
<div className='mt-3 font-mono text-5xl font-bold leading-none tabular-nums text-emerald-600 md:text-6xl dark:text-emerald-400'>
{renderQuota(userState?.user?.quota)}
</div>
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
账户可用额度 · 可继续调用 API
</div>
</div>
{/* 累计消费 */}
<div className='min-w-0 md:border-l md:border-slate-100 md:pl-8 md:dark:border-white/5'>
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-white/40'>
<TrendingUp size={14} />
{t('累计消费')}
</div>
<div className='mt-3 font-mono text-4xl font-semibold leading-none tabular-nums text-slate-900 md:text-5xl dark:text-white'>
{renderQuota(userState?.user?.used_quota)}
</div>
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
历史总消费
</div>
</div>
{/* 平均单次消费 */}
<div className='min-w-0 md:border-l md:border-slate-100 md:pl-8 md:dark:border-white/5'>
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-white/40'>
<Receipt size={14} />
{t('平均单次')}
</div>
<div className='mt-3 font-mono text-4xl font-semibold leading-none tabular-nums text-slate-900 md:text-5xl dark:text-white'>
{userState?.user?.used_quota && userState?.user?.request_count
? renderQuota(
Math.round(
Number(userState.user.used_quota) /
Number(userState.user.request_count),
),
)
: '—'}
</div>
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
单次调用平均花费
</div>
</div>
</div>
</div>
{/* 在线充值表单 */} {/* 在线充值表单 */}
{statusLoading ? ( {statusLoading ? (
<div className='py-8 flex justify-center'> <div className='py-8 flex justify-center'>
@ -570,17 +536,22 @@ const RechargeCard = ({
closeIcon={null} closeIcon={null}
/> />
)} )}
</Card>
{/* 兑换码充值 */} {/* 兑换码充值 */}
<Card <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'>
className='!rounded-xl w-full' <div className='mb-4 flex items-center gap-3'>
title={ <div className='flex h-10 w-10 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
<Text type='tertiary' strong> <IconGift size={18} />
{t('兑换码充值')} </div>
</Text> <div>
} <div className='text-sm font-semibold text-slate-900 dark:text-white'>
> {t('兑换码充值')}
</div>
<div className='mt-0.5 text-[11px] text-slate-400 dark:text-white/40'>
{t('输入兑换码直接到账,无需在线支付')}
</div>
</div>
</div>
<Form <Form
getFormApi={(api) => (redeemFormApiRef.current = api)} getFormApi={(api) => (redeemFormApiRef.current = api)}
initValues={{ redemptionCode: redemptionCode }} initValues={{ redemptionCode: redemptionCode }}
@ -623,32 +594,33 @@ const RechargeCard = ({
} }
/> />
</Form> </Form>
</Card> </div>
</Space> </Space>
); );
return ( return (
<Card className='!rounded-2xl shadow-sm border-0'> <Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */} {/* 卡片头部 */}
<div className='flex items-center justify-between mb-4'> <div className='mb-6 flex flex-wrap items-center justify-between gap-3'>
<div className='flex items-center'> <div className='flex items-center gap-3'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'> <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'>
<CreditCard size={16} /> <CreditCard size={18} />
</Avatar> </div>
<div> <div>
<Typography.Text className='text-lg font-medium'> <div className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
{t('账户充值')} {t('账户充值')}
</Typography.Text> </div>
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div> <div className='mt-0.5 text-[11px] text-slate-400 dark:text-white/40'>
{t('多种充值方式,安全便捷')}
</div>
</div> </div>
</div> </div>
<Button <button
icon={<Receipt size={16} />}
theme='solid'
onClick={onOpenHistory} onClick={onOpenHistory}
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'
> >
{t('账单')} <Receipt size={13} /> {t('账单')}
</Button> </button>
</div> </div>
{shouldShowSubscription ? ( {shouldShowSubscription ? (

View File

@ -864,7 +864,7 @@ const TopUp = () => {
}; };
return ( return (
<div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'> <div className='mx-auto w-full max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
{/* 划转模态框 */} {/* 划转模态框 */}
<TransferModal <TransferModal
t={t} t={t}

View File

@ -1,103 +1,44 @@
/* 充值额度卡片:hover 渐变边框 + 彩色背景 + 外发光 */ /* 充值额度卡片:简洁 hover对齐 dashboard 风格(无渐变) */
.preset-amount-card { .preset-amount-card {
position: relative; position: relative;
isolation: isolate;
cursor: pointer; cursor: pointer;
height: 100%; height: 100%;
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
padding: 1.5px;
background: var(--semi-color-border);
transition: transition:
background 0.35s ease, border-color 0.15s ease,
box-shadow 0.35s ease, box-shadow 0.15s ease,
transform 0.25s ease; transform 0.15s ease;
} }
/* 外圈光晕hover 时扩散) */ .preset-amount-card .semi-card {
.preset-amount-card::before { border: 1px solid rgb(226 232 240) !important;
content: ''; border-radius: 12px !important;
position: absolute; height: 100%;
inset: -6px; width: 100%;
border-radius: 16px; background: #ffffff !important;
background: radial-gradient( transition:
ellipse 90% 70% at 50% 50%, border-color 0.15s ease,
rgba(96, 165, 250, 0.45) 0%, box-shadow 0.15s ease !important;
rgba(167, 139, 250, 0.2) 45%, }
transparent 72%
); .preset-amount-card:hover .semi-card {
opacity: 0; border-color: rgb(96 165 250) !important;
pointer-events: none; box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12) !important;
z-index: -1;
transition: opacity 0.35s ease;
} }
.preset-amount-card:hover { .preset-amount-card:hover {
background: linear-gradient( transform: translateY(-1px);
135deg,
#3b82f6 0%,
#8b5cf6 48%,
#22d3ee 100%
);
box-shadow:
0 0 0 1px rgba(96, 165, 250, 0.65),
0 6px 22px rgba(59, 130, 246, 0.38),
0 0 36px rgba(139, 92, 246, 0.32),
0 0 56px rgba(34, 211, 238, 0.18);
transform: translateY(-2px);
}
.preset-amount-card:hover::before {
opacity: 1;
}
.preset-amount-card__inner.semi-card {
border: none !important;
border-radius: 10.5px !important;
height: 100%;
width: 100%;
background: var(--semi-color-bg-1) !important;
box-shadow: none !important;
transition:
background 0.35s ease,
box-shadow 0.35s ease !important;
}
.preset-amount-card:hover .preset-amount-card__inner {
background: linear-gradient(
155deg,
#eff6ff 0%,
#eef2ff 42%,
#ecfeff 100%
) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85) !important;
} }
/* 深色模式 */ /* 深色模式 */
.dark .preset-amount-card:hover .preset-amount-card__inner { .dark .preset-amount-card .semi-card {
background: linear-gradient( background: rgba(255, 255, 255, 0.02) !important;
155deg, border-color: rgba(255, 255, 255, 0.05) !important;
rgba(30, 64, 175, 0.55) 0%,
rgba(67, 56, 202, 0.48) 42%,
rgba(14, 116, 144, 0.42) 100%
) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
} }
.dark .preset-amount-card:hover { .dark .preset-amount-card:hover .semi-card {
box-shadow: border-color: rgba(96, 165, 250, 0.6) !important;
0 0 0 1px rgba(147, 197, 253, 0.5), box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important;
0 6px 24px rgba(37, 99, 235, 0.45),
0 0 40px rgba(124, 58, 237, 0.38),
0 0 64px rgba(34, 211, 238, 0.22);
}
.dark .preset-amount-card::before {
background: radial-gradient(
ellipse 90% 70% at 50% 50%,
rgba(59, 130, 246, 0.55) 0%,
rgba(124, 58, 237, 0.28) 45%,
transparent 72%
);
} }

View File

@ -22,8 +22,7 @@ export const CHART_CONFIG = { mode: 'desktop-browser' };
export const CARD_PROPS = { export const CARD_PROPS = {
shadows: '', shadows: '',
bordered: true, bordered: false,
headerLine: true,
}; };
export const FORM_FIELD_PROPS = { export const FORM_FIELD_PROPS = {

View File

@ -133,6 +133,7 @@ export const getTrendSpec = (data, color) => ({
background: { background: {
fill: 'transparent', fill: 'transparent',
}, },
transformSpec: [],
}); });
// ========== UI ========== // ========== UI ==========

View File

@ -91,6 +91,7 @@ import {
Info, Info,
LayoutGrid, LayoutGrid,
ClipboardCheck, ClipboardCheck,
FlaskConical,
} from 'lucide-react'; } from 'lucide-react';
import { import {
SiAtlassian, SiAtlassian,
@ -192,6 +193,10 @@ export function getLucideIcon(key, selected = false) {
return <ClipboardCheck {...commonProps} color={iconColor} />; return <ClipboardCheck {...commonProps} color={iconColor} />;
case 'benchmarks': case 'benchmarks':
return <BarChart3 {...commonProps} color={iconColor} />; return <BarChart3 {...commonProps} color={iconColor} />;
case 'lucky-bag':
return <Gift {...commonProps} color={iconColor} />;
case 'api-tester':
return <FlaskConical {...commonProps} color={iconColor} />;
default: default:
return <CircleUser {...commonProps} color={iconColor} />; return <CircleUser {...commonProps} color={iconColor} />;
} }

View File

@ -36,6 +36,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
console: { console: {
enabled: true, enabled: true,
benchmarks: true, benchmarks: true,
'lucky-bag': true,
detail: true, detail: true,
token: true, token: true,
log: true, log: true,
@ -280,10 +281,10 @@ export const useSidebar = () => {
return result; return result;
}, [roleBaseConfig, userConfig]); }, [roleBaseConfig, userConfig]);
// 检查特定功能是否应该显示 // 检查特定功能是否应该显示 (未配置的key默认可见)
const isModuleVisible = (sectionKey, moduleKey = null) => { const isModuleVisible = (sectionKey, moduleKey = null) => {
if (moduleKey) { if (moduleKey) {
return finalConfig[sectionKey]?.[moduleKey] === true; return finalConfig[sectionKey]?.[moduleKey] !== false;
} else { } else {
return finalConfig[sectionKey]?.enabled === true; return finalConfig[sectionKey]?.enabled === true;
} }

View File

@ -1,447 +0,0 @@
/*
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 { useState, useCallback, useEffect } from 'react';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import {
modelColorMap,
renderNumber,
renderQuota,
modelToColor,
getQuotaWithUnit,
} from '../../helpers';
import {
processRawData,
calculateTrendData,
aggregateDataByTimeAndModel,
generateChartTimePoints,
updateChartSpec,
updateMapValue,
initializeMaps,
} from '../../helpers/dashboard';
export const useDashboardCharts = (
dataExportDefaultTime,
setTrendData,
setConsumeQuota,
setTimes,
setConsumeTokens,
setPieData,
setLineData,
setModelColors,
t,
) => {
// ========== ==========
const [spec_pie, setSpecPie] = useState({
type: 'pie',
data: [
{
id: 'id0',
values: [{ type: 'null', value: '0' }],
},
],
outerRadius: 0.8,
innerRadius: 0.5,
padAngle: 0.6,
valueField: 'value',
categoryField: 'type',
pie: {
style: {
cornerRadius: 10,
},
state: {
hover: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
selected: {
outerRadius: 0.85,
stroke: '#000',
lineWidth: 1,
},
},
},
title: {
visible: true,
text: t('模型调用次数占比'),
subtext: `${t('总计')}${renderNumber(0)}`,
},
legends: {
visible: true,
orient: 'left',
},
label: {
visible: true,
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['type'],
value: (datum) => renderNumber(datum['value']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
const [spec_line, setSpecLine] = useState({
type: 'bar',
data: [
{
id: 'barData',
values: [],
},
],
xField: 'Time',
yField: 'Usage',
seriesField: 'Model',
stack: true,
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型消耗分布'),
subtext: `${t('总计')}${renderQuota(0, 2)}`,
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
},
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderQuota(datum['rawQuota'] || 0),
},
],
},
dimension: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => datum['rawQuota'] || 0,
},
],
updateContent: (array) => {
array.sort((a, b) => b.value - a.value);
let sum = 0;
for (let i = 0; i < array.length; i++) {
if (array[i].key == '其他') {
continue;
}
let value = parseFloat(array[i].value);
if (isNaN(value)) {
value = 0;
}
if (array[i].datum && array[i].datum.TimeSum) {
sum = array[i].datum.TimeSum;
}
array[i].value = renderQuota(value);
}
array.unshift({
key: t('总计'),
value: renderQuota(sum),
});
return array;
},
},
},
color: {
specified: modelColorMap,
},
});
// 线
const [spec_model_line, setSpecModelLine] = useState({
type: 'line',
data: [
{
id: 'lineData',
values: [],
},
],
xField: 'Time',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型消耗趋势'),
subtext: '',
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
//
const [spec_rank_bar, setSpecRankBar] = useState({
type: 'bar',
data: [
{
id: 'rankData',
values: [],
},
],
xField: 'Model',
yField: 'Count',
seriesField: 'Model',
legends: {
visible: true,
selectMode: 'single',
},
title: {
visible: true,
text: t('模型调用次数排行'),
subtext: '',
},
bar: {
state: {
hover: {
stroke: '#000',
lineWidth: 1,
},
},
},
tooltip: {
mark: {
content: [
{
key: (datum) => datum['Model'],
value: (datum) => renderNumber(datum['Count']),
},
],
},
},
color: {
specified: modelColorMap,
},
});
// ========== ==========
const generateModelColors = useCallback((uniqueModels, modelColors) => {
const newModelColors = {};
Array.from(uniqueModels).forEach((modelName) => {
newModelColors[modelName] =
modelColorMap[modelName] ||
modelColors[modelName] ||
modelToColor(modelName);
});
return newModelColors;
}, []);
const updateChartData = useCallback(
(data) => {
const processedData = processRawData(
data,
dataExportDefaultTime,
initializeMaps,
updateMapValue,
);
const {
totalQuota,
totalTimes,
totalTokens,
uniqueModels,
timePoints,
timeQuotaMap,
timeTokensMap,
timeCountMap,
} = processedData;
const trendDataResult = calculateTrendData(
timePoints,
timeQuotaMap,
timeTokensMap,
timeCountMap,
dataExportDefaultTime,
);
setTrendData(trendDataResult);
const newModelColors = generateModelColors(uniqueModels, {});
setModelColors(newModelColors);
const aggregatedData = aggregateDataByTimeAndModel(
data,
dataExportDefaultTime,
);
const modelTotals = new Map();
for (let [_, value] of aggregatedData) {
updateMapValue(modelTotals, value.model, value.count);
}
const newPieData = Array.from(modelTotals)
.map(([model, count]) => ({
type: model,
value: count,
}))
.sort((a, b) => b.value - a.value);
const chartTimePoints = generateChartTimePoints(
aggregatedData,
data,
dataExportDefaultTime,
);
let newLineData = [];
chartTimePoints.forEach((time) => {
let timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
const aggregated = aggregatedData.get(key);
return {
Time: time,
Model: model,
rawQuota: aggregated?.quota || 0,
Usage: aggregated?.quota
? getQuotaWithUnit(aggregated.quota, 4)
: 0,
};
});
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
newLineData.push(...timeData);
});
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
updateChartSpec(
setSpecPie,
newPieData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'id0',
);
updateChartSpec(
setSpecLine,
newLineData,
`${t('总计')}${renderQuota(totalQuota, 2)}`,
newModelColors,
'barData',
);
// ===== 线 =====
let modelLineData = [];
chartTimePoints.forEach((time) => {
const timeData = Array.from(uniqueModels).map((model) => {
const key = `${time}-${model}`;
const aggregated = aggregatedData.get(key);
return {
Time: time,
Model: model,
Count: aggregated?.count || 0,
};
});
modelLineData.push(...timeData);
});
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
// ===== =====
const rankData = Array.from(modelTotals)
.map(([model, count]) => ({
Model: model,
Count: count,
}))
.sort((a, b) => b.Count - a.Count);
updateChartSpec(
setSpecModelLine,
modelLineData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'lineData',
);
updateChartSpec(
setSpecRankBar,
rankData,
`${t('总计')}${renderNumber(totalTimes)}`,
newModelColors,
'rankData',
);
setPieData(newPieData);
setLineData(newLineData);
setConsumeQuota(totalQuota);
setTimes(totalTimes);
setConsumeTokens(totalTokens);
},
[
dataExportDefaultTime,
setTrendData,
generateModelColors,
setModelColors,
setPieData,
setLineData,
setConsumeQuota,
setTimes,
setConsumeTokens,
t,
],
);
// ========== ==========
useEffect(() => {
initVChartSemiTheme({
isWatchingThemeSwitch: true,
});
}, []);
return {
//
spec_pie,
spec_line,
spec_model_line,
spec_rank_bar,
//
updateChartData,
generateModelColors,
};
};

View File

@ -18,180 +18,50 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { API, isAdmin, showError } from '../../helpers';
import { useTranslation } from 'react-i18next';
import { API, isAdmin, showError, timestamp2string } from '../../helpers';
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
import { useIsMobile } from '../common/useIsMobile';
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
export const useDashboardData = (userState, userDispatch, statusState) => { const PAGE_SIZE = 200;
const { t } = useTranslation();
const navigate = useNavigate(); const isSuccess = (l) =>
const isMobile = useIsMobile(); l?.type === 0 || l?.status === 0 || l?.type === 'success';
const sum = (arr, key) =>
arr.reduce((s, l) => s + (Number(l[key]) || 0), 0);
export const useDashboardData = (userState) => {
const initialized = useRef(false); const initialized = useRef(false);
// ========== 基础状态 ==========
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [greetingVisible, setGreetingVisible] = useState(false); const [recentLogs, setRecentLogs] = useState([]);
const [searchModalVisible, setSearchModalVisible] = useState(false);
const showLoading = useMinimumLoadingTime(loading);
// ========== 输入状态 ==========
const [inputs, setInputs] = useState({
username: '',
token_name: '',
model_name: '',
start_timestamp: getInitialTimestamp(),
end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600),
channel: '',
data_export_default_time: '',
});
const [dataExportDefaultTime, setDataExportDefaultTime] =
useState(getDefaultTime());
// ========== 数据状态 ==========
const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0);
const [consumeTokens, setConsumeTokens] = useState(0);
const [times, setTimes] = useState(0);
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
const [lineData, setLineData] = useState([]);
const [modelColors, setModelColors] = useState({});
// ========== 图表状态 ==========
const [activeChartTab, setActiveChartTab] = useState('1');
// ========== 趋势数据 ==========
const [trendData, setTrendData] = useState({
balance: [],
usedQuota: [],
requestCount: [],
times: [],
consumeQuota: [],
tokens: [],
rpm: [],
tpm: [],
});
// ========== Uptime 数据 ==========
const [uptimeData, setUptimeData] = useState([]); const [uptimeData, setUptimeData] = useState([]);
const [uptimeLoading, setUptimeLoading] = useState(false); const [uptimeLoading, setUptimeLoading] = useState(false);
const [activeUptimeTab, setActiveUptimeTab] = useState('');
// ========== 常量 ==========
const now = new Date();
const isAdminUser = isAdmin(); const isAdminUser = isAdmin();
const user = userState?.user;
const remaining = Number(user?.quota || 0);
const used = Number(user?.used_quota || 0);
const total = remaining + used;
// ========== Panel enable flags ========== const loadRecentLogs = useCallback(async () => {
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
const announcementsEnabled =
statusState?.status?.announcements_enabled ?? true;
const faqEnabled = statusState?.status?.faq_enabled ?? true;
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
const hasApiInfoPanel = apiInfoEnabled;
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
// ========== Memoized Values ==========
const timeOptions = useMemo(
() =>
TIME_OPTIONS.map((option) => ({
...option,
label: t(option.label),
})),
[t],
);
const performanceMetrics = useMemo(() => {
const { start_timestamp, end_timestamp } = inputs;
const timeDiff =
(Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
const avgRPM = isNaN(times / timeDiff)
? '0'
: (times / timeDiff).toFixed(3);
const avgTPM = isNaN(consumeTokens / timeDiff)
? '0'
: (consumeTokens / timeDiff).toFixed(3);
return { avgRPM, avgTPM, timeDiff };
}, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
const getGreeting = useMemo(() => {
const hours = new Date().getHours();
let greeting = '';
if (hours >= 5 && hours < 12) {
greeting = t('早上好');
} else if (hours >= 12 && hours < 14) {
greeting = t('中午好');
} else if (hours >= 14 && hours < 18) {
greeting = t('下午好');
} else {
greeting = t('晚上好');
}
const username = userState?.user?.username || '';
return `👋${greeting}${username}`;
}, [t, userState?.user?.username]);
// ========== 回调函数 ==========
const handleInputChange = useCallback((value, name) => {
if (name === 'data_export_default_time') {
setDataExportDefaultTime(value);
localStorage.setItem('data_export_default_time', value);
return;
}
setInputs((inputs) => ({ ...inputs, [name]: value }));
}, []);
const showSearchModal = useCallback(() => {
setSearchModalVisible(true);
}, []);
const handleCloseModal = useCallback(() => {
setSearchModalVisible(false);
}, []);
// ========== API 调用函数 ==========
const loadQuotaData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
let url = ''; const now = Math.floor(Date.now() / 1000);
const { start_timestamp, end_timestamp, username } = inputs; const start = now - 30 * 86400;
let localStartTimestamp = Date.parse(start_timestamp) / 1000; const url = isAdminUser
let localEndTimestamp = Date.parse(end_timestamp) / 1000; ? `/api/log/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}`
: `/api/log/self/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}`;
if (isAdminUser) {
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
} else {
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
}
const res = await API.get(url); const res = await API.get(url);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setQuotaData(data); setRecentLogs(data?.items || []);
if (data.length === 0) {
data.push({
count: 0,
model_name: '无数据',
quota: 0,
created_at: now.getTime() / 1000,
});
}
data.sort((a, b) => a.created_at - b.created_at);
return data;
} else { } else {
showError(message); showError(message);
return [];
} }
} catch (err) {
console.error(err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [inputs, dataExportDefaultTime, isAdminUser, now]); }, [isAdminUser]);
const loadUptimeData = useCallback(async () => { const loadUptimeData = useCallback(async () => {
setUptimeLoading(true); setUptimeLoading(true);
@ -200,9 +70,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setUptimeData(data || []); setUptimeData(data || []);
if (data && data.length > 0 && !activeUptimeTab) {
setActiveUptimeTab(data[0].categoryName);
}
} else { } else {
showError(message); showError(message);
} }
@ -211,114 +78,208 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
} finally { } finally {
setUptimeLoading(false); setUptimeLoading(false);
} }
}, [activeUptimeTab]);
const getUserData = useCallback(async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
}, [userDispatch]);
const refresh = useCallback(async () => {
const data = await loadQuotaData();
await loadUptimeData();
return data;
}, [loadQuotaData, loadUptimeData]);
const handleSearchConfirm = useCallback(
async (updateChartDataCallback) => {
const data = await refresh();
if (data && data.length > 0 && updateChartDataCallback) {
updateChartDataCallback(data);
}
setSearchModalVisible(false);
},
[refresh],
);
// ========== Effects ==========
useEffect(() => {
const timer = setTimeout(() => {
setGreetingVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!initialized.current) { if (!initialized.current) {
getUserData(); loadRecentLogs();
loadUptimeData();
initialized.current = true; initialized.current = true;
} }
}, [getUserData]); }, [loadRecentLogs, loadUptimeData]);
// ========== Today / month metrics ==========
const metrics = useMemo(() => {
const now = new Date();
const startOfDay = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime() / 1000;
const startOfMonth = new Date(
now.getFullYear(),
now.getMonth(),
1,
).getTime() / 1000;
const todayLogs = recentLogs.filter(
(l) => (l?.created_at || 0) >= startOfDay && isSuccess(l),
);
const monthLogs = recentLogs.filter(
(l) => (l?.created_at || 0) >= startOfMonth && isSuccess(l),
);
const todayLatencies = todayLogs
.map((l) => Number(l?.use_time) || 0)
.filter((v) => v > 0);
return {
today: {
requests: todayLogs.length,
tokens: sum(todayLogs, 'token_used'),
cost: sum(todayLogs, 'quota'),
avgLatency:
todayLatencies.length > 0
? Math.round(
todayLatencies.reduce((s, v) => s + v, 0) /
todayLatencies.length,
)
: null,
},
month: {
requests: monthLogs.length,
tokens: sum(monthLogs, 'token_used'),
cost: sum(monthLogs, 'quota'),
},
};
}, [recentLogs]);
// ========== N-day daily stats (for sparkline) ==========
const dailyStats = useMemo(() => {
const now = new Date();
const days = [];
const n = 7;
for (let i = n - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
const dayStart = Math.floor(d.getTime() / 1000);
const dayEnd = dayStart + 86400;
const dayLogs = recentLogs.filter(
(l) => (l?.created_at || 0) >= dayStart && (l?.created_at || 0) < dayEnd && isSuccess(l),
);
days.push({
date: d,
count: dayLogs.length,
cost: sum(dayLogs, 'quota'),
tokens: sum(dayLogs, 'token_used'),
});
}
return days;
}, [recentLogs]);
// ========== MoM delta (this month cost vs last month) ==========
const monthDelta = useMemo(() => {
const now = new Date();
const startOfLastMonth = new Date(
now.getFullYear(),
now.getMonth() - 1,
1,
).getTime() / 1000;
const startOfThisMonth = new Date(
now.getFullYear(),
now.getMonth(),
1,
).getTime() / 1000;
const lastMonthLogs = recentLogs.filter(
(l) =>
(l?.created_at || 0) >= startOfLastMonth &&
(l?.created_at || 0) < startOfThisMonth &&
isSuccess(l),
);
const lastCost = sum(lastMonthLogs, 'quota');
if (lastCost === 0) return null;
return ((metrics.month.cost - lastCost) / lastCost) * 100;
}, [recentLogs, metrics.month.cost]);
// ========== Monthly forecast (projected end-of-month cost) ==========
const monthlyForecast = useMemo(() => {
const now = new Date();
const daysInMonth = new Date(
now.getFullYear(),
now.getMonth() + 1,
0,
).getDate();
const daysElapsed = now.getDate();
const spent = metrics.month.cost;
const dailyAvg = daysElapsed > 0 ? spent / daysElapsed : 0;
const projected = Math.round(dailyAvg * daysInMonth);
return {
spent,
balance: remaining,
dailyAvg: Math.round(dailyAvg),
daysInMonth,
daysElapsed,
projected,
monthDelta,
};
}, [metrics.month.cost, remaining, monthDelta]);
// ========== Days remaining estimate ==========
const daysRemaining = useMemo(() => {
const now = new Date();
const daysElapsed = now.getDate();
if (metrics.month.cost === 0 || daysElapsed === 0) return null;
const dailyAvg = metrics.month.cost / daysElapsed;
if (dailyAvg === 0) return null;
const days = Math.floor(remaining / dailyAvg);
return days > 0 ? days : 0;
}, [metrics.month.cost, remaining]);
// ========== SLA overview (uptime / error rate / p95 latency) ==========
const sla = useMemo(() => {
const totalCalls = recentLogs.length;
const successCalls = recentLogs.filter((l) => isSuccess(l)).length;
const errorRate =
totalCalls > 0 ? ((totalCalls - successCalls) / totalCalls) * 100 : null;
const latencies = recentLogs
.map((l) => Number(l?.use_time) || 0)
.filter((v) => v > 0)
.sort((a, b) => a - b);
const p95Latency =
latencies.length > 0
? latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95))]
: null;
const healthyChannels = uptimeData.filter((c) => c?.status === 1).length;
const uptime =
uptimeData.length > 0
? uptimeData.reduce((s, c) => s + (Number(c?.uptime) || 0), 0) /
uptimeData.length
: null;
const uptimePct =
uptime != null ? uptime * 100 : healthyChannels / Math.max(uptimeData.length, 1) * 100;
return {
uptime: uptime != null ? uptimePct : null,
errorRate,
p95Latency,
totalCalls,
channelCount: uptimeData.length,
healthyChannels,
};
}, [recentLogs, uptimeData]);
// ========== Top models ==========
const topModels = useMemo(() => {
const m = new Map();
recentLogs.forEach((l) => {
const name = l?.model_name || '未知模型';
m.set(name, (m.get(name) || 0) + 1);
});
return Array.from(m.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
}, [recentLogs]);
const refresh = useCallback(async () => {
await Promise.all([loadRecentLogs(), loadUptimeData()]);
}, [loadRecentLogs, loadUptimeData]);
return { return {
// 基础状态 loading,
loading: showLoading, recentLogs,
greetingVisible,
searchModalVisible,
// 输入状态
inputs,
dataExportDefaultTime,
// 数据状态
quotaData,
consumeQuota,
setConsumeQuota,
consumeTokens,
setConsumeTokens,
times,
setTimes,
pieData,
setPieData,
lineData,
setLineData,
modelColors,
setModelColors,
// 图表状态
activeChartTab,
setActiveChartTab,
// 趋势数据
trendData,
setTrendData,
// Uptime 数据
uptimeData, uptimeData,
uptimeLoading, uptimeLoading,
activeUptimeTab, metrics,
setActiveUptimeTab, dailyStats,
monthDelta,
// 计算值 daysRemaining,
timeOptions, topModels,
performanceMetrics, sla,
getGreeting, monthlyForecast,
isAdminUser,
hasApiInfoPanel,
hasInfoPanels,
apiInfoEnabled,
announcementsEnabled,
faqEnabled,
uptimeEnabled,
// 函数
handleInputChange,
showSearchModal,
handleCloseModal,
loadQuotaData,
loadUptimeData,
getUserData,
refresh, refresh,
handleSearchConfirm, quotaTotal: total,
// 导航和翻译
navigate,
t,
isMobile,
}; };
}; };

View File

@ -1,153 +0,0 @@
/*
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 { useMemo } from 'react';
import { Wallet, Activity, Zap, Gauge } from 'lucide-react';
import {
IconMoneyExchangeStroked,
IconHistogram,
IconCoinMoneyStroked,
IconTextStroked,
IconPulse,
IconStopwatchStroked,
IconTypograph,
IconSend,
} from '@douyinfe/semi-icons';
import { renderQuota } from '../../helpers';
import { createSectionTitle } from '../../helpers/dashboard';
export const useDashboardStats = (
userState,
consumeQuota,
consumeTokens,
times,
trendData,
performanceMetrics,
navigate,
t,
) => {
const groupedStatsData = useMemo(
() => [
{
title: createSectionTitle(Wallet, t('账户数据')),
color: 'bg-blue-50',
items: [
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked />,
avatarColor: 'blue',
trendData: [],
trendColor: '#3b82f6',
},
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram />,
avatarColor: 'purple',
trendData: [],
trendColor: '#8b5cf6',
},
],
},
{
title: createSectionTitle(Activity, t('使用统计')),
color: 'bg-green-50',
items: [
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconSend />,
avatarColor: 'green',
trendData: [],
trendColor: '#10b981',
},
{
title: t('统计次数'),
value: times,
icon: <IconPulse />,
avatarColor: 'cyan',
trendData: trendData.times,
trendColor: '#06b6d4',
},
],
},
{
title: createSectionTitle(Zap, t('资源消耗')),
color: 'bg-yellow-50',
items: [
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked />,
avatarColor: 'yellow',
trendData: trendData.consumeQuota,
trendColor: '#f59e0b',
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
icon: <IconTextStroked />,
avatarColor: 'pink',
trendData: trendData.tokens,
trendColor: '#ec4899',
},
],
},
{
title: createSectionTitle(Gauge, t('性能指标')),
color: 'bg-indigo-50',
items: [
{
title: t('平均RPM'),
value: performanceMetrics.avgRPM,
icon: <IconStopwatchStroked />,
avatarColor: 'indigo',
trendData: trendData.rpm,
trendColor: '#6366f1',
},
{
title: t('平均TPM'),
value: performanceMetrics.avgTPM,
icon: <IconTypograph />,
avatarColor: 'orange',
trendData: trendData.tpm,
trendColor: '#f97316',
},
],
},
],
[
userState?.user?.quota,
userState?.user?.used_quota,
userState?.user?.request_count,
times,
consumeQuota,
consumeTokens,
trendData,
performanceMetrics,
navigate,
t,
],
);
return {
groupedStatsData,
};
};

View File

@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com
import i18n from 'i18next'; import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from './locales/en.json'; import enTranslation from './locales/en.json';
import frTranslation from './locales/fr.json'; import frTranslation from './locales/fr.json';
@ -35,7 +34,6 @@ import swTranslation from './locales/sw.json';
import { supportedLanguages } from './language'; import { supportedLanguages } from './language';
i18n i18n
.use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
load: 'currentOnly', load: 'currentOnly',
@ -54,6 +52,7 @@ i18n
sw: swTranslation, sw: swTranslation,
}, },
fallbackLng: 'zh-CN', fallbackLng: 'zh-CN',
lng: 'zh-CN',
nsSeparator: false, nsSeparator: false,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,

421
web/src/index.css vendored
View File

@ -33,34 +33,81 @@
} }
.dark { .dark {
--background: 240 10% 3.9%; --background: 0 0% 5.5%;
--foreground: 0 0% 98%; --foreground: 0 0% 90%;
--card: 240 10% 3.9%; --card: 0 0% 10%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 90%;
--popover: 240 10% 3.9%; --popover: 0 0% 8%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 90%;
--primary: 0 0% 98%; --primary: 236 52% 62%;
--primary-foreground: 0 0% 9%; --primary-foreground: 0 0% 100%;
--secondary: 220 4% 16%; --secondary: 0 0% 13%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 90%;
--muted: 220 4% 16%; --muted: 0 0% 9%;
--muted-foreground: 220 5% 65%; --muted-foreground: 0 0% 56%;
--accent: 220 4% 16%; --accent: 236 52% 62%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 100%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62% 45%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 100%;
--border: 220 4% 16%; --border: 0 0% 100% / 0.08;
--input: 220 4% 16%; --input: 0 0% 100% / 0.06;
--ring: 222 56% 55%; --ring: 236 52% 62%;
--link: 222 56% 65%; --link: 236 52% 72%;
--link-hover: 222 56% 55%; --link-hover: 236 52% 62%;
--chart-1: 160 60% 45%; --radius: 0.375rem;
--chart-2: 217 91% 60%; --chart-1: 236 52% 62%;
--chart-2: 160 60% 50%;
--chart-3: 43 96% 56%; --chart-3: 43 96% 56%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
} }
/* === Linear Dark: Semi Design 暗色覆盖 === */
.dark {
--semi-color-primary: rgba(94, 106, 210, 1);
--semi-color-primary-hover: rgba(114, 126, 230, 1);
--semi-color-primary-active: rgba(78, 90, 190, 1);
--semi-color-primary-disabled: rgba(94, 106, 210, 0.35);
--semi-color-primary-light-default: rgba(94, 106, 210, 0.12);
--semi-color-primary-light-hover: rgba(94, 106, 210, 0.18);
--semi-color-primary-light-active: rgba(94, 106, 210, 0.24);
--semi-color-bg-0: rgba(14, 14, 14, 1);
--semi-color-bg-1: rgba(19, 19, 19, 1);
--semi-color-bg-2: rgba(25, 25, 25, 1);
--semi-color-bg-3: rgba(32, 32, 32, 1);
--semi-color-bg-4: rgba(40, 40, 40, 1);
--semi-color-text-0: rgba(230, 230, 230, 1);
--semi-color-text-1: rgba(190, 190, 190, 1);
--semi-color-text-2: rgba(140, 140, 140, 1);
--semi-color-text-3: rgba(100, 100, 100, 1);
--semi-color-border: rgba(255, 255, 255, 0.08);
--semi-color-fill-0: rgba(255, 255, 255, 0.04);
--semi-color-fill-1: rgba(255, 255, 255, 0.06);
--semi-color-fill-2: rgba(255, 255, 255, 0.08);
--semi-color-link: rgba(130, 143, 255, 1);
--semi-color-link-hover: rgba(158, 168, 255, 1);
--semi-color-link-active: rgba(110, 122, 235, 1);
--semi-color-link-visited: rgba(130, 143, 255, 0.8);
--semi-color-tertiary: rgba(255, 255, 255, 0.06);
--semi-color-tertiary-hover: rgba(255, 255, 255, 0.10);
--semi-color-tertiary-active: rgba(255, 255, 255, 0.14);
--semi-shadow-elevated: 0 0 0 1px rgba(255,255,255,0.06), 0 2px 8px rgba(0,0,0,0.3);
--semi-color-success: rgba(52, 211, 153, 1);
--semi-color-danger: rgba(248, 113, 113, 1);
--semi-color-warning: rgba(251, 191, 36, 1);
--semi-color-info: rgba(94, 106, 210, 1);
}
/* 确保按钮圆角在暗色模式下生效(覆盖 Semi 库默认值) */
.dark .semi-button,
.dark .semi-button-primary,
.dark .semi-button-secondary,
.dark .semi-button-tertiary,
.dark .semi-button-warning,
.dark .semi-button-danger {
border-radius: 8px !important;
}
/* 全局基础样式 — 使用新字体族 */ /* 全局基础样式 — 使用新字体族 */
body { body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', 'Microsoft YaHei', sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
@ -1266,6 +1313,191 @@ html.dark .distributor-apply-hero-orb--b {
); );
} }
/* ==================== Linear Dark Console Theme — 表格 / 表单 / 侧边栏精细调整 ==================== */
/* 暗色模式表格 */
.dark .semi-table-thead > .semi-table-row {
background: rgba(255,255,255,0.03);
}
.dark .semi-table-row:hover {
background: rgba(255,255,255,0.04) !important;
transition: background 0.15s ease;
}
.dark .semi-table-thead .semi-table-cell {
color: rgba(255,255,255,0.45);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dark .semi-table-tbody .semi-table-cell {
border-bottom: 1px solid rgba(255,255,255,0.04);
}
/* 暗色模式表单 */
.dark .semi-input-wrapper {
background: rgba(255,255,255,0.05) !important;
border-color: rgba(255,255,255,0.08) !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.dark .semi-input-wrapper:hover {
border-color: rgba(255,255,255,0.14) !important;
}
.dark .semi-input-wrapper:focus-within,
.dark .semi-input-wrapper.semi-input-wrapper-focus {
border-color: rgba(94,106,210,0.5) !important;
box-shadow: 0 0 0 3px rgba(94,106,210,0.12) !important;
}
.dark .semi-select {
background: rgba(255,255,255,0.05) !important;
border-color: rgba(255,255,255,0.08) !important;
}
.dark .semi-select:hover {
border-color: rgba(255,255,255,0.14) !important;
}
/* 暗色模式分页 */
.dark .semi-page-item {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.65);
}
.dark .semi-page-item:hover {
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.85);
}
.dark .semi-page-item-active {
background: rgba(94,106,210,0.2) !important;
border-color: rgba(94,106,210,0.3) !important;
color: rgba(130,143,255,1) !important;
}
/* 暗色模式 Tag */
.dark .semi-tag {
border-radius: 6px;
font-weight: 500;
}
/* 暗色模式按钮 */
.dark .semi-button-primary {
background: rgba(94,106,210,0.9) !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.dark .semi-button-primary:hover {
background: rgba(114,126,230,0.95) !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.dark .semi-button-secondary,
.dark .semi-button-tertiary {
background: rgba(255,255,255,0.06) !important;
border-color: rgba(255,255,255,0.08) !important;
color: rgba(255,255,255,0.75) !important;
}
.dark .semi-button-secondary:hover,
.dark .semi-button-tertiary:hover {
background: rgba(255,255,255,0.10) !important;
border-color: rgba(255,255,255,0.14) !important;
}
/* 侧边栏暗色 */
.dark .semi-navigation {
background: rgba(14,14,14,1) !important;
}
.dark .semi-navigation-item {
border-radius: 6px;
margin: 2px 8px;
color: rgba(255,255,255,0.55);
transition: all 0.15s ease;
}
.dark .semi-navigation-item:hover {
background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.80);
}
.dark .semi-navigation-item-selected,
.dark .semi-navigation-item-active {
background: rgba(94,106,210,0.15) !important;
color: rgba(130,143,255,1) !important;
}
.dark .semi-navigation-item-selected .semi-navigation-item-icon,
.dark .semi-navigation-item-active .semi-navigation-item-icon {
color: rgba(130,143,255,1) !important;
}
.dark .semi-navigation-divider {
border-color: rgba(255,255,255,0.06) !important;
}
/* 暗色模式 Tabs — homepage nav pill style */
.dark .semi-tabs {
border-bottom: none;
}
.dark .semi-tabs-bar {
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.dark .semi-tabs-tab {
border-radius: 8px;
padding: 6px 14px;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.dark .semi-tabs-tab-button {
color: rgba(255,255,255,0.45);
font-family: 'Source Serif 4', 'Noto Serif SC', Georgia, serif;
font-size: 14px;
}
.dark .semi-tabs-tab:hover {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.06);
}
.dark .semi-tabs-tab:hover .semi-tabs-tab-button {
color: rgba(255,255,255,0.75);
}
.dark .semi-tabs-tab-active {
background: rgba(255,255,255,0.06) !important;
border-color: rgba(255,255,255,0.1) !important;
}
.dark .semi-tabs-tab-active .semi-tabs-tab-button {
color: rgba(255,255,255,0.9) !important;
}
.dark .semi-tabs-content {
padding: 0;
}
.dark .semi-tabs-tabpane {
padding: 0;
}
/* 暗色模式 Modal/Popover */
.dark .semi-modal-content,
.dark .semi-popover {
background: rgba(25,25,25,1) !important;
border: 1px solid rgba(255,255,255,0.08) !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.4) !important;
}
/* 暗色模式 Toast */
.dark .Toastify__toast {
background: rgba(25,25,25,1) !important;
box-shadow: 0 0 0 1px rgba(255,255,255,0.06), 0 4px 16px rgba(0,0,0,0.3) !important;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.home-distributor-recruit-card, .home-distributor-recruit-card,
.home-distributor-recruit-card::before, .home-distributor-recruit-card::before,
@ -1605,7 +1837,7 @@ html.dark .with-pastel-balls::before {
.semi-select, .semi-select,
.semi-button, .semi-button,
.semi-datepicker-range-input { .semi-datepicker-range-input {
border-radius: 10px !important; border-radius: 8px !important;
} }
@keyframes slideInLeft { @keyframes slideInLeft {
@ -1685,3 +1917,144 @@ html.dark .with-pastel-balls::before {
.font-body { .font-body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
/* ==================== Dashboard Section ==================== */
.dashboard-section {
background-color: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
padding: 16px;
transition: border-color 0.2s ease;
}
.dashboard-section:hover {
border-color: rgba(255,255,255,0.1);
}
/* ==================== Dashboard 样式覆盖 ==================== */
/* Timeline — 暗色适配 */
.dark .semi-timeline-item-body-wrapper::before {
border-left-color: var(--semi-color-border) !important;
}
.dark .semi-timeline-item-connect-line {
border-left-color: var(--semi-color-border) !important;
}
.dark .semi-timeline-item-dot {
background-color: var(--semi-color-primary) !important;
border-color: var(--semi-color-border) !important;
}
.dark .semi-timeline-item-time {
color: var(--semi-color-text-2) !important;
font-family: 'JetBrains Mono', monospace !important;
font-size: 11px !important;
}
.dark .semi-timeline-item-content {
color: var(--semi-color-text-0) !important;
}
.dark .semi-timeline-item-extra {
color: var(--semi-color-text-2) !important;
}
/* Collapse — 暗色适配 */
.dark .semi-collapse-item {
border-color: var(--semi-color-border) !important;
}
.dark .semi-collapse-item-header {
background-color: transparent !important;
color: var(--semi-color-text-0) !important;
}
.dark .semi-collapse-item-content {
background-color: transparent !important;
color: var(--semi-color-text-2) !important;
font-family: 'JetBrains Mono', monospace !important;
font-size: 12px !important;
}
.dark .semi-collapse-item-header:hover {
background-color: var(--semi-color-fill-0) !important;
}
.dark .semi-collapse-expand-icon {
color: var(--semi-color-text-2) !important;
}
.dark .semi-collapse-item-header-text {
color: var(--semi-color-text-0) !important;
}
/* Scrollbar — 暗色滚动条 */
.dark ::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.dark ::-webkit-scrollbar-track {
background: transparent;
}
.dark ::-webkit-scrollbar-thumb {
background: var(--semi-color-border);
border-radius: 2px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--semi-color-text-2);
}
/* Chart container — 透明背景 */
.dark .vchart canvas,
.dark .vchart svg {
background-color: transparent !important;
}
/* ==================== Lucky Bag Animations ==================== */
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
opacity: 0.2;
}
25% {
transform: translateY(-20px) rotate(5deg);
opacity: 0.35;
}
50% {
transform: translateY(-10px) rotate(-3deg);
opacity: 0.25;
}
75% {
transform: translateY(-25px) rotate(2deg);
opacity: 0.3;
}
}
.animate-float {
animation: float linear infinite;
}
@keyframes confetti-fall {
0% {
transform: translateY(-10px) rotate(0deg) scale(1);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg) scale(0.3);
opacity: 0;
}
}
.animate-confetti {
animation: confetti-fall ease-out forwards;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-float,
.animate-confetti,
.animate-shimmer {
animation: none !important;
}
}

2
web/src/index.jsx vendored
View File

@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import '@douyinfe/semi-ui/dist/css/semi.css'; import './semi.css';
import { UserProvider } from './context/User'; import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status'; import { StatusProvider } from './context/Status';

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;

View File

@ -22,12 +22,13 @@ export default {
darkMode: 'class', darkMode: 'class',
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
theme: { theme: {
colors: { extend: {
transparent: 'transparent', colors: {
current: 'currentColor', transparent: 'transparent',
black: '#000', current: 'currentColor',
white: '#fff', black: '#000',
// ---- shadcn/ui CSS variable tokens (TokenDance 风格) ---- white: '#fff',
// ---- shadcn/ui CSS variable tokens (TokenDance 风格) ----
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
card: 'hsl(var(--card))', card: 'hsl(var(--card))',
@ -156,8 +157,7 @@ export default {
'semi-color-data-17': 'var(--semi-color-data-17)', 'semi-color-data-17': 'var(--semi-color-data-17)',
'semi-color-data-18': 'var(--semi-color-data-18)', 'semi-color-data-18': 'var(--semi-color-data-18)',
'semi-color-data-19': 'var(--semi-color-data-19)', 'semi-color-data-19': 'var(--semi-color-data-19)',
}, },
extend: {
fontFamily: { fontFamily: {
heading: ['"Source Serif 4"', '"Noto Serif SC"', 'Georgia', 'serif'], heading: ['"Source Serif 4"', '"Noto Serif SC"', 'Georgia', 'serif'],
serif: ['"Playfair Display"', '"Noto Serif SC"', 'serif'], serif: ['"Playfair Display"', '"Noto Serif SC"', 'serif'],
@ -202,6 +202,9 @@ export default {
animation: { animation: {
'fade-in': 'fadeIn 0.2s ease-in-out', 'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.2s ease-out', 'slide-up': 'slideUp 0.2s ease-out',
'float': 'float linear infinite',
'confetti': 'confettiFall ease-out forwards',
'shimmer': 'shimmer 2s infinite',
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {
@ -212,6 +215,20 @@ export default {
'0%': { opacity: '0', transform: 'translateY(8px)' }, '0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
float: {
'0%, 100%': { transform: 'translateY(0) rotate(0deg)', opacity: '0.2' },
'25%': { transform: 'translateY(-20px) rotate(5deg)', opacity: '0.35' },
'50%': { transform: 'translateY(-10px) rotate(-3deg)', opacity: '0.25' },
'75%': { transform: 'translateY(-25px) rotate(2deg)', opacity: '0.3' },
},
confettiFall: {
'0%': { transform: 'translateY(-10px) rotate(0deg) scale(1)', opacity: '1' },
'100%': { transform: 'translateY(400px) rotate(720deg) scale(0.3)', opacity: '0' },
},
shimmer: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
},
}, },
}, },
}, },