feat: 重构控制台 Dashboard + 新增 API 快速测试页
- 新增 Bento 风格 Dashboard 组件:TopBar、Hero、BentoStats、BentoTrend、MonthlyForecast、RecentActivity、Sparkline - 新增 /console/api-tester API 快速测试页:支持 OpenAI/Anthropic 协议切换、多家国产模型预设、Key 清洗、流式/非流式切换、cURL 一键复制 - 注释和提示语统一中文化 - App.jsx 新增 /console/api-tester 路由(PrivateRoute 守卫)
This commit is contained in:
parent
1e3ce2c0d2
commit
3fb6326f4a
|
|
@ -46,6 +46,7 @@ import ModelPage from './pages/Model';
|
|||
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||
import ModelHeatPage from './pages/ModelHeat';
|
||||
import Playground from './pages/Playground';
|
||||
import ApiTester from './pages/ApiTester';
|
||||
import Subscription from './pages/Subscription';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
|
|
@ -58,6 +59,10 @@ import Suppliers from './pages/SupplierAdmin/list';
|
|||
import Setup from './pages/Setup';
|
||||
import SetupCheck from './components/layout/SetupCheck';
|
||||
import OperationLog from './pages/OperationLog';
|
||||
import LuckyBag from './pages/LuckyBag';
|
||||
import Drawing from './pages/Drawing';
|
||||
import ProfitDashboard from './pages/ProfitDashboard';
|
||||
import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
|
|
@ -177,6 +182,14 @@ function App() {
|
|||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/api-tester'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ApiTester />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/redemption'
|
||||
element={
|
||||
|
|
@ -193,6 +206,38 @@ function App() {
|
|||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/lucky-bag'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<LuckyBag />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/drawing'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Drawing />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/profit'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ProfitDashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/anti-abuse'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<SettingsAntiAbuse />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/supplier-application'
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Activity, CreditCard, Hash, Layers, Gauge } from 'lucide-react';
|
||||
import { renderQuota } from '../../helpers';
|
||||
|
||||
const formatNumber = (n) => {
|
||||
if (n == null || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M';
|
||||
if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K';
|
||||
return v.toLocaleString();
|
||||
};
|
||||
|
||||
const formatLatency = (v) => {
|
||||
if (v == null) return '—';
|
||||
if (v < 1) return `${Math.round(v * 1000)}ms`;
|
||||
return `${v.toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const BentoStat = ({ label, value, unit, sub, delta, icon: Icon, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>{label}</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col justify-center'>
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='font-mono text-3xl font-bold tabular-nums leading-none text-slate-900 md:text-4xl dark:text-white'>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className='text-xs text-slate-400 dark:text-white/40'>{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
{delta != null && (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 text-xs tabular-nums ${
|
||||
delta < 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: delta > 0
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-slate-500 dark:text-white/50'
|
||||
}`}
|
||||
>
|
||||
<span className='font-semibold'>
|
||||
{delta > 0 ? '↑' : delta < 0 ? '↓' : '·'} {Math.abs(delta).toFixed(0)}%
|
||||
</span>
|
||||
<span className='font-normal text-slate-400 dark:text-white/40'>对比上月</span>
|
||||
</div>
|
||||
)}
|
||||
{sub && (
|
||||
<div className='text-[11px] text-slate-500 tabular-nums dark:text-white/40'>{sub}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BentoStats = ({ metrics, monthDelta }) => {
|
||||
return (
|
||||
<>
|
||||
<BentoStat
|
||||
label='今日请求'
|
||||
value={metrics.today.requests}
|
||||
unit='次'
|
||||
sub={
|
||||
<span>
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>
|
||||
{formatNumber(metrics.today.tokens)}
|
||||
</span>{' '}
|
||||
tokens
|
||||
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
|
||||
{renderQuota(metrics.today.cost)}
|
||||
</span>
|
||||
}
|
||||
icon={Activity}
|
||||
/>
|
||||
<BentoStat
|
||||
label='今日 token'
|
||||
value={formatNumber(metrics.today.tokens)}
|
||||
unit='tokens'
|
||||
sub={`今日花费 ${renderQuota(metrics.today.cost)}`}
|
||||
icon={Hash}
|
||||
/>
|
||||
<BentoStat
|
||||
label='平均响应'
|
||||
value={formatLatency(metrics.today.avgLatency)}
|
||||
sub='今日成功调用'
|
||||
icon={Gauge}
|
||||
/>
|
||||
<BentoStat
|
||||
label='本月消费'
|
||||
value={metrics.month.cost ? renderQuota(metrics.month.cost) : '—'}
|
||||
sub={
|
||||
<span>
|
||||
<span className='font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||
{metrics.month.requests}
|
||||
</span>{' '}
|
||||
次调用
|
||||
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
|
||||
<span className='tabular-nums'>{formatNumber(metrics.month.tokens)}</span> tokens
|
||||
</span>
|
||||
}
|
||||
delta={monthDelta}
|
||||
icon={CreditCard}
|
||||
/>
|
||||
<BentoStat
|
||||
label='累计 token'
|
||||
value={formatNumber(metrics.month.tokens)}
|
||||
unit='本月'
|
||||
sub='输入+输出'
|
||||
icon={Layers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BentoStats;
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import Sparkline from './Sparkline';
|
||||
import { renderQuota } from '../../helpers';
|
||||
|
||||
const WEEKDAYS_SHORT = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const BentoTrend = ({
|
||||
dailyStats = [],
|
||||
topModels = [],
|
||||
className = '',
|
||||
}) => {
|
||||
const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
|
||||
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
|
||||
|
||||
const totalCalls = counts.reduce((s, v) => s + v, 0);
|
||||
const totalCost = costs.reduce((s, v) => s + v, 0);
|
||||
const avg = totalCalls > 0 ? (totalCalls / Math.max(counts.length, 1)).toFixed(1) : '0.0';
|
||||
const peak = counts.length > 0 ? Math.max(...counts) : 0;
|
||||
const topMax = topModels[0]?.count || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>Token 使用趋势</div>
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||
近 7 天
|
||||
<span className='font-semibold text-slate-900 dark:text-white'> {totalCalls}</span> 次
|
||||
· 日均{' '}
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='hidden text-right md:block'>
|
||||
<div className='text-[10px] uppercase tracking-wider text-slate-400 font-mono dark:text-white/30'>
|
||||
花费
|
||||
</div>
|
||||
<div className='font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||
{totalCost > 0 ? renderQuota(totalCost) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
|
||||
<Sparkline data={counts} width={400} height={80} />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between text-[11px]'>
|
||||
<div className='flex items-center gap-1 tabular-nums'>
|
||||
{dailyStats.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`w-8 text-center ${
|
||||
d.count === peak && peak > 0
|
||||
? 'font-semibold text-slate-900 dark:text-white'
|
||||
: 'text-slate-300 dark:text-white/30'
|
||||
}`}
|
||||
title={`周${WEEKDAYS_SHORT[d.date.getDay()]} ${d.count} 次`}
|
||||
>
|
||||
{WEEKDAYS_SHORT[d.date.getDay()]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className='text-slate-500 tabular-nums dark:text-white/50'>
|
||||
峰值{' '}
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span> 次
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topModels.length > 0 && (
|
||||
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||
<div className='mb-2 flex items-center justify-between text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
<span>Top 5 模型</span>
|
||||
<span className='font-mono normal-case tracking-normal'>近 30 日</span>
|
||||
</div>
|
||||
<ul className='space-y-1.5'>
|
||||
{topModels.map((m, i) => {
|
||||
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
|
||||
return (
|
||||
<li
|
||||
key={m.name + i}
|
||||
className='flex items-center gap-3 text-xs'
|
||||
title={m.name}
|
||||
>
|
||||
<span className='w-3 shrink-0 text-right font-mono text-[10px] text-slate-300 tabular-nums dark:text-white/30'>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-slate-700 dark:text-white/80'>
|
||||
{m.name}
|
||||
</span>
|
||||
<div className='hidden h-1 w-24 overflow-hidden rounded-full bg-slate-100 sm:block dark:bg-white/5'>
|
||||
<div
|
||||
className='h-full rounded-full bg-slate-300 dark:bg-white/30'
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='w-12 shrink-0 text-right font-mono tabular-nums text-slate-500 dark:text-white/60'>
|
||||
{m.count}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BentoTrend;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
const getGreeting = () => {
|
||||
const h = new Date().getHours();
|
||||
if (h < 5) return '深夜好';
|
||||
if (h < 12) return '早上好';
|
||||
if (h < 14) return '中午好';
|
||||
if (h < 18) return '下午好';
|
||||
return '晚上好';
|
||||
};
|
||||
|
||||
const Hero = ({ user }) => {
|
||||
const dateStr = useMemo(() => {
|
||||
const now = new Date();
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 · ${weekdays[now.getDay()]}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap items-center justify-between gap-4'>
|
||||
<div className='min-w-0'>
|
||||
<div className='mb-1.5 flex items-center gap-2'>
|
||||
<span className='flex h-1.5 w-1.5 rounded-full bg-emerald-500' />
|
||||
<span className='text-xs text-slate-500 dark:text-white/50'>{dateStr}</span>
|
||||
</div>
|
||||
<h1 className='text-2xl font-semibold leading-tight tracking-tight md:text-3xl'>
|
||||
<span className='text-slate-500 dark:text-white/60'>{getGreeting()},</span>
|
||||
<span className='text-slate-900 dark:text-white'>
|
||||
{user?.username || '访客'}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Receipt, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { renderQuota } from '../../helpers';
|
||||
|
||||
const SubStat = ({ label, value, hint }) => (
|
||||
<div className='min-w-0'>
|
||||
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
{label}
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-base font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||
{value}
|
||||
</div>
|
||||
{hint && (
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const MonthlyForecast = ({ forecast = {}, className = '' }) => {
|
||||
const {
|
||||
spent = 0,
|
||||
balance = 0,
|
||||
dailyAvg = 0,
|
||||
daysInMonth = 0,
|
||||
daysElapsed = 0,
|
||||
projected = 0,
|
||||
monthDelta = null,
|
||||
} = forecast;
|
||||
|
||||
const daysRemaining = Math.max(0, daysInMonth - daysElapsed);
|
||||
const hasData = spent > 0;
|
||||
const overBudget = hasData && balance > 0 && projected > balance;
|
||||
|
||||
const DeltaIcon =
|
||||
monthDelta == null ? Minus : monthDelta > 0 ? TrendingUp : TrendingDown;
|
||||
const deltaTone =
|
||||
monthDelta == null
|
||||
? 'text-slate-500 dark:text-white/50'
|
||||
: monthDelta > 5
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: monthDelta < -5
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-slate-500 dark:text-white/50';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||
<Receipt size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
本月账单预测
|
||||
</div>
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||
已过 {daysElapsed} / {daysInMonth} 天 · 剩 {daysRemaining} 天
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
月底预计
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1.5 font-mono text-3xl font-bold tabular-nums leading-none md:text-4xl ${
|
||||
overBudget
|
||||
? 'text-red-500 dark:text-red-400'
|
||||
: 'text-slate-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{hasData ? renderQuota(projected) : '—'}
|
||||
</div>
|
||||
{monthDelta != null && (
|
||||
<div className={`mt-2 inline-flex items-center gap-1 text-xs tabular-nums ${deltaTone}`}>
|
||||
<DeltaIcon size={12} />
|
||||
<span className='font-semibold'>
|
||||
{monthDelta > 0 ? '+' : ''}
|
||||
{monthDelta.toFixed(0)}%
|
||||
</span>
|
||||
<span className='text-slate-400 dark:text-white/40'>较上月</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4 border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||
<SubStat
|
||||
label='已花费'
|
||||
value={hasData ? renderQuota(spent) : '—'}
|
||||
hint={`${daysElapsed} 天累计`}
|
||||
/>
|
||||
<SubStat
|
||||
label='日均'
|
||||
value={hasData ? renderQuota(dailyAvg) : '—'}
|
||||
hint='本月至今日'
|
||||
/>
|
||||
<SubStat
|
||||
label='账户余额'
|
||||
value={balance > 0 ? renderQuota(balance) : '—'}
|
||||
hint={overBudget ? '预计不足' : '可继续使用'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonthlyForecast;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { ArrowUpRight, Clock, Inbox } from 'lucide-react';
|
||||
import { renderQuota, renderNumber, timestamp2string } from '../../helpers';
|
||||
|
||||
const isSuccess = (l) =>
|
||||
l?.type === 0 || l?.status === 0 || l?.type === 'success' || l?.type === undefined;
|
||||
|
||||
const RecentActivity = ({ logs = [], className = '' }) => {
|
||||
const recent = useMemo(() => logs.slice(0, 10), [logs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl border border-slate-200 bg-white transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||
>
|
||||
<div className='flex items-center justify-between p-6 pb-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>最近调用</div>
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||
共{' '}
|
||||
<span className='font-semibold text-slate-900 dark:text-white'>
|
||||
{recent.length}
|
||||
</span>{' '}
|
||||
条记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href='/console/log'
|
||||
className='flex items-center gap-1 text-xs text-slate-500 transition-colors hover:text-slate-900 dark:text-white/60 dark:hover:text-white'
|
||||
>
|
||||
查看全部 <ArrowUpRight size={11} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{recent.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center px-6 py-14'>
|
||||
<div className='mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 dark:bg-white/5'>
|
||||
<Inbox size={28} className='text-slate-400 dark:text-white/30' />
|
||||
</div>
|
||||
<h3 className='text-sm font-semibold text-slate-900 dark:text-white'>暂无使用记录</h3>
|
||||
<p className='mt-1 text-xs text-slate-500 dark:text-white/40'>
|
||||
开始使用 API 后,您的使用历史将显示在这里
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden border-t border-slate-100 dark:border-white/5'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='border-b border-slate-100 bg-slate-50/50 text-slate-500 dark:border-white/5 dark:bg-white/[0.02] dark:text-white/40'>
|
||||
<th className='w-[140px] px-6 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||
time
|
||||
</th>
|
||||
<th className='px-2 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||
model
|
||||
</th>
|
||||
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||
tokens
|
||||
</th>
|
||||
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||
cost
|
||||
</th>
|
||||
<th className='w-[120px] px-6 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||
status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((l) => {
|
||||
const ok = isSuccess(l);
|
||||
return (
|
||||
<tr
|
||||
key={l.id}
|
||||
className='border-b border-slate-100 transition-colors last:border-b-0 hover:bg-slate-50/50 dark:border-white/5 dark:hover:bg-white/[0.02]'
|
||||
>
|
||||
<td className='whitespace-nowrap px-6 py-3 font-mono text-xs tabular-nums text-slate-500 dark:text-white/50'>
|
||||
{timestamp2string(l.created_at).slice(5, 16)}
|
||||
</td>
|
||||
<td className='max-w-[300px] truncate px-2 py-3 text-slate-700 dark:text-white/90'>
|
||||
{l.model_name || '—'}
|
||||
</td>
|
||||
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
|
||||
{renderNumber(l.token_used || 0)}
|
||||
</td>
|
||||
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
|
||||
{renderQuota(l.quota || 0)}
|
||||
</td>
|
||||
<td className='whitespace-nowrap px-6 py-3 text-right'>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 ${
|
||||
ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
ok ? 'bg-emerald-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className='text-xs font-medium'>{ok ? '成功' : '失败'}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentActivity;
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const Sparkline = ({
|
||||
data = [],
|
||||
width = 200,
|
||||
height = 40,
|
||||
stroke = 'currentColor',
|
||||
className = '',
|
||||
}) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
const max = Math.max(...data, 1);
|
||||
const step = data.length > 1 ? width / (data.length - 1) : 0;
|
||||
const points = data
|
||||
.map((v, i) => {
|
||||
const x = i * step;
|
||||
const y = height - (v / max) * (height - 4) - 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
const areaPoints = `0,${height} ${points} ${width},${height}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width='100%'
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio='none'
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id='sparkline-gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
|
||||
<stop offset='0%' stopColor={stroke} stopOpacity='0.18' />
|
||||
<stop offset='100%' stopColor={stroke} stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={areaPoints} fill='url(#sparkline-gradient)' />
|
||||
<polyline
|
||||
points={points}
|
||||
fill='none'
|
||||
stroke={stroke}
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
vectorEffect='non-scaling-stroke'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sparkline;
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Search, RefreshCw, Wallet } from 'lucide-react';
|
||||
import { renderQuota } from '../../helpers';
|
||||
|
||||
const TopBar = ({ onSearch, onRefresh, loading, user, unreadCount = 0 }) => {
|
||||
const initial = (user?.username || '?').charAt(0).toUpperCase();
|
||||
const balance = Number(user?.quota || 0);
|
||||
return (
|
||||
<div className='flex items-center justify-between border-b border-slate-200 py-5 dark:border-white/10'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
TokenFactory
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
className='flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
|
||||
title='搜索'
|
||||
>
|
||||
<Search size={13} className='text-slate-400 dark:text-white/40' />
|
||||
<span>搜索</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className='flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
|
||||
title='刷新'
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<a
|
||||
href='/console/topup'
|
||||
className='hidden items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm transition-colors hover:border-slate-300 hover:bg-slate-50 sm:inline-flex dark:border-white/10 dark:bg-white/[0.02] dark:hover:bg-white/5'
|
||||
title='账户余额'
|
||||
>
|
||||
<Wallet size={13} className='text-slate-400 dark:text-white/40' />
|
||||
<span className='font-mono font-semibold tabular-nums text-emerald-600 dark:text-emerald-400'>
|
||||
{renderQuota(balance)}
|
||||
</span>
|
||||
</a>
|
||||
<div className='relative ml-1 flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-white/80'>
|
||||
{initial}
|
||||
{unreadCount > 0 && (
|
||||
<span className='absolute -right-0.5 -top-0.5 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-mono text-white'>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
|
|
@ -17,260 +17,72 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { getRelativeTime, userIsDistributorUser } from '../../helpers';
|
||||
import React, { useContext } from 'react';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import StatsCards from './StatsCards';
|
||||
import ChartsPanel from './ChartsPanel';
|
||||
import ApiInfoPanel from './ApiInfoPanel';
|
||||
import AnnouncementsPanel from './AnnouncementsPanel';
|
||||
import FaqPanel from './FaqPanel';
|
||||
import UptimePanel from './UptimePanel';
|
||||
import SearchModal from './modals/SearchModal';
|
||||
import DistributorAnalyticsBoard from '../distributor/DistributorAnalyticsBoard';
|
||||
|
||||
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
|
||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
||||
|
||||
import {
|
||||
CHART_CONFIG,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
ANNOUNCEMENT_LEGEND_DATA,
|
||||
UPTIME_STATUS_MAP,
|
||||
} from '../../constants/dashboard.constants';
|
||||
import {
|
||||
getTrendSpec,
|
||||
handleCopyUrl,
|
||||
handleSpeedTest,
|
||||
getUptimeStatusColor,
|
||||
getUptimeStatusText,
|
||||
renderMonitorList,
|
||||
} from '../../helpers/dashboard';
|
||||
import TopBar from './TopBar';
|
||||
import Hero from './Hero';
|
||||
import BentoStats from './BentoStats';
|
||||
import BentoTrend from './BentoTrend';
|
||||
import MonthlyForecast from './MonthlyForecast';
|
||||
import RecentActivity from './RecentActivity';
|
||||
|
||||
const Dashboard = () => {
|
||||
// ========== Context ==========
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
const user = userState?.user;
|
||||
|
||||
// ========== 主要数据管理 ==========
|
||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
||||
|
||||
// ========== 图表管理 ==========
|
||||
const dashboardCharts = useDashboardCharts(
|
||||
dashboardData.dataExportDefaultTime,
|
||||
dashboardData.setTrendData,
|
||||
dashboardData.setConsumeQuota,
|
||||
dashboardData.setTimes,
|
||||
dashboardData.setConsumeTokens,
|
||||
dashboardData.setPieData,
|
||||
dashboardData.setLineData,
|
||||
dashboardData.setModelColors,
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 统计数据 ==========
|
||||
const { groupedStatsData } = useDashboardStats(
|
||||
userState,
|
||||
dashboardData.consumeQuota,
|
||||
dashboardData.consumeTokens,
|
||||
dashboardData.times,
|
||||
dashboardData.trendData,
|
||||
dashboardData.performanceMetrics,
|
||||
dashboardData.navigate,
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const data = await dashboardData.refresh();
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
const apiInfoData = statusState?.status?.api_info || [];
|
||||
const announcementData = (statusState?.status?.announcements || []).map(
|
||||
(item) => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime =
|
||||
pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: item?.publishDate || '';
|
||||
const relativeTime = getRelativeTime(item.publishDate);
|
||||
return {
|
||||
...item,
|
||||
time: absoluteTime,
|
||||
relative: relativeTime,
|
||||
};
|
||||
},
|
||||
);
|
||||
const faqData = statusState?.status?.faq || [];
|
||||
|
||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
|
||||
([status, info]) => ({
|
||||
status: Number(status),
|
||||
color: info.color,
|
||||
label: dashboardData.t(info.label),
|
||||
}),
|
||||
);
|
||||
|
||||
// ========== Effects ==========
|
||||
useEffect(() => {
|
||||
initChart();
|
||||
}, []);
|
||||
const dashboard = useDashboardData(userState);
|
||||
const { unreadCount } = useUserMessageUnreadCount(user);
|
||||
|
||||
return (
|
||||
<div className='h-full flex flex-col gap-6'>
|
||||
<DashboardHeader
|
||||
getGreeting={dashboardData.getGreeting}
|
||||
greetingVisible={dashboardData.greetingVisible}
|
||||
showSearchModal={dashboardData.showSearchModal}
|
||||
refresh={handleRefresh}
|
||||
loading={dashboardData.loading}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<TopBar
|
||||
onSearch={() => {}}
|
||||
onRefresh={dashboard.refresh}
|
||||
loading={dashboard.loading}
|
||||
user={user}
|
||||
unreadCount={unreadCount}
|
||||
/>
|
||||
|
||||
<SearchModal
|
||||
searchModalVisible={dashboardData.searchModalVisible}
|
||||
handleSearchConfirm={handleSearchConfirm}
|
||||
handleCloseModal={dashboardData.handleCloseModal}
|
||||
isMobile={dashboardData.isMobile}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
inputs={dashboardData.inputs}
|
||||
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
||||
timeOptions={dashboardData.timeOptions}
|
||||
handleInputChange={dashboardData.handleInputChange}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
<div className='space-y-6 pt-8 pb-8'>
|
||||
<Hero user={user} />
|
||||
|
||||
<StatsCards
|
||||
groupedStatsData={groupedStatsData}
|
||||
loading={dashboardData.loading}
|
||||
getTrendSpec={getTrendSpec}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
/>
|
||||
|
||||
{userIsDistributorUser(userState?.user) ? (
|
||||
<DistributorAnalyticsBoard />
|
||||
) : null}
|
||||
|
||||
{/* API信息和图表面板 */}
|
||||
<div>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-6 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
|
||||
>
|
||||
<ChartsPanel
|
||||
activeChartTab={dashboardData.activeChartTab}
|
||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
||||
spec_line={dashboardCharts.spec_line}
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
{dashboardData.hasApiInfoPanel && (
|
||||
<ApiInfoPanel
|
||||
apiInfoData={apiInfoData}
|
||||
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
||||
handleSpeedTest={handleSpeedTest}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
{/* 第一行:5 个核心指标卡 */}
|
||||
<div className='grid grid-cols-2 gap-5 md:grid-cols-3 xl:grid-cols-5'>
|
||||
<BentoStats
|
||||
metrics={dashboard.metrics}
|
||||
monthDelta={dashboard.monthDelta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第二行:七日用量趋势 + 本月账单预测 */}
|
||||
<div className='grid grid-cols-1 gap-5 md:grid-cols-4'>
|
||||
<BentoTrend
|
||||
dailyStats={dashboard.dailyStats}
|
||||
topModels={dashboard.topModels}
|
||||
className='md:col-span-2'
|
||||
/>
|
||||
<MonthlyForecast
|
||||
forecast={dashboard.monthlyForecast}
|
||||
className='md:col-span-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 第三行:最近调用 */}
|
||||
<RecentActivity
|
||||
logs={dashboard.recentLogs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center border-t border-slate-200 py-6 text-xs text-slate-400 dark:border-white/10 dark:text-white/30'>
|
||||
<span>TokenFactory · 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{dashboardData.hasInfoPanels && (
|
||||
<div>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-6'>
|
||||
{/* 公告卡片 */}
|
||||
{dashboardData.announcementsEnabled && (
|
||||
<AnnouncementsPanel
|
||||
announcementData={announcementData}
|
||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
|
||||
(item) => ({
|
||||
...item,
|
||||
label: dashboardData.t(item.label),
|
||||
}),
|
||||
)}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 常见问答卡片 */}
|
||||
{dashboardData.faqEnabled && (
|
||||
<FaqPanel
|
||||
faqData={faqData}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 大模型部署定制服务 / 可用性监控卡片 */}
|
||||
{dashboardData.uptimeEnabled && (
|
||||
<UptimePanel
|
||||
uptimeData={dashboardData.uptimeData}
|
||||
uptimeLoading={dashboardData.uptimeLoading}
|
||||
activeUptimeTab={dashboardData.activeUptimeTab}
|
||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
||||
loadUptimeData={dashboardData.loadUptimeData}
|
||||
uptimeLegendData={uptimeLegendData}
|
||||
renderMonitorList={(monitors) =>
|
||||
renderMonitorList(
|
||||
monitors,
|
||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||
(status) =>
|
||||
getUptimeStatusText(
|
||||
status,
|
||||
UPTIME_STATUS_MAP,
|
||||
dashboardData.t,
|
||||
),
|
||||
dashboardData.t,
|
||||
)
|
||||
}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
|
|
@ -28,6 +28,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring
|
|||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
|
||||
import SettingsDistributor from '../../pages/Setting/Operation/SettingsDistributor';
|
||||
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
|
||||
import SettingsAntiAbuse from '../../pages/Setting/Operation/SettingsAntiAbuse';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
|
||||
const OperationSetting = () => {
|
||||
|
|
@ -169,6 +170,10 @@ const OperationSetting = () => {
|
|||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsCheckin options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 风控设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAntiAbuse />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
} from '../../helpers';
|
||||
import { normalizeSmsVerificationEnabled } from '../../helpers/data';
|
||||
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';
|
||||
|
||||
// 导入子组件
|
||||
|
|
@ -555,26 +555,24 @@ const PersonalSetting = () => {
|
|||
|
||||
|
||||
return (
|
||||
<div className='mt-[60px]'>
|
||||
<div className='flex justify-center'>
|
||||
<div className='w-full max-w-7xl mx-auto px-2'>
|
||||
{/* 顶部用户信息区域 */}
|
||||
<UserInfoHeader t={t} userState={userState} />
|
||||
<div className='mx-auto mt-[60px] max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
|
||||
{/* 顶部用户信息区域 */}
|
||||
<UserInfoHeader t={t} userState={userState} />
|
||||
|
||||
{/* 签到日历 - 仅在启用时显示 */}
|
||||
{status?.checkin_enabled && (
|
||||
<div className='mt-4 md:mt-6'>
|
||||
<CheckinCalendar
|
||||
t={t}
|
||||
status={status}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 签到日历 - 仅在启用时显示 */}
|
||||
{status?.checkin_enabled && (
|
||||
<div className='mt-4 md:mt-6'>
|
||||
<CheckinCalendar
|
||||
t={t}
|
||||
status={status}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
/>
|
||||
</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'>
|
||||
<AccountManagement
|
||||
|
|
@ -609,8 +607,6 @@ const PersonalSetting = () => {
|
|||
handleNotificationSettingChange={handleNotificationSettingChange}
|
||||
saveNotificationSettings={saveNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模态框组件 */}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,18 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Calendar,
|
||||
Button,
|
||||
Typography,
|
||||
Avatar,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Collapsible,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
CalendarCheck,
|
||||
Sparkles,
|
||||
Gift,
|
||||
Check,
|
||||
ChevronDown,
|
||||
|
|
@ -213,7 +210,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
|||
};
|
||||
|
||||
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
|
||||
title='Security Check'
|
||||
visible={turnstileModalVisible}
|
||||
|
|
@ -241,24 +238,24 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
|||
{/* 卡片头部 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div
|
||||
className='flex items-center flex-1 cursor-pointer'
|
||||
className='flex flex-1 cursor-pointer items-center gap-3'
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
<Avatar size='small' color='green' className='mr-3 shadow-md'>
|
||||
<CalendarCheck size={16} />
|
||||
</Avatar>
|
||||
<div className='flex-1'>
|
||||
<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'>
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<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('每日签到')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
{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 className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||
{!initialLoaded
|
||||
? t('正在加载签到状态...')
|
||||
: checkinData.stats?.checked_in_today
|
||||
|
|
@ -276,7 +273,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
|||
onClick={() => doCheckin()}
|
||||
loading={checkinLoading || !initialLoaded}
|
||||
disabled={!initialLoaded || checkinData.stats?.checked_in_today}
|
||||
className='!bg-green-600 hover:!bg-green-700'
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{!initialLoaded
|
||||
? t('加载中...')
|
||||
|
|
@ -289,30 +286,36 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
|||
{/* 可折叠内容 */}
|
||||
<Collapsible isOpen={isCollapsed === false} keepDOM>
|
||||
{/* 签到统计 */}
|
||||
<div className='grid grid-cols-3 gap-3 mb-4 mt-4'>
|
||||
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
||||
<div className='text-xl font-bold text-green-600'>
|
||||
<div className='mt-4 mb-4 grid grid-cols-3 gap-3'>
|
||||
<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 tabular-nums text-emerald-600 dark:text-emerald-400'>
|
||||
{checkinData.stats?.total_checkins || 0}
|
||||
</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 className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
||||
<div className='text-xl font-bold text-orange-600'>
|
||||
<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 tabular-nums text-amber-600 dark:text-amber-400'>
|
||||
{renderQuota(monthlyQuota)}
|
||||
</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 className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
||||
<div className='text-xl font-bold text-blue-600'>
|
||||
<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 tabular-nums text-blue-600 dark:text-blue-400'>
|
||||
{renderQuota(checkinData.stats?.total_quota || 0)}
|
||||
</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>
|
||||
|
||||
{/* 签到日历 - 使用更紧凑的样式 */}
|
||||
<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>{`
|
||||
.checkin-calendar .semi-calendar {
|
||||
font-size: 13px;
|
||||
|
|
@ -367,17 +370,15 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
|||
</Spin>
|
||||
|
||||
{/* 签到说明 */}
|
||||
<div className='mt-3 p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
||||
<Typography.Text type='tertiary' className='text-xs'>
|
||||
<ul className='list-disc list-inside space-y-0.5'>
|
||||
<li>{t('每日签到可获得随机额度奖励')}</li>
|
||||
<li>{t('签到奖励将直接添加到您的账户余额')}</li>
|
||||
<li>{t('每日仅可签到一次,请勿重复签到')}</li>
|
||||
</ul>
|
||||
</Typography.Text>
|
||||
<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]'>
|
||||
<ul className='list-disc space-y-0.5 pl-4 text-xs text-slate-400 dark:text-white/40'>
|
||||
<li>{t('每日签到可获得随机额度奖励')}</li>
|
||||
<li>{t('签到奖励将直接添加到您的账户余额')}</li>
|
||||
<li>{t('每日仅可签到一次,请勿重复签到')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import React, { useRef, useEffect, useState, useContext } from 'react';
|
|||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Card,
|
||||
Avatar,
|
||||
Form,
|
||||
Radio,
|
||||
Toast,
|
||||
|
|
@ -33,7 +31,13 @@ import {
|
|||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
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 {
|
||||
renderQuotaWithPrompt,
|
||||
API,
|
||||
|
|
@ -317,48 +321,16 @@ const NotificationSettings = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
footer={
|
||||
<div className='flex justify-end gap-3'>
|
||||
{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 className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||
<div className='mb-5 flex items-center gap-3'>
|
||||
<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'>
|
||||
<Wrench size={18} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<Bell size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{t('其他设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
</div>
|
||||
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('通知、价格和隐私相关设置')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -379,7 +351,7 @@ const NotificationSettings = ({
|
|||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<Bell size={16} className='mr-2' />
|
||||
<BellRing size={14} className='mr-2' />
|
||||
{t('通知配置')}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -719,7 +691,7 @@ const NotificationSettings = ({
|
|||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<DollarSign size={16} className='mr-2' />
|
||||
<Coins size={14} className='mr-2' />
|
||||
{t('价格设置')}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -745,7 +717,7 @@ const NotificationSettings = ({
|
|||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<ShieldCheck size={16} className='mr-2' />
|
||||
<EyeOff size={14} className='mr-2' />
|
||||
{t('隐私设置')}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -770,7 +742,7 @@ const NotificationSettings = ({
|
|||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<Settings size={16} className='mr-2' />
|
||||
<LayoutPanelLeft size={14} className='mr-2' />
|
||||
{t('边栏设置')}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -791,36 +763,19 @@ const NotificationSettings = ({
|
|||
</Typography.Text>
|
||||
</div>
|
||||
{/* 边栏设置功能区域容器 */}
|
||||
<div
|
||||
className='border rounded-xl p-4'
|
||||
style={{
|
||||
borderColor: 'var(--semi-color-border)',
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
<div className='rounded-2xl border border-slate-200 bg-slate-50/50 p-4 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
<div key={section.key} className='mb-6 last:mb-0'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
className='flex justify-between items-center mb-4 p-4 rounded-lg'
|
||||
style={{
|
||||
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'>
|
||||
<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]'>
|
||||
<div className='min-w-0'>
|
||||
<div className='mb-1 text-base font-semibold text-slate-900 dark:text-white'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
className='text-xs text-slate-400 dark:text-white/40'
|
||||
>
|
||||
{section.description}
|
||||
</Typography.Text>
|
||||
|
|
@ -845,30 +800,23 @@ const NotificationSettings = ({
|
|||
lg={8}
|
||||
xl={8}
|
||||
>
|
||||
<Card
|
||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
||||
<div
|
||||
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 !==
|
||||
false
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
>
|
||||
<div className='flex justify-between items-center h-full'>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
||||
<div className='flex h-full items-center justify-between'>
|
||||
<div className='min-w-0 flex-1 text-left'>
|
||||
<div className='mb-1 text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
{module.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
className='mt-1 block text-xs text-slate-400 dark:text-white/40'
|
||||
>
|
||||
{module.description}
|
||||
</Typography.Text>
|
||||
|
|
@ -892,7 +840,7 @@ const NotificationSettings = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
|
@ -906,7 +854,41 @@ const NotificationSettings = ({
|
|||
</Tabs>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { Select } from '@douyinfe/semi-ui';
|
||||
import { SlidersHorizontal, Globe } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showSuccess, showError } from '../../../../helpers';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
|
|
@ -124,62 +124,55 @@ const PreferencesSettings = ({ t }) => {
|
|||
};
|
||||
|
||||
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 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
||||
<Languages size={16} />
|
||||
</Avatar>
|
||||
<div className='mb-5 flex items-center gap-3'>
|
||||
<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'>
|
||||
<SlidersHorizontal size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{t('偏好设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600 dark:text-gray-400'>
|
||||
</div>
|
||||
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('界面语言和其他个人偏好')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Language Setting Card */}
|
||||
<Card className='!rounded-xl border dark:border-gray-700'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<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'>
|
||||
<Languages
|
||||
size={20}
|
||||
className='text-violet-600 dark:text-violet-400'
|
||||
/>
|
||||
<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 w-full items-start gap-3 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'>
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
{t('语言偏好')}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('语言偏好')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
|
||||
</Typography.Text>
|
||||
<div className='mt-0.5 text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={handleLanguagePreferenceChange}
|
||||
style={{ width: 180 }}
|
||||
loading={loading}
|
||||
optionList={languageOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}))}
|
||||
/>
|
||||
</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 */}
|
||||
<div className='mt-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||
<Typography.Text type='tertiary'>
|
||||
{t(
|
||||
'提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<div className='mt-4 text-xs text-slate-400 dark:text-white/40'>
|
||||
{t(
|
||||
'提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。',
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -32,18 +32,18 @@ import {
|
|||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconShield,
|
||||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ScanFace } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const TwoFASetting = ({ t }) => {
|
||||
const TwoFASetting = ({ t, compact = false }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState({
|
||||
enabled: false,
|
||||
|
|
@ -366,20 +366,23 @@ const TwoFASetting = ({ t }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<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
|
||||
size='large'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? ''
|
||||
: 'rounded-2xl border border-slate-200 bg-white p-5 dark:border-white/5 dark:bg-white/[0.02]'
|
||||
}
|
||||
>
|
||||
<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 className='flex-1'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<Typography.Title heading={6} className='mb-0'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<span className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
{t('两步验证设置')}
|
||||
</Typography.Title>
|
||||
</span>
|
||||
{status.enabled ? (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
|
|
@ -395,23 +398,24 @@ const TwoFASetting = ({ t }) => {
|
|||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
className='text-xs text-slate-400 dark:text-white/40'
|
||||
>
|
||||
{t(
|
||||
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
{status.enabled && (
|
||||
<div className='mt-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('剩余备用码:')}
|
||||
{status.backup_codes_remaining || 0}
|
||||
{t('个')}
|
||||
</Text>
|
||||
<div className='mt-2 text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('剩余备用码:')}
|
||||
{status.backup_codes_remaining || 0}
|
||||
{t('个')}
|
||||
</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 ? (
|
||||
<Button
|
||||
type='primary'
|
||||
|
|
@ -419,19 +423,19 @@ const TwoFASetting = ({ t }) => {
|
|||
size='default'
|
||||
onClick={handleSetup2FA}
|
||||
loading={loading}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
icon={<IconShield />}
|
||||
className='!rounded-lg'
|
||||
icon={<ScanFace />}
|
||||
>
|
||||
{t('启用验证')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={() => setDisableModalVisible(true)}
|
||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
||||
className='!rounded-lg'
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
{t('禁用两步验证')}
|
||||
|
|
@ -450,7 +454,7 @@ const TwoFASetting = ({ t }) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 2FA设置模态框 */}
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -18,211 +18,154 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
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 {
|
||||
isRoot,
|
||||
isAdmin,
|
||||
isDistributor,
|
||||
renderQuota,
|
||||
stringToColor,
|
||||
} 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 getUsername = () => {
|
||||
if (userState.user) {
|
||||
return userState.user.username;
|
||||
} else {
|
||||
return 'null';
|
||||
}
|
||||
};
|
||||
const username = userState.user?.username || 'null';
|
||||
const roleKey = isRoot()
|
||||
? 'root'
|
||||
: isAdmin()
|
||||
? 'admin'
|
||||
: isDistributor()
|
||||
? 'distributor'
|
||||
: 'user';
|
||||
const RoleIcon =
|
||||
roleKey === 'root'
|
||||
? Crown
|
||||
: roleKey === 'admin'
|
||||
? ShieldCheck
|
||||
: roleKey === 'distributor'
|
||||
? Store
|
||||
: UserIcon;
|
||||
|
||||
const getAvatarText = () => {
|
||||
const username = getUsername();
|
||||
if (username && username.length > 0) {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return 'NA';
|
||||
};
|
||||
const stats = [
|
||||
{
|
||||
key: 'quota',
|
||||
label: t('当前余额'),
|
||||
value: renderQuota(userState?.user?.quota),
|
||||
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 (
|
||||
<Card
|
||||
className='!rounded-2xl overflow-hidden'
|
||||
cover={
|
||||
<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='flex items-center gap-4'>
|
||||
<div
|
||||
className='relative h-32'
|
||||
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',
|
||||
}}
|
||||
className={`flex h-14 w-14 items-center justify-center rounded-2xl text-base font-semibold ${roleAccent[roleKey]}`}
|
||||
>
|
||||
{/* 用户信息内容 */}
|
||||
<div className='relative z-10 h-full flex flex-col justify-end p-6'>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'>
|
||||
<Avatar size='large' color={stringToColor(getUsername())}>
|
||||
{getAvatarText()}
|
||||
</Avatar>
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between'>
|
||||
<div
|
||||
className='text-3xl font-bold truncate'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{getUsername()}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{isRoot() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('超级管理员')}
|
||||
</Tag>
|
||||
) : isAdmin() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('管理员')}
|
||||
</Tag>
|
||||
) : isDistributor() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('代理')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag size='large' shape='circle' style={{ color: 'white' }}>
|
||||
ID: {userState?.user?.id}
|
||||
</Tag>
|
||||
</div>
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h1 className='truncate text-xl font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{username}
|
||||
</h1>
|
||||
<Tag
|
||||
shape='circle'
|
||||
size='small'
|
||||
className='!rounded-full'
|
||||
style={{
|
||||
color: 'inherit',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${roleAccent[roleKey]}`}
|
||||
>
|
||||
<RoleIcon size={12} />
|
||||
{t(roleLabelKey[roleKey])}
|
||||
</span>
|
||||
</Tag>
|
||||
<span className='text-xs text-slate-400 dark:text-white/40 tabular-nums'>
|
||||
ID: {userState?.user?.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('账户概览与使用统计')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据三宫格 */}
|
||||
<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'>
|
||||
{stats.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div
|
||||
key={s.key}
|
||||
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]'
|
||||
>
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${s.accent}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||
{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>
|
||||
}
|
||||
>
|
||||
{/* 当前余额和桌面版统计信息(勿用 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 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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import PricingGroups from '../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||
|
|
@ -99,17 +98,22 @@ const PricingSidebar = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<div className='text-lg font-semibold text-gray-800'>{t('筛选')}</div>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
<div className='px-4 py-5'>
|
||||
<div className='mb-5 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-white/5'>
|
||||
<div>
|
||||
<div className='text-sm font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{t('筛选')}
|
||||
</div>
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||
调整左侧条件实时刷新结果
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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('重置')}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PricingVendors
|
||||
|
|
|
|||
|
|
@ -18,49 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Tooltip, Modal } from '@douyinfe/semi-ui';
|
||||
import { Layers } from 'lucide-react';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
import SearchActions from './SearchActions';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const CONFIG = {
|
||||
CAROUSEL_INTERVAL: 2000,
|
||||
ICON_SIZE: 40,
|
||||
ICON_SIZE: 32,
|
||||
UNKNOWN_VENDOR: 'unknown',
|
||||
};
|
||||
|
||||
const THEME_COLORS = {
|
||||
const THEME = {
|
||||
allVendors: {
|
||||
primary: '37 99 235',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
iconWrap:
|
||||
'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: {
|
||||
primary: '16 185 129',
|
||||
background: 'rgba(16, 185, 129, 0.1)',
|
||||
iconWrap:
|
||||
'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 = {
|
||||
unknown: {
|
||||
displayName: (t) => t('未知模型类型'),
|
||||
|
|
@ -85,35 +68,32 @@ const getVendorDisplayName = (vendorName, t) => {
|
|||
};
|
||||
|
||||
const createDefaultAvatar = () => (
|
||||
<div className={COMPONENT_STYLES.avatarContainer}>
|
||||
<Avatar size='large' color='transparent'>
|
||||
AI
|
||||
</Avatar>
|
||||
<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'>
|
||||
<Layers size={22} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const getAvatarBackgroundColor = (isAllVendors) =>
|
||||
isAllVendors
|
||||
? THEME_COLORS.allVendors.background
|
||||
: THEME_COLORS.specific.background;
|
||||
|
||||
const getAvatarText = (vendorName) =>
|
||||
vendorName === CONFIG.UNKNOWN_VENDOR
|
||||
? '?'
|
||||
: (vendorName || '').charAt(0).toUpperCase() || '?';
|
||||
|
||||
const createAvatarContent = (vendor, isAllVendors) => {
|
||||
const createAvatarContent = (vendor, iconWrapClass) => {
|
||||
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 (
|
||||
<Avatar
|
||||
size='large'
|
||||
style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }}
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-xl font-mono text-lg font-semibold ${iconWrapClass}`}
|
||||
>
|
||||
{getAvatarText(vendor.name)}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -123,11 +103,14 @@ const renderVendorAvatar = (vendor, t, isAllVendors = false) => {
|
|||
}
|
||||
|
||||
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 (
|
||||
<Tooltip content={displayName} position='top'>
|
||||
<div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div>
|
||||
{avatarContent}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -259,17 +242,6 @@ const PricingVendorIntro = memo(
|
|||
[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(
|
||||
() => (
|
||||
<SearchActions
|
||||
|
|
@ -318,51 +290,41 @@ const PricingVendorIntro = memo(
|
|||
);
|
||||
|
||||
const renderHeaderCard = useCallback(
|
||||
({ title, count, description, rightContent, primaryDarkerChannel }) => (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
cover={
|
||||
<div
|
||||
className='relative h-full'
|
||||
style={createCoverStyle(primaryDarkerChannel)}
|
||||
>
|
||||
<div className='relative z-10 h-full flex items-center justify-between p-4'>
|
||||
<div className='flex-1 min-w-0 mr-4'>
|
||||
<div className='flex flex-row flex-wrap items-center gap-2 sm:gap-3 mb-2'>
|
||||
<h2
|
||||
className='text-lg sm:text-xl font-bold truncate'
|
||||
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>
|
||||
({ title, count, description, rightContent, theme }) => (
|
||||
<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='flex items-start gap-4'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='mb-1.5 flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white'>
|
||||
{title}
|
||||
</h2>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium ${theme.badge}`}
|
||||
>
|
||||
{t('共 {{count}} 个模型', { count })}
|
||||
</span>
|
||||
</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>
|
||||
}
|
||||
>
|
||||
{renderSearchActions()}
|
||||
</Card>
|
||||
<div className='flex shrink-0 items-center'>{rightContent}</div>
|
||||
</div>
|
||||
<div className='mt-5 border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||
{renderSearchActions()}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[renderSearchActions, createCoverStyle, handleOpenDescModal, t],
|
||||
[renderSearchActions, handleOpenDescModal, t],
|
||||
);
|
||||
|
||||
const renderAllVendorsAvatar = useCallback(() => {
|
||||
|
|
@ -379,7 +341,7 @@ const PricingVendorIntro = memo(
|
|||
count: currentModelCount,
|
||||
description: getVendorDescription('all'),
|
||||
rightContent: renderAllVendorsAvatar(),
|
||||
primaryDarkerChannel: THEME_COLORS.allVendors.primary,
|
||||
theme: THEME.allVendors,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
|
|
@ -402,7 +364,7 @@ const PricingVendorIntro = memo(
|
|||
description:
|
||||
currentVendor.description || getVendorDescription(currentVendor.name),
|
||||
rightContent: renderVendorAvatar(currentVendor, t, false),
|
||||
primaryDarkerChannel: THEME_COLORS.specific.primary,
|
||||
theme: THEME.specific,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ const SearchActions = memo(
|
|||
}, [tokenUnit, setTokenUnit]);
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2 w-full'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-2 w-full'>
|
||||
<div className='min-w-[180px] flex-1'>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
|
|
@ -80,11 +80,10 @@ const SearchActions = memo(
|
|||
|
||||
<Button
|
||||
theme='outline'
|
||||
type='primary'
|
||||
type='tertiary'
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyClick}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className='!bg-blue-500 hover:!bg-blue-600 !text-white disabled:!bg-gray-300 disabled:!text-gray-500'
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
|
|
@ -96,7 +95,7 @@ const SearchActions = memo(
|
|||
{/* 充值价格显示开关 */}
|
||||
{supportsCurrencyDisplay && (
|
||||
<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('充值价格显示')}
|
||||
</span>
|
||||
<Switch
|
||||
|
|
@ -121,7 +120,7 @@ const SearchActions = memo(
|
|||
|
||||
{/* 显示倍率开关 */}
|
||||
{/* <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} />
|
||||
</div> */}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,11 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, Table, Empty } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Card, Table } from '@douyinfe/semi-ui';
|
||||
import { SearchX } from 'lucide-react';
|
||||
import { getPricingTableColumns } from './PricingTableColumns';
|
||||
|
||||
const PricingTable = ({
|
||||
|
|
@ -114,16 +111,17 @@ const PricingTable = ({
|
|||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center px-6 py-16 text-center'>
|
||||
<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'>
|
||||
<SearchX size={22} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className='text-sm font-medium text-slate-700 dark:text-white/80'>
|
||||
{t('搜索无结果')}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
||||
{t('试试调整筛选条件或换个关键词')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
|
|
|
|||
|
|
@ -184,8 +184,12 @@ const AddUserModal = (props) => {
|
|||
field='password'
|
||||
label={t('密码')}
|
||||
type='password'
|
||||
placeholder={t('请输入密码')}
|
||||
rules={[{ required: true, message: t('请输入密码') }]}
|
||||
placeholder={t('请输入密码(8-20位)')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入密码') },
|
||||
{ min: 8, message: t('密码长度至少为8个字符') },
|
||||
{ max: 20, message: t('密码长度不能超过20个字符') },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -271,8 +271,12 @@ const EditUserModal = (props) => {
|
|||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
placeholder={t('请输入新的密码(8-20位)')}
|
||||
mode='password'
|
||||
rules={[
|
||||
{ min: 8, message: t('密码长度至少为8个字符') },
|
||||
{ max: 20, message: t('密码长度不能超过20个字符') },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -105,108 +105,74 @@ const RechargeCard = ({
|
|||
}, [shouldShowSubscription, activeTab]);
|
||||
const topupContent = (
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
{/* 统计数据 */}
|
||||
<Card
|
||||
className='!rounded-xl w-full'
|
||||
cover={
|
||||
<div
|
||||
className='relative h-30'
|
||||
style={{
|
||||
'--palette-primary-darkerChannel': '37 99 235',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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 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'>
|
||||
<div className='mb-8 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<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'>
|
||||
<BarChart2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{t('账户统计')}
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<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 className='mt-1 text-xs text-slate-400 tabular-nums dark:text-white/40'>
|
||||
实时余额与消费概览
|
||||
</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 ? (
|
||||
<div className='py-8 flex justify-center'>
|
||||
|
|
@ -570,17 +536,22 @@ const RechargeCard = ({
|
|||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 兑换码充值 */}
|
||||
<Card
|
||||
className='!rounded-xl w-full'
|
||||
title={
|
||||
<Text type='tertiary' strong>
|
||||
{t('兑换码充值')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<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 gap-3'>
|
||||
<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'>
|
||||
<IconGift size={18} />
|
||||
</div>
|
||||
<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
|
||||
getFormApi={(api) => (redeemFormApiRef.current = api)}
|
||||
initValues={{ redemptionCode: redemptionCode }}
|
||||
|
|
@ -623,32 +594,33 @@ const RechargeCard = ({
|
|||
}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<CreditCard size={16} />
|
||||
</Avatar>
|
||||
<div className='mb-6 flex flex-wrap 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'>
|
||||
<CreditCard size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
<div className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||
{t('账户充值')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
||||
</div>
|
||||
<div className='mt-0.5 text-[11px] text-slate-400 dark:text-white/40'>
|
||||
{t('多种充值方式,安全便捷')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon={<Receipt size={16} />}
|
||||
theme='solid'
|
||||
<button
|
||||
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('账单')}
|
||||
</Button>
|
||||
<Receipt size={13} /> {t('账单')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shouldShowSubscription ? (
|
||||
|
|
|
|||
|
|
@ -864,7 +864,7 @@ const TopUp = () => {
|
|||
};
|
||||
|
||||
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
|
||||
t={t}
|
||||
|
|
|
|||
|
|
@ -1,103 +1,44 @@
|
|||
/* 充值额度卡片:hover 渐变边框 + 彩色背景 + 外发光 */
|
||||
/* 充值额度卡片:简洁 hover,对齐 dashboard 风格(无渐变) */
|
||||
|
||||
.preset-amount-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
padding: 1.5px;
|
||||
background: var(--semi-color-border);
|
||||
transition:
|
||||
background 0.35s ease,
|
||||
box-shadow 0.35s ease,
|
||||
transform 0.25s ease;
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
/* 外圈光晕(hover 时扩散) */
|
||||
.preset-amount-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 16px;
|
||||
background: radial-gradient(
|
||||
ellipse 90% 70% at 50% 50%,
|
||||
rgba(96, 165, 250, 0.45) 0%,
|
||||
rgba(167, 139, 250, 0.2) 45%,
|
||||
transparent 72%
|
||||
);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
transition: opacity 0.35s ease;
|
||||
.preset-amount-card .semi-card {
|
||||
border: 1px solid rgb(226 232 240) !important;
|
||||
border-radius: 12px !important;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #ffffff !important;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease !important;
|
||||
}
|
||||
|
||||
.preset-amount-card:hover .semi-card {
|
||||
border-color: rgb(96 165 250) !important;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12) !important;
|
||||
}
|
||||
|
||||
.preset-amount-card:hover {
|
||||
background: linear-gradient(
|
||||
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;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
.dark .preset-amount-card:hover .preset-amount-card__inner {
|
||||
background: linear-gradient(
|
||||
155deg,
|
||||
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 .semi-card {
|
||||
background: rgba(255, 255, 255, 0.02) !important;
|
||||
border-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.dark .preset-amount-card:hover {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(147, 197, 253, 0.5),
|
||||
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%
|
||||
);
|
||||
}
|
||||
.dark .preset-amount-card:hover .semi-card {
|
||||
border-color: rgba(96, 165, 250, 0.6) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
|
@ -22,8 +22,7 @@ export const CHART_CONFIG = { mode: 'desktop-browser' };
|
|||
|
||||
export const CARD_PROPS = {
|
||||
shadows: '',
|
||||
bordered: true,
|
||||
headerLine: true,
|
||||
bordered: false,
|
||||
};
|
||||
|
||||
export const FORM_FIELD_PROPS = {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export const getTrendSpec = (data, color) => ({
|
|||
background: {
|
||||
fill: 'transparent',
|
||||
},
|
||||
transformSpec: [],
|
||||
});
|
||||
|
||||
// ========== UI 工具函数 ==========
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
Info,
|
||||
LayoutGrid,
|
||||
ClipboardCheck,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SiAtlassian,
|
||||
|
|
@ -192,6 +193,10 @@ export function getLucideIcon(key, selected = false) {
|
|||
return <ClipboardCheck {...commonProps} color={iconColor} />;
|
||||
case 'benchmarks':
|
||||
return <BarChart3 {...commonProps} color={iconColor} />;
|
||||
case 'lucky-bag':
|
||||
return <Gift {...commonProps} color={iconColor} />;
|
||||
case 'api-tester':
|
||||
return <FlaskConical {...commonProps} color={iconColor} />;
|
||||
default:
|
||||
return <CircleUser {...commonProps} color={iconColor} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
|||
console: {
|
||||
enabled: true,
|
||||
benchmarks: true,
|
||||
'lucky-bag': true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
|
|
@ -280,10 +281,10 @@ export const useSidebar = () => {
|
|||
return result;
|
||||
}, [roleBaseConfig, userConfig]);
|
||||
|
||||
// 检查特定功能是否应该显示
|
||||
// 检查特定功能是否应该显示 (未配置的key默认可见)
|
||||
const isModuleVisible = (sectionKey, moduleKey = null) => {
|
||||
if (moduleKey) {
|
||||
return finalConfig[sectionKey]?.[moduleKey] === true;
|
||||
return finalConfig[sectionKey]?.[moduleKey] !== false;
|
||||
} else {
|
||||
return finalConfig[sectionKey]?.enabled === true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -18,180 +18,50 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
import { API, isAdmin, showError } from '../../helpers';
|
||||
|
||||
export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const isSuccess = (l) =>
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [greetingVisible, setGreetingVisible] = useState(false);
|
||||
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 [recentLogs, setRecentLogs] = useState([]);
|
||||
const [uptimeData, setUptimeData] = useState([]);
|
||||
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||
const [activeUptimeTab, setActiveUptimeTab] = useState('');
|
||||
|
||||
// ========== 常量 ==========
|
||||
const now = new Date();
|
||||
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 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 () => {
|
||||
const loadRecentLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = '';
|
||||
const { start_timestamp, end_timestamp, username } = inputs;
|
||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
|
||||
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 now = Math.floor(Date.now() / 1000);
|
||||
const start = now - 30 * 86400;
|
||||
const url = isAdminUser
|
||||
? `/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}`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setQuotaData(data);
|
||||
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;
|
||||
setRecentLogs(data?.items || []);
|
||||
} else {
|
||||
showError(message);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
|
||||
}, [isAdminUser]);
|
||||
|
||||
const loadUptimeData = useCallback(async () => {
|
||||
setUptimeLoading(true);
|
||||
|
|
@ -200,9 +70,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
|||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUptimeData(data || []);
|
||||
if (data && data.length > 0 && !activeUptimeTab) {
|
||||
setActiveUptimeTab(data[0].categoryName);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
|
|
@ -211,114 +78,208 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
|||
} finally {
|
||||
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(() => {
|
||||
if (!initialized.current) {
|
||||
getUserData();
|
||||
loadRecentLogs();
|
||||
loadUptimeData();
|
||||
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 {
|
||||
// 基础状态
|
||||
loading: showLoading,
|
||||
greetingVisible,
|
||||
searchModalVisible,
|
||||
|
||||
// 输入状态
|
||||
inputs,
|
||||
dataExportDefaultTime,
|
||||
|
||||
// 数据状态
|
||||
quotaData,
|
||||
consumeQuota,
|
||||
setConsumeQuota,
|
||||
consumeTokens,
|
||||
setConsumeTokens,
|
||||
times,
|
||||
setTimes,
|
||||
pieData,
|
||||
setPieData,
|
||||
lineData,
|
||||
setLineData,
|
||||
modelColors,
|
||||
setModelColors,
|
||||
|
||||
// 图表状态
|
||||
activeChartTab,
|
||||
setActiveChartTab,
|
||||
|
||||
// 趋势数据
|
||||
trendData,
|
||||
setTrendData,
|
||||
|
||||
// Uptime 数据
|
||||
loading,
|
||||
recentLogs,
|
||||
uptimeData,
|
||||
uptimeLoading,
|
||||
activeUptimeTab,
|
||||
setActiveUptimeTab,
|
||||
|
||||
// 计算值
|
||||
timeOptions,
|
||||
performanceMetrics,
|
||||
getGreeting,
|
||||
isAdminUser,
|
||||
hasApiInfoPanel,
|
||||
hasInfoPanels,
|
||||
apiInfoEnabled,
|
||||
announcementsEnabled,
|
||||
faqEnabled,
|
||||
uptimeEnabled,
|
||||
|
||||
// 函数
|
||||
handleInputChange,
|
||||
showSearchModal,
|
||||
handleCloseModal,
|
||||
loadQuotaData,
|
||||
loadUptimeData,
|
||||
getUserData,
|
||||
metrics,
|
||||
dailyStats,
|
||||
monthDelta,
|
||||
daysRemaining,
|
||||
topModels,
|
||||
sla,
|
||||
monthlyForecast,
|
||||
refresh,
|
||||
handleSearchConfirm,
|
||||
|
||||
// 导航和翻译
|
||||
navigate,
|
||||
t,
|
||||
isMobile,
|
||||
quotaTotal: total,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enTranslation from './locales/en.json';
|
||||
import frTranslation from './locales/fr.json';
|
||||
|
|
@ -35,7 +34,6 @@ import swTranslation from './locales/sw.json';
|
|||
import { supportedLanguages } from './language';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
load: 'currentOnly',
|
||||
|
|
@ -54,6 +52,7 @@ i18n
|
|||
sw: swTranslation,
|
||||
},
|
||||
fallbackLng: 'zh-CN',
|
||||
lng: 'zh-CN',
|
||||
nsSeparator: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
|
|
|
|||
|
|
@ -33,34 +33,81 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 220 4% 16%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 220 4% 16%;
|
||||
--muted-foreground: 220 5% 65%;
|
||||
--accent: 220 4% 16%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 220 4% 16%;
|
||||
--input: 220 4% 16%;
|
||||
--ring: 222 56% 55%;
|
||||
--link: 222 56% 65%;
|
||||
--link-hover: 222 56% 55%;
|
||||
--chart-1: 160 60% 45%;
|
||||
--chart-2: 217 91% 60%;
|
||||
--background: 0 0% 5.5%;
|
||||
--foreground: 0 0% 90%;
|
||||
--card: 0 0% 10%;
|
||||
--card-foreground: 0 0% 90%;
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 0 0% 90%;
|
||||
--primary: 236 52% 62%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 13%;
|
||||
--secondary-foreground: 0 0% 90%;
|
||||
--muted: 0 0% 9%;
|
||||
--muted-foreground: 0 0% 56%;
|
||||
--accent: 236 52% 62%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 62% 45%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 100% / 0.08;
|
||||
--input: 0 0% 100% / 0.06;
|
||||
--ring: 236 52% 62%;
|
||||
--link: 236 52% 72%;
|
||||
--link-hover: 236 52% 62%;
|
||||
--radius: 0.375rem;
|
||||
--chart-1: 236 52% 62%;
|
||||
--chart-2: 160 60% 50%;
|
||||
--chart-3: 43 96% 56%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--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 {
|
||||
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) {
|
||||
.home-distributor-recruit-card,
|
||||
.home-distributor-recruit-card::before,
|
||||
|
|
@ -1605,7 +1837,7 @@ html.dark .with-pastel-balls::before {
|
|||
.semi-select,
|
||||
.semi-button,
|
||||
.semi-datepicker-range-input {
|
||||
border-radius: 10px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
|
|
@ -1685,3 +1917,144 @@ html.dark .with-pastel-balls::before {
|
|||
.font-body {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import '@douyinfe/semi-ui/dist/css/semi.css';
|
||||
import './semi.css';
|
||||
import { UserProvider } from './context/User';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { StatusProvider } from './context/Status';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,665 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Send,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCcw,
|
||||
Loader2,
|
||||
KeyRound,
|
||||
Cpu,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Sparkles,
|
||||
Bookmark,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_PATH = '/v1/chat/completions';
|
||||
const DEFAULT_MAX_TOKENS = 1024;
|
||||
|
||||
// 推断协议格式:路径以 /v1/messages 结尾 → Anthropic Messages API
|
||||
const detectFormat = (rawPath) =>
|
||||
/\/v1\/messages\/?$/i.test((rawPath || '').trim()) ? 'anthropic' : 'openai';
|
||||
|
||||
const CUSTOM_SCENES_KEY = 'api-tester-custom-scenes';
|
||||
|
||||
const SCENES = [
|
||||
// 本平台
|
||||
{ id: 'platform-chat', group: '本平台', label: '本平台 Chat', path: '/v1/chat/completions', Icon: MessageSquare },
|
||||
{ id: 'platform-embedding', group: '本平台', label: '本平台 Embedding', path: '/v1/embeddings', Icon: Zap },
|
||||
{ id: 'platform-image', group: '本平台', label: '本平台 图像生成', path: '/v1/images/generations', Icon: Cpu },
|
||||
// 国产 · OpenAI 兼容
|
||||
{ id: 'deepseek-openai', group: '国产 · OpenAI 兼容', label: 'DeepSeek', base: 'https://api.deepseek.com', path: '/v1/chat/completions', Icon: Sparkles },
|
||||
{ id: 'qwen', group: '国产 · OpenAI 兼容', label: '通义千问 Qwen', base: 'https://dashscope.aliyuncs.com/compatible-mode', path: '/v1/chat/completions', Icon: Sparkles },
|
||||
{ id: 'kimi', group: '国产 · OpenAI 兼容', label: 'Kimi 月之暗面', base: 'https://api.moonshot.cn', path: '/v1/chat/completions', Icon: Sparkles },
|
||||
{ id: 'glm', group: '国产 · OpenAI 兼容', label: '智谱 GLM', base: 'https://open.bigmodel.cn/api/paas/v4', path: '/chat/completions', Icon: Sparkles },
|
||||
{ id: 'doubao', group: '国产 · OpenAI 兼容', label: '豆包 Doubao', base: 'https://ark.cn-beijing.volces.com/api/v3', path: '/chat/completions', Icon: Sparkles },
|
||||
// Anthropic 兼容
|
||||
{ id: 'deepseek-anthropic', group: 'Anthropic 兼容', label: 'DeepSeek · Anthropic', base: 'https://api.deepseek.com/anthropic', path: '/v1/messages', Icon: Sparkles },
|
||||
{ id: 'anthropic', group: 'Anthropic 兼容', label: 'Anthropic 官方', base: 'https://api.anthropic.com', path: '/v1/messages', Icon: Sparkles },
|
||||
// 本地部署
|
||||
{ id: 'ollama', group: '本地部署', label: 'Ollama', base: 'http://localhost:11434', path: '/v1/chat/completions', Icon: Cpu },
|
||||
];
|
||||
|
||||
// 清洗用户粘贴的 API Key:
|
||||
// 1) 去首尾空白和引号
|
||||
// 2) 去掉 Bearer 前缀
|
||||
// 3) 找到 sk- 起点后,只保留 Key 字符(字母数字 _-),避免 "sk-xxx 备注" 这种污染
|
||||
const sanitizeKey = (raw) => {
|
||||
if (!raw) return '';
|
||||
let s = String(raw).trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
s = s.slice(1, -1).trim();
|
||||
}
|
||||
s = s.replace(/^Bearer\s+/i, '').trim();
|
||||
const idx = s.search(/sk-[A-Za-z0-9_-]/);
|
||||
if (idx >= 0) {
|
||||
return s.slice(idx).match(/sk-[A-Za-z0-9_-]+/)?.[0] || '';
|
||||
}
|
||||
const first = s.split(/\s+/).find(Boolean) || '';
|
||||
return first.replace(/[^\w.\-]/g, '');
|
||||
};
|
||||
|
||||
const buildPayload = (format, model, prompt, systemPrompt, maxTokens) => {
|
||||
if (format === 'anthropic') {
|
||||
return {
|
||||
model,
|
||||
max_tokens: Number(maxTokens) || DEFAULT_MAX_TOKENS,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
model,
|
||||
messages: [
|
||||
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
};
|
||||
|
||||
const formatJSON = (data) => {
|
||||
if (typeof data === 'string') return data;
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
};
|
||||
|
||||
const ApiTester = () => {
|
||||
const { t } = useTranslation();
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [endpointPath, setEndpointPath] = useState(DEFAULT_PATH);
|
||||
const [endpointBase, setEndpointBase] = useState('');
|
||||
const [showCustomBase, setShowCustomBase] = useState(false);
|
||||
const [model, setModel] = useState('deepseek-v4-pro');
|
||||
const [maxTokens, setMaxTokens] = useState(DEFAULT_MAX_TOKENS);
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [prompt, setPrompt] = useState('用一句话介绍你自己。');
|
||||
|
||||
const [response, setResponse] = useState('');
|
||||
const [status, setStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [customScenes, setCustomScenes] = useState([]);
|
||||
|
||||
const userRaw = useMemo(() => localStorage.getItem('user'), []);
|
||||
const user = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(userRaw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [userRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.token) setApiKey(sanitizeKey(user.token));
|
||||
}, [user?.token]);
|
||||
|
||||
const handleApiKeyChange = (e) => {
|
||||
setApiKey(sanitizeKey(e.target.value));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const apiBase =
|
||||
import.meta.env?.VITE_API_BASE ||
|
||||
(window.location.port === '3000'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3001`
|
||||
: window.location.origin);
|
||||
setEndpointBase(apiBase);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(CUSTOM_SCENES_KEY) || '[]');
|
||||
if (Array.isArray(saved)) setCustomScenes(saved);
|
||||
} catch {
|
||||
// 忽略解析失败
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCustomScenes = (list) => {
|
||||
setCustomScenes(list);
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_SCENES_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
// 忽略写入失败(quota 等)
|
||||
}
|
||||
};
|
||||
|
||||
const format = useMemo(() => detectFormat(endpointPath), [endpointPath]);
|
||||
|
||||
const endpoint = useMemo(() => {
|
||||
const base = (endpointBase || window.location.origin).replace(/\/+$/, '');
|
||||
const path = endpointPath.startsWith('/') ? endpointPath : `/${endpointPath}`;
|
||||
return `${base}${path}`;
|
||||
}, [endpointBase, endpointPath]);
|
||||
|
||||
const isAbsoluteEndpoint = useMemo(
|
||||
() => /^https?:\/\//i.test(endpointPath),
|
||||
[endpointPath],
|
||||
);
|
||||
const finalEndpoint = useMemo(
|
||||
() => (isAbsoluteEndpoint ? endpointPath : endpoint),
|
||||
[isAbsoluteEndpoint, endpoint, endpointPath],
|
||||
);
|
||||
|
||||
const applyScene = (scene) => {
|
||||
setShowCustomBase(false);
|
||||
setEndpointPath(scene.path);
|
||||
setEndpointBase(scene.base || window.location.origin);
|
||||
};
|
||||
|
||||
const saveCurrentAsCustom = () => {
|
||||
const label = window.prompt('给当前目标起个名字', '我的目标');
|
||||
if (!label) return;
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
persistCustomScenes([
|
||||
...customScenes,
|
||||
{
|
||||
id: `custom-${Date.now()}`,
|
||||
label: trimmed,
|
||||
base: endpointBase,
|
||||
path: endpointPath,
|
||||
},
|
||||
]);
|
||||
Toast.success('已保存到我的目标');
|
||||
};
|
||||
|
||||
const deleteCustomScene = (id) => {
|
||||
persistCustomScenes(customScenes.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
const applyFullUrl = (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
setEndpointPath(DEFAULT_PATH);
|
||||
setEndpointBase(window.location.origin);
|
||||
return;
|
||||
}
|
||||
const m = trimmed.match(/^(https?:\/\/[^\s/?#]+)(.*)$/i);
|
||||
if (m) {
|
||||
setEndpointBase(m[1]);
|
||||
const tail = m[2] || DEFAULT_PATH;
|
||||
setEndpointPath(tail.startsWith('/') ? tail : `/${tail}`);
|
||||
} else {
|
||||
setEndpointBase(window.location.origin);
|
||||
setEndpointPath(trimmed.startsWith('/') ? trimmed : `/${trimmed}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cURL = useMemo(() => {
|
||||
const body = buildPayload(format, model, prompt, systemPrompt, maxTokens);
|
||||
return `curl ${finalEndpoint} \\
|
||||
-H "Authorization: Bearer ${apiKey || '<YOUR_API_KEY>'}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(body)}'`;
|
||||
}, [finalEndpoint, apiKey, format, model, prompt, systemPrompt, maxTokens]);
|
||||
|
||||
const send = async () => {
|
||||
if (!apiKey) {
|
||||
Toast.warning('请先填写 API Key');
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
Toast.warning('请输入 Prompt');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setResponse('');
|
||||
setStatus(null);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const res = await fetch(finalEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildPayload(format, model, prompt, systemPrompt, maxTokens),
|
||||
),
|
||||
});
|
||||
const ms = Math.round(performance.now() - start);
|
||||
setElapsed(ms);
|
||||
setStatus({ code: res.status, ok: res.ok });
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
setResponse(formatJSON(data));
|
||||
} else {
|
||||
const text = await res.text();
|
||||
setResponse(text);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus({ code: 0, ok: false });
|
||||
setResponse(`请求失败: ${err?.message || String(err)}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCurl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cURL);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
Toast.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setPrompt('用一句话介绍你自己。');
|
||||
setSystemPrompt('');
|
||||
setResponse('');
|
||||
setStatus(null);
|
||||
setElapsed(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div className='space-y-6 pt-8 pb-8'>
|
||||
{/* 页面标题区 */}
|
||||
<div className='flex flex-wrap items-end justify-between gap-4'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight text-slate-900 md:text-4xl dark:text-white'>
|
||||
API 快速测试
|
||||
</h1>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-white/50'>
|
||||
当前协议:{format === 'anthropic' ? 'Anthropic Messages' : 'OpenAI Chat Completions'} · 请求地址 {finalEndpoint}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={reset}
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-white/20 dark:hover:text-white'
|
||||
>
|
||||
<RefreshCcw size={13} /> 重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第一行:API Key / 测试目标 / 模型 */}
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-3'>
|
||||
{/* API Key 输入卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<KeyRound size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>API Key</div>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
placeholder='sk-...'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 pr-9 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
|
||||
>
|
||||
{showKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className='mt-2 text-[11px] text-slate-400 dark:text-white/40'>
|
||||
{user?.token ? '已自动填充当前登录用户的 Token' : '请填写你的 API Key'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试目标选择卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Globe size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
测试目标
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCustomBase((v) => !v)}
|
||||
className='inline-flex items-center gap-1 text-[11px] text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
|
||||
title='切换自定义域名'
|
||||
>
|
||||
{showCustomBase ? '收起域名' : '换个平台'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const grouped = SCENES.reduce((acc, s) => {
|
||||
if (!acc[s.group]) acc[s.group] = [];
|
||||
acc[s.group].push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
{Object.entries(grouped).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<div className='mb-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
{group}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{items.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => applyScene(s)}
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-blue-400 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
>
|
||||
<s.Icon size={11} /> {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className='border-t border-slate-100 pt-2.5 dark:border-white/5'>
|
||||
<div className='mb-1.5 flex items-center justify-between'>
|
||||
<div className='text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
我的目标
|
||||
</div>
|
||||
<button
|
||||
onClick={saveCurrentAsCustom}
|
||||
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:text-blue-400'
|
||||
>
|
||||
<Plus size={10} /> 保存当前
|
||||
</button>
|
||||
</div>
|
||||
{customScenes.length === 0 ? (
|
||||
<div className='text-[11px] text-slate-400 dark:text-white/40'>
|
||||
调整完地址后,点右上「保存当前」即可收藏到本地
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{customScenes.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className='inline-flex items-center overflow-hidden rounded-full border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'
|
||||
>
|
||||
<button
|
||||
onClick={() => applyScene(s)}
|
||||
className='inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:bg-blue-50 hover:text-blue-600 dark:text-white/70 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
>
|
||||
<Bookmark size={11} /> {s.label}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteCustomScene(s.id)}
|
||||
className='border-l border-slate-200 px-2 py-1.5 text-slate-400 transition hover:bg-red-50 hover:text-red-500 dark:border-white/10 dark:hover:bg-red-500/10 dark:hover:text-red-400'
|
||||
title='删除'
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className='mt-3'>
|
||||
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
|
||||
完整请求地址
|
||||
</label>
|
||||
<input
|
||||
value={isAbsoluteEndpoint ? endpointPath : `${endpointBase}${endpointPath}`}
|
||||
onChange={(e) => applyFullUrl(e.target.value)}
|
||||
placeholder='https://你的平台域名/v1/chat/completions'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
<div className='mt-3 grid grid-cols-[1fr_1.4fr] gap-1.5'>
|
||||
<input
|
||||
value={endpointBase}
|
||||
onChange={(e) => setEndpointBase(e.target.value)}
|
||||
placeholder='https://api.example.com'
|
||||
className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
<input
|
||||
value={endpointPath}
|
||||
onChange={(e) => setEndpointPath(e.target.value)}
|
||||
placeholder='/v1/chat/completions'
|
||||
className='rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-3 flex items-center gap-1.5 truncate font-mono text-[11px] text-slate-400 dark:text-white/40'>
|
||||
<span className='shrink-0'>实际请求:</span>
|
||||
<span className='truncate'>{finalEndpoint}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型选择卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Cpu size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>模型</div>
|
||||
</div>
|
||||
<input
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder='例如 gpt-4o-mini / claude-3-5-sonnet ...'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
{format === 'anthropic' && (
|
||||
<div className='mt-3'>
|
||||
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
|
||||
max_tokens(Anthropic 必填)
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
min={1}
|
||||
max={8192}
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(Number(e.target.value) || 1)}
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:请求参数 + 响应结果 */}
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
|
||||
{/* 请求参数卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<MessageSquare size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
请求参数
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={loading}
|
||||
className='inline-flex items-center gap-1.5 rounded-full bg-blue-600 px-4 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-blue-500 dark:hover:bg-blue-400'
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={13} className='animate-spin' />
|
||||
) : (
|
||||
<Send size={13} />
|
||||
)}
|
||||
{loading ? '发送中...' : '发送请求'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
{format === 'anthropic' ? 'system(顶层字段)' : '系统提示'}
|
||||
</label>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={format === 'anthropic' ? '(可选)Anthropic 顶层 system 字段' : '(可选)设置模型角色'}
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
用户提示 <span className='text-red-500'>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder='输入你想让模型回答的内容'
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 响应结果卡片 */}
|
||||
<div className='flex flex-col rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Zap size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
响应结果
|
||||
</div>
|
||||
{status && (
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium ${
|
||||
status.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
status.ok ? 'bg-emerald-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
{status.code > 0 ? `${status.code}` : 'ERR'} · {elapsed}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{response && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(response);
|
||||
Toast.success('已复制响应');
|
||||
}}
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:border-white/20 dark:hover:text-white'
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-hidden rounded-xl border border-slate-100 bg-slate-50/50 dark:border-white/5 dark:bg-neutral-900/50'>
|
||||
{loading ? (
|
||||
<div className='flex h-full min-h-[280px] items-center justify-center text-sm text-slate-400 dark:text-white/40'>
|
||||
<Loader2 size={20} className='mr-2 animate-spin' />
|
||||
请求中...
|
||||
</div>
|
||||
) : response ? (
|
||||
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-[11px] leading-relaxed text-slate-800 dark:text-white/80'>
|
||||
{response}
|
||||
</pre>
|
||||
) : (
|
||||
<div className='flex h-full min-h-[280px] flex-col items-center justify-center px-6 text-center'>
|
||||
<div className='text-sm text-slate-400 dark:text-white/40'>
|
||||
填写参数后点击「发送请求」即可看到原始响应
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* cURL 命令展示区 */}
|
||||
<div className='overflow-hidden rounded-2xl border border-slate-900 bg-neutral-950 dark:border-white/10'>
|
||||
<div className='flex items-center justify-between border-b border-white/10 bg-neutral-900 px-4 py-2.5'>
|
||||
<div className='flex gap-1.5'>
|
||||
<span className='h-3 w-3 rounded-full bg-red-500/60' />
|
||||
<span className='h-3 w-3 rounded-full bg-yellow-500/60' />
|
||||
<span className='h-3 w-3 rounded-full bg-green-500/60' />
|
||||
</div>
|
||||
<span className='font-mono text-[10px] tracking-wide text-white/30'>终端</span>
|
||||
<button
|
||||
onClick={copyCurl}
|
||||
className='text-white/40 transition-colors hover:text-white/80'
|
||||
aria-label='复制'
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className='overflow-x-auto p-5 font-mono text-[12px] leading-relaxed text-white/80'>
|
||||
{cURL}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiTester;
|
||||
|
|
@ -22,12 +22,13 @@ export default {
|
|||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
black: '#000',
|
||||
white: '#fff',
|
||||
// ---- shadcn/ui CSS variable tokens (TokenDance 风格) ----
|
||||
extend: {
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
current: 'currentColor',
|
||||
black: '#000',
|
||||
white: '#fff',
|
||||
// ---- shadcn/ui CSS variable tokens (TokenDance 风格) ----
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: 'hsl(var(--card))',
|
||||
|
|
@ -156,8 +157,7 @@ export default {
|
|||
'semi-color-data-17': 'var(--semi-color-data-17)',
|
||||
'semi-color-data-18': 'var(--semi-color-data-18)',
|
||||
'semi-color-data-19': 'var(--semi-color-data-19)',
|
||||
},
|
||||
extend: {
|
||||
},
|
||||
fontFamily: {
|
||||
heading: ['"Source Serif 4"', '"Noto Serif SC"', 'Georgia', 'serif'],
|
||||
serif: ['"Playfair Display"', '"Noto Serif SC"', 'serif'],
|
||||
|
|
@ -202,6 +202,9 @@ export default {
|
|||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-in-out',
|
||||
'slide-up': 'slideUp 0.2s ease-out',
|
||||
'float': 'float linear infinite',
|
||||
'confetti': 'confettiFall ease-out forwards',
|
||||
'shimmer': 'shimmer 2s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
|
|
@ -212,6 +215,20 @@ export default {
|
|||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||
'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%)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue