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 ModelDeploymentPage from './pages/ModelDeployment';
|
||||||
import ModelHeatPage from './pages/ModelHeat';
|
import ModelHeatPage from './pages/ModelHeat';
|
||||||
import Playground from './pages/Playground';
|
import Playground from './pages/Playground';
|
||||||
|
import ApiTester from './pages/ApiTester';
|
||||||
import Subscription from './pages/Subscription';
|
import Subscription from './pages/Subscription';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
import PersonalSetting from './components/settings/PersonalSetting';
|
||||||
|
|
@ -58,6 +59,10 @@ import Suppliers from './pages/SupplierAdmin/list';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupCheck from './components/layout/SetupCheck';
|
import SetupCheck from './components/layout/SetupCheck';
|
||||||
import OperationLog from './pages/OperationLog';
|
import OperationLog from './pages/OperationLog';
|
||||||
|
import LuckyBag from './pages/LuckyBag';
|
||||||
|
import Drawing from './pages/Drawing';
|
||||||
|
import ProfitDashboard from './pages/ProfitDashboard';
|
||||||
|
import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
|
@ -177,6 +182,14 @@ function App() {
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/api-tester'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<ApiTester />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/console/redemption'
|
path='/console/redemption'
|
||||||
element={
|
element={
|
||||||
|
|
@ -193,6 +206,38 @@ function App() {
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/lucky-bag'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<LuckyBag />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/drawing'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Drawing />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/profit'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<ProfitDashboard />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/anti-abuse'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<SettingsAntiAbuse />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/console/supplier-application'
|
path='/console/supplier-application'
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Activity, CreditCard, Hash, Layers, Gauge } from 'lucide-react';
|
||||||
|
import { renderQuota } from '../../helpers';
|
||||||
|
|
||||||
|
const formatNumber = (n) => {
|
||||||
|
if (n == null || isNaN(n)) return '—';
|
||||||
|
const v = Number(n);
|
||||||
|
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M';
|
||||||
|
if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K';
|
||||||
|
return v.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLatency = (v) => {
|
||||||
|
if (v == null) return '—';
|
||||||
|
if (v < 1) return `${Math.round(v * 1000)}ms`;
|
||||||
|
return `${v.toFixed(2)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BentoStat = ({ label, value, unit, sub, delta, icon: Icon, className = '' }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex h-9 w-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>{label}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-1 flex-col justify-center'>
|
||||||
|
<div className='flex items-baseline gap-2'>
|
||||||
|
<span className='font-mono text-3xl font-bold tabular-nums leading-none text-slate-900 md:text-4xl dark:text-white'>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit && <span className='text-xs text-slate-400 dark:text-white/40'>{unit}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
{delta != null && (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs tabular-nums ${
|
||||||
|
delta < 0
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: delta > 0
|
||||||
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: 'text-slate-500 dark:text-white/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className='font-semibold'>
|
||||||
|
{delta > 0 ? '↑' : delta < 0 ? '↓' : '·'} {Math.abs(delta).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className='font-normal text-slate-400 dark:text-white/40'>对比上月</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sub && (
|
||||||
|
<div className='text-[11px] text-slate-500 tabular-nums dark:text-white/40'>{sub}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BentoStats = ({ metrics, monthDelta }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BentoStat
|
||||||
|
label='今日请求'
|
||||||
|
value={metrics.today.requests}
|
||||||
|
unit='次'
|
||||||
|
sub={
|
||||||
|
<span>
|
||||||
|
<span className='font-semibold text-slate-900 dark:text-white'>
|
||||||
|
{formatNumber(metrics.today.tokens)}
|
||||||
|
</span>{' '}
|
||||||
|
tokens
|
||||||
|
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
|
||||||
|
{renderQuota(metrics.today.cost)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<BentoStat
|
||||||
|
label='今日 token'
|
||||||
|
value={formatNumber(metrics.today.tokens)}
|
||||||
|
unit='tokens'
|
||||||
|
sub={`今日花费 ${renderQuota(metrics.today.cost)}`}
|
||||||
|
icon={Hash}
|
||||||
|
/>
|
||||||
|
<BentoStat
|
||||||
|
label='平均响应'
|
||||||
|
value={formatLatency(metrics.today.avgLatency)}
|
||||||
|
sub='今日成功调用'
|
||||||
|
icon={Gauge}
|
||||||
|
/>
|
||||||
|
<BentoStat
|
||||||
|
label='本月消费'
|
||||||
|
value={metrics.month.cost ? renderQuota(metrics.month.cost) : '—'}
|
||||||
|
sub={
|
||||||
|
<span>
|
||||||
|
<span className='font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||||
|
{metrics.month.requests}
|
||||||
|
</span>{' '}
|
||||||
|
次调用
|
||||||
|
<span className='mx-1.5 text-slate-300 dark:text-white/30'>·</span>
|
||||||
|
<span className='tabular-nums'>{formatNumber(metrics.month.tokens)}</span> tokens
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
delta={monthDelta}
|
||||||
|
icon={CreditCard}
|
||||||
|
/>
|
||||||
|
<BentoStat
|
||||||
|
label='累计 token'
|
||||||
|
value={formatNumber(metrics.month.tokens)}
|
||||||
|
unit='本月'
|
||||||
|
sub='输入+输出'
|
||||||
|
icon={Layers}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BentoStats;
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { TrendingUp } from 'lucide-react';
|
||||||
|
import Sparkline from './Sparkline';
|
||||||
|
import { renderQuota } from '../../helpers';
|
||||||
|
|
||||||
|
const WEEKDAYS_SHORT = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
|
||||||
|
const BentoTrend = ({
|
||||||
|
dailyStats = [],
|
||||||
|
topModels = [],
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const counts = useMemo(() => dailyStats.map((d) => d.count), [dailyStats]);
|
||||||
|
const costs = useMemo(() => dailyStats.map((d) => d.cost), [dailyStats]);
|
||||||
|
|
||||||
|
const totalCalls = counts.reduce((s, v) => s + v, 0);
|
||||||
|
const totalCost = costs.reduce((s, v) => s + v, 0);
|
||||||
|
const avg = totalCalls > 0 ? (totalCalls / Math.max(counts.length, 1)).toFixed(1) : '0.0';
|
||||||
|
const peak = counts.length > 0 ? Math.max(...counts) : 0;
|
||||||
|
const topMax = topModels[0]?.count || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
|
<TrendingUp size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>Token 使用趋势</div>
|
||||||
|
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
|
近 7 天
|
||||||
|
<span className='font-semibold text-slate-900 dark:text-white'> {totalCalls}</span> 次
|
||||||
|
· 日均{' '}
|
||||||
|
<span className='font-semibold text-slate-900 dark:text-white'>{avg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='hidden text-right md:block'>
|
||||||
|
<div className='text-[10px] uppercase tracking-wider text-slate-400 font-mono dark:text-white/30'>
|
||||||
|
花费
|
||||||
|
</div>
|
||||||
|
<div className='font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||||
|
{totalCost > 0 ? renderQuota(totalCost) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex min-h-[80px] flex-1 items-end text-blue-500'>
|
||||||
|
<Sparkline data={counts} width={400} height={80} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between text-[11px]'>
|
||||||
|
<div className='flex items-center gap-1 tabular-nums'>
|
||||||
|
{dailyStats.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`w-8 text-center ${
|
||||||
|
d.count === peak && peak > 0
|
||||||
|
? 'font-semibold text-slate-900 dark:text-white'
|
||||||
|
: 'text-slate-300 dark:text-white/30'
|
||||||
|
}`}
|
||||||
|
title={`周${WEEKDAYS_SHORT[d.date.getDay()]} ${d.count} 次`}
|
||||||
|
>
|
||||||
|
{WEEKDAYS_SHORT[d.date.getDay()]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='text-slate-500 tabular-nums dark:text-white/50'>
|
||||||
|
峰值{' '}
|
||||||
|
<span className='font-semibold text-slate-900 dark:text-white'>{peak}</span> 次
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{topModels.length > 0 && (
|
||||||
|
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
|
<div className='mb-2 flex items-center justify-between text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
|
<span>Top 5 模型</span>
|
||||||
|
<span className='font-mono normal-case tracking-normal'>近 30 日</span>
|
||||||
|
</div>
|
||||||
|
<ul className='space-y-1.5'>
|
||||||
|
{topModels.map((m, i) => {
|
||||||
|
const pct = topMax > 0 ? (m.count / topMax) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={m.name + i}
|
||||||
|
className='flex items-center gap-3 text-xs'
|
||||||
|
title={m.name}
|
||||||
|
>
|
||||||
|
<span className='w-3 shrink-0 text-right font-mono text-[10px] text-slate-300 tabular-nums dark:text-white/30'>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className='min-w-0 flex-1 truncate text-slate-700 dark:text-white/80'>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<div className='hidden h-1 w-24 overflow-hidden rounded-full bg-slate-100 sm:block dark:bg-white/5'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full bg-slate-300 dark:bg-white/30'
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className='w-12 shrink-0 text-right font-mono tabular-nums text-slate-500 dark:text-white/60'>
|
||||||
|
{m.count}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BentoTrend;
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
const getGreeting = () => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 5) return '深夜好';
|
||||||
|
if (h < 12) return '早上好';
|
||||||
|
if (h < 14) return '中午好';
|
||||||
|
if (h < 18) return '下午好';
|
||||||
|
return '晚上好';
|
||||||
|
};
|
||||||
|
|
||||||
|
const Hero = ({ user }) => {
|
||||||
|
const dateStr = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
|
return `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 · ${weekdays[now.getDay()]}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-4'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='mb-1.5 flex items-center gap-2'>
|
||||||
|
<span className='flex h-1.5 w-1.5 rounded-full bg-emerald-500' />
|
||||||
|
<span className='text-xs text-slate-500 dark:text-white/50'>{dateStr}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className='text-2xl font-semibold leading-tight tracking-tight md:text-3xl'>
|
||||||
|
<span className='text-slate-500 dark:text-white/60'>{getGreeting()},</span>
|
||||||
|
<span className='text-slate-900 dark:text-white'>
|
||||||
|
{user?.username || '访客'}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Receipt, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { renderQuota } from '../../helpers';
|
||||||
|
|
||||||
|
const SubStat = ({ label, value, hint }) => (
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 font-mono text-base font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{hint && (
|
||||||
|
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MonthlyForecast = ({ forecast = {}, className = '' }) => {
|
||||||
|
const {
|
||||||
|
spent = 0,
|
||||||
|
balance = 0,
|
||||||
|
dailyAvg = 0,
|
||||||
|
daysInMonth = 0,
|
||||||
|
daysElapsed = 0,
|
||||||
|
projected = 0,
|
||||||
|
monthDelta = null,
|
||||||
|
} = forecast;
|
||||||
|
|
||||||
|
const daysRemaining = Math.max(0, daysInMonth - daysElapsed);
|
||||||
|
const hasData = spent > 0;
|
||||||
|
const overBudget = hasData && balance > 0 && projected > balance;
|
||||||
|
|
||||||
|
const DeltaIcon =
|
||||||
|
monthDelta == null ? Minus : monthDelta > 0 ? TrendingUp : TrendingDown;
|
||||||
|
const deltaTone =
|
||||||
|
monthDelta == null
|
||||||
|
? 'text-slate-500 dark:text-white/50'
|
||||||
|
: monthDelta > 5
|
||||||
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: monthDelta < -5
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'text-slate-500 dark:text-white/50';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
|
<Receipt size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
|
本月账单预测
|
||||||
|
</div>
|
||||||
|
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
|
已过 {daysElapsed} / {daysInMonth} 天 · 剩 {daysRemaining} 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
|
<div className='text-[11px] uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
|
月底预计
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-1.5 font-mono text-3xl font-bold tabular-nums leading-none md:text-4xl ${
|
||||||
|
overBudget
|
||||||
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: 'text-slate-900 dark:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hasData ? renderQuota(projected) : '—'}
|
||||||
|
</div>
|
||||||
|
{monthDelta != null && (
|
||||||
|
<div className={`mt-2 inline-flex items-center gap-1 text-xs tabular-nums ${deltaTone}`}>
|
||||||
|
<DeltaIcon size={12} />
|
||||||
|
<span className='font-semibold'>
|
||||||
|
{monthDelta > 0 ? '+' : ''}
|
||||||
|
{monthDelta.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className='text-slate-400 dark:text-white/40'>较上月</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-3 gap-4 border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
|
<SubStat
|
||||||
|
label='已花费'
|
||||||
|
value={hasData ? renderQuota(spent) : '—'}
|
||||||
|
hint={`${daysElapsed} 天累计`}
|
||||||
|
/>
|
||||||
|
<SubStat
|
||||||
|
label='日均'
|
||||||
|
value={hasData ? renderQuota(dailyAvg) : '—'}
|
||||||
|
hint='本月至今日'
|
||||||
|
/>
|
||||||
|
<SubStat
|
||||||
|
label='账户余额'
|
||||||
|
value={balance > 0 ? renderQuota(balance) : '—'}
|
||||||
|
hint={overBudget ? '预计不足' : '可继续使用'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MonthlyForecast;
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ArrowUpRight, Clock, Inbox } from 'lucide-react';
|
||||||
|
import { renderQuota, renderNumber, timestamp2string } from '../../helpers';
|
||||||
|
|
||||||
|
const isSuccess = (l) =>
|
||||||
|
l?.type === 0 || l?.status === 0 || l?.type === 'success' || l?.type === undefined;
|
||||||
|
|
||||||
|
const RecentActivity = ({ logs = [], className = '' }) => {
|
||||||
|
const recent = useMemo(() => logs.slice(0, 10), [logs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-2xl border border-slate-200 bg-white transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20 ${className}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between p-6 pb-4'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
|
<Clock size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>最近调用</div>
|
||||||
|
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
|
共{' '}
|
||||||
|
<span className='font-semibold text-slate-900 dark:text-white'>
|
||||||
|
{recent.length}
|
||||||
|
</span>{' '}
|
||||||
|
条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href='/console/log'
|
||||||
|
className='flex items-center gap-1 text-xs text-slate-500 transition-colors hover:text-slate-900 dark:text-white/60 dark:hover:text-white'
|
||||||
|
>
|
||||||
|
查看全部 <ArrowUpRight size={11} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<div className='flex flex-col items-center justify-center px-6 py-14'>
|
||||||
|
<div className='mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 dark:bg-white/5'>
|
||||||
|
<Inbox size={28} className='text-slate-400 dark:text-white/30' />
|
||||||
|
</div>
|
||||||
|
<h3 className='text-sm font-semibold text-slate-900 dark:text-white'>暂无使用记录</h3>
|
||||||
|
<p className='mt-1 text-xs text-slate-500 dark:text-white/40'>
|
||||||
|
开始使用 API 后,您的使用历史将显示在这里
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='overflow-hidden border-t border-slate-100 dark:border-white/5'>
|
||||||
|
<table className='w-full text-sm'>
|
||||||
|
<thead>
|
||||||
|
<tr className='border-b border-slate-100 bg-slate-50/50 text-slate-500 dark:border-white/5 dark:bg-white/[0.02] dark:text-white/40'>
|
||||||
|
<th className='w-[140px] px-6 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||||
|
time
|
||||||
|
</th>
|
||||||
|
<th className='px-2 py-2.5 text-left font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||||
|
model
|
||||||
|
</th>
|
||||||
|
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||||
|
tokens
|
||||||
|
</th>
|
||||||
|
<th className='w-[120px] px-2 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||||
|
cost
|
||||||
|
</th>
|
||||||
|
<th className='w-[120px] px-6 py-2.5 text-right font-mono text-[10px] font-normal uppercase tracking-wider'>
|
||||||
|
status
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.map((l) => {
|
||||||
|
const ok = isSuccess(l);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={l.id}
|
||||||
|
className='border-b border-slate-100 transition-colors last:border-b-0 hover:bg-slate-50/50 dark:border-white/5 dark:hover:bg-white/[0.02]'
|
||||||
|
>
|
||||||
|
<td className='whitespace-nowrap px-6 py-3 font-mono text-xs tabular-nums text-slate-500 dark:text-white/50'>
|
||||||
|
{timestamp2string(l.created_at).slice(5, 16)}
|
||||||
|
</td>
|
||||||
|
<td className='max-w-[300px] truncate px-2 py-3 text-slate-700 dark:text-white/90'>
|
||||||
|
{l.model_name || '—'}
|
||||||
|
</td>
|
||||||
|
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
|
||||||
|
{renderNumber(l.token_used || 0)}
|
||||||
|
</td>
|
||||||
|
<td className='px-2 py-3 text-right font-mono text-xs tabular-nums text-slate-500 dark:text-white/70'>
|
||||||
|
{renderQuota(l.quota || 0)}
|
||||||
|
</td>
|
||||||
|
<td className='whitespace-nowrap px-6 py-3 text-right'>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 ${
|
||||||
|
ok
|
||||||
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||||
|
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
|
ok ? 'bg-emerald-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className='text-xs font-medium'>{ok ? '成功' : '失败'}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentActivity;
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Sparkline = ({
|
||||||
|
data = [],
|
||||||
|
width = 200,
|
||||||
|
height = 40,
|
||||||
|
stroke = 'currentColor',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const step = data.length > 1 ? width / (data.length - 1) : 0;
|
||||||
|
const points = data
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = i * step;
|
||||||
|
const y = height - (v / max) * (height - 4) - 2;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const areaPoints = `0,${height} ${points} ${width},${height}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width='100%'
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
preserveAspectRatio='none'
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id='sparkline-gradient' x1='0%' y1='0%' x2='0%' y2='100%'>
|
||||||
|
<stop offset='0%' stopColor={stroke} stopOpacity='0.18' />
|
||||||
|
<stop offset='100%' stopColor={stroke} stopOpacity='0' />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon points={areaPoints} fill='url(#sparkline-gradient)' />
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill='none'
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
vectorEffect='non-scaling-stroke'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sparkline;
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Search, RefreshCw, Wallet } from 'lucide-react';
|
||||||
|
import { renderQuota } from '../../helpers';
|
||||||
|
|
||||||
|
const TopBar = ({ onSearch, onRefresh, loading, user, unreadCount = 0 }) => {
|
||||||
|
const initial = (user?.username || '?').charAt(0).toUpperCase();
|
||||||
|
const balance = Number(user?.quota || 0);
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between border-b border-slate-200 py-5 dark:border-white/10'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
|
TokenFactory
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={onSearch}
|
||||||
|
className='flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
|
||||||
|
title='搜索'
|
||||||
|
>
|
||||||
|
<Search size={13} className='text-slate-400 dark:text-white/40' />
|
||||||
|
<span>搜索</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className='flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700 disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:bg-white/5 dark:hover:text-white'
|
||||||
|
title='刷新'
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href='/console/topup'
|
||||||
|
className='hidden items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm transition-colors hover:border-slate-300 hover:bg-slate-50 sm:inline-flex dark:border-white/10 dark:bg-white/[0.02] dark:hover:bg-white/5'
|
||||||
|
title='账户余额'
|
||||||
|
>
|
||||||
|
<Wallet size={13} className='text-slate-400 dark:text-white/40' />
|
||||||
|
<span className='font-mono font-semibold tabular-nums text-emerald-600 dark:text-emerald-400'>
|
||||||
|
{renderQuota(balance)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className='relative ml-1 flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-slate-100 text-xs font-semibold text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-white/80'>
|
||||||
|
{initial}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className='absolute -right-0.5 -top-0.5 flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[9px] font-mono text-white'>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopBar;
|
||||||
|
|
@ -17,258 +17,70 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { getRelativeTime, userIsDistributorUser } from '../../helpers';
|
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { StatusContext } from '../../context/Status';
|
import { useUserMessageUnreadCount } from '../../hooks/common/useUserMessageUnreadCount';
|
||||||
|
|
||||||
import DashboardHeader from './DashboardHeader';
|
|
||||||
import StatsCards from './StatsCards';
|
|
||||||
import ChartsPanel from './ChartsPanel';
|
|
||||||
import ApiInfoPanel from './ApiInfoPanel';
|
|
||||||
import AnnouncementsPanel from './AnnouncementsPanel';
|
|
||||||
import FaqPanel from './FaqPanel';
|
|
||||||
import UptimePanel from './UptimePanel';
|
|
||||||
import SearchModal from './modals/SearchModal';
|
|
||||||
import DistributorAnalyticsBoard from '../distributor/DistributorAnalyticsBoard';
|
|
||||||
|
|
||||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
|
||||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
|
||||||
|
|
||||||
import {
|
import TopBar from './TopBar';
|
||||||
CHART_CONFIG,
|
import Hero from './Hero';
|
||||||
CARD_PROPS,
|
import BentoStats from './BentoStats';
|
||||||
FLEX_CENTER_GAP2,
|
import BentoTrend from './BentoTrend';
|
||||||
ILLUSTRATION_SIZE,
|
import MonthlyForecast from './MonthlyForecast';
|
||||||
ANNOUNCEMENT_LEGEND_DATA,
|
import RecentActivity from './RecentActivity';
|
||||||
UPTIME_STATUS_MAP,
|
|
||||||
} from '../../constants/dashboard.constants';
|
|
||||||
import {
|
|
||||||
getTrendSpec,
|
|
||||||
handleCopyUrl,
|
|
||||||
handleSpeedTest,
|
|
||||||
getUptimeStatusColor,
|
|
||||||
getUptimeStatusText,
|
|
||||||
renderMonitorList,
|
|
||||||
} from '../../helpers/dashboard';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
// ========== Context ==========
|
const [userState] = useContext(UserContext);
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
const user = userState?.user;
|
||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
|
||||||
|
|
||||||
// ========== 主要数据管理 ==========
|
const dashboard = useDashboardData(userState);
|
||||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
const { unreadCount } = useUserMessageUnreadCount(user);
|
||||||
|
|
||||||
// ========== 图表管理 ==========
|
|
||||||
const dashboardCharts = useDashboardCharts(
|
|
||||||
dashboardData.dataExportDefaultTime,
|
|
||||||
dashboardData.setTrendData,
|
|
||||||
dashboardData.setConsumeQuota,
|
|
||||||
dashboardData.setTimes,
|
|
||||||
dashboardData.setConsumeTokens,
|
|
||||||
dashboardData.setPieData,
|
|
||||||
dashboardData.setLineData,
|
|
||||||
dashboardData.setModelColors,
|
|
||||||
dashboardData.t,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== 统计数据 ==========
|
|
||||||
const { groupedStatsData } = useDashboardStats(
|
|
||||||
userState,
|
|
||||||
dashboardData.consumeQuota,
|
|
||||||
dashboardData.consumeTokens,
|
|
||||||
dashboardData.times,
|
|
||||||
dashboardData.trendData,
|
|
||||||
dashboardData.performanceMetrics,
|
|
||||||
dashboardData.navigate,
|
|
||||||
dashboardData.t,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== 数据处理 ==========
|
|
||||||
const initChart = async () => {
|
|
||||||
await dashboardData.loadQuotaData().then((data) => {
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
dashboardCharts.updateChartData(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await dashboardData.loadUptimeData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
const data = await dashboardData.refresh();
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
dashboardCharts.updateChartData(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchConfirm = async () => {
|
|
||||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== 数据准备 ==========
|
|
||||||
const apiInfoData = statusState?.status?.api_info || [];
|
|
||||||
const announcementData = (statusState?.status?.announcements || []).map(
|
|
||||||
(item) => {
|
|
||||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
|
||||||
const absoluteTime =
|
|
||||||
pubDate && !isNaN(pubDate.getTime())
|
|
||||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
|
||||||
: item?.publishDate || '';
|
|
||||||
const relativeTime = getRelativeTime(item.publishDate);
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
time: absoluteTime,
|
|
||||||
relative: relativeTime,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const faqData = statusState?.status?.faq || [];
|
|
||||||
|
|
||||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
|
|
||||||
([status, info]) => ({
|
|
||||||
status: Number(status),
|
|
||||||
color: info.color,
|
|
||||||
label: dashboardData.t(info.label),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== Effects ==========
|
|
||||||
useEffect(() => {
|
|
||||||
initChart();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-full flex flex-col gap-6'>
|
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||||
<DashboardHeader
|
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||||
getGreeting={dashboardData.getGreeting}
|
<TopBar
|
||||||
greetingVisible={dashboardData.greetingVisible}
|
onSearch={() => {}}
|
||||||
showSearchModal={dashboardData.showSearchModal}
|
onRefresh={dashboard.refresh}
|
||||||
refresh={handleRefresh}
|
loading={dashboard.loading}
|
||||||
loading={dashboardData.loading}
|
user={user}
|
||||||
t={dashboardData.t}
|
unreadCount={unreadCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchModal
|
<div className='space-y-6 pt-8 pb-8'>
|
||||||
searchModalVisible={dashboardData.searchModalVisible}
|
<Hero user={user} />
|
||||||
handleSearchConfirm={handleSearchConfirm}
|
|
||||||
handleCloseModal={dashboardData.handleCloseModal}
|
|
||||||
isMobile={dashboardData.isMobile}
|
|
||||||
isAdminUser={dashboardData.isAdminUser}
|
|
||||||
inputs={dashboardData.inputs}
|
|
||||||
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
|
||||||
timeOptions={dashboardData.timeOptions}
|
|
||||||
handleInputChange={dashboardData.handleInputChange}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StatsCards
|
{/* 第一行:5 个核心指标卡 */}
|
||||||
groupedStatsData={groupedStatsData}
|
<div className='grid grid-cols-2 gap-5 md:grid-cols-3 xl:grid-cols-5'>
|
||||||
loading={dashboardData.loading}
|
<BentoStats
|
||||||
getTrendSpec={getTrendSpec}
|
metrics={dashboard.metrics}
|
||||||
CARD_PROPS={CARD_PROPS}
|
monthDelta={dashboard.monthDelta}
|
||||||
CHART_CONFIG={CHART_CONFIG}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{userIsDistributorUser(userState?.user) ? (
|
|
||||||
<DistributorAnalyticsBoard />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* API信息和图表面板 */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={`grid grid-cols-1 gap-6 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
|
|
||||||
>
|
|
||||||
<ChartsPanel
|
|
||||||
activeChartTab={dashboardData.activeChartTab}
|
|
||||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
|
||||||
spec_line={dashboardCharts.spec_line}
|
|
||||||
spec_model_line={dashboardCharts.spec_model_line}
|
|
||||||
spec_pie={dashboardCharts.spec_pie}
|
|
||||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
CHART_CONFIG={CHART_CONFIG}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dashboardData.hasApiInfoPanel && (
|
|
||||||
<ApiInfoPanel
|
|
||||||
apiInfoData={apiInfoData}
|
|
||||||
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
|
||||||
handleSpeedTest={handleSpeedTest}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 系统公告和常见问答卡片 */}
|
{/* 第二行:七日用量趋势 + 本月账单预测 */}
|
||||||
{dashboardData.hasInfoPanels && (
|
<div className='grid grid-cols-1 gap-5 md:grid-cols-4'>
|
||||||
<div>
|
<BentoTrend
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-6'>
|
dailyStats={dashboard.dailyStats}
|
||||||
{/* 公告卡片 */}
|
topModels={dashboard.topModels}
|
||||||
{dashboardData.announcementsEnabled && (
|
className='md:col-span-2'
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<MonthlyForecast
|
||||||
|
forecast={dashboard.monthlyForecast}
|
||||||
|
className='md:col-span-2'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 常见问答卡片 */}
|
{/* 第三行:最近调用 */}
|
||||||
{dashboardData.faqEnabled && (
|
<RecentActivity
|
||||||
<FaqPanel
|
logs={dashboard.recentLogs}
|
||||||
faqData={faqData}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</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'>
|
||||||
{dashboardData.uptimeEnabled && (
|
<span>TokenFactory · 2026</span>
|
||||||
<UptimePanel
|
|
||||||
uptimeData={dashboardData.uptimeData}
|
|
||||||
uptimeLoading={dashboardData.uptimeLoading}
|
|
||||||
activeUptimeTab={dashboardData.activeUptimeTab}
|
|
||||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
|
||||||
loadUptimeData={dashboardData.loadUptimeData}
|
|
||||||
uptimeLegendData={uptimeLegendData}
|
|
||||||
renderMonitorList={(monitors) =>
|
|
||||||
renderMonitorList(
|
|
||||||
monitors,
|
|
||||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
|
||||||
(status) =>
|
|
||||||
getUptimeStatusText(
|
|
||||||
status,
|
|
||||||
UPTIME_STATUS_MAP,
|
|
||||||
dashboardData.t,
|
|
||||||
),
|
|
||||||
dashboardData.t,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CARD_PROPS={CARD_PROPS}
|
|
||||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
|
||||||
t={dashboardData.t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring
|
||||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
|
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
|
||||||
import SettingsDistributor from '../../pages/Setting/Operation/SettingsDistributor';
|
import SettingsDistributor from '../../pages/Setting/Operation/SettingsDistributor';
|
||||||
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
|
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
|
||||||
|
import SettingsAntiAbuse from '../../pages/Setting/Operation/SettingsAntiAbuse';
|
||||||
import { API, showError, toBoolean } from '../../helpers';
|
import { API, showError, toBoolean } from '../../helpers';
|
||||||
|
|
||||||
const OperationSetting = () => {
|
const OperationSetting = () => {
|
||||||
|
|
@ -169,6 +170,10 @@ const OperationSetting = () => {
|
||||||
<Card style={{ marginTop: '10px' }}>
|
<Card style={{ marginTop: '10px' }}>
|
||||||
<SettingsCheckin options={inputs} refresh={onRefresh} />
|
<SettingsCheckin options={inputs} refresh={onRefresh} />
|
||||||
</Card>
|
</Card>
|
||||||
|
{/* 风控设置 */}
|
||||||
|
<Card style={{ marginTop: '10px' }}>
|
||||||
|
<SettingsAntiAbuse />
|
||||||
|
</Card>
|
||||||
</Spin>
|
</Spin>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ import {
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import { normalizeSmsVerificationEnabled } from '../../helpers/data';
|
import { normalizeSmsVerificationEnabled } from '../../helpers/data';
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { Modal, Card, Button, Typography } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// 导入子组件
|
// 导入子组件
|
||||||
|
|
@ -555,9 +555,7 @@ const PersonalSetting = () => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-[60px]'>
|
<div className='mx-auto mt-[60px] max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
|
||||||
<div className='flex justify-center'>
|
|
||||||
<div className='w-full max-w-7xl mx-auto px-2'>
|
|
||||||
{/* 顶部用户信息区域 */}
|
{/* 顶部用户信息区域 */}
|
||||||
<UserInfoHeader t={t} userState={userState} />
|
<UserInfoHeader t={t} userState={userState} />
|
||||||
|
|
||||||
|
|
@ -574,7 +572,7 @@ const PersonalSetting = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 账户管理和其他设置 */}
|
{/* 账户管理和其他设置 */}
|
||||||
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
|
<div className='mt-4 grid grid-cols-1 items-start gap-4 md:mt-6 md:gap-6 xl:grid-cols-2'>
|
||||||
{/* 左侧:账户管理设置 */}
|
{/* 左侧:账户管理设置 */}
|
||||||
<div className='flex flex-col gap-4 md:gap-6'>
|
<div className='flex flex-col gap-4 md:gap-6'>
|
||||||
<AccountManagement
|
<AccountManagement
|
||||||
|
|
@ -610,8 +608,6 @@ const PersonalSetting = () => {
|
||||||
saveNotificationSettings={saveNotificationSettings}
|
saveNotificationSettings={saveNotificationSettings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模态框组件 */}
|
{/* 模态框组件 */}
|
||||||
<EmailBindModal
|
<EmailBindModal
|
||||||
|
|
|
||||||
|
|
@ -20,26 +20,30 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Input,
|
Input,
|
||||||
Space,
|
|
||||||
Typography,
|
Typography,
|
||||||
Avatar,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
TabPane,
|
TabPane,
|
||||||
Popover,
|
Popover,
|
||||||
Modal,
|
Modal,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconMail,
|
IconAlertTriangle,
|
||||||
IconShield,
|
|
||||||
IconGithubLogo,
|
IconGithubLogo,
|
||||||
IconKey,
|
|
||||||
IconLock,
|
|
||||||
IconDelete,
|
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
|
import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
|
||||||
import { UserPlus, ShieldCheck, Phone } from 'lucide-react';
|
import {
|
||||||
|
UserCog,
|
||||||
|
Lock,
|
||||||
|
Phone,
|
||||||
|
Fingerprint,
|
||||||
|
UserX,
|
||||||
|
Link2,
|
||||||
|
Mail,
|
||||||
|
ShieldCheck,
|
||||||
|
Stamp,
|
||||||
|
KeySquare,
|
||||||
|
} from 'lucide-react';
|
||||||
import TelegramLoginButton from 'react-telegram-login';
|
import TelegramLoginButton from 'react-telegram-login';
|
||||||
import {
|
import {
|
||||||
API,
|
API,
|
||||||
|
|
@ -54,6 +58,52 @@ import {
|
||||||
} from '../../../../helpers';
|
} from '../../../../helpers';
|
||||||
import TwoFASetting from '../components/TwoFASetting';
|
import TwoFASetting from '../components/TwoFASetting';
|
||||||
|
|
||||||
|
const iconWrap =
|
||||||
|
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70';
|
||||||
|
|
||||||
|
const BindingRow = ({ icon, label, info, action }) => (
|
||||||
|
<div className='flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300 dark:border-white/5 dark:bg-white/[0.02] dark:hover:border-white/10'>
|
||||||
|
<div className='flex min-w-0 flex-1 items-center gap-3'>
|
||||||
|
<div className={iconWrap}>{icon}</div>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='truncate text-sm font-medium text-slate-900 dark:text-white'>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className='truncate text-xs text-slate-400 dark:text-white/40'>
|
||||||
|
{info}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action && <div className='flex-shrink-0'>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SecurityRow = ({ iconWrapClass, icon, title, description, action, footer }) => (
|
||||||
|
<div className='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 ${iconWrapClass}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className='mt-0.5 text-xs text-slate-400 dark:text-white/40'>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
{footer && <div className='mt-3'>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action && (
|
||||||
|
<div className='flex-shrink-0 sm:self-center'>{action}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const AccountManagement = ({
|
const AccountManagement = ({
|
||||||
t,
|
t,
|
||||||
userState,
|
userState,
|
||||||
|
|
@ -76,23 +126,25 @@ const AccountManagement = ({
|
||||||
}) => {
|
}) => {
|
||||||
const renderAccountInfo = (accountId, label) => {
|
const renderAccountInfo = (accountId, label) => {
|
||||||
if (!accountId || accountId === '') {
|
if (!accountId || accountId === '') {
|
||||||
return <span className='text-gray-500'>{t('未绑定')}</span>;
|
return (
|
||||||
|
<span className='text-slate-400 dark:text-white/40'>{t('未绑定')}</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const popContent = (
|
const popContent = (
|
||||||
<div className='text-xs p-2'>
|
<div className='p-2 text-xs'>
|
||||||
<Typography.Paragraph copyable={{ content: accountId }}>
|
<Typography.Paragraph copyable={{ content: accountId }}>
|
||||||
{accountId}
|
{accountId}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
{label ? (
|
{label ? (
|
||||||
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
|
<div className='mt-1 text-[11px] text-slate-500'>{label}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover content={popContent} position='top' trigger='hover'>
|
<Popover content={popContent} position='top' trigger='hover'>
|
||||||
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
|
<span className='block max-w-full cursor-pointer truncate text-slate-600 hover:text-blue-600 dark:text-white/60 dark:hover:text-blue-400'>
|
||||||
{accountId}
|
{accountId}
|
||||||
</span>
|
</span>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
@ -181,17 +233,17 @@ const AccountManagement = ({
|
||||||
: t('尚未使用');
|
: t('尚未使用');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl'>
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
<div className='flex items-center mb-4'>
|
<div className='mb-5 flex items-center gap-3'>
|
||||||
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-teal-50 text-teal-600 dark:bg-teal-500/10 dark:text-teal-400'>
|
||||||
<UserPlus size={16} />
|
<UserCog size={18} />
|
||||||
</Avatar>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text className='text-lg font-medium'>
|
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
{t('账户管理')}
|
{t('账户管理')}
|
||||||
</Typography.Text>
|
</div>
|
||||||
<div className='text-xs text-gray-600'>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
{t('账户绑定、安全设置和身份验证')}
|
{t('账户绑定、安全设置和身份验证')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -202,78 +254,43 @@ const AccountManagement = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<UserPlus size={16} className='mr-2' />
|
<Link2 size={14} className='mr-2' />
|
||||||
{t('账户绑定')}
|
{t('账户绑定')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
itemKey='binding'
|
itemKey='binding'
|
||||||
>
|
>
|
||||||
<div className='py-4'>
|
<div className='py-4'>
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
<div className='grid grid-cols-1 gap-3 lg:grid-cols-2'>
|
||||||
{/* 邮箱绑定 */}
|
{/* 邮箱绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<Mail size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('邮箱')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(userState.user?.email, t('邮箱地址'))}
|
||||||
<IconMail
|
action={
|
||||||
size='default'
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('邮箱')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.email,
|
|
||||||
t('邮箱地址'),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
onClick={() => setShowEmailBindModal(true)}
|
onClick={() => setShowEmailBindModal(true)}
|
||||||
>
|
>
|
||||||
{isBound(userState.user?.email)
|
{isBound(userState.user?.email)
|
||||||
? t('修改绑定')
|
? t('修改绑定')
|
||||||
: t('绑定')}
|
: t('绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 手机号绑定 */}
|
{/* 手机号绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<Phone size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('手机号')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(userState.user?.phone, t('手机号'))}
|
||||||
<Phone
|
action={
|
||||||
size={20}
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('手机号')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.phone,
|
|
||||||
t('手机号'),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
disabled={!smsVerificationEnabled}
|
disabled={!smsVerificationEnabled}
|
||||||
onClick={() => setShowPhoneBindModal(true)}
|
onClick={() => setShowPhoneBindModal(true)}
|
||||||
>
|
>
|
||||||
|
|
@ -283,38 +300,25 @@ const AccountManagement = ({
|
||||||
: t('绑定')
|
: t('绑定')
|
||||||
: t('未启用')}
|
: t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 微信绑定 */}
|
{/* 微信绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<SiWechat size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('微信')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={
|
||||||
<SiWechat
|
!status.wechat_login
|
||||||
size={20}
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('微信')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{!status.wechat_login
|
|
||||||
? t('未启用')
|
? t('未启用')
|
||||||
: isBound(userState.user?.wechat_id)
|
: isBound(userState.user?.wechat_id)
|
||||||
? t('已绑定')
|
? t('已绑定')
|
||||||
: t('未绑定')}
|
: t('未绑定')
|
||||||
</div>
|
}
|
||||||
</div>
|
action={
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
disabled={!status.wechat_login}
|
disabled={!status.wechat_login}
|
||||||
onClick={() => setShowWeChatBindModal(true)}
|
onClick={() => setShowWeChatBindModal(true)}
|
||||||
>
|
>
|
||||||
|
|
@ -324,119 +328,64 @@ const AccountManagement = ({
|
||||||
? t('绑定')
|
? t('绑定')
|
||||||
: t('未启用')}
|
: t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* GitHub绑定 */}
|
{/* GitHub绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<IconGithubLogo size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('GitHub')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(userState.user?.github_id, t('GitHub ID'))}
|
||||||
<IconGithubLogo
|
action={
|
||||||
size='default'
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('GitHub')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.github_id,
|
|
||||||
t('GitHub ID'),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
onClick={() =>
|
theme='light'
|
||||||
onGitHubOAuthClicked(status.github_client_id)
|
type='tertiary'
|
||||||
}
|
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
|
||||||
disabled={
|
disabled={
|
||||||
isBound(userState.user?.github_id) ||
|
isBound(userState.user?.github_id) || !status.github_oauth
|
||||||
!status.github_oauth
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Discord绑定 */}
|
{/* Discord绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<SiDiscord size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('Discord')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(
|
||||||
<SiDiscord
|
|
||||||
size={20}
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('Discord')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.discord_id,
|
userState.user?.discord_id,
|
||||||
t('Discord ID'),
|
t('Discord ID'),
|
||||||
)}
|
)}
|
||||||
</div>
|
action={
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onDiscordOAuthClicked(status.discord_client_id)
|
onDiscordOAuthClicked(status.discord_client_id)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
isBound(userState.user?.discord_id) ||
|
isBound(userState.user?.discord_id) || !status.discord_oauth
|
||||||
!status.discord_oauth
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.discord_oauth ? t('绑定') : t('未启用')}
|
{status.discord_oauth ? t('绑定') : t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* OIDC绑定 */}
|
{/* OIDC绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<ShieldCheck size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('OIDC')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(userState.user?.oidc_id, t('OIDC ID'))}
|
||||||
<IconShield
|
action={
|
||||||
size='default'
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('OIDC')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.oidc_id,
|
|
||||||
t('OIDC ID'),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onOIDCClicked(
|
onOIDCClicked(
|
||||||
status.oidc_authorization_endpoint,
|
status.oidc_authorization_endpoint,
|
||||||
|
|
@ -449,73 +398,47 @@ const AccountManagement = ({
|
||||||
>
|
>
|
||||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Telegram绑定 */}
|
{/* Telegram绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<SiTelegram size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('Telegram')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(
|
||||||
<SiTelegram
|
|
||||||
size={20}
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('Telegram')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.telegram_id,
|
userState.user?.telegram_id,
|
||||||
t('Telegram ID'),
|
t('Telegram ID'),
|
||||||
)}
|
)}
|
||||||
</div>
|
action={
|
||||||
</div>
|
status.telegram_oauth ? (
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
{status.telegram_oauth ? (
|
|
||||||
isBound(userState.user?.telegram_id) ? (
|
isBound(userState.user?.telegram_id) ? (
|
||||||
<Button
|
<Button size='small' disabled theme='light' type='tertiary'>
|
||||||
disabled
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
>
|
|
||||||
{t('已绑定')}
|
{t('已绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
onClick={() => setShowTelegramBindModal(true)}
|
onClick={() => setShowTelegramBindModal(true)}
|
||||||
>
|
>
|
||||||
{t('绑定')}
|
{t('绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button size='small' disabled theme='light' type='tertiary'>
|
||||||
disabled
|
|
||||||
size='small'
|
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
>
|
|
||||||
{t('未启用')}
|
{t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
<Modal
|
<Modal
|
||||||
title={t('绑定 Telegram')}
|
title={t('绑定 Telegram')}
|
||||||
visible={showTelegramBindModal}
|
visible={showTelegramBindModal}
|
||||||
onCancel={() => setShowTelegramBindModal(false)}
|
onCancel={() => setShowTelegramBindModal(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
<div className='my-3 text-sm text-gray-600'>
|
<div className='my-3 text-sm text-slate-600 dark:text-slate-300'>
|
||||||
{t('点击下方按钮通过 Telegram 完成绑定')}
|
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
|
|
@ -529,45 +452,27 @@ const AccountManagement = ({
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* LinuxDO绑定 */}
|
{/* LinuxDO绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
icon={<SiLinux size={18} />}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
label={t('LinuxDO')}
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
info={renderAccountInfo(
|
||||||
<SiLinux
|
|
||||||
size={20}
|
|
||||||
className='text-slate-600 dark:text-slate-300'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 min-w-0'>
|
|
||||||
<div className='font-medium text-gray-900'>
|
|
||||||
{t('LinuxDO')}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{renderAccountInfo(
|
|
||||||
userState.user?.linux_do_id,
|
userState.user?.linux_do_id,
|
||||||
t('LinuxDO ID'),
|
t('LinuxDO ID'),
|
||||||
)}
|
)}
|
||||||
</div>
|
action={
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
onClick={() =>
|
theme='light'
|
||||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
type='tertiary'
|
||||||
}
|
onClick={() => onLinuxDOOAuthClicked(status.linuxdo_client_id)}
|
||||||
disabled={
|
disabled={
|
||||||
isBound(userState.user?.linux_do_id) ||
|
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||||
!status.linuxdo_oauth
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 自定义 OAuth 提供商绑定 */}
|
{/* 自定义 OAuth 提供商绑定 */}
|
||||||
{status.custom_oauth_providers &&
|
{status.custom_oauth_providers &&
|
||||||
|
|
@ -575,35 +480,27 @@ const AccountManagement = ({
|
||||||
const bound = isCustomOAuthBound(provider.id);
|
const bound = isCustomOAuthBound(provider.id);
|
||||||
const binding = getCustomOAuthBinding(provider.id);
|
const binding = getCustomOAuthBinding(provider.id);
|
||||||
return (
|
return (
|
||||||
<Card key={provider.slug} className='!rounded-xl'>
|
<BindingRow
|
||||||
<div className='flex items-center justify-between gap-3'>
|
key={provider.slug}
|
||||||
<div className='flex items-center flex-1 min-w-0'>
|
icon={getOAuthProviderIcon(
|
||||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
|
||||||
{getOAuthProviderIcon(
|
|
||||||
provider.icon || binding?.provider_icon || '',
|
provider.icon || binding?.provider_icon || '',
|
||||||
20,
|
18,
|
||||||
)}
|
)}
|
||||||
</div>
|
label={provider.name}
|
||||||
<div className='flex-1 min-w-0'>
|
info={
|
||||||
<div className='font-medium text-gray-900'>
|
bound
|
||||||
{provider.name}
|
|
||||||
</div>
|
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
|
||||||
{bound
|
|
||||||
? renderAccountInfo(
|
? renderAccountInfo(
|
||||||
binding?.provider_user_id,
|
binding?.provider_user_id,
|
||||||
t('{{name}} ID', { name: provider.name }),
|
t('{{name}} ID', { name: provider.name }),
|
||||||
)
|
)
|
||||||
: t('未绑定')}
|
: t('未绑定')
|
||||||
</div>
|
}
|
||||||
</div>
|
action={
|
||||||
</div>
|
bound ? (
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
{bound ? (
|
|
||||||
<Button
|
<Button
|
||||||
type='danger'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
type='danger'
|
||||||
|
theme='light'
|
||||||
loading={customOAuthLoading[provider.id]}
|
loading={customOAuthLoading[provider.id]}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleUnbindCustomOAuth(
|
handleUnbindCustomOAuth(
|
||||||
|
|
@ -616,17 +513,16 @@ const AccountManagement = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='outline'
|
|
||||||
size='small'
|
size='small'
|
||||||
|
theme='light'
|
||||||
|
type='tertiary'
|
||||||
onClick={() => handleBindCustomOAuth(provider)}
|
onClick={() => handleBindCustomOAuth(provider)}
|
||||||
>
|
>
|
||||||
{t('绑定')}
|
{t('绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -637,123 +533,88 @@ const AccountManagement = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<ShieldCheck size={16} className='mr-2' />
|
<Lock size={14} className='mr-2' />
|
||||||
{t('安全设置')}
|
{t('安全设置')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
itemKey='security'
|
itemKey='security'
|
||||||
>
|
>
|
||||||
<div className='py-4'>
|
<div className='space-y-3 py-4'>
|
||||||
<div className='space-y-6'>
|
|
||||||
<Space vertical className='w-full'>
|
|
||||||
{/* 系统访问令牌 */}
|
{/* 系统访问令牌 */}
|
||||||
<Card className='!rounded-xl w-full'>
|
<SecurityRow
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
iconWrapClass='bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
icon={<Stamp size={18} />}
|
||||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
title={t('系统访问令牌')}
|
||||||
<IconKey size='large' className='text-slate-600' />
|
description={t('用于API调用的身份验证令牌,请妥善保管')}
|
||||||
</div>
|
footer={
|
||||||
<div className='flex-1'>
|
systemToken && (
|
||||||
<Typography.Title heading={6} className='mb-1'>
|
|
||||||
{t('系统访问令牌')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
|
||||||
{t('用于API调用的身份验证令牌,请妥善保管')}
|
|
||||||
</Typography.Text>
|
|
||||||
{systemToken && (
|
|
||||||
<div className='mt-3'>
|
|
||||||
<Input
|
<Input
|
||||||
readonly
|
readonly
|
||||||
value={systemToken}
|
value={systemToken}
|
||||||
onClick={handleSystemTokenClick}
|
onClick={handleSystemTokenClick}
|
||||||
size='large'
|
size='large'
|
||||||
prefix={<IconKey />}
|
prefix={<Stamp size={14} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
)
|
||||||
)}
|
}
|
||||||
</div>
|
action={
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='solid'
|
theme='solid'
|
||||||
|
type='primary'
|
||||||
onClick={generateAccessToken}
|
onClick={generateAccessToken}
|
||||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
className='!rounded-lg w-full sm:w-auto'
|
||||||
icon={<IconKey />}
|
icon={<Stamp />}
|
||||||
>
|
>
|
||||||
{systemToken ? t('重新生成') : t('生成令牌')}
|
{systemToken ? t('重新生成') : t('生成令牌')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
{/* 密码管理 */}
|
{/* 密码管理 */}
|
||||||
<Card className='!rounded-xl w-full'>
|
<SecurityRow
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
iconWrapClass='bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
icon={<KeySquare size={18} />}
|
||||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
title={t('密码管理')}
|
||||||
<IconLock size='large' className='text-slate-600' />
|
description={t('定期更改密码可以提高账户安全性')}
|
||||||
</div>
|
action={
|
||||||
<div>
|
|
||||||
<Typography.Title heading={6} className='mb-1'>
|
|
||||||
{t('密码管理')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
|
||||||
{t('定期更改密码可以提高账户安全性')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
|
||||||
theme='solid'
|
theme='solid'
|
||||||
|
type='primary'
|
||||||
onClick={() => setShowChangePasswordModal(true)}
|
onClick={() => setShowChangePasswordModal(true)}
|
||||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
className='!rounded-lg w-full sm:w-auto'
|
||||||
icon={<IconLock />}
|
icon={<KeySquare />}
|
||||||
>
|
>
|
||||||
{t('修改密码')}
|
{t('修改密码')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
{/* Passkey 设置 */}
|
{/* Passkey 设置 */}
|
||||||
<Card className='!rounded-xl w-full'>
|
<SecurityRow
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
iconWrapClass='bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
icon={<Fingerprint size={18} />}
|
||||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
title={t('Passkey 登录')}
|
||||||
<IconKey size='large' className='text-slate-600' />
|
description={
|
||||||
</div>
|
passkeyEnabled
|
||||||
<div>
|
|
||||||
<Typography.Title heading={6} className='mb-1'>
|
|
||||||
{t('Passkey 登录')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
|
||||||
{passkeyEnabled
|
|
||||||
? t('已启用 Passkey,无需密码即可登录')
|
? t('已启用 Passkey,无需密码即可登录')
|
||||||
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
: t('使用 Passkey 实现免密且更安全的登录体验')
|
||||||
</Typography.Text>
|
}
|
||||||
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
footer={
|
||||||
|
<div className='space-y-1 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<div>
|
<div>
|
||||||
{t('最后使用时间')}:{lastUsedLabel}
|
{t('最后使用时间')}:{lastUsedLabel}
|
||||||
</div>
|
</div>
|
||||||
{/*{passkeyEnabled && (*/}
|
|
||||||
{/* <div>*/}
|
|
||||||
{/* {t('备份支持')}:*/}
|
|
||||||
{/* {passkeyStatus?.backup_eligible*/}
|
|
||||||
{/* ? t('支持备份')*/}
|
|
||||||
{/* : t('不支持')}*/}
|
|
||||||
{/* ,{t('备份状态')}:*/}
|
|
||||||
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
{!passkeySupported && (
|
{!passkeySupported && (
|
||||||
<div className='text-amber-600'>
|
<div className='text-amber-600 dark:text-amber-400'>
|
||||||
{t('当前设备不支持 Passkey')}
|
{t('当前设备不支持 Passkey')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
action={
|
||||||
<Button
|
<Button
|
||||||
type={passkeyEnabled ? 'danger' : 'primary'}
|
type={passkeyEnabled ? 'danger' : 'primary'}
|
||||||
theme={passkeyEnabled ? 'solid' : 'solid'}
|
theme='solid'
|
||||||
onClick={
|
onClick={
|
||||||
passkeyEnabled
|
passkeyEnabled
|
||||||
? () => {
|
? () => {
|
||||||
|
|
@ -770,8 +631,8 @@ const AccountManagement = ({
|
||||||
}
|
}
|
||||||
: onPasskeyRegister
|
: onPasskeyRegister
|
||||||
}
|
}
|
||||||
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
|
className='!rounded-lg w-full sm:w-auto'
|
||||||
icon={<IconKey />}
|
icon={<Fingerprint />}
|
||||||
disabled={!passkeySupported && !passkeyEnabled}
|
disabled={!passkeySupported && !passkeyEnabled}
|
||||||
loading={
|
loading={
|
||||||
passkeyEnabled
|
passkeyEnabled
|
||||||
|
|
@ -781,48 +642,36 @@ const AccountManagement = ({
|
||||||
>
|
>
|
||||||
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
/>
|
||||||
|
|
||||||
{/* 两步验证设置 */}
|
{/* 两步验证设置 */}
|
||||||
<TwoFASetting t={t} />
|
<div className='rounded-2xl border border-slate-200 bg-white p-5 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
|
<TwoFASetting t={t} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 危险区域 */}
|
{/* 危险区域 */}
|
||||||
<Card className='!rounded-xl w-full'>
|
<SecurityRow
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
iconWrapClass='bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400'
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
icon={<UserX size={18} />}
|
||||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
title={t('删除账户')}
|
||||||
<IconDelete size='large' className='text-slate-600' />
|
description={t('此操作不可逆,所有数据将被永久删除')}
|
||||||
</div>
|
action={
|
||||||
<div>
|
|
||||||
<Typography.Title
|
|
||||||
heading={6}
|
|
||||||
className='mb-1 text-slate-700'
|
|
||||||
>
|
|
||||||
{t('删除账户')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
|
||||||
{t('此操作不可逆,所有数据将被永久删除')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
type='danger'
|
type='danger'
|
||||||
theme='solid'
|
theme='solid'
|
||||||
onClick={() => setShowAccountDeleteModal(true)}
|
onClick={() => setShowAccountDeleteModal(true)}
|
||||||
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
|
className='!rounded-lg w-full sm:w-auto'
|
||||||
icon={<IconDelete />}
|
icon={<IconAlertTriangle />}
|
||||||
>
|
>
|
||||||
{t('删除账户')}
|
{t('删除账户')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</Card>
|
/>
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Button,
|
Button,
|
||||||
Typography,
|
|
||||||
Avatar,
|
|
||||||
Spin,
|
Spin,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Collapsible,
|
Collapsible,
|
||||||
Modal,
|
Modal,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
CalendarCheck,
|
Sparkles,
|
||||||
Gift,
|
Gift,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -213,7 +210,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl'>
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
<Modal
|
<Modal
|
||||||
title='Security Check'
|
title='Security Check'
|
||||||
visible={turnstileModalVisible}
|
visible={turnstileModalVisible}
|
||||||
|
|
@ -241,24 +238,24 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div
|
<div
|
||||||
className='flex items-center flex-1 cursor-pointer'
|
className='flex flex-1 cursor-pointer items-center gap-3'
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
>
|
>
|
||||||
<Avatar size='small' color='green' className='mr-3 shadow-md'>
|
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
|
||||||
<CalendarCheck size={16} />
|
<Sparkles size={18} />
|
||||||
</Avatar>
|
</div>
|
||||||
<div className='flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Typography.Text className='text-lg font-medium'>
|
<span className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
{t('每日签到')}
|
{t('每日签到')}
|
||||||
</Typography.Text>
|
</span>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronDown size={16} className='text-gray-400' />
|
<ChevronDown size={16} className='text-slate-400 dark:text-white/40' />
|
||||||
) : (
|
) : (
|
||||||
<ChevronUp size={16} className='text-gray-400' />
|
<ChevronUp size={16} className='text-slate-400 dark:text-white/40' />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
{!initialLoaded
|
{!initialLoaded
|
||||||
? t('正在加载签到状态...')
|
? t('正在加载签到状态...')
|
||||||
: checkinData.stats?.checked_in_today
|
: checkinData.stats?.checked_in_today
|
||||||
|
|
@ -276,7 +273,7 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
||||||
onClick={() => doCheckin()}
|
onClick={() => doCheckin()}
|
||||||
loading={checkinLoading || !initialLoaded}
|
loading={checkinLoading || !initialLoaded}
|
||||||
disabled={!initialLoaded || checkinData.stats?.checked_in_today}
|
disabled={!initialLoaded || checkinData.stats?.checked_in_today}
|
||||||
className='!bg-green-600 hover:!bg-green-700'
|
className='!rounded-lg'
|
||||||
>
|
>
|
||||||
{!initialLoaded
|
{!initialLoaded
|
||||||
? t('加载中...')
|
? t('加载中...')
|
||||||
|
|
@ -289,30 +286,36 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
||||||
{/* 可折叠内容 */}
|
{/* 可折叠内容 */}
|
||||||
<Collapsible isOpen={isCollapsed === false} keepDOM>
|
<Collapsible isOpen={isCollapsed === false} keepDOM>
|
||||||
{/* 签到统计 */}
|
{/* 签到统计 */}
|
||||||
<div className='grid grid-cols-3 gap-3 mb-4 mt-4'>
|
<div className='mt-4 mb-4 grid grid-cols-3 gap-3'>
|
||||||
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
<div className='rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 text-center dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
<div className='text-xl font-bold text-green-600'>
|
<div className='text-xl font-bold tabular-nums text-emerald-600 dark:text-emerald-400'>
|
||||||
{checkinData.stats?.total_checkins || 0}
|
{checkinData.stats?.total_checkins || 0}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-xs text-gray-500'>{t('累计签到')}</div>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
|
{t('累计签到')}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
</div>
|
||||||
<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)}
|
{renderQuota(monthlyQuota)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-xs text-gray-500'>{t('本月获得')}</div>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
|
{t('本月获得')}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-center p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
</div>
|
||||||
<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)}
|
{renderQuota(checkinData.stats?.total_quota || 0)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-xs text-gray-500'>{t('累计获得')}</div>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
|
{t('累计获得')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 签到日历 - 使用更紧凑的样式 */}
|
{/* 签到日历 - 使用更紧凑的样式 */}
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<div className='border rounded-lg overflow-hidden checkin-calendar'>
|
<div className='checkin-calendar overflow-hidden rounded-xl border border-slate-200 dark:border-white/5'>
|
||||||
<style>{`
|
<style>{`
|
||||||
.checkin-calendar .semi-calendar {
|
.checkin-calendar .semi-calendar {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -367,17 +370,15 @@ const CheckinCalendar = ({ t, status, turnstileEnabled, turnstileSiteKey }) => {
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
{/* 签到说明 */}
|
{/* 签到说明 */}
|
||||||
<div className='mt-3 p-2.5 bg-slate-50 dark:bg-slate-800 rounded-lg'>
|
<div className='mt-3 rounded-xl border border-slate-200 bg-slate-50/60 p-2.5 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
<Typography.Text type='tertiary' className='text-xs'>
|
<ul className='list-disc space-y-0.5 pl-4 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<ul className='list-disc list-inside space-y-0.5'>
|
|
||||||
<li>{t('每日签到可获得随机额度奖励')}</li>
|
<li>{t('每日签到可获得随机额度奖励')}</li>
|
||||||
<li>{t('签到奖励将直接添加到您的账户余额')}</li>
|
<li>{t('签到奖励将直接添加到您的账户余额')}</li>
|
||||||
<li>{t('每日仅可签到一次,请勿重复签到')}</li>
|
<li>{t('每日仅可签到一次,请勿重复签到')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ import React, { useRef, useEffect, useState, useContext } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
Card,
|
|
||||||
Avatar,
|
|
||||||
Form,
|
Form,
|
||||||
Radio,
|
Radio,
|
||||||
Toast,
|
Toast,
|
||||||
|
|
@ -33,7 +31,13 @@ import {
|
||||||
Col,
|
Col,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
|
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
|
||||||
import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react';
|
import {
|
||||||
|
BellRing,
|
||||||
|
Coins,
|
||||||
|
EyeOff,
|
||||||
|
LayoutPanelLeft,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
renderQuotaWithPrompt,
|
renderQuotaWithPrompt,
|
||||||
API,
|
API,
|
||||||
|
|
@ -317,48 +321,16 @@ const NotificationSettings = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
className='!rounded-2xl shadow-sm border-0'
|
<div className='mb-5 flex items-center gap-3'>
|
||||||
footer={
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||||
<div className='flex justify-end gap-3'>
|
<Wrench size={18} />
|
||||||
{activeTabKey === 'sidebar' ? (
|
|
||||||
// 边栏设置标签页的按钮
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type='tertiary'
|
|
||||||
onClick={resetSidebarModules}
|
|
||||||
className='!rounded-lg'
|
|
||||||
>
|
|
||||||
{t('重置为默认')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='primary'
|
|
||||||
onClick={saveSidebarSettings}
|
|
||||||
loading={sidebarLoading}
|
|
||||||
className='!rounded-lg'
|
|
||||||
>
|
|
||||||
{t('保存设置')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// 其他标签页的通用保存按钮
|
|
||||||
<Button type='primary' onClick={handleSubmit}>
|
|
||||||
{t('保存设置')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 卡片头部 */}
|
|
||||||
<div className='flex items-center mb-4'>
|
|
||||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
|
||||||
<Bell size={16} />
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text className='text-lg font-medium'>
|
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
{t('其他设置')}
|
{t('其他设置')}
|
||||||
</Typography.Text>
|
</div>
|
||||||
<div className='text-xs text-gray-600'>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
{t('通知、价格和隐私相关设置')}
|
{t('通知、价格和隐私相关设置')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -379,7 +351,7 @@ const NotificationSettings = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<Bell size={16} className='mr-2' />
|
<BellRing size={14} className='mr-2' />
|
||||||
{t('通知配置')}
|
{t('通知配置')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -719,7 +691,7 @@ const NotificationSettings = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<DollarSign size={16} className='mr-2' />
|
<Coins size={14} className='mr-2' />
|
||||||
{t('价格设置')}
|
{t('价格设置')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -745,7 +717,7 @@ const NotificationSettings = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<ShieldCheck size={16} className='mr-2' />
|
<EyeOff size={14} className='mr-2' />
|
||||||
{t('隐私设置')}
|
{t('隐私设置')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -770,7 +742,7 @@ const NotificationSettings = ({
|
||||||
<TabPane
|
<TabPane
|
||||||
tab={
|
tab={
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center'>
|
||||||
<Settings size={16} className='mr-2' />
|
<LayoutPanelLeft size={14} className='mr-2' />
|
||||||
{t('边栏设置')}
|
{t('边栏设置')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -791,36 +763,19 @@ const NotificationSettings = ({
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
{/* 边栏设置功能区域容器 */}
|
{/* 边栏设置功能区域容器 */}
|
||||||
<div
|
<div className='rounded-2xl border border-slate-200 bg-slate-50/50 p-4 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
className='border rounded-xl p-4'
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--semi-color-border)',
|
|
||||||
backgroundColor: 'var(--semi-color-bg-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sectionConfigs.map((section) => (
|
{sectionConfigs.map((section) => (
|
||||||
<div key={section.key} className='mb-6'>
|
<div key={section.key} className='mb-6 last:mb-0'>
|
||||||
{/* 区域标题和总开关 */}
|
{/* 区域标题和总开关 */}
|
||||||
<div
|
<div className='mb-4 flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
className='flex justify-between items-center mb-4 p-4 rounded-lg'
|
<div className='min-w-0'>
|
||||||
style={{
|
<div className='mb-1 text-base font-semibold text-slate-900 dark:text-white'>
|
||||||
backgroundColor: 'var(--semi-color-fill-0)',
|
|
||||||
border: '1px solid var(--semi-color-border-light)',
|
|
||||||
borderColor: 'var(--semi-color-fill-1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
|
||||||
{section.title}
|
{section.title}
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
type='secondary'
|
type='secondary'
|
||||||
size='small'
|
size='small'
|
||||||
style={{
|
className='text-xs text-slate-400 dark:text-white/40'
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{section.description}
|
{section.description}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
@ -845,30 +800,23 @@ const NotificationSettings = ({
|
||||||
lg={8}
|
lg={8}
|
||||||
xl={8}
|
xl={8}
|
||||||
>
|
>
|
||||||
<Card
|
<div
|
||||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
className={`rounded-xl border border-slate-200 bg-white p-4 transition-colors hover:border-blue-300 dark:border-white/5 dark:bg-white/[0.02] dark:hover:border-blue-400/40 ${
|
||||||
sidebarModulesUser[section.key]?.enabled !==
|
sidebarModulesUser[section.key]?.enabled !==
|
||||||
false
|
false
|
||||||
? ''
|
? ''
|
||||||
: 'opacity-50'
|
: 'opacity-50'
|
||||||
}`}
|
}`}
|
||||||
bodyStyle={{ padding: '16px' }}
|
|
||||||
>
|
>
|
||||||
<div className='flex justify-between items-center h-full'>
|
<div className='flex h-full items-center justify-between'>
|
||||||
<div className='flex-1 text-left'>
|
<div className='min-w-0 flex-1 text-left'>
|
||||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
<div className='mb-1 text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
{module.title}
|
{module.title}
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
type='secondary'
|
type='secondary'
|
||||||
size='small'
|
size='small'
|
||||||
className='block'
|
className='mt-1 block text-xs text-slate-400 dark:text-white/40'
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
color: 'var(--semi-color-text-2)',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{module.description}
|
{module.description}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
@ -892,7 +840,7 @@ const NotificationSettings = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -906,7 +854,41 @@ const NotificationSettings = ({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
|
||||||
|
{/* Footer: 保存按钮 */}
|
||||||
|
<div className='mt-6 flex justify-end gap-3 border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
|
{activeTabKey === 'sidebar' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
theme='light'
|
||||||
|
onClick={resetSidebarModules}
|
||||||
|
className='!rounded-lg'
|
||||||
|
>
|
||||||
|
{t('重置为默认')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
theme='solid'
|
||||||
|
onClick={saveSidebarSettings}
|
||||||
|
loading={sidebarLoading}
|
||||||
|
className='!rounded-lg'
|
||||||
|
>
|
||||||
|
{t('保存设置')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
theme='solid'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className='!rounded-lg'
|
||||||
|
>
|
||||||
|
{t('保存设置')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useContext } from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import { Card, Select, Typography, Avatar } from '@douyinfe/semi-ui';
|
import { Select } from '@douyinfe/semi-ui';
|
||||||
import { Languages } from 'lucide-react';
|
import { SlidersHorizontal, Globe } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { API, showSuccess, showError } from '../../../../helpers';
|
import { API, showSuccess, showError } from '../../../../helpers';
|
||||||
import { UserContext } from '../../../../context/User';
|
import { UserContext } from '../../../../context/User';
|
||||||
|
|
@ -124,38 +124,34 @@ const PreferencesSettings = ({ t }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
{/* Card Header */}
|
{/* Card Header */}
|
||||||
<div className='flex items-center mb-4'>
|
<div className='mb-5 flex items-center gap-3'>
|
||||||
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
|
||||||
<Languages size={16} />
|
<SlidersHorizontal size={18} />
|
||||||
</Avatar>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text className='text-lg font-medium'>
|
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
{t('偏好设置')}
|
{t('偏好设置')}
|
||||||
</Typography.Text>
|
</div>
|
||||||
<div className='text-xs text-gray-600 dark:text-gray-400'>
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
{t('界面语言和其他个人偏好')}
|
{t('界面语言和其他个人偏好')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Language Setting Card */}
|
{/* Language Setting Card */}
|
||||||
<Card className='!rounded-xl border dark:border-gray-700'>
|
<div className='flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between dark:border-white/5 dark:bg-white/[0.02]'>
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:items-center sm:justify-between gap-4'>
|
<div className='flex w-full items-start gap-3 sm:w-auto'>
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
|
||||||
<div className='w-12 h-12 rounded-full bg-violet-50 dark:bg-violet-900/30 flex items-center justify-center mr-4 flex-shrink-0'>
|
<Globe size={18} />
|
||||||
<Languages
|
|
||||||
size={20}
|
|
||||||
className='text-violet-600 dark:text-violet-400'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className='min-w-0'>
|
||||||
<Typography.Title heading={6} className='mb-1'>
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
{t('语言偏好')}
|
{t('语言偏好')}
|
||||||
</Typography.Title>
|
</div>
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
<div className='mt-0.5 text-xs text-slate-400 dark:text-white/40'>
|
||||||
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
|
{t('选择您的首选界面语言,设置将自动保存并同步到所有设备')}
|
||||||
</Typography.Text>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -169,17 +165,14 @@ const PreferencesSettings = ({ t }) => {
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Additional info */}
|
{/* Additional info */}
|
||||||
<div className='mt-4 text-xs text-gray-500 dark:text-gray-400'>
|
<div className='mt-4 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<Typography.Text type='tertiary'>
|
|
||||||
{t(
|
{t(
|
||||||
'提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。',
|
'提示:语言偏好会同步到您登录的所有设备,并影响API返回的错误消息语言。',
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,18 @@ import {
|
||||||
Badge,
|
Badge,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconShield,
|
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { ScanFace } from 'lucide-react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
const TwoFASetting = ({ t }) => {
|
const TwoFASetting = ({ t, compact = false }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [status, setStatus] = useState({
|
const [status, setStatus] = useState({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
@ -366,20 +366,23 @@ const TwoFASetting = ({ t }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className='!rounded-xl w-full'>
|
<div
|
||||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
className={
|
||||||
<div className='flex items-start w-full sm:w-auto'>
|
compact
|
||||||
<div className='w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0'>
|
? ''
|
||||||
<IconShield
|
: 'rounded-2xl border border-slate-200 bg-white p-5 dark:border-white/5 dark:bg-white/[0.02]'
|
||||||
size='large'
|
}
|
||||||
className='text-slate-600 dark:text-slate-300'
|
>
|
||||||
/>
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
|
<div className='flex min-w-0 items-start gap-3'>
|
||||||
|
<div className='flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
|
||||||
|
<ScanFace size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='flex items-center gap-2 mb-1'>
|
<div className='mb-1 flex items-center gap-2'>
|
||||||
<Typography.Title heading={6} className='mb-0'>
|
<span className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
{t('两步验证设置')}
|
{t('两步验证设置')}
|
||||||
</Typography.Title>
|
</span>
|
||||||
{status.enabled ? (
|
{status.enabled ? (
|
||||||
<Tag color='green' shape='circle' size='small'>
|
<Tag color='green' shape='circle' size='small'>
|
||||||
{t('已启用')}
|
{t('已启用')}
|
||||||
|
|
@ -395,23 +398,24 @@ const TwoFASetting = ({ t }) => {
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text type='tertiary' className='text-sm'>
|
<Typography.Text
|
||||||
|
type='tertiary'
|
||||||
|
className='text-xs text-slate-400 dark:text-white/40'
|
||||||
|
>
|
||||||
{t(
|
{t(
|
||||||
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
|
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{status.enabled && (
|
{status.enabled && (
|
||||||
<div className='mt-2'>
|
<div className='mt-2 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<Text size='small' type='secondary'>
|
|
||||||
{t('剩余备用码:')}
|
{t('剩余备用码:')}
|
||||||
{status.backup_codes_remaining || 0}
|
{status.backup_codes_remaining || 0}
|
||||||
{t('个')}
|
{t('个')}
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col space-y-2 w-full sm:w-auto'>
|
<div className='flex w-full flex-col gap-2 sm:w-auto sm:self-center'>
|
||||||
{!status.enabled ? (
|
{!status.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
type='primary'
|
||||||
|
|
@ -419,19 +423,19 @@ const TwoFASetting = ({ t }) => {
|
||||||
size='default'
|
size='default'
|
||||||
onClick={handleSetup2FA}
|
onClick={handleSetup2FA}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
className='!rounded-lg'
|
||||||
icon={<IconShield />}
|
icon={<ScanFace />}
|
||||||
>
|
>
|
||||||
{t('启用验证')}
|
{t('启用验证')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex flex-col space-y-2'>
|
<div className='flex flex-col gap-2'>
|
||||||
<Button
|
<Button
|
||||||
type='danger'
|
type='danger'
|
||||||
theme='solid'
|
theme='solid'
|
||||||
size='default'
|
size='default'
|
||||||
onClick={() => setDisableModalVisible(true)}
|
onClick={() => setDisableModalVisible(true)}
|
||||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
className='!rounded-lg'
|
||||||
icon={<IconAlertTriangle />}
|
icon={<IconAlertTriangle />}
|
||||||
>
|
>
|
||||||
{t('禁用两步验证')}
|
{t('禁用两步验证')}
|
||||||
|
|
@ -450,7 +454,7 @@ const TwoFASetting = ({ t }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* 2FA设置模态框 */}
|
{/* 2FA设置模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|
|
||||||
|
|
@ -18,211 +18,154 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Avatar, Card, Tag, Divider, Typography } from '@douyinfe/semi-ui';
|
import { Tag } from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
Coins,
|
||||||
|
BarChart2,
|
||||||
|
Users,
|
||||||
|
Crown,
|
||||||
|
ShieldCheck,
|
||||||
|
User as UserIcon,
|
||||||
|
Store,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
isRoot,
|
isRoot,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isDistributor,
|
isDistributor,
|
||||||
renderQuota,
|
renderQuota,
|
||||||
stringToColor,
|
|
||||||
} from '../../../../helpers';
|
} from '../../../../helpers';
|
||||||
import { Coins, BarChart2, Users } from 'lucide-react';
|
|
||||||
|
const iconWrap =
|
||||||
|
'flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70';
|
||||||
|
const roleAccent = {
|
||||||
|
root: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
|
||||||
|
admin:
|
||||||
|
'bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400',
|
||||||
|
distributor:
|
||||||
|
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
|
||||||
|
user: 'bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70',
|
||||||
|
};
|
||||||
|
const roleLabelKey = {
|
||||||
|
root: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
distributor: '代理',
|
||||||
|
user: '普通用户',
|
||||||
|
};
|
||||||
|
|
||||||
const UserInfoHeader = ({ t, userState }) => {
|
const UserInfoHeader = ({ t, userState }) => {
|
||||||
const getUsername = () => {
|
const username = userState.user?.username || 'null';
|
||||||
if (userState.user) {
|
const roleKey = isRoot()
|
||||||
return userState.user.username;
|
? 'root'
|
||||||
} else {
|
: isAdmin()
|
||||||
return 'null';
|
? 'admin'
|
||||||
}
|
: isDistributor()
|
||||||
};
|
? 'distributor'
|
||||||
|
: 'user';
|
||||||
|
const RoleIcon =
|
||||||
|
roleKey === 'root'
|
||||||
|
? Crown
|
||||||
|
: roleKey === 'admin'
|
||||||
|
? ShieldCheck
|
||||||
|
: roleKey === 'distributor'
|
||||||
|
? Store
|
||||||
|
: UserIcon;
|
||||||
|
|
||||||
const getAvatarText = () => {
|
const stats = [
|
||||||
const username = getUsername();
|
{
|
||||||
if (username && username.length > 0) {
|
key: 'quota',
|
||||||
return username.slice(0, 2).toUpperCase();
|
label: t('当前余额'),
|
||||||
}
|
value: renderQuota(userState?.user?.quota),
|
||||||
return 'NA';
|
accent:
|
||||||
};
|
'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||||
|
icon: Coins,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'used',
|
||||||
|
label: t('历史消耗'),
|
||||||
|
value: renderQuota(userState?.user?.used_quota),
|
||||||
|
accent:
|
||||||
|
'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400',
|
||||||
|
icon: BarChart2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'group',
|
||||||
|
label: t('用户分组'),
|
||||||
|
value: userState?.user?.group || t('默认'),
|
||||||
|
accent:
|
||||||
|
'bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||||
className='!rounded-2xl overflow-hidden'
|
{/* 头部:头像 + 用户名 + 角色 */}
|
||||||
cover={
|
<div className='flex items-center gap-4'>
|
||||||
<div
|
<div
|
||||||
className='relative h-32'
|
className={`flex h-14 w-14 items-center justify-center rounded-2xl text-base font-semibold ${roleAccent[roleKey]}`}
|
||||||
|
>
|
||||||
|
{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={{
|
style={{
|
||||||
'--palette-primary-darkerChannel': '0 75 80',
|
color: 'inherit',
|
||||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
backgroundColor: 'transparent',
|
||||||
backgroundSize: 'cover',
|
border: 'none',
|
||||||
backgroundPosition: 'center',
|
padding: 0,
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 用户信息内容 */}
|
<span
|
||||||
<div className='relative z-10 h-full flex flex-col justify-end p-6'>
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${roleAccent[roleKey]}`}
|
||||||
<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()}
|
<RoleIcon size={12} />
|
||||||
</div>
|
{t(roleLabelKey[roleKey])}
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
</span>
|
||||||
{isRoot() ? (
|
|
||||||
<Tag
|
|
||||||
size='large'
|
|
||||||
shape='circle'
|
|
||||||
style={{ color: 'white' }}
|
|
||||||
>
|
|
||||||
{t('超级管理员')}
|
|
||||||
</Tag>
|
</Tag>
|
||||||
) : isAdmin() ? (
|
<span className='text-xs text-slate-400 dark:text-white/40 tabular-nums'>
|
||||||
<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}
|
ID: {userState?.user?.id}
|
||||||
</Tag>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
||||||
</div>
|
{t('账户概览与使用统计')}
|
||||||
</div>
|
</p>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 桌面版统计信息(Semi UI 卡片) */}
|
{/* 数据三宫格 */}
|
||||||
<div className='hidden min-w-0 shrink-0 lg:block'>
|
<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'>
|
||||||
<Card
|
{stats.map((s) => {
|
||||||
size='small'
|
const Icon = s.icon;
|
||||||
className='!rounded-xl max-w-full'
|
return (
|
||||||
bodyStyle={{ padding: '12px 16px' }}
|
<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 flex-wrap items-center gap-x-4 gap-y-2'>
|
<div
|
||||||
<div className='flex items-center gap-2'>
|
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${s.accent}`}
|
||||||
<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'>
|
<Icon size={18} />
|
||||||
<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>
|
</div>
|
||||||
<Typography.Text size='small' type='tertiary' strong>
|
<div className='min-w-0'>
|
||||||
{renderQuota(userState?.user?.used_quota)}
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
||||||
</Typography.Text>
|
{s.label}
|
||||||
</div>
|
</div>
|
||||||
<Divider margin='8px' />
|
<div className='truncate text-base font-semibold tabular-nums text-slate-900 dark:text-white'>
|
||||||
<div className='flex items-center justify-between'>
|
{s.value}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
|
||||||
import PricingGroups from '../filter/PricingGroups';
|
import PricingGroups from '../filter/PricingGroups';
|
||||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||||
|
|
@ -99,17 +98,22 @@ const PricingSidebar = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='p-2'>
|
<div className='px-4 py-5'>
|
||||||
<div className='flex items-center justify-between mb-6'>
|
<div className='mb-5 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-white/5'>
|
||||||
<div className='text-lg font-semibold text-gray-800'>{t('筛选')}</div>
|
<div>
|
||||||
<Button
|
<div className='text-sm font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
theme='outline'
|
{t('筛选')}
|
||||||
type='tertiary'
|
</div>
|
||||||
|
<div className='mt-0.5 text-[11px] text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
|
调整左侧条件实时刷新结果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
onClick={handleResetFilters}
|
onClick={handleResetFilters}
|
||||||
className='text-gray-500 hover:text-gray-700'
|
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:border-white/20 dark:hover:text-white'
|
||||||
>
|
>
|
||||||
{t('重置')}
|
{t('重置')}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PricingVendors
|
<PricingVendors
|
||||||
|
|
|
||||||
|
|
@ -18,49 +18,32 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
|
||||||
import {
|
import { Tooltip, Modal } from '@douyinfe/semi-ui';
|
||||||
Card,
|
import { Layers } from 'lucide-react';
|
||||||
Tag,
|
|
||||||
Avatar,
|
|
||||||
Typography,
|
|
||||||
Tooltip,
|
|
||||||
Modal,
|
|
||||||
} from '@douyinfe/semi-ui';
|
|
||||||
import { getLobeHubIcon } from '../../../../../helpers';
|
import { getLobeHubIcon } from '../../../../../helpers';
|
||||||
import SearchActions from './SearchActions';
|
import SearchActions from './SearchActions';
|
||||||
|
|
||||||
const { Paragraph } = Typography;
|
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
CAROUSEL_INTERVAL: 2000,
|
CAROUSEL_INTERVAL: 2000,
|
||||||
ICON_SIZE: 40,
|
ICON_SIZE: 32,
|
||||||
UNKNOWN_VENDOR: 'unknown',
|
UNKNOWN_VENDOR: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
const THEME_COLORS = {
|
const THEME = {
|
||||||
allVendors: {
|
allVendors: {
|
||||||
primary: '37 99 235',
|
iconWrap:
|
||||||
background: 'rgba(59, 130, 246, 0.08)',
|
'bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70',
|
||||||
|
accent: 'text-slate-600 dark:text-slate-300',
|
||||||
|
badge: 'bg-slate-100 text-slate-700 dark:bg-white/5 dark:text-slate-300',
|
||||||
},
|
},
|
||||||
specific: {
|
specific: {
|
||||||
primary: '16 185 129',
|
iconWrap:
|
||||||
background: 'rgba(16, 185, 129, 0.1)',
|
'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
|
||||||
|
accent: 'text-emerald-600 dark:text-emerald-400',
|
||||||
|
badge: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPONENT_STYLES = {
|
|
||||||
tag: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
|
||||||
color: '#1f2937',
|
|
||||||
border: '1px solid rgba(255,255,255,0.8)',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
avatarContainer:
|
|
||||||
'w-16 h-16 rounded-2xl bg-white/90 shadow-md backdrop-blur-sm flex items-center justify-center',
|
|
||||||
titleText: { color: 'white' },
|
|
||||||
descriptionText: { color: 'rgba(255,255,255,0.9)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTENT_TEXTS = {
|
const CONTENT_TEXTS = {
|
||||||
unknown: {
|
unknown: {
|
||||||
displayName: (t) => t('未知模型类型'),
|
displayName: (t) => t('未知模型类型'),
|
||||||
|
|
@ -85,35 +68,32 @@ const getVendorDisplayName = (vendorName, t) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDefaultAvatar = () => (
|
const createDefaultAvatar = () => (
|
||||||
<div className={COMPONENT_STYLES.avatarContainer}>
|
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 text-slate-500 dark:bg-white/5 dark:text-white/60'>
|
||||||
<Avatar size='large' color='transparent'>
|
<Layers size={22} />
|
||||||
AI
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAvatarBackgroundColor = (isAllVendors) =>
|
|
||||||
isAllVendors
|
|
||||||
? THEME_COLORS.allVendors.background
|
|
||||||
: THEME_COLORS.specific.background;
|
|
||||||
|
|
||||||
const getAvatarText = (vendorName) =>
|
const getAvatarText = (vendorName) =>
|
||||||
vendorName === CONFIG.UNKNOWN_VENDOR
|
vendorName === CONFIG.UNKNOWN_VENDOR
|
||||||
? '?'
|
? '?'
|
||||||
: (vendorName || '').charAt(0).toUpperCase() || '?';
|
: (vendorName || '').charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
const createAvatarContent = (vendor, isAllVendors) => {
|
const createAvatarContent = (vendor, iconWrapClass) => {
|
||||||
if (vendor.icon) {
|
if (vendor.icon) {
|
||||||
return getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<div
|
||||||
size='large'
|
className={`flex h-12 w-12 items-center justify-center rounded-xl ${iconWrapClass}`}
|
||||||
style={{ backgroundColor: getAvatarBackgroundColor(isAllVendors) }}
|
>
|
||||||
|
{getLobeHubIcon(vendor.icon, CONFIG.ICON_SIZE)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 items-center justify-center rounded-xl font-mono text-lg font-semibold ${iconWrapClass}`}
|
||||||
>
|
>
|
||||||
{getAvatarText(vendor.name)}
|
{getAvatarText(vendor.name)}
|
||||||
</Avatar>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,11 +103,14 @@ const renderVendorAvatar = (vendor, t, isAllVendors = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = getVendorDisplayName(vendor.name, t);
|
const displayName = getVendorDisplayName(vendor.name, t);
|
||||||
const avatarContent = createAvatarContent(vendor, isAllVendors);
|
const iconWrapClass = isAllVendors
|
||||||
|
? THEME.allVendors.iconWrap
|
||||||
|
: THEME.specific.iconWrap;
|
||||||
|
const avatarContent = createAvatarContent(vendor, iconWrapClass);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={displayName} position='top'>
|
<Tooltip content={displayName} position='top'>
|
||||||
<div className={COMPONENT_STYLES.avatarContainer}>{avatarContent}</div>
|
{avatarContent}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -259,17 +242,6 @@ const PricingVendorIntro = memo(
|
||||||
[vendorInfo, t],
|
[vendorInfo, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createCoverStyle = useCallback(
|
|
||||||
(primaryColor) => ({
|
|
||||||
'--palette-primary-darkerChannel': primaryColor,
|
|
||||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSearchActions = useCallback(
|
const renderSearchActions = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<SearchActions
|
<SearchActions
|
||||||
|
|
@ -318,51 +290,41 @@ const PricingVendorIntro = memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderHeaderCard = useCallback(
|
const renderHeaderCard = useCallback(
|
||||||
({ title, count, description, rightContent, primaryDarkerChannel }) => (
|
({ title, count, description, rightContent, theme }) => (
|
||||||
<Card
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||||
className='!rounded-2xl shadow-sm border-0'
|
<div className='flex items-start gap-4'>
|
||||||
cover={
|
<div className='min-w-0 flex-1'>
|
||||||
<div
|
<div className='mb-1.5 flex flex-wrap items-center gap-2'>
|
||||||
className='relative h-full'
|
<h2 className='truncate text-lg font-semibold tracking-tight text-slate-900 sm:text-xl dark:text-white'>
|
||||||
style={createCoverStyle(primaryDarkerChannel)}
|
|
||||||
>
|
|
||||||
<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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<Tag
|
<span
|
||||||
style={COMPONENT_STYLES.tag}
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium ${theme.badge}`}
|
||||||
shape='circle'
|
|
||||||
size='small'
|
|
||||||
className='self-center'
|
|
||||||
>
|
>
|
||||||
{t('共 {{count}} 个模型', { count })}
|
{t('共 {{count}} 个模型', { count })}
|
||||||
</Tag>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<p
|
||||||
className='text-xs sm:text-sm leading-relaxed !mb-0 cursor-pointer'
|
className='cursor-pointer text-xs leading-relaxed text-slate-500 sm:text-sm dark:text-white/50'
|
||||||
style={COMPONENT_STYLES.descriptionText}
|
|
||||||
ellipsis={{ rows: 2 }}
|
|
||||||
onClick={() => handleOpenDescModal(description)}
|
onClick={() => handleOpenDescModal(description)}
|
||||||
|
style={{
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
</Paragraph>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='flex shrink-0 items-center'>{rightContent}</div>
|
||||||
<div className='flex-shrink-0'>{rightContent}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className='mt-5 border-t border-slate-100 pt-4 dark:border-white/5'>
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderSearchActions()}
|
{renderSearchActions()}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
[renderSearchActions, createCoverStyle, handleOpenDescModal, t],
|
[renderSearchActions, handleOpenDescModal, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAllVendorsAvatar = useCallback(() => {
|
const renderAllVendorsAvatar = useCallback(() => {
|
||||||
|
|
@ -379,7 +341,7 @@ const PricingVendorIntro = memo(
|
||||||
count: currentModelCount,
|
count: currentModelCount,
|
||||||
description: getVendorDescription('all'),
|
description: getVendorDescription('all'),
|
||||||
rightContent: renderAllVendorsAvatar(),
|
rightContent: renderAllVendorsAvatar(),
|
||||||
primaryDarkerChannel: THEME_COLORS.allVendors.primary,
|
theme: THEME.allVendors,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -402,7 +364,7 @@ const PricingVendorIntro = memo(
|
||||||
description:
|
description:
|
||||||
currentVendor.description || getVendorDescription(currentVendor.name),
|
currentVendor.description || getVendorDescription(currentVendor.name),
|
||||||
rightContent: renderVendorAvatar(currentVendor, t, false),
|
rightContent: renderVendorAvatar(currentVendor, t, false),
|
||||||
primaryDarkerChannel: THEME_COLORS.specific.primary,
|
theme: THEME.specific,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ const SearchActions = memo(
|
||||||
}, [tokenUnit, setTokenUnit]);
|
}, [tokenUnit, setTokenUnit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-2 w-full'>
|
<div className='flex flex-wrap items-center gap-2 w-full'>
|
||||||
<div className='flex-1'>
|
<div className='min-w-[180px] flex-1'>
|
||||||
<Input
|
<Input
|
||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
placeholder={t('模糊搜索模型名称')}
|
placeholder={t('模糊搜索模型名称')}
|
||||||
|
|
@ -80,11 +80,10 @@ const SearchActions = memo(
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
theme='outline'
|
theme='outline'
|
||||||
type='primary'
|
type='tertiary'
|
||||||
icon={<IconCopy />}
|
icon={<IconCopy />}
|
||||||
onClick={handleCopyClick}
|
onClick={handleCopyClick}
|
||||||
disabled={selectedRowKeys.length === 0}
|
disabled={selectedRowKeys.length === 0}
|
||||||
className='!bg-blue-500 hover:!bg-blue-600 !text-white disabled:!bg-gray-300 disabled:!text-gray-500'
|
|
||||||
>
|
>
|
||||||
{t('复制')}
|
{t('复制')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -96,7 +95,7 @@ const SearchActions = memo(
|
||||||
{/* 充值价格显示开关 */}
|
{/* 充值价格显示开关 */}
|
||||||
{supportsCurrencyDisplay && (
|
{supportsCurrencyDisplay && (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span className='text-sm text-gray-600'>
|
<span className='text-sm text-slate-500 dark:text-white/50'>
|
||||||
{t('充值价格显示')}
|
{t('充值价格显示')}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -121,7 +120,7 @@ const SearchActions = memo(
|
||||||
|
|
||||||
{/* 显示倍率开关 */}
|
{/* 显示倍率开关 */}
|
||||||
{/* <div className='flex items-center gap-2'>
|
{/* <div className='flex items-center gap-2'>
|
||||||
<span className='text-sm text-gray-600'>{t('倍率')}</span>
|
<span className='text-sm text-slate-500 dark:text-white/50'>{t('倍率')}</span>
|
||||||
<Switch checked={showRatio} onChange={setShowRatio} />
|
<Switch checked={showRatio} onChange={setShowRatio} />
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, Table, Empty } from '@douyinfe/semi-ui';
|
import { Card, Table } from '@douyinfe/semi-ui';
|
||||||
import {
|
import { SearchX } from 'lucide-react';
|
||||||
IllustrationNoResult,
|
|
||||||
IllustrationNoResultDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { getPricingTableColumns } from './PricingTableColumns';
|
import { getPricingTableColumns } from './PricingTableColumns';
|
||||||
|
|
||||||
const PricingTable = ({
|
const PricingTable = ({
|
||||||
|
|
@ -114,16 +111,17 @@ const PricingTable = ({
|
||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
})}
|
})}
|
||||||
empty={
|
empty={
|
||||||
<Empty
|
<div className='flex flex-col items-center justify-center px-6 py-16 text-center'>
|
||||||
image={
|
<div className='mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-400 dark:bg-white/5 dark:text-white/40'>
|
||||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
<SearchX size={22} strokeWidth={1.5} />
|
||||||
}
|
</div>
|
||||||
darkModeImage={
|
<div className='text-sm font-medium text-slate-700 dark:text-white/80'>
|
||||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
{t('搜索无结果')}
|
||||||
}
|
</div>
|
||||||
description={t('搜索无结果')}
|
<div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
||||||
style={{ padding: 30 }}
|
{t('试试调整筛选条件或换个关键词')}
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,12 @@ const AddUserModal = (props) => {
|
||||||
field='password'
|
field='password'
|
||||||
label={t('密码')}
|
label={t('密码')}
|
||||||
type='password'
|
type='password'
|
||||||
placeholder={t('请输入密码')}
|
placeholder={t('请输入密码(8-20位)')}
|
||||||
rules={[{ required: true, message: t('请输入密码') }]}
|
rules={[
|
||||||
|
{ required: true, message: t('请输入密码') },
|
||||||
|
{ min: 8, message: t('密码长度至少为8个字符') },
|
||||||
|
{ max: 20, message: t('密码长度不能超过20个字符') },
|
||||||
|
]}
|
||||||
showClear
|
showClear
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -271,8 +271,12 @@ const EditUserModal = (props) => {
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field='password'
|
field='password'
|
||||||
label={t('密码')}
|
label={t('密码')}
|
||||||
placeholder={t('请输入新的密码,最短 8 位')}
|
placeholder={t('请输入新的密码(8-20位)')}
|
||||||
mode='password'
|
mode='password'
|
||||||
|
rules={[
|
||||||
|
{ min: 8, message: t('密码长度至少为8个字符') },
|
||||||
|
{ max: 20, message: t('密码长度不能超过20个字符') },
|
||||||
|
]}
|
||||||
showClear
|
showClear
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -105,108 +105,74 @@ const RechargeCard = ({
|
||||||
}, [shouldShowSubscription, activeTab]);
|
}, [shouldShowSubscription, activeTab]);
|
||||||
const topupContent = (
|
const topupContent = (
|
||||||
<Space vertical style={{ width: '100%' }}>
|
<Space vertical style={{ width: '100%' }}>
|
||||||
{/* 统计数据 */}
|
{/* 账户统计 */}
|
||||||
<Card
|
<div className='rounded-3xl border border-slate-200 bg-white p-10 transition-shadow hover:shadow-lg dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/30'>
|
||||||
className='!rounded-xl w-full'
|
<div className='mb-8 flex items-center justify-between'>
|
||||||
cover={
|
<div className='flex items-center gap-4'>
|
||||||
<div
|
<div className='flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
className='relative h-30'
|
<BarChart2 size={24} />
|
||||||
style={{
|
|
||||||
'--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>
|
</div>
|
||||||
|
<div>
|
||||||
{/* 统计数据 */}
|
<div className='text-lg font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
<div className='grid grid-cols-3 gap-6 mt-4'>
|
{t('账户统计')}
|
||||||
{/* 当前余额 */}
|
</div>
|
||||||
<div className='text-center'>
|
<div className='mt-1 text-xs text-slate-400 tabular-nums dark:text-white/40'>
|
||||||
<div
|
实时余额与消费概览
|
||||||
className='text-base sm:text-2xl font-bold mb-2'
|
</div>
|
||||||
style={{ color: 'white' }}
|
</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)}
|
{renderQuota(userState?.user?.quota)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-center text-sm'>
|
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<Wallet
|
账户可用额度 · 可继续调用 API
|
||||||
size={14}
|
|
||||||
className='mr-1'
|
|
||||||
style={{ color: 'rgba(255,255,255,0.8)' }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: 'rgba(255,255,255,0.8)',
|
|
||||||
fontSize: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('当前余额')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 历史消耗 */}
|
{/* 累计消费 */}
|
||||||
<div className='text-center'>
|
<div className='min-w-0 md:border-l md:border-slate-100 md:pl-8 md:dark:border-white/5'>
|
||||||
<div
|
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
className='text-base sm:text-2xl font-bold mb-2'
|
<TrendingUp size={14} />
|
||||||
style={{ color: 'white' }}
|
{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)}
|
{renderQuota(userState?.user?.used_quota)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-center text-sm'>
|
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* 请求次数 */}
|
{/* 平均单次消费 */}
|
||||||
<div className='text-center'>
|
<div className='min-w-0 md:border-l md:border-slate-100 md:pl-8 md:dark:border-white/5'>
|
||||||
<div
|
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||||
className='text-base sm:text-2xl font-bold mb-2'
|
<Receipt size={14} />
|
||||||
style={{ color: 'white' }}
|
{t('平均单次')}
|
||||||
>
|
|
||||||
{userState?.user?.request_count || 0}
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-center text-sm'>
|
<div className='mt-3 font-mono text-4xl font-semibold leading-none tabular-nums text-slate-900 md:text-5xl dark:text-white'>
|
||||||
<BarChart2
|
{userState?.user?.used_quota && userState?.user?.request_count
|
||||||
size={14}
|
? renderQuota(
|
||||||
className='mr-1'
|
Math.round(
|
||||||
style={{ color: 'rgba(255,255,255,0.8)' }}
|
Number(userState.user.used_quota) /
|
||||||
/>
|
Number(userState.user.request_count),
|
||||||
<Text
|
),
|
||||||
style={{
|
)
|
||||||
color: 'rgba(255,255,255,0.8)',
|
: '—'}
|
||||||
fontSize: '12px',
|
</div>
|
||||||
}}
|
<div className='mt-3 text-xs text-slate-400 dark:text-white/40'>
|
||||||
>
|
单次调用平均花费
|
||||||
{t('请求次数')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 在线充值表单 */}
|
{/* 在线充值表单 */}
|
||||||
{statusLoading ? (
|
{statusLoading ? (
|
||||||
<div className='py-8 flex justify-center'>
|
<div className='py-8 flex justify-center'>
|
||||||
|
|
@ -570,17 +536,22 @@ const RechargeCard = ({
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 兑换码充值 */}
|
{/* 兑换码充值 */}
|
||||||
<Card
|
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||||
className='!rounded-xl w-full'
|
<div className='mb-4 flex items-center gap-3'>
|
||||||
title={
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-400'>
|
||||||
<Text type='tertiary' strong>
|
<IconGift size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||||
{t('兑换码充值')}
|
{t('兑换码充值')}
|
||||||
</Text>
|
</div>
|
||||||
}
|
<div className='mt-0.5 text-[11px] text-slate-400 dark:text-white/40'>
|
||||||
>
|
{t('输入兑换码直接到账,无需在线支付')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Form
|
<Form
|
||||||
getFormApi={(api) => (redeemFormApiRef.current = api)}
|
getFormApi={(api) => (redeemFormApiRef.current = api)}
|
||||||
initValues={{ redemptionCode: redemptionCode }}
|
initValues={{ redemptionCode: redemptionCode }}
|
||||||
|
|
@ -623,32 +594,33 @@ const RechargeCard = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
<div className='flex items-center justify-between mb-4'>
|
<div className='mb-6 flex flex-wrap items-center justify-between gap-3'>
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center gap-3'>
|
||||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
<div className='flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-white/5 dark:text-white/70'>
|
||||||
<CreditCard size={16} />
|
<CreditCard size={18} />
|
||||||
</Avatar>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text className='text-lg font-medium'>
|
<div className='text-base font-semibold tracking-tight text-slate-900 dark:text-white'>
|
||||||
{t('账户充值')}
|
{t('账户充值')}
|
||||||
</Typography.Text>
|
</div>
|
||||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
<div className='mt-0.5 text-[11px] text-slate-400 dark:text-white/40'>
|
||||||
|
{t('多种充值方式,安全便捷')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
icon={<Receipt size={16} />}
|
<button
|
||||||
theme='solid'
|
|
||||||
onClick={onOpenHistory}
|
onClick={onOpenHistory}
|
||||||
|
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-4 py-2 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-white/20 dark:hover:text-white'
|
||||||
>
|
>
|
||||||
{t('账单')}
|
<Receipt size={13} /> {t('账单')}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowSubscription ? (
|
{shouldShowSubscription ? (
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ const TopUp = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'>
|
<div className='mx-auto w-full max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
|
||||||
{/* 划转模态框 */}
|
{/* 划转模态框 */}
|
||||||
<TransferModal
|
<TransferModal
|
||||||
t={t}
|
t={t}
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,44 @@
|
||||||
/* 充值额度卡片:hover 渐变边框 + 彩色背景 + 外发光 */
|
/* 充值额度卡片:简洁 hover,对齐 dashboard 风格(无渐变) */
|
||||||
|
|
||||||
.preset-amount-card {
|
.preset-amount-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5px;
|
|
||||||
background: var(--semi-color-border);
|
|
||||||
transition:
|
transition:
|
||||||
background 0.35s ease,
|
border-color 0.15s ease,
|
||||||
box-shadow 0.35s ease,
|
box-shadow 0.15s ease,
|
||||||
transform 0.25s ease;
|
transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 外圈光晕(hover 时扩散) */
|
.preset-amount-card .semi-card {
|
||||||
.preset-amount-card::before {
|
border: 1px solid rgb(226 232 240) !important;
|
||||||
content: '';
|
border-radius: 12px !important;
|
||||||
position: absolute;
|
height: 100%;
|
||||||
inset: -6px;
|
width: 100%;
|
||||||
border-radius: 16px;
|
background: #ffffff !important;
|
||||||
background: radial-gradient(
|
transition:
|
||||||
ellipse 90% 70% at 50% 50%,
|
border-color 0.15s ease,
|
||||||
rgba(96, 165, 250, 0.45) 0%,
|
box-shadow 0.15s ease !important;
|
||||||
rgba(167, 139, 250, 0.2) 45%,
|
}
|
||||||
transparent 72%
|
|
||||||
);
|
.preset-amount-card:hover .semi-card {
|
||||||
opacity: 0;
|
border-color: rgb(96 165 250) !important;
|
||||||
pointer-events: none;
|
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12) !important;
|
||||||
z-index: -1;
|
|
||||||
transition: opacity 0.35s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-amount-card:hover {
|
.preset-amount-card:hover {
|
||||||
background: linear-gradient(
|
transform: translateY(-1px);
|
||||||
135deg,
|
|
||||||
#3b82f6 0%,
|
|
||||||
#8b5cf6 48%,
|
|
||||||
#22d3ee 100%
|
|
||||||
);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(96, 165, 250, 0.65),
|
|
||||||
0 6px 22px rgba(59, 130, 246, 0.38),
|
|
||||||
0 0 36px rgba(139, 92, 246, 0.32),
|
|
||||||
0 0 56px rgba(34, 211, 238, 0.18);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-amount-card:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-amount-card__inner.semi-card {
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 10.5px !important;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--semi-color-bg-1) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
transition:
|
|
||||||
background 0.35s ease,
|
|
||||||
box-shadow 0.35s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-amount-card:hover .preset-amount-card__inner {
|
|
||||||
background: linear-gradient(
|
|
||||||
155deg,
|
|
||||||
#eff6ff 0%,
|
|
||||||
#eef2ff 42%,
|
|
||||||
#ecfeff 100%
|
|
||||||
) !important;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色模式 */
|
/* 深色模式 */
|
||||||
.dark .preset-amount-card:hover .preset-amount-card__inner {
|
.dark .preset-amount-card .semi-card {
|
||||||
background: linear-gradient(
|
background: rgba(255, 255, 255, 0.02) !important;
|
||||||
155deg,
|
border-color: rgba(255, 255, 255, 0.05) !important;
|
||||||
rgba(30, 64, 175, 0.55) 0%,
|
|
||||||
rgba(67, 56, 202, 0.48) 42%,
|
|
||||||
rgba(14, 116, 144, 0.42) 100%
|
|
||||||
) !important;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .preset-amount-card:hover {
|
.dark .preset-amount-card:hover .semi-card {
|
||||||
box-shadow:
|
border-color: rgba(96, 165, 250, 0.6) !important;
|
||||||
0 0 0 1px rgba(147, 197, 253, 0.5),
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3) !important;
|
||||||
0 6px 24px rgba(37, 99, 235, 0.45),
|
|
||||||
0 0 40px rgba(124, 58, 237, 0.38),
|
|
||||||
0 0 64px rgba(34, 211, 238, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .preset-amount-card::before {
|
|
||||||
background: radial-gradient(
|
|
||||||
ellipse 90% 70% at 50% 50%,
|
|
||||||
rgba(59, 130, 246, 0.55) 0%,
|
|
||||||
rgba(124, 58, 237, 0.28) 45%,
|
|
||||||
transparent 72%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
@ -22,8 +22,7 @@ export const CHART_CONFIG = { mode: 'desktop-browser' };
|
||||||
|
|
||||||
export const CARD_PROPS = {
|
export const CARD_PROPS = {
|
||||||
shadows: '',
|
shadows: '',
|
||||||
bordered: true,
|
bordered: false,
|
||||||
headerLine: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FORM_FIELD_PROPS = {
|
export const FORM_FIELD_PROPS = {
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ export const getTrendSpec = (data, color) => ({
|
||||||
background: {
|
background: {
|
||||||
fill: 'transparent',
|
fill: 'transparent',
|
||||||
},
|
},
|
||||||
|
transformSpec: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== UI 工具函数 ==========
|
// ========== UI 工具函数 ==========
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ import {
|
||||||
Info,
|
Info,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SiAtlassian,
|
SiAtlassian,
|
||||||
|
|
@ -192,6 +193,10 @@ export function getLucideIcon(key, selected = false) {
|
||||||
return <ClipboardCheck {...commonProps} color={iconColor} />;
|
return <ClipboardCheck {...commonProps} color={iconColor} />;
|
||||||
case 'benchmarks':
|
case 'benchmarks':
|
||||||
return <BarChart3 {...commonProps} color={iconColor} />;
|
return <BarChart3 {...commonProps} color={iconColor} />;
|
||||||
|
case 'lucky-bag':
|
||||||
|
return <Gift {...commonProps} color={iconColor} />;
|
||||||
|
case 'api-tester':
|
||||||
|
return <FlaskConical {...commonProps} color={iconColor} />;
|
||||||
default:
|
default:
|
||||||
return <CircleUser {...commonProps} color={iconColor} />;
|
return <CircleUser {...commonProps} color={iconColor} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||||
console: {
|
console: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
benchmarks: true,
|
benchmarks: true,
|
||||||
|
'lucky-bag': true,
|
||||||
detail: true,
|
detail: true,
|
||||||
token: true,
|
token: true,
|
||||||
log: true,
|
log: true,
|
||||||
|
|
@ -280,10 +281,10 @@ export const useSidebar = () => {
|
||||||
return result;
|
return result;
|
||||||
}, [roleBaseConfig, userConfig]);
|
}, [roleBaseConfig, userConfig]);
|
||||||
|
|
||||||
// 检查特定功能是否应该显示
|
// 检查特定功能是否应该显示 (未配置的key默认可见)
|
||||||
const isModuleVisible = (sectionKey, moduleKey = null) => {
|
const isModuleVisible = (sectionKey, moduleKey = null) => {
|
||||||
if (moduleKey) {
|
if (moduleKey) {
|
||||||
return finalConfig[sectionKey]?.[moduleKey] === true;
|
return finalConfig[sectionKey]?.[moduleKey] !== false;
|
||||||
} else {
|
} else {
|
||||||
return finalConfig[sectionKey]?.enabled === true;
|
return finalConfig[sectionKey]?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { API, isAdmin, showError } from '../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { API, isAdmin, showError, timestamp2string } from '../../helpers';
|
|
||||||
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
|
|
||||||
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
|
|
||||||
import { useIsMobile } from '../common/useIsMobile';
|
|
||||||
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
|
|
||||||
|
|
||||||
export const useDashboardData = (userState, userDispatch, statusState) => {
|
const PAGE_SIZE = 200;
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const isSuccess = (l) =>
|
||||||
const isMobile = useIsMobile();
|
l?.type === 0 || l?.status === 0 || l?.type === 'success';
|
||||||
|
|
||||||
|
const sum = (arr, key) =>
|
||||||
|
arr.reduce((s, l) => s + (Number(l[key]) || 0), 0);
|
||||||
|
|
||||||
|
export const useDashboardData = (userState) => {
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
// ========== 基础状态 ==========
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [greetingVisible, setGreetingVisible] = useState(false);
|
const [recentLogs, setRecentLogs] = useState([]);
|
||||||
const [searchModalVisible, setSearchModalVisible] = useState(false);
|
|
||||||
const showLoading = useMinimumLoadingTime(loading);
|
|
||||||
|
|
||||||
// ========== 输入状态 ==========
|
|
||||||
const [inputs, setInputs] = useState({
|
|
||||||
username: '',
|
|
||||||
token_name: '',
|
|
||||||
model_name: '',
|
|
||||||
start_timestamp: getInitialTimestamp(),
|
|
||||||
end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600),
|
|
||||||
channel: '',
|
|
||||||
data_export_default_time: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [dataExportDefaultTime, setDataExportDefaultTime] =
|
|
||||||
useState(getDefaultTime());
|
|
||||||
|
|
||||||
// ========== 数据状态 ==========
|
|
||||||
const [quotaData, setQuotaData] = useState([]);
|
|
||||||
const [consumeQuota, setConsumeQuota] = useState(0);
|
|
||||||
const [consumeTokens, setConsumeTokens] = useState(0);
|
|
||||||
const [times, setTimes] = useState(0);
|
|
||||||
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
|
|
||||||
const [lineData, setLineData] = useState([]);
|
|
||||||
const [modelColors, setModelColors] = useState({});
|
|
||||||
|
|
||||||
// ========== 图表状态 ==========
|
|
||||||
const [activeChartTab, setActiveChartTab] = useState('1');
|
|
||||||
|
|
||||||
// ========== 趋势数据 ==========
|
|
||||||
const [trendData, setTrendData] = useState({
|
|
||||||
balance: [],
|
|
||||||
usedQuota: [],
|
|
||||||
requestCount: [],
|
|
||||||
times: [],
|
|
||||||
consumeQuota: [],
|
|
||||||
tokens: [],
|
|
||||||
rpm: [],
|
|
||||||
tpm: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== Uptime 数据 ==========
|
|
||||||
const [uptimeData, setUptimeData] = useState([]);
|
const [uptimeData, setUptimeData] = useState([]);
|
||||||
const [uptimeLoading, setUptimeLoading] = useState(false);
|
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||||
const [activeUptimeTab, setActiveUptimeTab] = useState('');
|
|
||||||
|
|
||||||
// ========== 常量 ==========
|
|
||||||
const now = new Date();
|
|
||||||
const isAdminUser = isAdmin();
|
const isAdminUser = isAdmin();
|
||||||
|
const user = userState?.user;
|
||||||
|
const remaining = Number(user?.quota || 0);
|
||||||
|
const used = Number(user?.used_quota || 0);
|
||||||
|
const total = remaining + used;
|
||||||
|
|
||||||
// ========== Panel enable flags ==========
|
const loadRecentLogs = useCallback(async () => {
|
||||||
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
|
|
||||||
const announcementsEnabled =
|
|
||||||
statusState?.status?.announcements_enabled ?? true;
|
|
||||||
const faqEnabled = statusState?.status?.faq_enabled ?? true;
|
|
||||||
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
|
|
||||||
|
|
||||||
const hasApiInfoPanel = apiInfoEnabled;
|
|
||||||
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
|
|
||||||
|
|
||||||
// ========== Memoized Values ==========
|
|
||||||
const timeOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
TIME_OPTIONS.map((option) => ({
|
|
||||||
...option,
|
|
||||||
label: t(option.label),
|
|
||||||
})),
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const performanceMetrics = useMemo(() => {
|
|
||||||
const { start_timestamp, end_timestamp } = inputs;
|
|
||||||
const timeDiff =
|
|
||||||
(Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
|
||||||
const avgRPM = isNaN(times / timeDiff)
|
|
||||||
? '0'
|
|
||||||
: (times / timeDiff).toFixed(3);
|
|
||||||
const avgTPM = isNaN(consumeTokens / timeDiff)
|
|
||||||
? '0'
|
|
||||||
: (consumeTokens / timeDiff).toFixed(3);
|
|
||||||
|
|
||||||
return { avgRPM, avgTPM, timeDiff };
|
|
||||||
}, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
|
|
||||||
|
|
||||||
const getGreeting = useMemo(() => {
|
|
||||||
const hours = new Date().getHours();
|
|
||||||
let greeting = '';
|
|
||||||
|
|
||||||
if (hours >= 5 && hours < 12) {
|
|
||||||
greeting = t('早上好');
|
|
||||||
} else if (hours >= 12 && hours < 14) {
|
|
||||||
greeting = t('中午好');
|
|
||||||
} else if (hours >= 14 && hours < 18) {
|
|
||||||
greeting = t('下午好');
|
|
||||||
} else {
|
|
||||||
greeting = t('晚上好');
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = userState?.user?.username || '';
|
|
||||||
return `👋${greeting},${username}`;
|
|
||||||
}, [t, userState?.user?.username]);
|
|
||||||
|
|
||||||
// ========== 回调函数 ==========
|
|
||||||
const handleInputChange = useCallback((value, name) => {
|
|
||||||
if (name === 'data_export_default_time') {
|
|
||||||
setDataExportDefaultTime(value);
|
|
||||||
localStorage.setItem('data_export_default_time', value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showSearchModal = useCallback(() => {
|
|
||||||
setSearchModalVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
|
||||||
setSearchModalVisible(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ========== API 调用函数 ==========
|
|
||||||
const loadQuotaData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let url = '';
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const { start_timestamp, end_timestamp, username } = inputs;
|
const start = now - 30 * 86400;
|
||||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
const url = isAdminUser
|
||||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
? `/api/log/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}`
|
||||||
|
: `/api/log/self/?p=0&page_size=${PAGE_SIZE}&start_timestamp=${start}&end_timestamp=${now}`;
|
||||||
if (isAdminUser) {
|
|
||||||
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
|
||||||
} else {
|
|
||||||
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await API.get(url);
|
const res = await API.get(url);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setQuotaData(data);
|
setRecentLogs(data?.items || []);
|
||||||
if (data.length === 0) {
|
|
||||||
data.push({
|
|
||||||
count: 0,
|
|
||||||
model_name: '无数据',
|
|
||||||
quota: 0,
|
|
||||||
created_at: now.getTime() / 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
data.sort((a, b) => a.created_at - b.created_at);
|
|
||||||
return data;
|
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
|
}, [isAdminUser]);
|
||||||
|
|
||||||
const loadUptimeData = useCallback(async () => {
|
const loadUptimeData = useCallback(async () => {
|
||||||
setUptimeLoading(true);
|
setUptimeLoading(true);
|
||||||
|
|
@ -200,9 +70,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setUptimeData(data || []);
|
setUptimeData(data || []);
|
||||||
if (data && data.length > 0 && !activeUptimeTab) {
|
|
||||||
setActiveUptimeTab(data[0].categoryName);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
|
|
@ -211,114 +78,208 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||||
} finally {
|
} finally {
|
||||||
setUptimeLoading(false);
|
setUptimeLoading(false);
|
||||||
}
|
}
|
||||||
}, [activeUptimeTab]);
|
|
||||||
|
|
||||||
const getUserData = useCallback(async () => {
|
|
||||||
let res = await API.get(`/api/user/self`);
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success) {
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
|
||||||
} else {
|
|
||||||
showError(message);
|
|
||||||
}
|
|
||||||
}, [userDispatch]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
const data = await loadQuotaData();
|
|
||||||
await loadUptimeData();
|
|
||||||
return data;
|
|
||||||
}, [loadQuotaData, loadUptimeData]);
|
|
||||||
|
|
||||||
const handleSearchConfirm = useCallback(
|
|
||||||
async (updateChartDataCallback) => {
|
|
||||||
const data = await refresh();
|
|
||||||
if (data && data.length > 0 && updateChartDataCallback) {
|
|
||||||
updateChartDataCallback(data);
|
|
||||||
}
|
|
||||||
setSearchModalVisible(false);
|
|
||||||
},
|
|
||||||
[refresh],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========== Effects ==========
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setGreetingVisible(true);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current) {
|
if (!initialized.current) {
|
||||||
getUserData();
|
loadRecentLogs();
|
||||||
|
loadUptimeData();
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
}
|
}
|
||||||
}, [getUserData]);
|
}, [loadRecentLogs, loadUptimeData]);
|
||||||
|
|
||||||
|
// ========== Today / month metrics ==========
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfDay = new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
).getTime() / 1000;
|
||||||
|
const startOfMonth = new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
1,
|
||||||
|
).getTime() / 1000;
|
||||||
|
|
||||||
|
const todayLogs = recentLogs.filter(
|
||||||
|
(l) => (l?.created_at || 0) >= startOfDay && isSuccess(l),
|
||||||
|
);
|
||||||
|
const monthLogs = recentLogs.filter(
|
||||||
|
(l) => (l?.created_at || 0) >= startOfMonth && isSuccess(l),
|
||||||
|
);
|
||||||
|
|
||||||
|
const todayLatencies = todayLogs
|
||||||
|
.map((l) => Number(l?.use_time) || 0)
|
||||||
|
.filter((v) => v > 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 基础状态
|
today: {
|
||||||
loading: showLoading,
|
requests: todayLogs.length,
|
||||||
greetingVisible,
|
tokens: sum(todayLogs, 'token_used'),
|
||||||
searchModalVisible,
|
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) ==========
|
||||||
inputs,
|
const dailyStats = useMemo(() => {
|
||||||
dataExportDefaultTime,
|
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) ==========
|
||||||
quotaData,
|
const monthDelta = useMemo(() => {
|
||||||
consumeQuota,
|
const now = new Date();
|
||||||
setConsumeQuota,
|
const startOfLastMonth = new Date(
|
||||||
consumeTokens,
|
now.getFullYear(),
|
||||||
setConsumeTokens,
|
now.getMonth() - 1,
|
||||||
times,
|
1,
|
||||||
setTimes,
|
).getTime() / 1000;
|
||||||
pieData,
|
const startOfThisMonth = new Date(
|
||||||
setPieData,
|
now.getFullYear(),
|
||||||
lineData,
|
now.getMonth(),
|
||||||
setLineData,
|
1,
|
||||||
modelColors,
|
).getTime() / 1000;
|
||||||
setModelColors,
|
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) ==========
|
||||||
activeChartTab,
|
const monthlyForecast = useMemo(() => {
|
||||||
setActiveChartTab,
|
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 {
|
||||||
trendData,
|
spent,
|
||||||
setTrendData,
|
balance: remaining,
|
||||||
|
dailyAvg: Math.round(dailyAvg),
|
||||||
|
daysInMonth,
|
||||||
|
daysElapsed,
|
||||||
|
projected,
|
||||||
|
monthDelta,
|
||||||
|
};
|
||||||
|
}, [metrics.month.cost, remaining, monthDelta]);
|
||||||
|
|
||||||
// Uptime 数据
|
// ========== 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,
|
||||||
|
recentLogs,
|
||||||
uptimeData,
|
uptimeData,
|
||||||
uptimeLoading,
|
uptimeLoading,
|
||||||
activeUptimeTab,
|
metrics,
|
||||||
setActiveUptimeTab,
|
dailyStats,
|
||||||
|
monthDelta,
|
||||||
// 计算值
|
daysRemaining,
|
||||||
timeOptions,
|
topModels,
|
||||||
performanceMetrics,
|
sla,
|
||||||
getGreeting,
|
monthlyForecast,
|
||||||
isAdminUser,
|
|
||||||
hasApiInfoPanel,
|
|
||||||
hasInfoPanels,
|
|
||||||
apiInfoEnabled,
|
|
||||||
announcementsEnabled,
|
|
||||||
faqEnabled,
|
|
||||||
uptimeEnabled,
|
|
||||||
|
|
||||||
// 函数
|
|
||||||
handleInputChange,
|
|
||||||
showSearchModal,
|
|
||||||
handleCloseModal,
|
|
||||||
loadQuotaData,
|
|
||||||
loadUptimeData,
|
|
||||||
getUserData,
|
|
||||||
refresh,
|
refresh,
|
||||||
handleSearchConfirm,
|
quotaTotal: total,
|
||||||
|
|
||||||
// 导航和翻译
|
|
||||||
navigate,
|
|
||||||
t,
|
|
||||||
isMobile,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
||||||
|
|
||||||
import enTranslation from './locales/en.json';
|
import enTranslation from './locales/en.json';
|
||||||
import frTranslation from './locales/fr.json';
|
import frTranslation from './locales/fr.json';
|
||||||
|
|
@ -35,7 +34,6 @@ import swTranslation from './locales/sw.json';
|
||||||
import { supportedLanguages } from './language';
|
import { supportedLanguages } from './language';
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
load: 'currentOnly',
|
load: 'currentOnly',
|
||||||
|
|
@ -54,6 +52,7 @@ i18n
|
||||||
sw: swTranslation,
|
sw: swTranslation,
|
||||||
},
|
},
|
||||||
fallbackLng: 'zh-CN',
|
fallbackLng: 'zh-CN',
|
||||||
|
lng: 'zh-CN',
|
||||||
nsSeparator: false,
|
nsSeparator: false,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
|
|
|
||||||
|
|
@ -33,34 +33,81 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 10% 3.9%;
|
--background: 0 0% 5.5%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 90%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 0 0% 10%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 90%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 0 0% 8%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 90%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 236 52% 62%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 220 4% 16%;
|
--secondary: 0 0% 13%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 90%;
|
||||||
--muted: 220 4% 16%;
|
--muted: 0 0% 9%;
|
||||||
--muted-foreground: 220 5% 65%;
|
--muted-foreground: 0 0% 56%;
|
||||||
--accent: 220 4% 16%;
|
--accent: 236 52% 62%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62% 45%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 220 4% 16%;
|
--border: 0 0% 100% / 0.08;
|
||||||
--input: 220 4% 16%;
|
--input: 0 0% 100% / 0.06;
|
||||||
--ring: 222 56% 55%;
|
--ring: 236 52% 62%;
|
||||||
--link: 222 56% 65%;
|
--link: 236 52% 72%;
|
||||||
--link-hover: 222 56% 55%;
|
--link-hover: 236 52% 62%;
|
||||||
--chart-1: 160 60% 45%;
|
--radius: 0.375rem;
|
||||||
--chart-2: 217 91% 60%;
|
--chart-1: 236 52% 62%;
|
||||||
|
--chart-2: 160 60% 50%;
|
||||||
--chart-3: 43 96% 56%;
|
--chart-3: 43 96% 56%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Linear Dark: Semi Design 暗色覆盖 === */
|
||||||
|
.dark {
|
||||||
|
--semi-color-primary: rgba(94, 106, 210, 1);
|
||||||
|
--semi-color-primary-hover: rgba(114, 126, 230, 1);
|
||||||
|
--semi-color-primary-active: rgba(78, 90, 190, 1);
|
||||||
|
--semi-color-primary-disabled: rgba(94, 106, 210, 0.35);
|
||||||
|
--semi-color-primary-light-default: rgba(94, 106, 210, 0.12);
|
||||||
|
--semi-color-primary-light-hover: rgba(94, 106, 210, 0.18);
|
||||||
|
--semi-color-primary-light-active: rgba(94, 106, 210, 0.24);
|
||||||
|
--semi-color-bg-0: rgba(14, 14, 14, 1);
|
||||||
|
--semi-color-bg-1: rgba(19, 19, 19, 1);
|
||||||
|
--semi-color-bg-2: rgba(25, 25, 25, 1);
|
||||||
|
--semi-color-bg-3: rgba(32, 32, 32, 1);
|
||||||
|
--semi-color-bg-4: rgba(40, 40, 40, 1);
|
||||||
|
--semi-color-text-0: rgba(230, 230, 230, 1);
|
||||||
|
--semi-color-text-1: rgba(190, 190, 190, 1);
|
||||||
|
--semi-color-text-2: rgba(140, 140, 140, 1);
|
||||||
|
--semi-color-text-3: rgba(100, 100, 100, 1);
|
||||||
|
--semi-color-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--semi-color-fill-0: rgba(255, 255, 255, 0.04);
|
||||||
|
--semi-color-fill-1: rgba(255, 255, 255, 0.06);
|
||||||
|
--semi-color-fill-2: rgba(255, 255, 255, 0.08);
|
||||||
|
--semi-color-link: rgba(130, 143, 255, 1);
|
||||||
|
--semi-color-link-hover: rgba(158, 168, 255, 1);
|
||||||
|
--semi-color-link-active: rgba(110, 122, 235, 1);
|
||||||
|
--semi-color-link-visited: rgba(130, 143, 255, 0.8);
|
||||||
|
--semi-color-tertiary: rgba(255, 255, 255, 0.06);
|
||||||
|
--semi-color-tertiary-hover: rgba(255, 255, 255, 0.10);
|
||||||
|
--semi-color-tertiary-active: rgba(255, 255, 255, 0.14);
|
||||||
|
--semi-shadow-elevated: 0 0 0 1px rgba(255,255,255,0.06), 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
--semi-color-success: rgba(52, 211, 153, 1);
|
||||||
|
--semi-color-danger: rgba(248, 113, 113, 1);
|
||||||
|
--semi-color-warning: rgba(251, 191, 36, 1);
|
||||||
|
--semi-color-info: rgba(94, 106, 210, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保按钮圆角在暗色模式下生效(覆盖 Semi 库默认值) */
|
||||||
|
.dark .semi-button,
|
||||||
|
.dark .semi-button-primary,
|
||||||
|
.dark .semi-button-secondary,
|
||||||
|
.dark .semi-button-tertiary,
|
||||||
|
.dark .semi-button-warning,
|
||||||
|
.dark .semi-button-danger {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局基础样式 — 使用新字体族 */
|
/* 全局基础样式 — 使用新字体族 */
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
|
@ -1266,6 +1313,191 @@ html.dark .distributor-apply-hero-orb--b {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ==================== Linear Dark Console Theme — 表格 / 表单 / 侧边栏精细调整 ==================== */
|
||||||
|
|
||||||
|
/* 暗色模式表格 */
|
||||||
|
.dark .semi-table-thead > .semi-table-row {
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-table-row:hover {
|
||||||
|
background: rgba(255,255,255,0.04) !important;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-table-thead .semi-table-cell {
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-table-tbody .semi-table-cell {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式表单 */
|
||||||
|
.dark .semi-input-wrapper {
|
||||||
|
background: rgba(255,255,255,0.05) !important;
|
||||||
|
border-color: rgba(255,255,255,0.08) !important;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-input-wrapper:hover {
|
||||||
|
border-color: rgba(255,255,255,0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-input-wrapper:focus-within,
|
||||||
|
.dark .semi-input-wrapper.semi-input-wrapper-focus {
|
||||||
|
border-color: rgba(94,106,210,0.5) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(94,106,210,0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-select {
|
||||||
|
background: rgba(255,255,255,0.05) !important;
|
||||||
|
border-color: rgba(255,255,255,0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-select:hover {
|
||||||
|
border-color: rgba(255,255,255,0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式分页 */
|
||||||
|
.dark .semi-page-item {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-color: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-page-item:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-page-item-active {
|
||||||
|
background: rgba(94,106,210,0.2) !important;
|
||||||
|
border-color: rgba(94,106,210,0.3) !important;
|
||||||
|
color: rgba(130,143,255,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式 Tag */
|
||||||
|
.dark .semi-tag {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式按钮 */
|
||||||
|
.dark .semi-button-primary {
|
||||||
|
background: rgba(94,106,210,0.9) !important;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-button-primary:hover {
|
||||||
|
background: rgba(114,126,230,0.95) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-button-secondary,
|
||||||
|
.dark .semi-button-tertiary {
|
||||||
|
background: rgba(255,255,255,0.06) !important;
|
||||||
|
border-color: rgba(255,255,255,0.08) !important;
|
||||||
|
color: rgba(255,255,255,0.75) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-button-secondary:hover,
|
||||||
|
.dark .semi-button-tertiary:hover {
|
||||||
|
background: rgba(255,255,255,0.10) !important;
|
||||||
|
border-color: rgba(255,255,255,0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧边栏暗色 */
|
||||||
|
.dark .semi-navigation {
|
||||||
|
background: rgba(14,14,14,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-navigation-item {
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-navigation-item:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: rgba(255,255,255,0.80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-navigation-item-selected,
|
||||||
|
.dark .semi-navigation-item-active {
|
||||||
|
background: rgba(94,106,210,0.15) !important;
|
||||||
|
color: rgba(130,143,255,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-navigation-item-selected .semi-navigation-item-icon,
|
||||||
|
.dark .semi-navigation-item-active .semi-navigation-item-icon {
|
||||||
|
color: rgba(130,143,255,1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .semi-navigation-divider {
|
||||||
|
border-color: rgba(255,255,255,0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式 Tabs — homepage nav pill style */
|
||||||
|
.dark .semi-tabs {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-bar {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab-button {
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
font-family: 'Source Serif 4', 'Noto Serif SC', Georgia, serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-color: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab:hover .semi-tabs-tab-button {
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab-active {
|
||||||
|
background: rgba(255,255,255,0.06) !important;
|
||||||
|
border-color: rgba(255,255,255,0.1) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tab-active .semi-tabs-tab-button {
|
||||||
|
color: rgba(255,255,255,0.9) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.dark .semi-tabs-tabpane {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式 Modal/Popover */
|
||||||
|
.dark .semi-modal-content,
|
||||||
|
.dark .semi-popover {
|
||||||
|
background: rgba(25,25,25,1) !important;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08) !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式 Toast */
|
||||||
|
.dark .Toastify__toast {
|
||||||
|
background: rgba(25,25,25,1) !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255,255,255,0.06), 0 4px 16px rgba(0,0,0,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.home-distributor-recruit-card,
|
.home-distributor-recruit-card,
|
||||||
.home-distributor-recruit-card::before,
|
.home-distributor-recruit-card::before,
|
||||||
|
|
@ -1605,7 +1837,7 @@ html.dark .with-pastel-balls::before {
|
||||||
.semi-select,
|
.semi-select,
|
||||||
.semi-button,
|
.semi-button,
|
||||||
.semi-datepicker-range-input {
|
.semi-datepicker-range-input {
|
||||||
border-radius: 10px !important;
|
border-radius: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
@keyframes slideInLeft {
|
||||||
|
|
@ -1685,3 +1917,144 @@ html.dark .with-pastel-balls::before {
|
||||||
.font-body {
|
.font-body {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== Dashboard Section ==================== */
|
||||||
|
.dashboard-section {
|
||||||
|
background-color: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section:hover {
|
||||||
|
border-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Dashboard 样式覆盖 ==================== */
|
||||||
|
|
||||||
|
/* Timeline — 暗色适配 */
|
||||||
|
.dark .semi-timeline-item-body-wrapper::before {
|
||||||
|
border-left-color: var(--semi-color-border) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-timeline-item-connect-line {
|
||||||
|
border-left-color: var(--semi-color-border) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-timeline-item-dot {
|
||||||
|
background-color: var(--semi-color-primary) !important;
|
||||||
|
border-color: var(--semi-color-border) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-timeline-item-time {
|
||||||
|
color: var(--semi-color-text-2) !important;
|
||||||
|
font-family: 'JetBrains Mono', monospace !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
.dark .semi-timeline-item-content {
|
||||||
|
color: var(--semi-color-text-0) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-timeline-item-extra {
|
||||||
|
color: var(--semi-color-text-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapse — 暗色适配 */
|
||||||
|
.dark .semi-collapse-item {
|
||||||
|
border-color: var(--semi-color-border) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-collapse-item-header {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--semi-color-text-0) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-collapse-item-content {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--semi-color-text-2) !important;
|
||||||
|
font-family: 'JetBrains Mono', monospace !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
.dark .semi-collapse-item-header:hover {
|
||||||
|
background-color: var(--semi-color-fill-0) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-collapse-expand-icon {
|
||||||
|
color: var(--semi-color-text-2) !important;
|
||||||
|
}
|
||||||
|
.dark .semi-collapse-item-header-text {
|
||||||
|
color: var(--semi-color-text-0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar — 暗色滚动条 */
|
||||||
|
.dark ::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--semi-color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--semi-color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart container — 透明背景 */
|
||||||
|
.dark .vchart canvas,
|
||||||
|
.dark .vchart svg {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== Lucky Bag Animations ==================== */
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-20px) rotate(5deg);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px) rotate(-3deg);
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-25px) rotate(2deg);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-float {
|
||||||
|
animation: float linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-10px) rotate(0deg) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(400px) rotate(720deg) scale(0.3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-confetti {
|
||||||
|
animation: confetti-fall ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-float,
|
||||||
|
.animate-confetti,
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import '@douyinfe/semi-ui/dist/css/semi.css';
|
import './semi.css';
|
||||||
import { UserProvider } from './context/User';
|
import { UserProvider } from './context/User';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { StatusProvider } from './context/Status';
|
import { StatusProvider } from './context/Status';
|
||||||
|
|
|
||||||
|
|
@ -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,6 +22,7 @@ export default {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
transparent: 'transparent',
|
transparent: 'transparent',
|
||||||
current: 'currentColor',
|
current: 'currentColor',
|
||||||
|
|
@ -157,7 +158,6 @@ export default {
|
||||||
'semi-color-data-18': 'var(--semi-color-data-18)',
|
'semi-color-data-18': 'var(--semi-color-data-18)',
|
||||||
'semi-color-data-19': 'var(--semi-color-data-19)',
|
'semi-color-data-19': 'var(--semi-color-data-19)',
|
||||||
},
|
},
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
heading: ['"Source Serif 4"', '"Noto Serif SC"', 'Georgia', 'serif'],
|
heading: ['"Source Serif 4"', '"Noto Serif SC"', 'Georgia', 'serif'],
|
||||||
serif: ['"Playfair Display"', '"Noto Serif SC"', 'serif'],
|
serif: ['"Playfair Display"', '"Noto Serif SC"', 'serif'],
|
||||||
|
|
@ -202,6 +202,9 @@ export default {
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.2s ease-in-out',
|
'fade-in': 'fadeIn 0.2s ease-in-out',
|
||||||
'slide-up': 'slideUp 0.2s ease-out',
|
'slide-up': 'slideUp 0.2s ease-out',
|
||||||
|
'float': 'float linear infinite',
|
||||||
|
'confetti': 'confettiFall ease-out forwards',
|
||||||
|
'shimmer': 'shimmer 2s infinite',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
|
|
@ -212,6 +215,20 @@ export default {
|
||||||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
},
|
},
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0) rotate(0deg)', opacity: '0.2' },
|
||||||
|
'25%': { transform: 'translateY(-20px) rotate(5deg)', opacity: '0.35' },
|
||||||
|
'50%': { transform: 'translateY(-10px) rotate(-3deg)', opacity: '0.25' },
|
||||||
|
'75%': { transform: 'translateY(-25px) rotate(2deg)', opacity: '0.3' },
|
||||||
|
},
|
||||||
|
confettiFall: {
|
||||||
|
'0%': { transform: 'translateY(-10px) rotate(0deg) scale(1)', opacity: '1' },
|
||||||
|
'100%': { transform: 'translateY(400px) rotate(720deg) scale(0.3)', opacity: '0' },
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue