Compare commits

...

10 Commits

Author SHA1 Message Date
wangxiaoji f55309e953 首页改版:紫色主题 + 登录页重构 2026-07-03 17:51:11 +08:00
wangxiaoji 6836012ab4 修复 react-icons 5.7.0 中 SiSlack 被移除导致的编译错误
- render.jsx: 用内联 SVG 替代已移除的 SiSlack 图标
- vite.config.js: 移除 force: true 并添加 date-fns/locale 预构建,修复 semi-ui locale 模块优化崩溃
- package.json: 添加 antd 为直接依赖(@lobehub/ui 的必要 peer 依赖)
2026-07-02 14:41:00 +08:00
wangxiaoji c8d76437cb 移除全局搜索和模型对比功能 2026-07-01 16:30:53 +08:00
wangxiaoji 04c4e17dcc 新增全局搜索、新用户引导、模型对比工具
- 顶部搜索框:/ 键唤起,搜索模型名称/描述/标签,点击跳转到定价页
- 新用户引导:首次登录弹出三步向导(获取 Key → 选择模型 → 首次调用)
- 模型对比:并排对比最多4个模型的价格、支持功能、标签等属性
2026-07-01 16:24:26 +08:00
wangxiaoji 42070a5300 RPM/TPM 卡片样式统一:移除蓝色字体与 monospace,与其他统计卡片保持一致 2026-07-01 16:07:24 +08:00
wangxiaoji 01900f1a3a 操作日志中文化与功能优化
- 后端操作描述统一使用中文冒号(:)替换英文冒号(:)
- 前端资源名称列 ID 显示改为中文冒号
- 清理日志改为弹窗内独立日期选择器,不再依赖筛选条件
- 侧边栏操作记录移至渠道管理上方
- RPM/TPM 卡片标签改为中文显示
2026-07-01 16:03:03 +08:00
wangxiaoji 006e723ac5 新增功能:用量可视化、操作日志页面优化、CSV导出
- RechargeCard 增加额度使用进度条与实时 RPM/TPM 展示(5列网格布局)
- 统一操作日志页面布局风格,与其他控制台页面一致
- 充值账单与使用日志均支持 CSV 导出
- 新增 helpers/export.js 导出工具函数
2026-07-01 15:43:41 +08:00
wangxiaoji 6a210b9667 统一控制台页面布局:替换 mt-[60px] px-2 为统一 min-h-full pb-16 pt-20 + max-w-[1400px] 居中容器
涉及页面:Channel、Token、Redemption、Log、Task、Midjourney、Model、ModelHeat、ModelDeployment、Subscription、User、Setting、Supplier Dashboard/Apply/Channel/PricingSettings、SupplierAdmin list/application、About、DistributorAdmin/Center/Apply、DeploymentAccessGuard、PersonalSetting、Dashboard 等全部控制台页面
2026-07-01 15:18:35 +08:00
wangxiaoji 001ed4c17f 缩小API测速页面字体,与Dashboard风格统一 2026-07-01 14:22:21 +08:00
wangxiaoji ac1120fc33 重构充值页面和统一控制台页面风格
- 充值页面:全新设计余额概览、预设金额卡片、支付方式、兑换码区域
- 控制台主页:移除灰色背景,统一页面边距和宽度
- API测速页:移至控制台下,调整字体大小和间距
- 侧边栏:新增API测速菜单入口
- 修复个人设置页TwoFASetting组件缺少IconShield导入
- 多个页面统一顶部间距和背景样式
2026-07-01 14:13:15 +08:00
64 changed files with 3563 additions and 1921 deletions

View File

@ -1348,7 +1348,7 @@ func AddChannel(c *gin.Context) {
if addChannelRequest.Channel != nil {
channelName = addChannelRequest.Channel.Name
}
service.RecordCreateOperation(c, "channel", 0, channelName, "创建渠道: "+channelName, "")
service.RecordCreateOperation(c, "channel", 0, channelName, "创建渠道"+channelName, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -1366,7 +1366,7 @@ func DeleteChannel(c *gin.Context) {
}
model.InitChannelCache()
// 记录操作日志
service.RecordDeleteOperation(c, "channel", id, "", fmt.Sprintf("删除渠道 (ID: %d)", id), "")
service.RecordDeleteOperation(c, "channel", id, "", fmt.Sprintf("删除渠道 (ID%d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -1721,7 +1721,7 @@ func UpdateChannel(c *gin.Context) {
channel.Key = ""
clearChannelInfo(&channel.Channel)
// 记录操作日志
service.RecordUpdateOperation(c, "channel", channel.Id, channel.Name, "更新渠道: "+channel.Name, "")
service.RecordUpdateOperation(c, "channel", channel.Id, channel.Name, "更新渠道"+channel.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -270,7 +270,7 @@ func CreateModelMeta(c *gin.Context) {
return
}
model.RefreshPricing()
service.RecordCreateOperation(c, "model", m.Id, m.ModelName, "创建模型: "+m.ModelName, "")
service.RecordCreateOperation(c, "model", m.Id, m.ModelName, "创建模型"+m.ModelName, "")
common.ApiSuccess(c, &m)
}
@ -319,7 +319,7 @@ func UpdateModelMeta(c *gin.Context) {
}
}
model.RefreshPricing()
service.RecordUpdateOperation(c, "model", m.Id, m.ModelName, "更新模型: "+m.ModelName, "")
service.RecordUpdateOperation(c, "model", m.Id, m.ModelName, "更新模型"+m.ModelName, "")
common.ApiSuccess(c, &m)
}
@ -336,7 +336,7 @@ func DeleteModelMeta(c *gin.Context) {
return
}
model.RefreshPricing()
service.RecordDeleteOperation(c, "model", id, "", fmt.Sprintf("删除模型 (ID: %d)", id), "")
service.RecordDeleteOperation(c, "model", id, "", fmt.Sprintf("删除模型 (ID%d)", id), "")
common.ApiSuccess(c, nil)
}

View File

@ -690,10 +690,10 @@ func UpdateOption(c *gin.Context) {
if modelPricingOptionKeys[option.Key] {
changes := computeModelPricingDiff(oldVal, valStr)
if len(changes) > 0 {
service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, changes, "")
service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置"+option.Key, changes, "")
}
} else {
service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, "")
service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置"+option.Key, "")
}
c.JSON(http.StatusOK, gin.H{

View File

@ -106,7 +106,7 @@ func AddRedemption(c *gin.Context) {
}
keys = append(keys, key)
}
service.RecordCreateOperation(c, "redemption", 0, redemption.Name, "创建兑换码: "+redemption.Name, "")
service.RecordCreateOperation(c, "redemption", 0, redemption.Name, "创建兑换码"+redemption.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -122,7 +122,7 @@ func DeleteRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordDeleteOperation(c, "redemption", id, "", fmt.Sprintf("删除兑换码 (ID: %d)", id), "")
service.RecordDeleteOperation(c, "redemption", id, "", fmt.Sprintf("删除兑换码 (ID%d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -161,7 +161,7 @@ func UpdateRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordUpdateOperation(c, "redemption", cleanRedemption.Id, cleanRedemption.Name, "更新兑换码: "+cleanRedemption.Name, "")
service.RecordUpdateOperation(c, "redemption", cleanRedemption.Id, cleanRedemption.Name, "更新兑换码"+cleanRedemption.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -228,7 +228,7 @@ func AddToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordCreateOperation(c, "token", cleanToken.Id, cleanToken.Name, "创建令牌: "+cleanToken.Name, "")
service.RecordCreateOperation(c, "token", cleanToken.Id, cleanToken.Name, "创建令牌"+cleanToken.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -243,7 +243,7 @@ func DeleteToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordDeleteOperation(c, "token", id, "", fmt.Sprintf("删除令牌 (ID: %d)", id), "")
service.RecordDeleteOperation(c, "token", id, "", fmt.Sprintf("删除令牌 (ID%d)", id), "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@ -308,7 +308,7 @@ func UpdateToken(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordUpdateOperation(c, "token", cleanToken.Id, cleanToken.Name, "更新令牌: "+cleanToken.Name, "")
service.RecordUpdateOperation(c, "token", cleanToken.Id, cleanToken.Name, "更新令牌"+cleanToken.Name, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",

View File

@ -1206,7 +1206,7 @@ func UpdateUser(c *gin.Context) {
if originUser.DistributorCommissionBps != updatedUser.DistributorCommissionBps {
changes = append(changes, service.FieldChange{Field: "分销佣金比例", OldValue: fmt.Sprintf("%d‱", originUser.DistributorCommissionBps), NewValue: fmt.Sprintf("%d‱", updatedUser.DistributorCommissionBps)})
}
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户: "+updatedUser.Username, changes, "")
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户"+updatedUser.Username, changes, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@ -1246,7 +1246,7 @@ func AdminClearUserBinding(c *gin.Context) {
}
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]: "+user.Username, "")
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]"+user.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@ -1421,7 +1421,7 @@ func DeleteUser(c *gin.Context) {
})
return
}
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户: "+originUser.Username, "")
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户"+originUser.Username, "")
}
func DeleteSelf(c *gin.Context) {
@ -1501,7 +1501,7 @@ func CreateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户: "+cleanUser.Username, "")
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户"+cleanUser.Username, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
@ -1781,7 +1781,7 @@ func ManageUser(c *gin.Context) {
}
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldLabel, NewValue: newLabel})
}
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+": "+user.Username, changes, "")
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+""+user.Username, changes, "")
}
switch req.Action {

View File

@ -222,7 +222,7 @@ func commonActionLabel(action string, targetType string, targetName string) stri
}
if targetName != "" {
return actionLabel + typeLabel + ": " + targetName
return actionLabel + typeLabel + "" + targetName
}
return actionLabel + typeLabel
}

76
web/deploy.py vendored Normal file
View File

@ -0,0 +1,76 @@
import paramiko
import os
import sys
host = "26.0.12.13"
user = "root"
password = "root@1234"
local_dist = r"D:\tuling_new\web\dist"
remote_dist = "/root/tokenFactory/web/dist"
print("Connecting...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(host, username=user, password=password)
# Clean old dist files AND rebuild Go
print("Cleaning old dist and rebuilding...")
stdin, stdout, stderr = ssh.exec_command(
"rm -rf /root/tokenFactory/web/dist && mkdir -p /root/tokenFactory/web/dist"
)
stdout.read()
sftp = ssh.open_sftp()
uploaded = 0
for root, dirs, files in os.walk(local_dist):
rel_path = os.path.relpath(root, local_dist)
if rel_path == ".":
remote_dir = remote_dist
else:
remote_dir = remote_dist + "/" + rel_path.replace("\\", "/")
stdin, stdout, stderr = ssh.exec_command(f"mkdir -p {remote_dir}")
stdout.read()
for f in files:
local_file = os.path.join(root, f)
remote_file = (remote_dir + "/" + f).replace("\\", "/")
try:
sftp.put(local_file, remote_file)
uploaded += 1
if uploaded % 30 == 0:
print(f" Uploaded {uploaded} files...")
except Exception as e:
print(f" FAILED: {f}: {e}")
sftp.close()
print(f"Uploaded {uploaded} files.")
# Rebuild Go
print("Building Go...")
stdin, stdout, stderr = ssh.exec_command(
"export PATH=$PATH:/usr/local/go/bin && cd /root/tokenFactory && go build -o tf . 2>&1"
)
out = stdout.read().decode()
err = stderr.read().decode()
if out.strip():
print("stdout:", out[-300:].strip())
if err.strip():
print("stderr:", err[-300:].strip())
# Replace binary file (not in a subdirectory — Docker mounts ./token-factory as a file)
print("Replacing binary...")
stdin, stdout, stderr = ssh.exec_command(
"rm -rf /root/tokenFactory/token-factory && cp /root/tokenFactory/tf /root/tokenFactory/token-factory && ls -la /root/tokenFactory/token-factory"
)
print(stdout.read().decode().strip())
# Docker restart
print("Restarting Docker...")
stdin, stdout, stderr = ssh.exec_command(
"cd /root/tokenFactory && docker compose down 2>&1 && docker compose up -d 2>&1"
)
print(stdout.read().decode()[-500:].strip())
ssh.close()
print("Done!")

1
web/package.json vendored
View File

@ -11,6 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"antd": "^5.29.3",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",

51
web/src/App.jsx vendored
View File

@ -18,6 +18,38 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { lazy, Suspense, useContext, useMemo } from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, info: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
this.setState({ info });
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 40, color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
<h2>页面崩溃: {this.state.error?.message}</h2>
<details>
<summary>Stack</summary>
<pre>{this.state.error?.stack}</pre>
</details>
<details>
<summary>Component Stack</summary>
<pre>{this.state.info?.componentStack}</pre>
</details>
</div>
);
}
return this.props.children;
}
}
import { Route, Routes, useLocation, useParams } from 'react-router-dom';
import Loading from './components/common/ui/Loading';
import User from './pages/User';
@ -63,6 +95,11 @@ import LuckyBag from './pages/LuckyBag';
import Drawing from './pages/Drawing';
import ProfitDashboard from './pages/ProfitDashboard';
import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse';
import Contact from './pages/Contact';
import About from './pages/About';
import UserAgreement from './pages/UserAgreement';
import PrivacyPolicy from './pages/PrivacyPolicy';
import Docs from './pages/Docs';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
@ -70,10 +107,6 @@ const DistributorApply = lazy(() => import('./pages/DistributorApply'));
const DistributorCenter = lazy(() => import('./pages/DistributorCenter'));
const DistributorAdmin = lazy(() => import('./pages/DistributorAdmin'));
const InviteRedirect = lazy(() => import('./pages/InviteRedirect'));
const About = lazy(() => import('./pages/About'));
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
const Docs = lazy(() => import('./pages/Docs'));
function DynamicOAuth2Callback() {
const { provider } = useParams();
@ -352,9 +385,11 @@ function App() {
path='/console/personal'
element={
<PrivateRoute>
<ErrorBoundary>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<PersonalSetting />
</Suspense>
</ErrorBoundary>
</PrivateRoute>
}
/>
@ -511,6 +546,14 @@ function App() {
</Suspense>
}
/>
<Route
path='/contact'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Contact />
</Suspense>
}
/>
<Route
path='/docs'
element={

View File

@ -15,16 +15,16 @@ import React, {
useRef,
useState,
} from 'react';
import { Modal, Form, Typography, Space, Button } from '@douyinfe/semi-ui';
import { IconLock } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
import { Lock as IconLock } from 'lucide-react';
import { Modal, Form, Typography, Space, Button } from '@douyinfe/semi-ui';
import {
API,
showError,
showSuccess,
mergeSelfResponseIntoLocalUser,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { buildAdminUserPhoneFieldRules } from '../table/users/modals/userPhoneFormRules';
const { Text, Title } = Typography;

View File

@ -44,18 +44,7 @@ import {
redirectApplyIntentToAdminIfNeeded,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Checkbox,
Divider,
Form,
Icon,
Input,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { Modal } from '@douyinfe/semi-ui';
import TelegramLoginButton from 'react-telegram-login';
import {
@ -71,6 +60,17 @@ import TwoFAVerification from './TwoFAVerification';
import { useTranslation } from 'react-i18next';
import { SiDiscord } from 'react-icons/si';
/* 与首页紫色版统一的色板 */
const ACC = '#635BFF';
const ACC_SOFT = '#EEEBFF';
const TINT = '#F6F4FF';
const INK = '#0A2540';
const SUB = '#425466';
const DIM = '#697386';
const LINE = '#E3E8EE';
const SOFT_BG = '#F6F9FC';
const BG_GRADIENT = `linear-gradient(180deg, ${TINT} 0%, #FFFFFF 70%, #FFFFFF 100%)`;
const LoginForm = () => {
let navigate = useNavigate();
const { t } = useTranslation();
@ -576,123 +576,112 @@ const LoginForm = () => {
};
const renderOAuthOptions = () => {
return (
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3} className='!text-gray-800'>
{systemName}
</Title>
</div>
const OauthButton = ({ icon, children, onClick, loading, disabled, brandColor }) => (
<button
type='button'
onClick={onClick}
disabled={disabled || loading}
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
style={{ borderColor: LINE, color: INK }}
>
<span
className='inline-flex items-center justify-center w-5 h-5 shrink-0'
style={{ color: brandColor || INK }}
>
{loading ? (
<span className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
style={{ borderColor: `${ACC} transparent transparent transparent` }}
/>
) : (
icon
)}
</span>
<span>{children}</span>
</button>
);
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div>
<div className='px-2 py-8'>
<div className='space-y-3'>
return (
<div className='w-full'>
<div
className='rounded-2xl border bg-white p-6 sm:p-7'
style={{ borderColor: LINE, boxShadow: '0 1px 2px rgba(15,23,42,0.04)' }}
>
<h2
className='text-center text-[18px] font-semibold mb-1'
style={{ color: INK }}
>
{t('欢迎回来')}
</h2>
<p className='text-center text-[12.5px] mb-6' style={{ color: DIM }}>
{t('选择登录方式继续')}
</p>
<div className='space-y-2.5'>
{status.wechat_login && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
}
<OauthButton
icon={<WeChatIcon style={{ color: '#07C160' }} />}
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className='ml-3'>{t('使用 微信 继续')}</span>
</Button>
{t('使用 微信 继续')}
</OauthButton>
)}
{status.github_oauth && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconGithubLogo size='large' />}
<OauthButton
icon={<IconGithubLogo size='large' style={{ color: '#181717' }} />}
onClick={handleGitHubClick}
loading={githubLoading}
disabled={githubButtonDisabled}
>
<span className='ml-3'>{githubButtonText}</span>
</Button>
{githubButtonText}
</OauthButton>
)}
{status.discord_oauth && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
<OauthButton
icon={
<SiDiscord
style={{
color: '#5865F2',
width: '20px',
height: '20px',
}}
/>
<SiDiscord style={{ color: '#5865F2', width: 18, height: 18 }} />
}
onClick={handleDiscordClick}
loading={discordLoading}
>
<span className='ml-3'>{t('使用 Discord 继续')}</span>
</Button>
{t('使用 Discord 继续')}
</OauthButton>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
<OauthButton
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
</Button>
{t('使用 OIDC 继续')}
</OauthButton>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
<OauthButton
icon={
<LinuxDoIcon
style={{
color: '#E95420',
width: '20px',
height: '20px',
}}
/>
<LinuxDoIcon style={{ color: '#E95420', width: 18, height: 18 }} />
}
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button>
{t('使用 LinuxDO 继续')}
</OauthButton>
)}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => (
<Button
<OauthButton
key={provider.slug}
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={getOAuthProviderIcon(provider.icon || '', 20)}
icon={getOAuthProviderIcon(provider.icon || '', 18)}
onClick={() => handleCustomOAuthClick(provider)}
loading={customOAuthLoading[provider.slug]}
>
<span className='ml-3'>
{t('使用 {{name}} 继续', { name: provider.name })}
</span>
</Button>
</OauthButton>
))}
{status.telegram_oauth && (
@ -705,188 +694,233 @@ const LoginForm = () => {
)}
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<IconKey size='large' />}
<OauthButton
icon={<IconKey size='large' style={{ color: INK }} />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
{t('使用 Passkey 登录')}
</OauthButton>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<Button
theme='solid'
type='primary'
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
icon={<IconMail size='large' />}
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
<div className='flex items-center gap-3 my-5'>
<div className='flex-1 h-px' style={{ background: LINE }} />
<span
className='text-[11px] tracking-[0.2em] font-mono'
style={{ color: DIM }}
>
{t('或')}
</span>
<div className='flex-1 h-px' style={{ background: LINE }} />
</div>
<button
type='button'
onClick={handleEmailLoginClick}
disabled={emailLoginLoading}
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full text-[13.5px] font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50'
style={{ background: INK }}
>
<IconMail size='large' style={{ color: '#FFFFFF' }} />
<span>{t('使用 邮箱或用户名 登录')}</span>
</button>
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='mt-6'>
<Checkbox
<label className='mt-5 flex items-start gap-2 cursor-pointer select-none'>
<span className='relative inline-flex items-center mt-0.5'>
<input
type='checkbox'
className='peer sr-only'
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
/>
<span
className='w-4 h-4 rounded border flex items-center justify-center transition-colors peer-checked:border-transparent'
style={{
borderColor: LINE,
background: agreedToTerms ? ACC : 'transparent',
}}
>
<Text size='small' className='text-gray-600'>
{agreedToTerms && (
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1.5 5.5L4 8L8.5 2.5'
stroke='#FFFFFF'
strokeWidth='1.6'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)}
</span>
</span>
<span className='text-[12px] leading-[1.5]' style={{ color: SUB }}>
{t('我已阅读并同意')}
{hasUserAgreement && (
<>
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
className='mx-1 font-medium'
style={{ color: ACC }}
>
{t('用户协议')}
</a>
</>
)}
{hasUserAgreement && hasPrivacyPolicy && t('和')}
{hasPrivacyPolicy && (
<>
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
className='mx-1 font-medium'
style={{ color: ACC }}
>
{t('隐私政策')}
</a>
</>
)}
</Text>
</Checkbox>
</span>
</label>
)}
</div>
)}
{!status.self_use_mode_enabled && (
<div className='mt-6 text-center text-sm'>
<Text>
<div className='mt-5 text-center text-[13px]' style={{ color: SUB }}>
{t('没有账户?')}{' '}
<Link
to='/register'
className='text-blue-600 hover:text-blue-800 font-medium'
className='font-medium'
style={{ color: ACC }}
>
{t('注册')}
</Link>
</Text>
</div>
)}
</div>
</Card>
</div>
</div>
);
};
const renderEmailLoginForm = () => {
const inputBaseClass =
'w-full h-11 px-3 rounded-lg border bg-white text-[13.5px] outline-none transition-colors focus:border-[#635BFF] focus:ring-2 focus:ring-[#635BFF]/15';
const inputStyle = {
borderColor: LINE,
color: INK,
};
const labelClass = 'text-[12.5px] font-medium mb-1.5 block';
return (
<div className='flex flex-col items-center'>
<div className='w-full max-w-md'>
<div className='flex items-center justify-center mb-6 gap-2'>
<img src={logo} alt='Logo' className='h-10 rounded-full' />
<Title heading={3}>{systemName}</Title>
</div>
<Card className='border-0 !rounded-2xl overflow-hidden'>
<div className='flex justify-center pt-6 pb-2'>
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
{t('登 录')}
</Title>
</div>
<div className='px-2 py-8'>
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
type='tertiary'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
<div className='w-full'>
<div
className='rounded-2xl border bg-white p-6 sm:p-7'
style={{ borderColor: LINE, boxShadow: '0 1px 2px rgba(15,23,42,0.04)' }}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Form className='space-y-3'>
<Form.Input
field='username'
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name='username'
onChange={(value) => handleChange('username', value)}
prefix={<IconMail />}
/>
<h2
className='text-center text-[18px] font-semibold mb-1'
style={{ color: INK }}
>
{t('登 录')}
</h2>
<p className='text-center text-[12.5px] mb-6' style={{ color: DIM }}>
{t('使用 邮箱或用户名 继续')}
</p>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入您的密码')}
name='password'
mode='password'
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
{status.passkey_login && passkeySupported && (
<button
type='button'
onClick={handlePasskeyLogin}
disabled={passkeyLoading}
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50 mb-4'
style={{ borderColor: LINE, color: INK }}
>
<span className='inline-flex items-center justify-center w-5 h-5 shrink-0'>
{passkeyLoading ? (
<span
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
style={{ borderColor: `${ACC} transparent transparent transparent` }}
/>
) : (
<IconKey size='large' style={{ color: INK }} />
)}
</span>
<span>{t('使用 Passkey 登录')}</span>
</button>
)}
<div className='space-y-3'>
<div>
<label className={labelClass} style={{ color: INK }}>
{t('用户名或邮箱')}
</label>
<div className='relative'>
<span className='absolute left-3 top-1/2 -translate-y-1/2 inline-flex items-center justify-center pointer-events-none'
style={{ color: DIM }}>
<IconMail />
</span>
<input
type='text'
name='username'
placeholder={t('请输入您的用户名或邮箱地址')}
value={username}
onChange={(e) => handleChange('username', e.target.value)}
className={inputBaseClass + ' pl-10'}
style={inputStyle}
autoComplete='username'
/>
</div>
</div>
<div>
<label className={labelClass} style={{ color: INK }}>
{t('密码')}
</label>
<div className='relative'>
<span className='absolute left-3 top-1/2 -translate-y-1/2 inline-flex items-center justify-center pointer-events-none'
style={{ color: DIM }}>
<IconLock />
</span>
<input
type='password'
name='password'
placeholder={t('请输入您的密码')}
value={password}
onChange={(e) => handleChange('password', e.target.value)}
className={inputBaseClass + ' pl-10'}
style={inputStyle}
autoComplete='current-password'
/>
</div>
</div>
{captchaEnabled && (
<div style={{ marginBottom: '8px' }}>
<div
style={{
fontSize: '14px',
fontWeight: 500,
color: 'var(--semi-color-text-0, #1c1f23)',
marginBottom: '8px',
}}
>
<div>
<label className={labelClass} style={{ color: INK }}>
{t('验证码')}
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
<div style={{ flex: 1 }}>
<Input
</label>
<div className='flex gap-2'>
<input
type='text'
placeholder={t('请输入验证码')}
onChange={(value) => setCaptchaCode(value)}
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
className={inputBaseClass + ' flex-1'}
style={inputStyle}
/>
</div>
<div
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
height: '32px',
borderRadius: '4px',
overflow: 'hidden',
border: '1px solid var(--semi-color-border, #d9d9d9)',
flexShrink: 0,
}}
onClick={fetchCaptcha}
title={t('点击刷新验证码')}
className='h-11 inline-flex items-center justify-center px-3 rounded-lg border cursor-pointer overflow-hidden shrink-0'
style={{ borderColor: LINE, background: '#FFFFFF' }}
>
{captchaImage ? (
<img
src={captchaImage}
alt='验证码'
style={{ height: '32px', width: 'auto', display: 'block' }}
className='h-8 w-auto block'
/>
) : (
<span
style={{
padding: '0 12px',
fontSize: '12px',
color: '#999',
whiteSpace: 'nowrap',
}}
>
<span className='text-[12px]' style={{ color: DIM }}>
{captchaLoading ? '...' : t('点击获取')}
</span>
)}
@ -896,107 +930,140 @@ const LoginForm = () => {
)}
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='pt-4'>
<Checkbox
<label className='pt-1 flex items-start gap-2 cursor-pointer select-none'>
<span className='relative inline-flex items-center mt-0.5'>
<input
type='checkbox'
className='peer sr-only'
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
/>
<span
className='w-4 h-4 rounded border flex items-center justify-center transition-colors peer-checked:border-transparent'
style={{
borderColor: LINE,
background: agreedToTerms ? ACC : 'transparent',
}}
>
<Text size='small' className='text-gray-600'>
{agreedToTerms && (
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
<path
d='M1.5 5.5L4 8L8.5 2.5'
stroke='#FFFFFF'
strokeWidth='1.6'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)}
</span>
</span>
<span className='text-[12px] leading-[1.5]' style={{ color: SUB }}>
{t('我已阅读并同意')}
{hasUserAgreement && (
<>
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
className='mx-1 font-medium'
style={{ color: ACC }}
>
{t('用户协议')}
</a>
</>
)}
{hasUserAgreement && hasPrivacyPolicy && t('和')}
{hasPrivacyPolicy && (
<>
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
className='mx-1 font-medium'
style={{ color: ACC }}
>
{t('隐私政策')}
</a>
</>
)}
</Text>
</Checkbox>
</div>
</span>
</label>
)}
<div className='space-y-2 pt-2'>
<Button
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
<button
type='submit'
onClick={handleSubmit}
loading={loginLoading}
disabled={
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
loginLoading ||
((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms)
}
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full text-[13.5px] font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50'
style={{ background: INK }}
>
{t('继续')}
</Button>
{loginLoading ? (
<span
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
style={{ borderColor: '#FFFFFF transparent transparent transparent' }}
/>
) : null}
<span>{t('继续')}</span>
</button>
<Button
theme='borderless'
type='tertiary'
className='w-full !rounded-full'
<button
type='button'
onClick={handleResetPasswordClick}
loading={resetPasswordLoading}
disabled={resetPasswordLoading}
className='w-full h-11 inline-flex items-center justify-center rounded-full text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
style={{ color: SUB }}
>
{t('忘记密码?')}
</Button>
</button>
</div>
</div>
</Form>
{hasOAuthLoginOptions && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className='mt-4 text-center'>
<Button
theme='outline'
type='tertiary'
className='w-full !rounded-full'
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
<div className='flex items-center gap-3 my-5'>
<div className='flex-1 h-px' style={{ background: LINE }} />
<span
className='text-[11px] tracking-[0.2em] font-mono'
style={{ color: DIM }}
>
{t('其他登录选项')}
</Button>
{t('或')}
</span>
<div className='flex-1 h-px' style={{ background: LINE }} />
</div>
<button
type='button'
onClick={handleOtherLoginOptionsClick}
disabled={otherLoginOptionsLoading}
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
style={{ borderColor: LINE, color: INK }}
>
{otherLoginOptionsLoading ? (
<span
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
style={{ borderColor: `${ACC} transparent transparent transparent` }}
/>
) : null}
<span>{t('其他登录选项')}</span>
</button>
</>
)}
</div>
{!status.self_use_mode_enabled && (
<div className='mt-6 text-center text-sm'>
<Text>
<div className='mt-5 text-center text-[13px]' style={{ color: SUB }}>
{t('没有账户?')}{' '}
<Link
to='/register'
className='text-blue-600 hover:text-blue-800 font-medium'
className='font-medium'
style={{ color: ACC }}
>
{t('注册')}
</Link>
</Text>
</div>
)}
</div>
</Card>
</div>
</div>
);
};
@ -1025,17 +1092,24 @@ const LoginForm = () => {
</p>
</div>
<Form>
<Form.Input
field='wechat_verification_code'
<div>
<label
className='text-[12.5px] font-medium mb-1.5 block'
style={{ color: INK }}
>
{t('验证码')}
</label>
<input
type='text'
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
onChange={(e) =>
handleChange('wechat_verification_code', e.target.value)
}
className='w-full h-11 px-3 rounded-lg border bg-white text-[13.5px] outline-none transition-colors focus:border-[#635BFF] focus:ring-2 focus:ring-[#635BFF]/15'
style={{ borderColor: LINE, color: INK }}
/>
</Form>
</div>
</Modal>
);
};
@ -1077,40 +1151,67 @@ const LoginForm = () => {
);
};
const brandTagline = t('统一 AI 模型网关,聚合全球顶尖大模型,一站接入即刻使用');
const brandTagline = t('统一 AI 模型接口,几十个模型,一个 base_url。');
return (
<div className='flex min-h-screen'>
{/* 左侧品牌展示区 */}
<div className='hidden lg:flex lg:w-1/2 relative overflow-hidden bg-zinc-900 flex-col items-center justify-center px-12'>
<div className='flex min-h-screen bg-white'>
{/* 左侧品牌展示区 — 浅紫渐变,呼应首页 hero */}
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-120px', right: '-120px', width: '400px', height: '400px' }}
/>
className='hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col items-center justify-center px-12'
style={{ background: BG_GRADIENT }}
>
<div
className='blur-ball blur-ball-teal'
style={{ bottom: '-80px', left: '-80px', width: '350px', height: '350px' }}
className='pointer-events-none absolute inset-0'
style={{
background: `radial-gradient(60% 60% at 50% 30%, ${ACC_SOFT} 0%, transparent 70%)`,
}}
/>
<div className='relative z-10 text-center max-w-md'>
<img src={logo} alt='Logo' className='w-20 h-20 rounded-2xl mx-auto mb-8 shadow-2xl' />
<h1 className='text-4xl font-bold text-white mb-4 tracking-tight'>{systemName}</h1>
<p className='text-lg text-zinc-400 leading-relaxed'>{brandTagline}</p>
<div className='mt-12 flex gap-3 justify-center'>
<div className='w-12 h-1 rounded-full bg-emerald-400' />
<div className='w-8 h-1 rounded-full bg-blue-400' />
<div className='w-8 h-1 rounded-full bg-violet-400' />
<div className='w-6 h-1 rounded-full bg-amber-400' />
<img
src={logo}
alt='Logo'
className='w-16 h-16 rounded-2xl mx-auto mb-8 object-cover'
style={{ boxShadow: '0 18px 40px -12px rgba(99,91,255,0.35)' }}
/>
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs mb-6'
style={{ borderColor: LINE, color: DIM, background: 'rgba(255,255,255,0.7)' }}>
<span className='h-1.5 w-1.5 rounded-full' style={{ background: ACC }} />
<span>{t('All systems normal')}</span>
</div>
<h1
className='font-heading text-[40px] font-semibold leading-[1.1] tracking-[-0.02em] mb-5'
style={{ color: INK }}
>
{systemName}
</h1>
<p
className='text-[15px] leading-relaxed max-w-[360px] mx-auto'
style={{ color: SUB }}
>
{brandTagline}
</p>
<div
className='mt-10 mx-auto inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-mono'
style={{ background: 'rgba(255,255,255,0.7)', color: DIM, border: `1px solid ${LINE}` }}
>
<span className='h-1.5 w-1.5 rounded-full bg-emerald-400' />
POST /v1/chat/completions · 312ms
</div>
</div>
</div>
{/* 右侧登录表单区 */}
<div className='flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-4 sm:px-6 lg:px-8 py-12'>
<div className='flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 bg-white'>
<div className='w-full max-w-sm'>
{/* 移动端显示 logo */}
<div className='lg:hidden flex items-center justify-center gap-2 mb-8'>
<img src={logo} alt='Logo' className='w-8 h-8 rounded-full' />
<span className='text-lg font-semibold'>{systemName}</span>
<span
className='text-lg font-semibold'
style={{ color: INK }}
>
{systemName}
</span>
</div>
{showEmailLogin || !hasOAuthLoginOptions
? renderEmailLoginForm()

View File

@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import PropTypes from 'prop-types';
import { Button } from '@douyinfe/semi-ui';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
/**

View File

@ -0,0 +1,69 @@
/*
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 { Inbox } from 'lucide-react';
const ACC = '#635BFF';
const ACC_SOFT = '#EEEBFF';
const INK = '#0A2540';
const SUB = '#425466';
const DIM = '#697386';
const toText = (v) =>
typeof v === 'string'
? v
: v?.props?.children || '';
const EmptyState = ({ icon, title, description, children, style }) => {
const Icon = icon || Inbox;
const titleText = toText(title);
const descText = toText(description);
return (
<div
className='flex flex-col items-center justify-center py-12 text-center'
style={style}
>
<div
className='inline-flex h-12 w-12 items-center justify-center rounded-2xl'
style={{ background: ACC_SOFT, color: ACC }}
>
<Icon size={22} />
</div>
{titleText && (
<p className='mt-4 text-sm font-semibold' style={{ color: INK }}>
{titleText}
</p>
)}
{descText && (
<p className='mt-1 text-sm' style={{ color: DIM }}>
{descText}
</p>
)}
{!titleText && !descText && (
<p className='mt-4 text-sm' style={{ color: DIM }}>
暂无数据
</p>
)}
{children && <div className='mt-4'>{children}</div>}
</div>
);
};
export default EmptyState;

View File

@ -50,8 +50,7 @@ const Dashboard = () => {
const { unreadCount } = useUserMessageUnreadCount(user);
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='flex flex-col gap-6 pb-8'>
<TopBar
onSearch={() => {}}
onRefresh={dashboard.refresh}
@ -60,7 +59,7 @@ const Dashboard = () => {
unreadCount={unreadCount}
/>
<div className='space-y-6 pt-8 pb-8'>
<div className='space-y-6 pb-8'>
<Hero user={user} />
{/* 第一行5 个核心指标卡 */}
@ -98,7 +97,6 @@ const Dashboard = () => {
<span>{getSystemName()} · 2026</span>
</div>
</div>
</div>
);
};

View File

@ -23,6 +23,7 @@ import { IconClose } from '@douyinfe/semi-icons';
import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import OnboardingWizard from '../onboarding/OnboardingWizard';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
@ -402,6 +403,7 @@ const PageLayout = () => {
)}
</Layout>
</Layout>
<OnboardingWizard />
<ToastContainer />
</Layout>
);

View File

@ -52,6 +52,7 @@ const routerMap = {
'model-heat': '/console/model-heat',
playground: '/console/playground',
benchmarks: '/benchmarks',
'api-tester': '/console/api-tester',
personal: '/console/personal',
supplier: null,
distributor: '/console/distributor/admin',
@ -90,6 +91,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
itemKey: 'benchmarks',
to: '/benchmarks',
},
{
text: t('API 测速'),
itemKey: 'api-tester',
to: '/api-tester',
},
{
text: t('数据看板'),
itemKey: 'detail',
@ -201,6 +207,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
const adminItems = useMemo(() => {
const items = [
{
text: t('操作记录'),
itemKey: 'operation-log',
to: '/console/operation-log',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('渠道管理'),
itemKey: 'channel',
@ -243,12 +255,6 @@ const SiderBar = ({ onNavigate = () => {} }) => {
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('操作记录'),
itemKey: 'operation-log',
to: '/console/operation-log',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('代理管理'),
itemKey: 'distributor',

View File

@ -17,86 +17,88 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useRef, useEffect } from 'react';
import { Input, Dropdown, Typography } from '@douyinfe/semi-ui';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Input, Typography } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, isAdmin, showSuccess } from '../../../helpers';
import { useNavigate } from 'react-router-dom';
const mockSearchData = [
{
month: '四月 2026',
items: [
{ id: 1, name: 'Google: Gemma 4 31B', icon: '⬥', color: 'text-blue-500' },
{
id: 2,
name: 'Qwen: Qwen3.6 Plus (free)',
icon: '⬡',
color: 'text-purple-500',
},
{
id: 3,
name: 'Z.ai: GLM 5V Turbo',
icon: '⬢',
color: 'text-gray-800 dark:text-white',
},
{
id: 4,
name: 'Arcee AI: Trinity Large Thinking',
icon: '⬢',
color: 'text-teal-500',
},
{
id: 5,
name: 'xAI: Grok 4.20 Multi-Agent',
icon: '⚡',
color: 'text-gray-800 dark:text-white',
},
{
id: 6,
name: 'xAI: Grok 4.20',
icon: '⚡',
color: 'text-gray-800 dark:text-white',
},
],
},
{
month: '三月 2026',
items: [
{
id: 7,
name: 'Google: Lyria 3 Pro Preview',
icon: '⬥',
color: 'text-blue-500',
},
],
},
];
const { Text } = Typography;
const SEARCH_DEBOUNCE_MS = 300;
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const SearchDropdown = ({ isMobile }) => {
const [searchValue, setSearchValue] = useState('');
const [visible, setVisible] = useState(false);
const [filteredData, setFilteredData] = useState(mockSearchData);
const [models, setModels] = useState([]);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef(null);
const inputRef = useRef(null);
const navigate = useNavigate();
//
useEffect(() => {
(async () => {
try {
const res = await API.get('/api/pricing', { disableDuplicate: true });
if (res?.data?.success && Array.isArray(res.data.data)) {
setModels(res.data.data);
}
} catch {}
})();
}, []);
const doSearch = useCallback(
(q) => {
const trimmed = q.trim();
if (!trimmed) {
setResults([]);
return;
}
setLoading(true);
const lower = trimmed.toLowerCase();
const matched = [];
for (const m of models) {
const name = (m.model_name || '').toLowerCase();
const desc = (m.description || '').toLowerCase();
const tags = (m.tags || '').toLowerCase();
if (name.includes(lower) || desc.includes(lower) || tags.includes(lower)) {
matched.push({
type: 'model',
label: m.model_name,
desc: m.description || '',
navigate: `/pricing?search=${encodeURIComponent(trimmed)}`,
});
}
if (matched.length >= 8) break;
}
setResults(matched);
setLoading(false);
},
[models],
);
const debouncedSearch = useCallback(
debounce(doSearch, SEARCH_DEBOUNCE_MS),
[doSearch],
);
useEffect(() => {
if (searchValue.trim() === '') {
setFilteredData(mockSearchData);
} else {
const filtered = mockSearchData
.map((group) => ({
...group,
items: group.items.filter((item) =>
item.name.toLowerCase().includes(searchValue.toLowerCase()),
),
}))
.filter((group) => group.items.length > 0);
setFilteredData(filtered);
}
}, [searchValue]);
debouncedSearch(searchValue);
}, [searchValue, debouncedSearch]);
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === '/') {
if (event.key === '/' && document.activeElement === document.body) {
event.preventDefault();
inputRef.current?.focus();
setVisible(true);
@ -106,89 +108,110 @@ const SearchDropdown = ({ isMobile }) => {
inputRef.current?.blur();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleItemClick = (item) => {
console.log('Selected:', item);
setVisible(false);
setSearchValue('');
setResults([]);
if (item.navigate) {
if (item.navigate.startsWith('/')) {
navigate(item.navigate);
} else {
window.open(item.navigate, '_blank');
}
}
};
const typeLabel = (type) => {
switch (type) {
case 'model': return '模型';
default: return '';
}
};
const renderDropdownContent = () => {
if (!searchValue.trim()) {
return (
<div className='w-80 md:w-96 max-h-96 overflow-y-auto'>
{filteredData.length > 0 ? (
filteredData.map((group) => (
<div key={group.month} className='py-2'>
<Typography.Text className='!px-4 !py-2 !text-xs !font-semibold !text-semi-color-text-2 dark:!text-gray-400 uppercase tracking-wider block'>
{group.month}
</Typography.Text>
<div>
{group.items.map((item) => (
<div className='px-4 py-8 text-center'>
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
输入关键词搜索模型
</Text>
</div>
);
}
if (loading) {
return (
<div className='px-4 py-8 text-center'>
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
搜索中...
</Text>
</div>
);
}
if (results.length === 0) {
return (
<div className='px-4 py-8 text-center'>
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
未找到匹配结果
</Text>
</div>
);
}
return (
<div className='max-h-96 overflow-y-auto py-2'>
{results.map((item, i) => (
<div
key={item.id}
key={`${item.type}-${i}`}
onClick={() => handleItemClick(item)}
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:bg-semi-color-fill-1 dark:hover:bg-gray-700 transition-colors'
>
<span className={`text-lg ${item.color}`}>{item.icon}</span>
<Typography.Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200'>
{item.name}
</Typography.Text>
<span className='shrink-0 rounded bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 text-xs text-blue-600 dark:text-blue-300'>
{typeLabel(item.type)}
</span>
<div className='min-w-0 flex-1'>
<Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200 block truncate'>
{item.label}
</Text>
{item.desc && (
<Text className='!text-xs !text-semi-color-text-2 dark:!text-gray-400 block truncate'>
{item.desc}
</Text>
)}
</div>
</div>
))}
</div>
</div>
))
) : (
<div className='px-4 py-8 text-center'>
<Typography.Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
No results found
</Typography.Text>
</div>
)}
</div>
);
};
return (
<div className='relative' ref={dropdownRef}>
<Dropdown
visible={visible}
onVisibleChange={setVisible}
position='bottomLeft'
trigger='custom'
getPopupContainer={() => dropdownRef.current}
render={
<div className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-800 dark:!border-gray-600'>
{renderDropdownContent()}
</div>
}
>
<div className='relative'>
<Input
ref={inputRef}
placeholder='Search'
prefix={
<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />
}
placeholder='搜索模型...'
prefix={<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />}
suffix={
<kbd className='px-1.5 py-0.5 text-xs font-semibold !text-semi-color-text-2 dark:!text-gray-400 !bg-semi-color-fill-0 dark:!bg-gray-700 border !border-semi-color-border dark:!border-gray-600 rounded'>
<kbd className='hidden sm:inline-block px-1.5 py-0.5 text-xs font-semibold text-semi-color-text-2 dark:text-gray-400 bg-semi-color-fill-0 dark:bg-gray-700 border border-semi-color-border dark:border-gray-600 rounded'>
/
</kbd>
}
value={searchValue}
onChange={setSearchValue}
onFocus={() => setVisible(true)}
className='!w-48 lg:!w-64 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
style={{ borderRadius: '6px', paddingRight: '10px' }}
onFocus={() => { if (searchValue.trim()) setVisible(true); }}
onBlur={() => setTimeout(() => setVisible(false), 150)}
className='!w-40 lg:!w-56 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
style={{ borderRadius: '6px' }}
/>
</div>
</Dropdown>
{visible && (results.length > 0 || searchValue.trim()) && (
<div className='absolute left-0 top-full mt-1 w-80 md:w-96 bg-semi-color-bg-overlay border border-semi-color-border shadow-lg rounded-lg dark:bg-gray-800 dark:border-gray-600 z-50'>
{renderDropdownContent()}
</div>
)}
</div>
);
};

View File

@ -43,22 +43,25 @@ const DeploymentAccessGuard = ({
if (loading) {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type='secondary'>{t('加载设置中...')}</Text>
</div>
</Card>
</div>
</div>
);
}
if (!isEnabled) {
return (
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
minHeight: 'calc(100vh - 160px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -277,18 +280,22 @@ const DeploymentAccessGuard = ({
</Card>
</div>
</div>
</div>
</div>
);
}
if (connectionLoading || (connectionOk === null && !connectionError)) {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type='secondary'>{t('正在检查 io.net 连接...')}</Text>
</div>
</Card>
</div>
</div>
);
}
@ -301,10 +308,11 @@ const DeploymentAccessGuard = ({
const detail = connectionError?.message || '';
return (
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
minHeight: 'calc(100vh - 160px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -403,6 +411,8 @@ const DeploymentAccessGuard = ({
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,198 @@
/*
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, { useState, useEffect, useContext } from 'react';
import { Modal, Button, Steps, Typography, Space } from '@douyinfe/semi-ui';
import { IconCopy, IconLink } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
import { copy, showSuccess } from '../../helpers';
const { Text, Title } = Typography;
const STORAGE_KEY = 'onboarding_completed';
const OnboardingWizard = () => {
const [visible, setVisible] = useState(false);
const [step, setStep] = useState(0);
const { t } = useTranslation();
const navigate = useNavigate();
const [userState] = useContext(UserContext);
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY);
if (!completed && userState?.user) {
setVisible(true);
}
}, [userState?.user]);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, '1');
setVisible(false);
};
const getTokenStepExample = () => {
const baseUrl = window.location.origin;
const demoKey = 'sk-your-api-key-here';
return `curl ${baseUrl}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${demoKey}" \\
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Hello!"}]
}'`;
};
const steps = [
{
title: t('获取 API Key'),
content: (
<div className='space-y-4'>
<Text>{t('前往令牌管理页面创建您的第一个 API Key。')}</Text>
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
<Text className='!text-sm !text-semi-color-text-2'>
1. 点击下方按钮进入令牌页面<br />
2. 点击添加令牌创建新 Key<br />
3. 复制生成的 sk- 开头的 Key 妥善保存
</Text>
</div>
<Button
type='primary'
icon={<IconLink />}
onClick={() => {
handleClose();
navigate('/console/token');
}}
>
{t('前往令牌管理')}
</Button>
</div>
),
},
{
title: t('选择模型'),
content: (
<div className='space-y-4'>
<Text>{t('浏览模型市场,选择适合您需求的模型。')}</Text>
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
<Text className='!text-sm !text-semi-color-text-2'>
支持文本对话图片生成视频生成语音等多种模型<br />
可按价格供应商功能标签筛选
</Text>
</div>
<Button
type='primary'
icon={<IconLink />}
onClick={() => {
handleClose();
navigate('/pricing');
}}
>
{t('浏览模型市场')}
</Button>
</div>
),
},
{
title: t('首次调用'),
content: (
<div className='space-y-4'>
<Text>{t('使用以下 cURL 命令发送您的第一个 API 请求:')}</Text>
<div className='relative rounded-lg bg-gray-900 dark:bg-gray-950 p-4 group'>
<Button
theme='borderless'
type='tertiary'
size='small'
icon={<IconCopy />}
className='!absolute top-2 right-2 !text-gray-400 hover:!text-white'
onClick={() => {
copy(getTokenStepExample());
showSuccess(t('已复制'));
}}
/>
<pre className='text-xs text-green-400 overflow-x-auto whitespace-pre-wrap'>
{getTokenStepExample()}
</pre>
</div>
<Text className='!text-xs !text-semi-color-text-2'>
{t('将 {key} 替换为您上一步创建的 API Key将 model 替换为您选择的模型名称。', { key: 'sk-your-api-key-here' })}
</Text>
<Button
type='tertiary'
icon={<IconLink />}
onClick={() => {
handleClose();
navigate('/docs');
}}
>
{t('查看完整 API 文档')}
</Button>
</div>
),
},
];
return (
<Modal
title={
<div className='flex items-center gap-2'>
<span className='text-lg'>🚀</span>
<Title heading={5} className='!mb-0'>
{t('欢迎使用')}
</Title>
</div>
}
visible={visible}
onCancel={handleClose}
footer={
<Space>
{step > 0 && (
<Button onClick={() => setStep((s) => s - 1)}>
{t('上一步')}
</Button>
)}
{step < steps.length - 1 ? (
<Button
type='primary'
onClick={() => setStep((s) => s + 1)}
>
{t('下一步')}
</Button>
) : (
<Button type='primary' onClick={handleClose}>
{t('开始使用')}
</Button>
)}
</Space>
}
width={560}
maskClosable={false}
>
<Steps current={step} className='mb-6'>
{steps.map((s) => (
<Steps.Step key={s.title} title={s.title} />
))}
</Steps>
<div className='min-h-[160px]'>{steps[step].content}</div>
</Modal>
);
};
export default OnboardingWizard;

View File

@ -555,7 +555,8 @@ const PersonalSetting = () => {
return (
<div className='mx-auto mt-[60px] max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
{/* 顶部用户信息区域 */}
<UserInfoHeader t={t} userState={userState} />
@ -677,6 +678,7 @@ const PersonalSetting = () => {
setTurnstileToken={setTurnstileToken}
/>
</div>
</div>
);
};

View File

@ -35,6 +35,7 @@ import {
IconAlertTriangle,
IconRefresh,
IconCopy,
IconShield,
} from '@douyinfe/semi-icons';
import { ScanFace } from 'lucide-react';
import React, { useEffect, useState } from 'react';

View File

@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Tag, Space, Skeleton } from '@douyinfe/semi-ui';
import { Tag, Space, Skeleton, Button } from '@douyinfe/semi-ui';
import { renderQuota } from '../../../helpers';
import CompactModeToggle from '../../common/ui/CompactModeToggle';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { Download } from 'lucide-react';
const LogsActions = ({
stat,
@ -29,6 +30,8 @@ const LogsActions = ({
showStat,
compactMode,
setCompactMode,
exporting,
handleExportLogs,
t,
}) => {
const showSkeleton = useMinimumLoadingTime(loadingStat);
@ -83,11 +86,23 @@ const LogsActions = ({
</Space>
</Skeleton>
<Space>
<Button
icon={<Download size={14} />}
size='small'
theme='outline'
type='tertiary'
loading={exporting}
onClick={handleExportLogs}
>
{t('导出 CSV')}
</Button>
<CompactModeToggle
compactMode={compactMode}
setCompactMode={setCompactMode}
t={t}
/>
</Space>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -28,15 +28,17 @@ import {
Input,
Tag,
Select,
Space,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { Coins } from 'lucide-react';
import { Coins, Download } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils';
import { exportCSV } from '../../../helpers/export';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Text } = Typography;
@ -202,6 +204,45 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
}
};
const [exporting, setExporting] = useState(false);
const handleExport = async () => {
setExporting(true);
try {
const adminNow = isAdmin();
const base = adminNow ? '/api/user/topup' : '/api/user/topup/self';
const tn = tradeNoFilter.trim();
const un = usernameFilter.trim();
const params = { p: 1, page_size: 10000 };
if (tn) params.trade_no = tn;
if (adminNow && un) params.username = un;
if (statusFilter && statusFilter !== TOPUP_STATUS_ALL) {
params.status = statusFilter;
}
const res = await API.get(base, { params, disableDuplicate: true });
const { success, data } = res.data;
if (success && data?.items?.length) {
const columns = [
{ title: t('订单号'), dataIndex: 'trade_no' },
{ title: t('用户名'), dataIndex: 'username' },
{ title: t('支付方式'), dataIndex: 'payment_method' },
{ title: t('充值额度'), dataIndex: 'amount' },
{ title: t('支付金额'), dataIndex: 'money' },
{ title: t('状态'), dataIndex: 'status' },
{ title: t('创建时间'), dataIndex: 'create_time', render: (v) => v ? timestamp2string(v) : '' },
];
exportCSV(data.items, columns, `topup-records-${new Date().toISOString().slice(0, 10)}`);
Toast.success({ content: t('导出成功') });
} else {
Toast.info({ content: t('无数据可导出') });
}
} catch {
Toast.error({ content: t('导出失败') });
} finally {
setExporting(false);
}
};
const confirmAdminComplete = (tradeNo) => {
Modal.confirm({
title: t('确认补单'),
@ -332,6 +373,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
>
{/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */}
<div className='mb-3 flex w-full flex-row flex-nowrap items-center gap-2 overflow-x-auto'>
<div className='shrink-0'>
<Button
icon={<Download size={14} />}
size='small'
theme='outline'
type='tertiary'
loading={exporting}
onClick={handleExport}
>
{t('导出 CSV')}
</Button>
</div>
{userIsAdmin ? (
<Input
className='min-w-[104px] flex-1'

View File

@ -19,9 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { reducer, initialState } from './reducer';
import { API } from '../../helpers/api';
import { initialState, reducer } from './reducer';
import { mergeSelfResponseIntoLocalUser } from '../../helpers/data';
import { normalizeLanguage } from '../../i18n/language';
import { API, mergeSelfResponseIntoLocalUser } from '../../helpers';
import AdminInitialSetupModal from '../../components/auth/AdminInitialSetupModal';
export const UserContext = React.createContext({

56
web/src/helpers/export.js vendored Normal file
View File

@ -0,0 +1,56 @@
/**
* 导出数据为 CSV 文件并触发浏览器下载
* @param {Array<object>} rows 数据行
* @param {Array<{title: string, dataIndex: string, render?: function}>} columns 列定义
* @param {string} filename 文件名不含扩展名
*/
export function exportCSV(rows, columns, filename = 'export') {
if (!rows || !rows.length) return;
const BOM = '';
const header = columns.map((col) => `"${(col.title || col.dataIndex).replace(/"/g, '""')}"`).join(',');
const body = rows
.map((row) =>
columns
.map((col) => {
let val = row[col.dataIndex];
if (typeof col.render === 'function') {
// render 函数返回 React 元素时提取文本
const rendered = col.render(val, row);
if (rendered == null || rendered === '') return '""';
if (typeof rendered === 'string' || typeof rendered === 'number') {
val = String(rendered).replace(/"/g, '""');
} else if (rendered && typeof rendered === 'object' && rendered.props) {
// 简单提取 React 元素文本
val = extractText(rendered).replace(/"/g, '""');
} else {
val = String(rendered).replace(/"/g, '""');
}
} else {
val = val != null ? String(val).replace(/"/g, '""') : '';
}
return `"${val}"`;
})
.join(','),
)
.join('\n');
const csv = BOM + header + '\n' + body;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function extractText(element) {
if (element == null) return '';
if (typeof element === 'string' || typeof element === 'number') return String(element);
if (Array.isArray(element)) return element.map(extractText).join('');
if (element.props && element.props.children) return extractText(element.props.children);
return '';
}

View File

@ -111,13 +111,18 @@ import {
SiOkta,
SiOpenid,
SiReddit,
SiSlack,
SiTelegram,
SiTwitch,
SiWechat,
SiX,
} from 'react-icons/si';
const SiSlack = (props) => (
<svg stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 24 24" height="1em" width="1em" {...props}>
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.528 2.528 0 0 1-2.521-2.522v-2.522h2.521zM15.165 17.688a2.527 2.527 0 0 1-2.521-2.523 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
</svg>
);
// Lucide
export function getLucideIcon(key, selected = false) {
const size = 16;

View File

@ -18,15 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Toast, Pagination } from '@douyinfe/semi-ui';
import { toastConstants } from '../constants';
import React from 'react';
import { toast } from 'react-toastify';
import {
THINK_TAG_REGEX,
MESSAGE_ROLES,
} from '../constants/playground.constants';
import { TABLE_COMPACT_MODES_KEY } from '../constants';
import { USER_ROLES } from '../constants/user.constants';
import { toastConstants } from '../constants/toast.constants';
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
const HTMLToastContent = ({ htmlContent }) => {
@ -144,6 +141,15 @@ export function parseCommissionPercentStringToBps(s) {
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return 'TuringToken';
// TokenFactory / TokenFactoryOpen / /
if (
system_name === 'TokenFactory' ||
system_name === 'TokenFactoryOpen' ||
system_name === '开放词元工厂'
) {
localStorage.setItem('system_name', 'TuringToken');
return 'TuringToken';
}
return system_name;
}

View File

@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Tag } from '@douyinfe/semi-ui';
import { Modal, Tag, Toast } from '@douyinfe/semi-ui';
import {
API,
getTodayStartTimestamp,
@ -38,6 +38,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { exportCSV } from '../../helpers/export';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => {
@ -550,6 +551,7 @@ export const useLogsData = () => {
visible: false,
record: null,
});
const [exporting, setExporting] = useState(false);
// Initialize default column visibility
const initDefaultColumns = () => {
@ -1307,6 +1309,60 @@ export const useLogsData = () => {
await loadLogs(1, pageSize);
};
const handleExportLogs = useCallback(async () => {
setExporting(true);
try {
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
request_id,
logType: formLogType,
} = getFormValues();
const currentLogType =
formLogType !== undefined ? formLogType : logType;
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url;
if (isAdminUser) {
url = `/api/log/?p=1&page_size=10000&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
} else {
url = `/api/log/self/?p=1&page_size=10000&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
}
url = encodeURI(url);
const res = await API.get(url);
const { success, data } = res.data;
if (success && data?.items?.length) {
const typeMap = { 0: t('未知'), 1: t('充值'), 2: t('消费'), 3: t('管理'), 4: t('系统'), 5: t('错误'), 6: t('退款') };
const columns = [
{ title: t('时间'), dataIndex: 'created_at', render: (v) => v ? timestamp2string(v) : '' },
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('用户名'), dataIndex: 'username' },
{ title: t('令牌'), dataIndex: 'token_name' },
{ title: t('分组'), dataIndex: 'group' },
{ title: t('类型'), dataIndex: 'type', render: (v) => typeMap[v] || t('未知') },
{ title: t('模型'), dataIndex: 'model_name' },
{ title: t('输入Tokens'), dataIndex: 'prompt_tokens' },
{ title: t('输出Tokens'), dataIndex: 'completion_tokens' },
{ title: t('花费'), dataIndex: 'quota', render: (v) => renderQuota(v) },
{ title: t('IP'), dataIndex: 'ip' },
];
exportCSV(data.items, columns, `usage-logs-${new Date().toISOString().slice(0, 10)}`);
Toast.success({ content: t('导出成功') });
} else {
Toast.info({ content: t('无数据可导出') });
}
} catch {
Toast.error({ content: t('导出失败') });
} finally {
setExporting(false);
}
}, [getFormValues, logType, isAdminUser, t]);
// Copy text function
const copyText = async (e, text) => {
e.stopPropagation();
@ -1408,6 +1464,10 @@ export const useLogsData = () => {
setLogType,
openParamOverrideModal,
// Export
exporting,
handleExportLogs,
// Translation
t,
};

22
web/src/i18n/i18n.js vendored
View File

@ -39,17 +39,17 @@ i18n
load: 'currentOnly',
supportedLngs: supportedLanguages,
resources: {
en: enTranslation,
'zh-CN': zhCNTranslation,
'zh-TW': zhTWTranslation,
fr: frTranslation,
ru: ruTranslation,
ja: jaTranslation,
vi: viTranslation,
id: idTranslation,
ms: msTranslation,
th: thTranslation,
sw: swTranslation,
en: { translation: enTranslation },
'zh-CN': { translation: zhCNTranslation },
'zh-TW': { translation: zhTWTranslation },
fr: { translation: frTranslation },
ru: { translation: ruTranslation },
ja: { translation: jaTranslation },
vi: { translation: viTranslation },
id: { translation: idTranslation },
ms: { translation: msTranslation },
th: { translation: thTranslation },
sw: { translation: swTranslation },
},
fallbackLng: 'zh-CN',
lng: 'zh-CN',

View File

@ -5695,6 +5695,21 @@
"主题切换": "Theme Toggle",
"顶栏显示主题切换按钮,允许用户切换亮色/暗色模式": "Show theme toggle in header bar, allowing users to switch between light/dark mode",
"语言选择器": "Language Selector",
"顶栏显示语言选择器,允许用户切换界面语言": "Show language selector in header bar, allowing users to switch interface language"
"顶栏显示语言选择器,允许用户切换界面语言": "Show language selector in header bar, allowing users to switch interface language",
"All systems normal": "All systems normal",
"函数": "function",
"一样轻。": "— that light.",
"把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url切换模型、改价格、改延迟不必动客户端。": "Bring OpenAI, Claude, Gemini, DeepSeek and dozens of other models behind a single base_url. Switch models, pricing, latency — no client changes.",
"免费注册": "Sign up free",
"联系销售": "Contact sales",
"注册即送 ¥10": "¥10 free credits on signup",
"无月费": "No monthly fee",
"比开放透明": "Transparent pricing",
"你的客户端只需一句": "Your client only needs one",
",其余的事——路由、价格、容灾——都替你做了。": ", and we handle routing, pricing, and failover for you.",
"立即接入。": "Integrate now.",
"注册送 ¥10 试用金,分键跑完一组 benchmark一拍压测。": "Get ¥10 trial credit on signup. Run a benchmark, then a load test.",
"定价": "Pricing",
"调用 AI 像调用": "Calling AI is as light as calling"
}
}

View File

@ -5554,6 +5554,21 @@
"主题切换": "主题切换",
"顶栏显示主题切换按钮,允许用户切换亮色/暗色模式": "顶栏显示主题切换按钮,允许用户切换亮色/暗色模式",
"语言选择器": "语言选择器",
"顶栏显示语言选择器,允许用户切换界面语言": "顶栏显示语言选择器,允许用户切换界面语言"
"顶栏显示语言选择器,允许用户切换界面语言": "顶栏显示语言选择器,允许用户切换界面语言",
"All systems normal": "All systems normal",
"函数": "函数",
"一样轻。": "一样轻。",
"把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url切换模型、改价格、改延迟不必动客户端。": "把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url切换模型、改价格、改延迟不必动客户端。",
"免费注册": "免费注册",
"联系销售": "联系销售",
"注册即送 ¥10": "注册即送 ¥10",
"无月费": "无月费",
"比开放透明": "比开放透明",
"你的客户端只需一句": "你的客户端只需一句",
",其余的事——路由、价格、容灾——都替你做了。": ",其余的事——路由、价格、容灾——都替你做了。",
"立即接入。": "立即接入。",
"注册送 ¥10 试用金,分键跑完一组 benchmark一拍压测。": "注册送 ¥10 试用金,分键跑完一组 benchmark一拍压测。",
"定价": "定价",
"调用 AI 像调用": "调用 AI 像调用"
}
}

2
web/src/index.css vendored
View File

@ -1187,7 +1187,7 @@ html.dark .home-distributor-cta-btn {
.distributor-apply-page-root {
position: fixed;
z-index: 1;
top: 64px;
top: 80px;
right: 0;
bottom: 0;
left: 0;

View File

@ -133,7 +133,8 @@ const About = () => {
);
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
{aboutLoaded && about === '' ? (
<div className='flex justify-center items-center h-screen p-8'>
<Empty
@ -167,6 +168,7 @@ const About = () => {
</>
)}
</div>
</div>
);
};

View File

@ -308,16 +308,16 @@ const ApiTester = () => {
};
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='min-h-full pb-16 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 pt-20 sm:px-6 lg:px-10'>
<div className='space-y-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'>
<h1 className='text-xl font-bold tracking-tight text-slate-900 md:text-2xl dark:text-white'>
API 快速测试
</h1>
<p className='mt-2 text-sm text-slate-500 dark:text-white/50'>
<p className='mt-2 text-xs text-slate-500 dark:text-white/50'>
当前协议{format === 'anthropic' ? 'Anthropic Messages' : 'OpenAI Chat Completions'} · 请求地址 {finalEndpoint}
</p>
</div>
@ -330,14 +330,14 @@ const ApiTester = () => {
</div>
{/* 第一行API Key / 测试目标 / 模型 */}
<div className='grid grid-cols-1 gap-5 lg:grid-cols-3'>
<div className='grid grid-cols-1 gap-6 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='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 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 className='text-xs font-semibold text-slate-900 dark:text-white'>API Key</div>
</div>
<div className='relative'>
<input
@ -360,19 +360,19 @@ const ApiTester = () => {
</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='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 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 className='text-xs 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'
className='inline-flex items-center gap-1 text-xs text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
title='切换自定义域名'
>
{showCustomBase ? '收起域名' : '换个平台'}
@ -386,10 +386,10 @@ const ApiTester = () => {
return acc;
}, {});
return (
<div className='space-y-2.5'>
<div className='space-y-3'>
{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'>
<div className='mb-1.5 text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
{group}
</div>
<div className='flex flex-wrap gap-1.5'>
@ -397,7 +397,7 @@ const ApiTester = () => {
<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'
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs 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>
@ -408,12 +408,12 @@ const ApiTester = () => {
<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 className='text-[11px] 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'
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs 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>
@ -431,7 +431,7 @@ const ApiTester = () => {
>
<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'
className='inline-flex items-center gap-1.5 px-3 py-1.5 text-xs 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>
@ -452,14 +452,14 @@ const ApiTester = () => {
})()}
<div className='mt-3'>
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
<label className='mb-1.5 block text-xs 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]'
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>
@ -487,22 +487,22 @@ const ApiTester = () => {
</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='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 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 className='text-xs 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]'
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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'>
<label className='mb-1.5 block text-xs font-medium text-slate-500 dark:text-white/50'>
max_tokensAnthropic 必填
</label>
<input
@ -511,7 +511,7 @@ const ApiTester = () => {
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]'
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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>
)}
@ -519,7 +519,7 @@ const ApiTester = () => {
</div>
{/* 第二行:请求参数 + 响应结果 */}
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
<div className='grid grid-cols-1 gap-6 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'>
@ -527,7 +527,7 @@ const ApiTester = () => {
<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 className='text-xs font-semibold text-slate-900 dark:text-white'>
请求参数
</div>
</div>
@ -547,7 +547,7 @@ const ApiTester = () => {
<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'>
<label className='mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
{format === 'anthropic' ? 'system顶层字段' : '系统提示'}
</label>
<textarea
@ -555,12 +555,12 @@ const ApiTester = () => {
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]'
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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'>
<label className='mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
用户提示 <span className='text-red-500'>*</span>
</label>
<textarea
@ -568,7 +568,7 @@ const ApiTester = () => {
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]'
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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>
@ -581,12 +581,12 @@ const ApiTester = () => {
<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 className='text-xs 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 ${
className={`ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs 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'
@ -607,7 +607,7 @@ const ApiTester = () => {
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'
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 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/60 dark:hover:border-white/20 dark:hover:text-white'
>
<Copy size={11} /> 复制
</button>
@ -621,7 +621,7 @@ const ApiTester = () => {
请求中...
</div>
) : response ? (
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-[11px] leading-relaxed text-slate-800 dark:text-white/80'>
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-xs leading-relaxed text-slate-800 dark:text-white/80'>
{response}
</pre>
) : (

View File

@ -38,9 +38,11 @@ const File = () => {
}, [editId]);
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<ChannelsTable />
</div>
</div>
);
};

View File

@ -0,0 +1,364 @@
/*
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, { useState } from 'react';
import { ArrowRight, Mail, MessageSquare, Building2, ShieldCheck, Check } from 'lucide-react';
import { Link } from 'react-router-dom';
const ACC = '#635BFF';
const ACC_SOFT = '#EEEBFF';
const BG = '#FFFFFF';
const SOFT = '#FBFAF7';
const TINT = '#F6F4FF';
const INK = '#0A2540';
const SUB = '#425466';
const DIM = '#697386';
const LINE = '#E3E8EE';
function Field({ label, value, onChange, type = 'text', required = false, placeholder = '' }) {
return (
<div>
<div className="mb-2 text-xs" style={{ color: DIM }}>
{label}{required && <span style={{ color: ACC }}> *</span>}
</div>
<input
type={type}
value={value}
onChange={onChange}
required={required}
placeholder={placeholder}
className="w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF]"
style={{ borderColor: LINE, color: INK }}
/>
</div>
);
}
function InfoCard({ icon, title, primary, sub }) {
return (
<div
className="rounded-xl border bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-sm"
style={{ borderColor: LINE }}
>
<div className="flex items-center gap-2.5">
<div
className="inline-flex h-8 w-8 items-center justify-center rounded-lg shrink-0"
style={{ background: ACC_SOFT, color: ACC }}
>
{icon}
</div>
<div className="text-sm font-semibold tracking-tight" style={{ color: INK }}>{title}</div>
</div>
<div className="mt-3.5 pl-[42px]">
<div className="text-[15px] font-medium" style={{ color: ACC }}>{primary}</div>
<div className="mt-1 text-xs leading-[1.6]" style={{ color: DIM }}>{sub}</div>
</div>
</div>
);
}
function MiniTopBar() {
return (
<header className="sticky top-0 z-50 border-b border-[#E3E8EE] bg-white/85 backdrop-blur-md">
<div className="flex h-16 items-center px-6 lg:px-10 max-w-[1200px] mx-auto">
<Link to="/" className="flex items-center gap-2.5">
<img src="/logo.jpg" alt="logo" className="h-7 w-7 rounded-full" />
<span className="font-heading text-[15px] font-semibold tracking-tight" style={{ color: INK }}>TuringToken</span>
</Link>
<div className="hidden md:flex items-center gap-2 ml-6 rounded-full px-2.5 py-1 text-[11px]" style={{ background: '#F6F9FC', color: DIM }}>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
<span>服务正常 · 99.95%</span>
</div>
<div className="flex-1" />
<Link
to="/console"
className="inline-flex h-9 items-center rounded-full px-3.5 text-sm hover:bg-[#0A254006]"
style={{ color: SUB }}
>
控制台
</Link>
<Link
to="/register"
className="ml-2 inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-sm font-medium text-white hover:opacity-90"
style={{ background: INK }}
>
免费注册 <ArrowRight size={12} />
</Link>
</div>
</header>
);
}
export default function Contact() {
const [form, setForm] = useState({
company: '',
name: '',
email: '',
volume: '',
message: '',
});
const update = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const submit = (e) => {
e.preventDefault();
alert('已收到,我们会在 24 小时内联系你。');
};
return (
<div className="min-h-screen" style={{ background: BG, color: INK }}>
<MiniTopBar />
{/* Hero */}
<section className="relative overflow-hidden" style={{ background: BG }}>
<div
className="pointer-events-none absolute inset-x-0 top-0 h-[600px]"
style={{ background: 'radial-gradient(80% 60% at 50% 0%, #EEEBFF 0%, transparent 70%)' }}
/>
<div className="relative max-w-[1200px] mx-auto px-6 lg:px-10 pt-20 pb-14 lg:pt-28 lg:pb-20 text-center">
<div className="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs" style={{ borderColor: LINE, color: DIM }}>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: ACC }} />
<span>contact sales</span>
</div>
<h1
className="mt-6 font-heading font-semibold leading-[1.04] tracking-[-0.025em]"
style={{ fontSize: 'clamp(40px, 5.4vw, 72px)', color: INK }}
>
告诉我们<br />
你的 <span style={{ color: ACC }}>AI 场景</span>
</h1>
<p className="mt-6 mx-auto max-w-2xl text-base leading-[1.65]" style={{ color: SUB }}>
大客户定制路由长期合约技术对接 都从这里开始
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-6 text-xs" style={{ color: DIM }}>
<span className="inline-flex items-center gap-1.5">
<Check size={12} style={{ color: ACC }} /> 24 小时内回复
</span>
<span className="inline-flex items-center gap-1.5">
<Check size={12} style={{ color: ACC }} /> 可签 NDA
</span>
<span className="inline-flex items-center gap-1.5">
<Check size={12} style={{ color: ACC }} /> 无电销骚扰
</span>
</div>
</div>
</section>
{/* Form + Info */}
<section style={{ background: BG }}>
<div className="max-w-[1200px] mx-auto px-6 lg:px-10 pb-24">
<div className="grid gap-8 lg:grid-cols-[1.4fr_1fr]">
{/* Form */}
<form
onSubmit={submit}
className="rounded-2xl border bg-white p-8 lg:p-10 shadow-sm"
style={{ borderColor: LINE }}
>
<div className="font-mono text-[11px] uppercase tracking-[0.25em]" style={{ color: DIM }}>get in touch</div>
<h2
className="mt-4 font-heading text-2xl font-semibold leading-[1.2] tracking-tight lg:text-[32px]"
style={{ color: INK }}
>
填一下<br />我们 24h 内联系你
</h2>
<div className="mt-8 grid gap-5 md:grid-cols-2">
<Field label="公司名" value={form.company} onChange={update('company')} required />
<Field label="你的姓名" value={form.name} onChange={update('name')} required />
</div>
<div className="mt-5">
<Field
label="工作邮箱"
type="email"
value={form.email}
onChange={update('email')}
required
placeholder="you@company.com"
/>
</div>
<div className="mt-5">
<div className="mb-2 text-xs" style={{ color: DIM }}>月调用量</div>
<div className="relative">
<select
value={form.volume}
onChange={update('volume')}
className="w-full appearance-none rounded-lg border bg-white pl-3.5 pr-9 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF]"
style={{ borderColor: LINE, color: form.volume ? INK : DIM }}
>
<option value="">选择大致量级</option>
<option>10 万以下</option>
<option>10 100 </option>
<option>100 1000 </option>
<option>1000 万以上</option>
<option>暂未确定</option>
</select>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: DIM }}></span>
</div>
</div>
<div className="mt-5">
<div className="mb-2 text-xs" style={{ color: DIM }}>你的场景</div>
<textarea
value={form.message}
onChange={update('message')}
rows={4}
placeholder="例如:做企业知识库客服,需要稳定的 Claude + 国产模型双备援,月调用量约 300 万..."
className="w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF] resize-y"
style={{ borderColor: LINE, color: INK }}
/>
</div>
<div className="mt-7 flex flex-wrap items-center gap-4">
<button
type="submit"
className="inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold text-white hover:opacity-90"
style={{ background: INK }}
>
发送 <ArrowRight size={13} />
</button>
<span className="text-xs" style={{ color: DIM }}>
提交即同意我们通过邮箱回访仅一次
</span>
</div>
</form>
{/* Info side */}
<div className="space-y-4">
<InfoCard
icon={<Mail size={16} />}
title="销售咨询"
primary="sales@turingtoken.ai"
sub="工作日 24h 内回复;加急请电话"
/>
<InfoCard
icon={<MessageSquare size={16} />}
title="技术支持"
primary="support@turingtoken.ai"
sub="已签合约客户优先P0 故障 1h 响应"
/>
<InfoCard
icon={<ShieldCheck size={16} />}
title="法务 / NDA"
primary="legal@turingtoken.ai"
sub="签前可先发 NDA 模板,含双向保密"
/>
<InfoCard
icon={<Building2 size={16} />}
title="办公室"
primary="上海 · 北京 · 深圳"
sub="仅预约接待,不接受 walk-in"
/>
</div>
</div>
</div>
</section>
{/* FAQ */}
<section style={{ background: SOFT }}>
<div className="max-w-[1100px] mx-auto px-6 lg:px-10 py-24 lg:py-32">
<div className="text-center">
<div className="font-mono text-[11px] uppercase tracking-[0.25em]" style={{ color: DIM }}>frequently asked</div>
<h2
className="mt-4 font-heading text-3xl font-semibold tracking-tight lg:text-4xl"
style={{ color: INK }}
>
常见问题
</h2>
</div>
<div className="mt-14 grid gap-5 md:grid-cols-2">
{[
{
q: '支持私有化部署吗?',
a: '支持。签约后 24 周内完成含多模型路由、监控面板、计费、权限管理、SSO。',
},
{
q: '大客户有专属价格吗?',
a: '月调用量 100 万以上可谈。多数客户能拿到比 OpenAI 直连便宜 30%+ 的合约价。',
},
{
q: '能签长期合约吗?',
a: '可以。年付 / 季付都支持,附 SLA 99.95% 兜底,故障按比例退款。',
},
{
q: '数据合规怎么做?',
a: '全链路 TLS 1.3;境内流量走国内供应商;日志 30 天滚动删除;支持 GDPR 与个保法。',
},
].map((f, i) => (
<div
key={i}
className="rounded-xl border bg-white p-6"
style={{ borderColor: LINE }}
>
<h3 className="font-heading text-lg font-semibold tracking-tight" style={{ color: INK }}>{f.q}</h3>
<p className="mt-3 text-sm leading-[1.7]" style={{ color: SUB }}>{f.a}</p>
</div>
))}
</div>
</div>
</section>
{/* Closing */}
<section className="relative overflow-hidden" style={{ background: BG }}>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[400px]"
style={{ background: 'radial-gradient(60% 60% at 50% 100%, #EEEBFF 0%, transparent 70%)' }}
/>
<div className="relative mx-auto max-w-3xl px-6 lg:px-10 py-24 lg:py-32 text-center">
<h2
className="font-heading text-3xl font-semibold leading-tight tracking-[-0.02em] lg:text-[44px]"
style={{ color: INK }}
>
先用起来再聊
</h2>
<p className="mt-5 text-base leading-[1.7]" style={{ color: SUB }}>
注册送 ¥10 试跑金足够跑完一组 benchmark一轮压测聊完再决定也不迟
</p>
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
<Link
to="/register"
className="inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold text-white hover:opacity-90"
style={{ background: INK }}
>
免费注册 <ArrowRight size={13} />
</Link>
<Link
to="/"
className="inline-flex h-11 items-center gap-1.5 rounded-full border px-6 text-sm hover:bg-[#0A254006]"
style={{ borderColor: INK, color: INK }}
>
先看首页
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t" style={{ borderColor: LINE, background: BG }}>
<div className="max-w-[1200px] mx-auto px-6 lg:px-10 py-12">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Link to="/" className="inline-flex items-center gap-2.5">
<img src="/logo.jpg" alt="logo" className="h-7 w-7 rounded-full" />
<span className="font-heading text-base font-semibold tracking-tight" style={{ color: INK }}>TuringToken</span>
</Link>
<span className="text-xs" style={{ color: DIM }}>© 2026 TuringToken · Made for developers.</span>
</div>
</div>
</footer>
</div>
);
}

View File

@ -21,9 +21,11 @@ import React from 'react';
import Dashboard from '../../components/dashboard';
const Detail = () => (
<div className='mt-[60px] w-full max-w-[1600px] mx-auto px-3 sm:px-4 lg:px-6 pb-6'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Dashboard />
</div>
</div>
);
export default Detail;

View File

@ -1128,7 +1128,8 @@ export default function DistributorAdmin() {
);
return (
<div className='mt-[60px] px-2 pb-16'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Typography.Title heading={3}>{t('代理管理中心')}</Typography.Title>
<Tabs
@ -1976,5 +1977,6 @@ export default function DistributorAdmin() {
/>
</Modal>
</div>
</div>
);
}

View File

@ -268,7 +268,7 @@ export default function DistributorApply() {
if (isDist) {
return (
<div className='distributor-apply-page-root'>
<div className='distributor-apply-page-root text-slate-900 dark:text-white'>
<div className='w-full max-w-3xl px-1'>
<ApplyStatusNotice variant='success' className='w-full'>
{t('您已是代理,无需再次申请')}

View File

@ -668,7 +668,8 @@ export default function DistributorCenter() {
}
return (
<div className='mt-14 px-4 pb-16 max-w-7xl mx-auto'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Title heading={3} className='mb-8'>
{t('代理分销')}
</Title>
@ -1197,5 +1198,6 @@ export default function DistributorCenter() {
) : null}
</Modal>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -21,9 +21,11 @@ import React from 'react';
import UsageLogsTable from '../../components/table/usage-logs';
const Token = () => (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<UsageLogsTable />
</div>
</div>
);
export default Token;

View File

@ -21,9 +21,11 @@ import React from 'react';
import MjLogsTable from '../../components/table/mj-logs';
const Midjourney = () => (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<MjLogsTable />
</div>
</div>
);
export default Midjourney;

View File

@ -36,9 +36,11 @@ const ModelPage = () => {
}, [editId, modelName]);
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<ModelsTable />
</div>
</div>
);
};

View File

@ -0,0 +1,231 @@
/*
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, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Table,
Select,
Tag,
Typography,
Card,
Button,
Empty,
Toast,
} from '@douyinfe/semi-ui';
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { API, renderQuota, isAdmin } from '../../helpers';
const { Text, Title } = Typography;
const MAX_COMPARE = 4;
const endpointLabels = {
chat: '对话',
image: '图片',
audio: '音频',
video: '视频',
embeddings: '嵌入',
rerank: '重排',
moderate: '审核',
};
const ModelComparison = () => {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
setLoading(true);
try {
const res = await API.get('/api/pricing', { disableDuplicate: true });
if (res?.data?.success && Array.isArray(res.data.data)) {
setModels(res.data.data);
}
} catch {
Toast.error({ content: t('加载模型列表失败') });
} finally {
setLoading(false);
}
})();
}, [t]);
const modelOptions = useMemo(
() =>
models.map((m) => ({
value: m.model_name,
label: m.model_name,
})),
[models],
);
const selectedModels = useMemo(
() => models.filter((m) => selected.includes(m.model_name)),
[models, selected],
);
const handleSelect = (vals) => {
if (vals.length > MAX_COMPARE) {
Toast.warning({ content: t('最多同时对比 {{max}} 个模型', { max: MAX_COMPARE }) });
return;
}
setSelected(vals);
};
const handleRemove = (name) => {
setSelected((prev) => prev.filter((n) => n !== name));
};
const formatEndpointTypes = (types) => {
if (!Array.isArray(types) || types.length === 0) return '-';
return types.map((ep) => endpointLabels[ep] || ep).join('、');
};
const formatTags = (tags) => {
if (!tags) return '-';
const arr = typeof tags === 'string' ? tags.split(',').map((s) => s.trim()).filter(Boolean) : [];
return arr.length > 0 ? arr.join('、') : '-';
};
const formatPrice = (model) => {
const ratio = Number(model.model_ratio || 0);
const completionRatio = model.completion_ratio != null
? Number(model.completion_ratio)
: ratio;
if (Number(model.model_price || 0) > 0) {
return `$${Number(model.model_price).toFixed(4)} (固定价)`;
}
return `输入 ${ratio.toFixed(1)}x / 输出 ${completionRatio.toFixed(1)}x`;
};
// Comparison rows
const compareRows = [
{ key: 'vendor', label: t('供应商'), render: (m) => m.owner_by || '-' },
{ key: 'description', label: t('简介'), render: (m) => m.description || '-' },
{ key: 'pricing', label: t('价格'), render: (m) => formatPrice(m) },
{ key: 'endpoints', label: t('支持功能'), render: (m) => formatEndpointTypes(m.supported_endpoint_types) },
{ key: 'tags', label: t('标签'), render: (m) => formatTags(m.tags) },
];
return (
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div className='mb-6 flex items-center justify-between'>
<Title heading={3} className='!mb-0'>
{t('模型对比')}
</Title>
<Text className='!text-semi-color-text-2'>
{t('已选 {{n}} / {{max}}', { n: selected.length, max: MAX_COMPARE })}
</Text>
</div>
<Card className='mb-6'>
<div className='flex items-center gap-3'>
<IconPlus className='text-semi-color-text-2' />
<Select
placeholder={t('选择模型进行对比(最多 {{max}} 个)', { max: MAX_COMPARE })}
multiple
value={selected}
onChange={handleSelect}
optionList={modelOptions}
filter
style={{ flex: 1, minWidth: 300 }}
loading={loading}
/>
</div>
{selected.length > 0 && (
<div className='mt-3 flex flex-wrap gap-2'>
{selected.map((name) => (
<Tag
key={name}
color='blue'
closable
onClose={() => handleRemove(name)}
>
{name}
</Tag>
))}
</div>
)}
</Card>
{selectedModels.length > 0 ? (
<Card>
<div className='overflow-x-auto'>
<table className='w-full border-collapse text-sm'>
<thead>
<tr>
<th className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 text-left font-medium text-semi-color-text-2 border-b border-semi-color-border w-[120px]'>
{t('属性')}
</th>
{selectedModels.map((m) => (
<th
key={m.model_name}
className='px-4 py-3 text-left font-semibold text-semi-color-text-0 border-b border-semi-color-border min-w-[180px]'
>
<div className='flex items-center gap-2'>
<Text strong>{m.model_name}</Text>
<Button
theme='borderless'
type='tertiary'
size='small'
icon={<IconDelete />}
onClick={() => handleRemove(m.model_name)}
/>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{compareRows.map((row) => (
<tr key={row.key}>
<td className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 font-medium text-semi-color-text-2 border-b border-semi-color-border'>
{row.label}
</td>
{selectedModels.map((m) => (
<td
key={m.model_name}
className='px-4 py-3 text-semi-color-text-0 border-b border-semi-color-border'
>
{row.render(m)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : (
<Card>
<Empty
title={t('请选择模型')}
description={t('在上方选择 2-4 个模型进行并排对比')}
/>
</Card>
)}
</div>
</div>
);
};
export default ModelComparison;

View File

@ -41,9 +41,11 @@ const ModelDeploymentPage = () => {
connectionError={connectionError}
onRetry={() => testConnection()}
>
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<DeploymentsTable />
</div>
</div>
</DeploymentAccessGuard>
);
};

View File

@ -22,9 +22,11 @@ import CombinedHeatConfig from './CombinedHeatConfig';
const ModelHeat = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<CombinedHeatConfig />
</div>
</div>
);
};

View File

@ -85,6 +85,10 @@ const OperationLog = () => {
const [detailVisible, setDetailVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
// Cleanup modal
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [deleteDate, setDeleteDate] = useState(null);
const fetchLogs = async () => {
setLoading(true);
try {
@ -132,14 +136,17 @@ const OperationLog = () => {
fetchLogs();
};
const handleDeleteOldLogs = async () => {
if (!dateRange || !dateRange[1]) {
const handleDeleteOldLogs = () => {
setDeleteDate(null);
setDeleteModalVisible(true);
};
const handleConfirmDelete = () => {
if (!deleteDate) {
showError('请选择截止日期');
return;
}
const targetTimestamp = Math.floor(
new Date(dateRange[1]).getTime() / 1000
);
const targetTimestamp = Math.floor(new Date(deleteDate).getTime() / 1000);
Modal.confirm({
title: '确认清理',
content: `确定要清理 ${timestamp2string(targetTimestamp)} 之前的操作日志吗?此操作不可逆。`,
@ -151,6 +158,7 @@ const OperationLog = () => {
const { success, data, message } = res.data;
if (success) {
showSuccess(`成功清理 ${data} 条操作日志`);
setDeleteModalVisible(false);
fetchLogs();
} else {
showError(message);
@ -206,7 +214,7 @@ const OperationLog = () => {
dataIndex: 'target_name',
key: 'target_name',
width: 160,
render: (text, record) => text || `ID: ${record.target_id}`,
render: (text, record) => text || `ID${record.target_id}`,
},
{
title: t('操作描述'),
@ -239,7 +247,8 @@ const OperationLog = () => {
];
return (
<div style={{ padding: '20px' }}>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Card>
<div
style={{
@ -338,6 +347,27 @@ const OperationLog = () => {
/>
</Card>
<Modal
title={t('清理日志')}
visible={deleteModalVisible}
onCancel={() => setDeleteModalVisible(false)}
onOk={handleConfirmDelete}
okText={t('确认清理')}
cancelText={t('取消')}
width={420}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Text>{t('选择截止日期,将清理该日期之前的所有操作日志。此操作不可逆。')}</Text>
<DatePicker
type='dateTime'
placeholder={t('选择截止日期')}
value={deleteDate}
onChange={setDeleteDate}
style={{ width: '100%' }}
/>
</div>
</Modal>
<Modal
title={t('操作详情')}
visible={detailVisible}
@ -402,6 +432,7 @@ const OperationLog = () => {
)}
</Modal>
</div>
</div>
);
};

View File

@ -22,9 +22,11 @@ import RedemptionsTable from '../../components/table/redemptions';
const Redemption = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<RedemptionsTable />
</div>
</div>
);
};

View File

@ -217,7 +217,8 @@ const Setting = () => {
}
}, [location.search]);
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Layout>
<Layout.Content>
<Tabs
@ -235,6 +236,7 @@ const Setting = () => {
</Layout.Content>
</Layout>
</div>
</div>
);
};

View File

@ -22,9 +22,11 @@ import SubscriptionsPage from '../../components/table/subscriptions';
const Subscription = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<SubscriptionsPage />
</div>
</div>
);
};

View File

@ -112,8 +112,8 @@ const SupplierApplyPage = () => {
if (loading) {
return (
<div
className='mt-[60px] px-2 flex items-center justify-center'
style={{ minHeight: 'calc(100vh - 360px)' }}
className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white flex items-center justify-center'
style={{ minHeight: 'calc(100vh - 200px)' }}
>
<Spin size='large' />
</div>
@ -121,7 +121,8 @@ const SupplierApplyPage = () => {
}
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<SupplierApplicationModal
visible={showApplicationModal}
handleClose={handleModalClose}
@ -221,6 +222,7 @@ const SupplierApplyPage = () => {
</div>
)}
</div>
</div>
);
};

View File

@ -48,7 +48,8 @@ const SupplierChannelContent = () => {
const { t } = useTranslation();
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
{/* Modals */}
<ColumnSelectorModal {...channelsData} />
<EditTagModal
@ -117,6 +118,7 @@ const SupplierChannelContent = () => {
<ChannelsTable {...channelsData} />
</CardPro>
</div>
</div>
);
};
@ -126,7 +128,8 @@ const SupplierChannelPage = () => {
if (!isSupplier()) {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div
className='flex items-center justify-center'
style={{ minHeight: 'calc(100vh - 360px)' }}
@ -153,6 +156,7 @@ const SupplierChannelPage = () => {
</Empty>
</div>
</div>
</div>
);
}

View File

@ -282,7 +282,8 @@ export default function SupplierDashboardPage() {
if (!canAccess) {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div
className='flex items-center justify-center'
style={{ minHeight: 'calc(100vh - 360px)' }}
@ -308,11 +309,13 @@ export default function SupplierDashboardPage() {
</Empty>
</div>
</div>
</div>
);
}
return (
<div className='mt-[60px] w-full max-w-[1600px] mx-auto px-3 sm:px-4 lg:px-6 pb-6'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Spin spinning={loading} size='large'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-3'>
<div className='flex items-center gap-2 min-w-0'>
@ -451,5 +454,6 @@ export default function SupplierDashboardPage() {
</Card>
</Spin>
</div>
</div>
);
}

View File

@ -157,7 +157,8 @@ const SupplierPricingSettingsContent = () => {
}, []);
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
@ -178,6 +179,7 @@ const SupplierPricingSettingsContent = () => {
</Card>
</Spin>
</div>
</div>
);
};
@ -190,7 +192,8 @@ const PricingSettingsPage = () => {
if (!isSupplier()) {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<div
className='flex items-center justify-center'
style={{ minHeight: 'calc(100vh - 360px)' }}
@ -217,6 +220,7 @@ const PricingSettingsPage = () => {
</Empty>
</div>
</div>
</div>
);
}

View File

@ -22,9 +22,11 @@ import SupplierApplicationsPage from '../../../components/table/supplier-applica
const SupplierApplication = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<SupplierApplicationsPage />
</div>
</div>
);
};

View File

@ -22,9 +22,11 @@ import SuppliersPage from '../../../components/table/suppliers';
const Suppliers = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<SuppliersPage />
</div>
</div>
);
};

View File

@ -21,9 +21,11 @@ import React from 'react';
import TaskLogsTable from '../../components/table/task-logs';
const Task = () => (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<TaskLogsTable />
</div>
</div>
);
export default Task;

View File

@ -22,9 +22,11 @@ import TokensTable from '../../components/table/tokens';
const Token = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<TokensTable />
</div>
</div>
);
};

View File

@ -22,9 +22,11 @@ import UsersTable from '../../components/table/users';
const User = () => {
return (
<div className='mt-[60px] px-2'>
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
<UsersTable />
</div>
</div>
);
};

2
web/vite.config.js vendored
View File

@ -30,7 +30,7 @@ export default defineConfig({
}),
],
optimizeDeps: {
force: true,
include: ['date-fns/locale'],
esbuildOptions: {
loader: {
'.js': 'jsx',