Merge branch 'alpha' into fix-balance-unit-sync

This commit is contained in:
Calcium-Ion
2025-06-09 20:48:50 +08:00
committed by GitHub
53 changed files with 3367 additions and 1780 deletions

View File

@@ -32,7 +32,6 @@ import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const LoginForm = () => {
const [inputs, setInputs] = useState({
@@ -266,7 +265,7 @@ const LoginForm = () => {
<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-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -500,19 +499,8 @@ const LoginForm = () => {
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm()
: renderOAuthOptions()}

View File

@@ -4,7 +4,6 @@ import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -79,24 +78,13 @@ const PasswordResetConfirm = () => {
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<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-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">

View File

@@ -5,7 +5,6 @@ import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
@@ -79,24 +78,13 @@ const PasswordResetForm = () => {
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
<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-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">

View File

@@ -33,7 +33,6 @@ import WeChatIcon from '../common/logo/WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const RegisterForm = () => {
const { t } = useTranslation();
@@ -272,7 +271,7 @@ const RegisterForm = () => {
<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-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -379,7 +378,7 @@ const RegisterForm = () => {
<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-white'>{systemName}</Title>
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
@@ -542,17 +541,8 @@ const RegisterForm = () => {
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-sm">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm()
: renderOAuthOptions()}

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
ApiInfo: '',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DashboardSetting;

View File

@@ -104,6 +104,33 @@ const PersonalSetting = () => {
});
const [modelsLoading, setModelsLoading] = useState(true);
const [showWebhookDocs, setShowWebhookDocs] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(false);
// 检测暗色模式
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
setIsDarkMode(isDark);
};
checkDarkMode();
// 监听主题变化
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener(checkDarkMode);
return () => {
observer.disconnect();
mediaQuery.removeListener(checkDarkMode);
};
}, []);
useEffect(() => {
let status = localStorage.getItem('status');
@@ -384,107 +411,81 @@ const PersonalSetting = () => {
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部用户信息区域 */}
<Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
background: isDarkMode
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
<div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 dark:bg-slate-500 opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 dark:bg-slate-400 opacity-8 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 dark:bg-slate-500 opacity-6 rounded-full"></div>
</div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
<div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
<div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex items-center flex-1 min-w-0">
<Avatar
size='large'
color={stringToColor(getUsername())}
border={{ motion: true }}
contentMotion={true}
className="mr-3 sm:mr-4 shadow-lg flex-shrink-0"
className="mr-3 sm:mr-4 shadow-md flex-shrink-0 bg-slate-500 dark:bg-slate-400"
>
{getAvatarText()}
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
<div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
{getUsername()}
</div>
<div className="mt-1 flex flex-wrap gap-1 sm:gap-2">
{isRoot() ? (
<Tag
color='red'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#dc2626',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600"
style={{ fontWeight: '500' }}
>
{t('超级管理员')}
</Tag>
) : isAdmin() ? (
<Tag
color='orange'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#ea580c',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-600"
style={{ fontWeight: '500' }}
>
{t('管理员')}
</Tag>
) : (
<Tag
color='blue'
size='small'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#2563eb',
fontWeight: '600'
}}
className="!rounded-full"
className="!rounded-full bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600"
style={{ fontWeight: '500' }}
>
{t('普通用户')}
</Tag>
)}
<Tag
color='green'
size='small'
className="!rounded-full"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
color: '#16a34a',
fontWeight: '600'
}}
className="!rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600"
style={{ fontWeight: '500' }}
>
ID: {userState?.user?.id}
</Tag>
</div>
</div>
</div>
<div
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconUser size="default" style={{ color: 'white' }} />
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
<IconUser size="default" className="text-white" />
</div>
</div>
<div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
<div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
{t('当前余额')}
</div>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
{renderQuota(userState?.user?.quota)}
</div>
</div>
@@ -492,33 +493,33 @@ const PersonalSetting = () => {
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('历史消耗')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{renderQuota(userState?.user?.used_quota)}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('请求次数')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState.user?.request_count || 0}
</div>
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户分组')}
</div>
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState?.user?.group || t('默认')}
</div>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
</div>
</Card>
@@ -537,10 +538,10 @@ const PersonalSetting = () => {
>
<div className="gap-6 py-4">
{/* 可用模型部分 */}
<div className="bg-gray-50 rounded-xl">
<div className="bg-gray-50 dark:bg-gray-800 rounded-xl">
<div className="flex items-center mb-4">
<div className="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center mr-3">
<Settings size={20} className="text-purple-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<Settings size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Typography.Title heading={6} className="mb-0">{t('模型列表')}</Typography.Title>
@@ -629,7 +630,7 @@ const PersonalSetting = () => {
</Tabs>
</div>
<div className="bg-white rounded-lg p-3">
<div className="bg-white dark:bg-gray-700 rounded-lg p-3">
{(() => {
// 根据当前选中的分类过滤模型
const categories = getModelCategories(t);
@@ -736,9 +737,9 @@ const PersonalSetting = () => {
shadows='hover'
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mr-3">
<IconMail size="default" className="text-red-500" />
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconMail size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('邮箱')}</div>
@@ -771,8 +772,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-green-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiWechat size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('微信')}</div>
@@ -808,8 +809,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-gray-700" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconGithubLogo size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('GitHub')}</div>
@@ -844,8 +845,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center mr-3">
<IconShield size="default" className="text-indigo-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<IconShield size="default" className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('OIDC')}</div>
@@ -883,8 +884,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-blue-50 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-blue-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiTelegram size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('Telegram')}</div>
@@ -926,8 +927,8 @@ const PersonalSetting = () => {
>
<div className="flex items-center justify-between">
<div className="flex items-center flex-1">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-orange-500" />
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3">
<SiLinux size={20} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{t('LinuxDO')}</div>
@@ -978,8 +979,8 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-blue-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconKey size="large" className="text-slate-600" />
</div>
<div className="flex-1">
<Typography.Title heading={6} className="mb-1">
@@ -1006,7 +1007,7 @@ const PersonalSetting = () => {
type="primary"
theme="solid"
onClick={generateAccessToken}
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 w-full sm:w-auto"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
icon={<IconKey />}
>
{systemToken ? t('重新生成') : t('生成令牌')}
@@ -1022,8 +1023,8 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-orange-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconLock size="large" className="text-slate-600" />
</div>
<div>
<Typography.Title heading={6} className="mb-1">
@@ -1038,7 +1039,7 @@ const PersonalSetting = () => {
type="primary"
theme="solid"
onClick={() => setShowChangePasswordModal(true)}
className="!rounded-lg !bg-orange-500 hover:!bg-orange-600 w-full sm:w-auto"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto"
icon={<IconLock />}
>
{t('修改密码')}
@@ -1054,11 +1055,11 @@ const PersonalSetting = () => {
>
<div className="flex flex-col sm:flex-row items-start sm:justify-between gap-4">
<div className="flex items-start w-full sm:w-auto">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-red-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0">
<IconDelete size="large" className="text-slate-600" />
</div>
<div>
<Typography.Title heading={6} className="mb-1 text-red-600">
<Typography.Title heading={6} className="mb-1 text-slate-700">
{t('删除账户')}
</Typography.Title>
<Typography.Text type="tertiary" className="text-sm">
@@ -1070,7 +1071,7 @@ const PersonalSetting = () => {
type="danger"
theme="solid"
onClick={() => setShowAccountDeleteModal(true)}
className="!rounded-lg w-full sm:w-auto"
className="!rounded-lg w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600"
icon={<IconDelete />}
>
{t('删除账户')}
@@ -1111,7 +1112,7 @@ const PersonalSetting = () => {
>
<Radio value='email' className="!p-4 !rounded-lg">
<div className="flex items-center">
<IconMail className="mr-2 text-blue-500" />
<IconMail className="mr-2 text-slate-600" />
<div>
<div className="font-medium">{t('邮件通知')}</div>
<div className="text-sm text-gray-500">{t('通过邮件接收通知')}</div>
@@ -1120,7 +1121,7 @@ const PersonalSetting = () => {
</Radio>
<Radio value='webhook' className="!p-4 !rounded-lg">
<div className="flex items-center">
<Webhook size={16} className="mr-2 text-green-500" />
<Webhook size={16} className="mr-2 text-slate-600" />
<div>
<div className="font-medium">{t('Webhook通知')}</div>
<div className="text-sm text-gray-500">{t('通过HTTP请求接收通知')}</div>
@@ -1167,11 +1168,11 @@ const PersonalSetting = () => {
</div>
</div>
<div className="bg-yellow-50 rounded-xl">
<div className="bg-slate-50 rounded-xl">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setShowWebhookDocs(!showWebhookDocs)}>
<div className="flex items-center">
<Globe size={16} className="mr-2 text-yellow-600" />
<Typography.Text strong className="text-yellow-800">
<Globe size={16} className="mr-2 text-slate-600" />
<Typography.Text strong className="text-slate-700">
{t('Webhook请求结构')}
</Typography.Text>
</div>
@@ -1254,11 +1255,11 @@ const PersonalSetting = () => {
itemKey='price'
>
<div className="py-4">
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center mt-1">
<Shield size={20} className="text-orange-500" />
</div>
<div className="bg-white rounded-xl">
<div className="flex items-start">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center mt-1">
<Shield size={20} className="text-slate-600" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
@@ -1292,7 +1293,7 @@ const PersonalSetting = () => {
type='primary'
onClick={saveNotificationSettings}
size="large"
className="!rounded-lg !bg-purple-500 hover:!bg-purple-600"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
icon={<IconSetting />}
>
{t('保存设置')}
@@ -1408,7 +1409,7 @@ const PersonalSetting = () => {
theme="solid"
size='large'
onClick={bindWeChat}
className="!rounded-lg w-full !bg-green-500 hover:!bg-green-600"
className="!rounded-lg w-full !bg-slate-600 hover:!bg-slate-700"
icon={<SiWechat size={16} />}
>
{t('绑定')}

View File

@@ -6,15 +6,31 @@ import {
showSuccess,
timestamp2string,
renderGroup,
renderQuotaWithAmount,
renderQuota
renderNumberWithPoint,
renderQuota,
getChannelIcon
} from '../../helpers/index.js';
import {
CheckCircle,
XCircle,
AlertCircle,
HelpCircle,
TestTube,
Zap,
Timer,
Clock,
AlertTriangle,
Coins,
Tags
} from 'lucide-react';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../../constants/index.js';
import {
Button,
Divider,
Dropdown,
Empty,
Input,
InputNumber,
Modal,
@@ -25,13 +41,15 @@ import {
Tag,
Tooltip,
Typography,
Checkbox,
Card,
Select
Form
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import EditChannel from '../../pages/Channel/EditChannel.js';
import {
IconList,
IconTreeTriangleDown,
IconFilter,
IconPlus,
@@ -64,7 +82,12 @@ const ChannelsTable = () => {
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
}
return (
<Tag size='large' color={type2label[type]?.color} shape='circle'>
<Tag
size='large'
color={type2label[type]?.color}
shape='circle'
prefixIcon={getChannelIcon(type)}
>
{type2label[type]?.label}
</Tag>
);
@@ -74,7 +97,7 @@ const ChannelsTable = () => {
return (
<Tag
color='light-blue'
prefixIcon={<IconList />}
prefixIcon={<Tags size={14} />}
size='large'
shape='circle'
type='light'
@@ -88,25 +111,25 @@ const ChannelsTable = () => {
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle'>
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -118,139 +141,48 @@ const ChannelsTable = () => {
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<TestTube size={14} />}>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size='large' color='green' shape='circle'>
<Tag size='large' color='green' shape='circle' prefixIcon={<Zap size={14} />}>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size='large' color='lime' shape='circle'>
<Tag size='large' color='lime' shape='circle' prefixIcon={<Timer size={14} />}>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag size='large' color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{time}
</Tag>
);
} else {
return (
<Tag size='large' color='red' shape='circle'>
<Tag size='large' color='red' shape='circle' prefixIcon={<AlertTriangle size={14} />}>
{time}
</Tag>
);
}
};
// Define column keys for selection
const COLUMN_KEYS = {
ID: 'id',
NAME: 'name',
GROUP: 'group',
TYPE: 'type',
STATUS: 'status',
RESPONSE_TIME: 'response_time',
BALANCE: 'balance',
PRIORITY: 'priority',
WEIGHT: 'weight',
OPERATE: 'operate',
};
// State for column visibility
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
// Load saved column preferences from localStorage
useEffect(() => {
const savedColumns = localStorage.getItem('channels-table-columns');
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
// Make sure all columns are accounted for
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// Update table when column visibility changes
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
// Save to localStorage
localStorage.setItem(
'channels-table-columns',
JSON.stringify(visibleColumns),
);
}
}, [visibleColumns]);
// Get default column visibility
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.ID]: true,
[COLUMN_KEYS.NAME]: true,
[COLUMN_KEYS.GROUP]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.STATUS]: true,
[COLUMN_KEYS.RESPONSE_TIME]: true,
[COLUMN_KEYS.BALANCE]: true,
[COLUMN_KEYS.PRIORITY]: true,
[COLUMN_KEYS.WEIGHT]: true,
[COLUMN_KEYS.OPERATE]: true,
};
};
// Initialize default column visibility
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
};
// Handle column visibility change
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// Handle "Select All" checkbox
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
updatedColumns[key] = checked;
});
setVisibleColumns(updatedColumns);
};
// Define all columns with keys
const allColumns = [
// Define all columns
const columns = [
{
key: COLUMN_KEYS.ID,
title: t('ID'),
dataIndex: 'id',
},
{
key: COLUMN_KEYS.NAME,
title: t('名称'),
dataIndex: 'name',
},
{
key: COLUMN_KEYS.GROUP,
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => (
@@ -269,7 +201,6 @@ const ChannelsTable = () => {
),
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'type',
render: (text, record, index) => {
@@ -281,7 +212,6 @@ const ChannelsTable = () => {
},
},
{
key: COLUMN_KEYS.STATUS,
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
@@ -307,7 +237,6 @@ const ChannelsTable = () => {
},
},
{
key: COLUMN_KEYS.RESPONSE_TIME,
title: t('响应时间'),
dataIndex: 'response_time',
render: (text, record, index) => (
@@ -315,7 +244,6 @@ const ChannelsTable = () => {
),
},
{
key: COLUMN_KEYS.BALANCE,
title: t('已用/剩余'),
dataIndex: 'expired_time',
render: (text, record, index) => {
@@ -324,7 +252,7 @@ const ChannelsTable = () => {
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -334,6 +262,7 @@ const ChannelsTable = () => {
type='ghost'
size='large'
shape='circle'
prefixIcon={<Coins size={14} />}
onClick={() => updateChannelBalance(record)}
>
{renderQuotaWithAmount(record.balance)}
@@ -345,7 +274,7 @@ const ChannelsTable = () => {
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' size='large' shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -354,7 +283,6 @@ const ChannelsTable = () => {
},
},
{
key: COLUMN_KEYS.PRIORITY,
title: t('优先级'),
dataIndex: 'priority',
render: (text, record, index) => {
@@ -406,7 +334,6 @@ const ChannelsTable = () => {
},
},
{
key: COLUMN_KEYS.WEIGHT,
title: t('权重'),
dataIndex: 'weight',
render: (text, record, index) => {
@@ -458,7 +385,6 @@ const ChannelsTable = () => {
},
},
{
key: COLUMN_KEYS.OPERATE,
title: '',
dataIndex: 'operate',
fixed: 'right',
@@ -631,96 +557,10 @@ const ChannelsTable = () => {
},
];
// Filter columns based on visibility settings
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
// Column selector modal
const renderColumnSelector = () => {
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('确定')}
</Button>
</div>
}
size="middle"
centered={true}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div
className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4"
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip columns without title
if (!column.title) {
return null;
}
return (
<div
key={column.key}
className="w-1/2 mb-4 pr-2"
>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [idSort, setIdSort] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchGroup, setSearchGroup] = useState('');
const [searchModel, setSearchModel] = useState('');
const [searching, setSearching] = useState(false);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [channelCount, setChannelCount] = useState(pageSize);
@@ -745,6 +585,16 @@ const ChannelsTable = () => {
const [testQueue, setTestQueue] = useState([]);
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
// Form API 引用
const [formApi, setFormApi] = useState(null);
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
searchModel: '',
};
const removeRecord = (record) => {
let newDataSource = [...channels];
if (record.id != null) {
@@ -896,15 +746,11 @@ const ChannelsTable = () => {
};
const refresh = async () => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
} else {
await searchChannels(
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
);
await searchChannels(enableTagMode);
}
};
@@ -1010,29 +856,40 @@ const ChannelsTable = () => {
}
};
const searchChannels = async (
searchKeyword,
searchGroup,
searchModel,
enableTagMode,
) => {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
// setActivePage(1);
return;
}
// 获取表单值的辅助函数,确保所有值都是字符串
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchGroup: formValues.searchGroup || '',
searchModel: formValues.searchModel || '',
};
};
const searchChannels = async (enableTagMode) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
setSearching(true);
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
const { success, message, data } = res.data;
if (success) {
setChannelFormat(data, enableTagMode);
setActivePage(1);
} else {
showError(message);
try {
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage - 1, pageSize, idSort, enableTagMode);
// setActivePage(1);
return;
}
const res = await API.get(
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${idSort}&tag_mode=${enableTagMode}`,
);
const { success, message, data } = res.data;
if (success) {
setChannelFormat(data, enableTagMode);
setActivePage(1);
} else {
showError(message);
}
} finally {
setSearching(false);
}
setSearching(false);
};
const updateChannelProperty = (channelId, updateFn) => {
@@ -1540,71 +1397,83 @@ const ChannelsTable = () => {
>
{t('刷新')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full w-full md:w-auto"
>
{t('列设置')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID名称密钥和API地址 ...')}
value={searchKeyword}
loading={searching}
onChange={(v) => {
setSearchKeyword(v.trim());
}}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Input
prefix={<IconFilter />}
placeholder={t('模型关键字')}
value={searchModel}
loading={searching}
onChange={(v) => {
setSearchModel(v.trim());
}}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
value={searchGroup}
onChange={(v) => {
setSearchGroup(v);
searchChannels(searchKeyword, v, searchModel, enableTagMode);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchChannels(searchKeyword, searchGroup, searchModel, enableTagMode);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => searchChannels(enableTagMode)}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="flex flex-col md:flex-row items-center gap-4 w-full"
>
{t('查询')}
</Button>
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索渠道的 ID名称密钥和API地址 ...')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Input
field="searchModel"
prefix={<IconFilter />}
placeholder={t('模型关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={[
{ label: t('选择分组'), value: null },
...groupOptions,
]}
className="!rounded-full w-full"
showClear
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
searchChannels(enableTagMode);
}, 0);
}}
/>
</div>
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full w-full md:w-auto"
>
{t('重置')}
</Button>
</Form>
</div>
</div>
</div>
@@ -1612,7 +1481,6 @@ const ChannelsTable = () => {
return (
<>
{renderColumnSelector()}
<EditTagModal
visible={showEditTag}
tag={editingTag}
@@ -1633,7 +1501,7 @@ const ChannelsTable = () => {
bordered={false}
>
<Table
columns={getVisibleColumns()}
columns={columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
@@ -1663,6 +1531,14 @@ const ChannelsTable = () => {
}
: null
}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
loading={loading}

View File

@@ -1,5 +1,18 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
CreditCard,
ShoppingCart,
Settings,
Server,
AlertTriangle,
HelpCircle,
Zap,
Play,
Clock,
Hash,
Key
} from 'lucide-react';
import {
API,
copy,
@@ -20,16 +33,16 @@ import {
renderQuota,
stringToColor,
getLogOther,
renderModelTag,
renderModelTag
} from '../../helpers';
import {
Avatar,
Button,
Descriptions,
Empty,
Modal,
Popover,
Select,
Space,
Spin,
Table,
@@ -39,24 +52,18 @@ import {
Card,
Typography,
Divider,
Input,
DatePicker,
Form
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
const MODE_OPTIONS = [
{ key: 'all', text: 'all', value: 'all' },
{ key: 'self', text: 'current user', value: 'self' },
];
const colors = [
'amber',
'blue',
@@ -238,11 +245,6 @@ const LogsTable = () => {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
suffixIcon: (
<IconForward
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
/>
),
})}
</Popover>
</Space>
@@ -737,39 +739,67 @@ const LogsTable = () => {
const [logType, setLogType] = useState(0);
const isAdminUser = isAdmin();
let now = new Date();
// 初始化start_timestamp为今天0点
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
username: '',
token_name: '',
model_name: '',
start_timestamp: timestamp2string(getTodayStartTimestamp()),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '',
group: '',
});
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
} = inputs;
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600)
],
logType: '0',
};
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数,确保所有值都是字符串
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
// 处理时间范围
let start_timestamp = timestamp2string(getTodayStartTimestamp());
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
username: formValues.username || '',
token_name: formValues.token_name || '',
model_name: formValues.model_name || '',
start_timestamp,
end_timestamp,
channel: formValues.channel || '',
group: formValues.group || '',
logType: formValues.logType ? parseInt(formValues.logType) : 0,
};
};
const getLogSelfStat = async () => {
const {
token_name,
model_name,
start_timestamp,
end_timestamp,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
let url = `/api/log/self/stat?type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = encodeURI(url);
let res = await API.get(url);
const { success, message, data } = res.data;
@@ -781,9 +811,20 @@ const LogsTable = () => {
};
const getLogStat = async () => {
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
const currentLogType = formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let url = `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
let url = `/api/log/stat?type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = encodeURI(url);
let res = await API.get(url);
const { success, message, data } = res.data;
@@ -1016,16 +1057,30 @@ const LogsTable = () => {
setLogs(logs);
};
const loadLogs = async (startIdx, pageSize, logType = 0) => {
const loadLogs = async (startIdx, pageSize, customLogType = null) => {
setLoading(true);
let url = '';
const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
group,
logType: formLogType,
} = getFormValues();
// 使用传入的 logType 或者表单中的 logType 或者状态中的 logType
const currentLogType = customLogType !== null ? customLogType : formLogType !== undefined ? formLogType : logType;
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
} else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
}
url = encodeURI(url);
const res = await API.get(url);
@@ -1045,7 +1100,7 @@ const LogsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize, logType).then((r) => { });
loadLogs(page, pageSize).then((r) => { }); // 不传入logType让其从表单获取最新值
};
const handlePageSizeChange = async (size) => {
@@ -1062,7 +1117,7 @@ const LogsTable = () => {
const refresh = async () => {
setActivePage(1);
handleEyeClick();
await loadLogs(activePage, pageSize, logType);
await loadLogs(1, pageSize); // 不传入logType让其从表单获取最新值
};
const copyText = async (e, text) => {
@@ -1083,9 +1138,15 @@ const LogsTable = () => {
.catch((reason) => {
showError(reason);
});
handleEyeClick();
}, []);
// 当 formApi 可用时,初始化统计
useEffect(() => {
if (formApi) {
handleEyeClick();
}
}, [formApi]);
const expandRowRender = (record, index) => {
return <Descriptions data={expandData[record.key]} />;
};
@@ -1149,115 +1210,144 @@ const LogsTable = () => {
<Divider margin='12px' />
{/* 搜索表单区域 */}
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<DatePicker
className='w-full'
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<Form.DatePicker
field='dateRange'
className='w-full'
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 其他搜索字段 */}
<Form.Input
field='token_name'
prefix={<IconSearch />}
placeholder={t('令牌名称')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='model_name'
prefix={<IconSearch />}
placeholder={t('模型名称')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='group'
prefix={<IconSearch />}
placeholder={t('分组')}
className='!rounded-full'
showClear
pure
/>
{isAdminUser && (
<>
<Form.Input
field='channel'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className='!rounded-full'
showClear
pure
/>
<Form.Input
field='username'
prefix={<IconSearch />}
placeholder={t('用户名称')}
className='!rounded-full'
showClear
pure
/>
</>
)}
</div>
{/* 日志类型选择器 */}
<Select
value={logType.toString()}
placeholder={t('日志类型')}
className='!rounded-full'
onChange={(value) => {
setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value));
}}
>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
<Select.Option value='5'>{t('错误')}</Select.Option>
</Select>
{/* 其他搜索字段 */}
<Input
prefix={<IconSearch />}
placeholder={t('令牌名称')}
value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('模型名称')}
value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('分组')}
value={group}
onChange={(value) => handleInputChange(value, 'group')}
className='!rounded-full'
showClear
/>
{isAdminUser && (
<>
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel}
onChange={(value) => handleInputChange(value, 'channel')}
className='!rounded-full'
{/* 操作按钮区域 */}
<div className='flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3'>
{/* 日志类型选择器 */}
<div className='w-full sm:w-auto'>
<Form.Select
field='logType'
placeholder={t('日志类型')}
className='!rounded-full w-full sm:w-auto min-w-[120px]'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('用户名称')}
value={username}
onChange={(value) => handleInputChange(value, 'username')}
className='!rounded-full'
showClear
/>
</>
)}
</div>
pure
onChange={() => {
// 延迟执行搜索,让表单值先更新
setTimeout(() => {
refresh();
}, 0);
}}
>
<Form.Select.Option value='0'>{t('全部')}</Form.Select.Option>
<Form.Select.Option value='1'>{t('充值')}</Form.Select.Option>
<Form.Select.Option value='2'>{t('消费')}</Form.Select.Option>
<Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
<Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
<Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
</Form.Select>
</div>
{/* 操作按钮区域 */}
<div className='flex justify-between items-center pt-2'>
<div></div>
<div className='flex gap-2'>
<Button
type='primary'
onClick={refresh}
loading={loading}
className='!rounded-full'
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
<div className='flex gap-2 w-full sm:w-auto justify-end'>
<Button
type='primary'
htmlType='submit'
loading={loading}
className='!rounded-full'
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
setLogType(0);
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className='!rounded-full'
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -1276,6 +1366,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -1,35 +1,65 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Palette,
ZoomIn,
Shuffle,
Move,
FileText,
Blend,
Upload,
Minimize2,
RotateCcw,
PaintBucket,
Focus,
Move3D,
Monitor,
UserCheck,
HelpCircle,
CheckCircle,
Clock,
Copy,
FileX,
Pause,
XCircle,
Loader,
AlertCircle,
Hash
} from 'lucide-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
timestamp2string
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Empty,
Form,
ImagePreview,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
IconSetting
} from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -154,103 +184,103 @@ const LogsTable = () => {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle'>
<Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle'>
<Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle'>
<Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle'>
<Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle'>
<Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -261,31 +291,31 @@ const LogsTable = () => {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -296,43 +326,43 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -362,7 +392,7 @@ const LogsTable = () => {
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle'>
<Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')}
</Tag>
);
@@ -398,6 +428,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
@@ -462,7 +493,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
style={{ minWidth: '200px' }}
style={{ minWidth: '160px' }}
/>
}
</div>
@@ -483,6 +514,7 @@ const LogsTable = () => {
setModalImageUrl(text);
setIsModalOpenurl(true);
}}
className="!rounded-full"
>
{t('查看图片')}
</Button>
@@ -570,7 +602,6 @@ const LogsTable = () => {
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
@@ -578,22 +609,44 @@ const LogsTable = () => {
// 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
channel_id: '',
mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
});
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
dateRange: [
timestamp2string(now.getTime() / 1000 - 2592000),
timestamp2string(now.getTime() / 1000 + 3600)
],
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
// 处理时间范围
let start_timestamp = timestamp2string(now.getTime() / 1000 - 2592000);
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
channel_id: formValues.channel_id || '',
mj_id: formValues.mj_id || '',
start_timestamp,
end_timestamp,
};
};
const setLogsFormat = (logs) => {
@@ -611,6 +664,7 @@ const LogsTable = () => {
setLoading(true);
let url = '';
const { channel_id, mj_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) {
@@ -673,7 +727,7 @@ const LogsTable = () => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
}, []);
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
@@ -788,70 +842,93 @@ const LogsTable = () => {
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={mj_id}
onChange={(value) => handleInputChange(value, 'mj_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
{/* 任务 ID */}
<Form.Input
field='mj_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
placeholder={t('任务 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
htmlType='submit'
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -865,6 +942,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -17,14 +17,19 @@ import {
Tabs,
TabPane,
Dropdown,
Empty
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconVerify,
IconHelpCircle,
IconSearch,
IconCopy,
IconInfoCircle,
IconLayers,
IconLayers
} from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
@@ -489,6 +494,14 @@ const ModelPricing = () => {
loading={loading}
rowSelection={rowSelection}
className="custom-table"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
defaultPageSize: 10,
pageSize: pageSize,

View File

@@ -8,20 +8,33 @@ import {
renderQuota
} from '../../helpers';
import {
CheckCircle,
XCircle,
Minus,
HelpCircle,
Coins
} from 'lucide-react';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Empty,
Form,
Modal,
Popover,
Space,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconPlus,
IconCopy,
@@ -31,7 +44,7 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore,
IconMore
} from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
@@ -49,25 +62,25 @@ const RedemptionsTable = () => {
switch (status) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Minus size={14} />}>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -95,7 +108,13 @@ const RedemptionsTable = () => {
title: t('额度'),
dataIndex: 'quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
@@ -223,7 +242,6 @@ const RedemptionsTable = () => {
const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
const [selectedKeys, setSelectedKeys] = useState([]);
@@ -233,6 +251,22 @@ const RedemptionsTable = () => {
});
const [showEdit, setShowEdit] = useState(false);
// Form 初始值
const formInitValues = {
searchKeyword: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
};
};
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
@@ -340,8 +374,14 @@ const RedemptionsTable = () => {
setLoading(false);
};
const searchRedemptions = async (keyword, page, pageSize) => {
if (searchKeyword === '') {
const searchRedemptions = async (keyword = null, page, pageSize) => {
// 如果没有传递keyword参数从表单获取值
if (keyword === null) {
const formValues = getFormValues();
keyword = formValues.searchKeyword;
}
if (keyword === '') {
await loadRedemptions(page, pageSize);
return;
}
@@ -361,10 +401,6 @@ const RedemptionsTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const sortRedemption = (key) => {
if (redemptions.length === 0) return;
setLoading(true);
@@ -381,6 +417,7 @@ const RedemptionsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(page, pageSize).then();
} else {
@@ -457,28 +494,59 @@ const RedemptionsTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => {
setActivePage(1);
searchRedemptions(null, 1, pageSize);
}}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadRedemptions(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<Button
type="primary"
onClick={() => {
searchRedemptions(searchKeyword, 1, pageSize).then();
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -517,6 +585,7 @@ const RedemptionsTable = () => {
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
loadRedemptions(1, size).then();
} else {
@@ -528,6 +597,14 @@ const RedemptionsTable = () => {
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
></Table>

View File

@@ -1,34 +1,51 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Music,
FileText,
HelpCircle,
CheckCircle,
Pause,
Clock,
Play,
XCircle,
Loader,
List,
Hash
} from 'lucide-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
timestamp2string
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Input,
Empty,
Form,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
IconSetting
} from '@douyinfe/semi-icons';
const { Text } = Typography;
@@ -97,7 +114,7 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large'>
<Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
{durationSec}
</Tag>
);
@@ -188,19 +205,19 @@ const LogsTable = () => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle'>
<Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -211,13 +228,13 @@ const LogsTable = () => {
switch (type) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
Suno
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -228,55 +245,55 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -321,6 +338,7 @@ const LogsTable = () => {
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
@@ -395,7 +413,7 @@ const LogsTable = () => {
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '200px' }}
style={{ minWidth: '160px' }}
/>
)
}
@@ -437,21 +455,43 @@ const LogsTable = () => {
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
let now = new Date();
// 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
// Form 初始值
const formInitValues = {
channel_id: '',
task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
end_timestamp: '',
});
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
dateRange: [
timestamp2string(zeroNow.getTime() / 1000),
timestamp2string(now.getTime() / 1000 + 3600)
],
};
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
// 处理时间范围
let start_timestamp = timestamp2string(zeroNow.getTime() / 1000);
let end_timestamp = timestamp2string(now.getTime() / 1000 + 3600);
if (formValues.dateRange && Array.isArray(formValues.dateRange) && formValues.dateRange.length === 2) {
start_timestamp = formValues.dateRange[0];
end_timestamp = formValues.dateRange[1];
}
return {
channel_id: formValues.channel_id || '',
task_id: formValues.task_id || '',
start_timestamp,
end_timestamp,
};
};
const setLogsFormat = (logs) => {
@@ -469,6 +509,7 @@ const LogsTable = () => {
setLoading(true);
let url = '';
const { channel_id, task_id, start_timestamp, end_timestamp } = getFormValues();
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) {
@@ -528,7 +569,7 @@ const LogsTable = () => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
}, []);
// 列选择器模态框
const renderColumnSelector = () => {
@@ -628,70 +669,93 @@ const LogsTable = () => {
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={refresh}
allowEmpty={true}
autoComplete="off"
layout="vertical"
trigger="change"
stopValidateWithError={false}
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<Form.DatePicker
field='dateRange'
className="w-full"
type='dateTimeRange'
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={task_id}
onChange={(value) => handleInputChange(value, 'task_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
{/* 任务 ID */}
<Form.Input
field='task_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
placeholder={t('任务 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Form.Input
field='channel_id'
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
className="!rounded-full"
showClear
pure
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
htmlType='submit'
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
refresh();
}, 100);
}
}}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
</Form>
</div>
}
shadows='always'
@@ -705,6 +769,14 @@ const LogsTable = () => {
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {

View File

@@ -6,7 +6,8 @@ import {
showSuccess,
timestamp2string,
renderGroup,
renderQuota
renderQuota,
getQuotaPerUnit
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
@@ -14,13 +15,29 @@ import {
Button,
Card,
Dropdown,
Empty,
Form,
Modal,
Space,
SplitButtonGroup,
Table,
Tag,
Input,
Tag
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
CheckCircle,
Shield,
XCircle,
Clock,
Gauge,
HelpCircle,
Infinity,
Coins
} from 'lucide-react';
import {
IconPlus,
@@ -32,7 +49,7 @@ import {
IconDelete,
IconStop,
IconPlay,
IconMore,
IconMore
} from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
@@ -49,38 +66,38 @@ const TokensTable = () => {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' size='large' shape='circle' prefixIcon={<Gauge size={14} />}>
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -111,21 +128,45 @@ const TokensTable = () => {
title: t('已用额度'),
dataIndex: 'used_quota',
render: (text, record, index) => {
return <div>{renderQuota(parseInt(text))}</div>;
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
{renderQuota(parseInt(text))}
</Tag>
</div>
);
},
},
{
title: t('剩余额度'),
dataIndex: 'remain_quota',
render: (text, record, index) => {
const getQuotaColor = (quotaValue) => {
const quotaPerUnit = getQuotaPerUnit();
const dollarAmount = quotaValue / quotaPerUnit;
if (dollarAmount <= 0) {
return 'red';
} else if (dollarAmount <= 100) {
return 'yellow';
} else {
return 'green';
}
};
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle'>
<Tag size={'large'} color={'white'} shape='circle' prefixIcon={<Infinity size={14} />}>
{t('无限制')}
</Tag>
) : (
<Tag size={'large'} color={'light-blue'} shape='circle'>
<Tag
size={'large'}
color={getQuotaColor(parseInt(text))}
shape='circle'
prefixIcon={<Coins size={14} />}
>
{renderQuota(parseInt(text))}
</Tag>
)}
@@ -335,14 +376,29 @@ const TokensTable = () => {
const [tokenCount, setTokenCount] = useState(pageSize);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchToken, setSearchToken] = useState('');
const [searching, setSearching] = useState(false);
const [chats, setChats] = useState([]);
const [editingToken, setEditingToken] = useState({
id: undefined,
});
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchToken: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchToken: formValues.searchToken || '',
};
};
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
@@ -416,8 +472,6 @@ const TokensTable = () => {
window.open(url, '_blank');
};
useEffect(() => {
loadTokens(0)
.then()
@@ -472,6 +526,7 @@ const TokensTable = () => {
};
const searchTokens = async () => {
const { searchKeyword, searchToken } = getFormValues();
if (searchKeyword === '' && searchToken === '') {
await loadTokens(0);
setActivePage(1);
@@ -491,14 +546,6 @@ const TokensTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handleSearchTokenChange = async (value) => {
setSearchToken(value.trim());
};
const sortToken = (key) => {
if (tokens.length === 0) return;
setLoading(true);
@@ -580,36 +627,65 @@ const TokensTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={searchTokens}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-56">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="relative w-full md:w-56">
<Form.Input
field="searchToken"
prefix={<IconSearch />}
placeholder={t('密钥')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
searchTokens();
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('密钥')}
value={searchToken}
onChange={handleSearchTokenChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={searchTokens}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -654,6 +730,14 @@ const TokensTable = () => {
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
></Table>

View File

@@ -1,18 +1,37 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
import {
User,
Shield,
Crown,
HelpCircle,
CheckCircle,
XCircle,
Minus,
Coins,
Activity,
Users,
DollarSign,
UserPlus
} from 'lucide-react';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Empty,
Form,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Typography
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
IconPlus,
IconSearch,
@@ -23,7 +42,7 @@ import {
IconMore,
IconUserAdd,
IconArrowUp,
IconArrowDown,
IconArrowDown
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
@@ -39,25 +58,25 @@ const UsersTable = () => {
switch (role) {
case 1:
return (
<Tag size='large' color='blue' shape='circle'>
<Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
{t('普通用户')}
</Tag>
);
case 10:
return (
<Tag color='yellow' size='large' shape='circle'>
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知身份')}
</Tag>
);
@@ -67,16 +86,16 @@ const UsersTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red' shape='circle'>
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -106,13 +125,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('剩余')}: {renderQuota(record.quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('已用')}: {renderQuota(record.used_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
{t('调用')}: {renderNumber(record.request_count)}
</Tag>
</Space>
@@ -127,13 +146,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
{t('邀请')}: {renderNumber(record.aff_count)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
{t('收益')}: {renderQuota(record.aff_history_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag>
</Space>
@@ -155,7 +174,7 @@ const UsersTable = () => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
<Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
@@ -285,9 +304,7 @@ const UsersTable = () => {
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false);
@@ -296,6 +313,24 @@ const UsersTable = () => {
id: undefined,
});
// Form 初始值
const formInitValues = {
searchKeyword: '',
searchGroup: '',
};
// Form API 引用
const [formApi, setFormApi] = useState(null);
// 获取表单值的辅助函数
const getFormValues = () => {
const formValues = formApi ? formApi.getValues() : {};
return {
searchKeyword: formValues.searchKeyword || '',
searchGroup: formValues.searchGroup || '',
};
};
const removeRecord = (key) => {
let newDataSource = [...users];
if (key != null) {
@@ -363,9 +398,16 @@ const UsersTable = () => {
const searchUsers = async (
startIdx,
pageSize,
searchKeyword,
searchGroup,
searchKeyword = null,
searchGroup = null,
) => {
// 如果没有传递参数,从表单获取值
if (searchKeyword === null || searchGroup === null) {
const formValues = getFormValues();
searchKeyword = formValues.searchKeyword;
searchGroup = formValues.searchGroup;
}
if (searchKeyword === '' && searchGroup === '') {
// if keyword is blank, load files instead.
await loadUsers(startIdx, pageSize);
@@ -387,12 +429,9 @@ const UsersTable = () => {
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => {
setActivePage(page);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
@@ -413,10 +452,11 @@ const UsersTable = () => {
const refresh = async () => {
setActivePage(1);
if (searchKeyword === '') {
await loadUsers(activePage, pageSize);
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
await loadUsers(1, pageSize);
} else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
await searchUsers(1, pageSize, searchKeyword, searchGroup);
}
};
@@ -488,41 +528,76 @@ const UsersTable = () => {
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
<Form
initValues={formInitValues}
getFormApi={(api) => setFormApi(api)}
onSubmit={() => {
setActivePage(1);
searchUsers(1, pageSize);
}}
allowEmpty={true}
autoComplete="off"
layout="horizontal"
trigger="change"
stopValidateWithError={false}
className="w-full md:w-auto order-1 md:order-2"
>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
<div className="relative w-full md:w-64">
<Form.Input
field="searchKeyword"
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
className="!rounded-full"
showClear
pure
/>
</div>
<div className="w-full md:w-48">
<Form.Select
field="searchGroup"
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(value) => {
// 分组变化时自动搜索
setTimeout(() => {
setActivePage(1);
searchUsers(1, pageSize);
}, 100);
}}
className="!rounded-full w-full"
showClear
pure
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
htmlType="submit"
loading={loading || searching}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('查询')}
</Button>
<Button
theme="light"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
}, 100);
}
}}
className="!rounded-full flex-1 md:flex-initial md:w-auto"
>
{t('重置')}
</Button>
</div>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={groupOptions}
value={searchGroup}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</Form>
</div>
</div>
);
@@ -570,6 +645,14 @@ const UsersTable = () => {
}}
loading={loading}
onRow={handleRow}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('搜索无结果')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
size="middle"
/>

View File

@@ -24,6 +24,13 @@ import {
XAI,
Ollama,
Doubao,
Suno,
Xinference,
OpenRouter,
Dify,
Coze,
SiliconCloud,
FastGPT
} from '@lobehub/icons';
import {
@@ -40,6 +47,7 @@ import {
User,
Settings,
CircleUser,
Users
} from 'lucide-react';
// 侧边栏图标颜色映射
@@ -308,6 +316,88 @@ export const getModelCategories = (() => {
};
})();
/**
* 根据渠道类型返回对应的厂商图标
* @param {number} channelType - 渠道类型值
* @returns {JSX.Element|null} - 对应的厂商图标组件
*/
export function getChannelIcon(channelType) {
const iconSize = 14;
switch (channelType) {
case 1: // OpenAI
case 3: // Azure OpenAI
return <OpenAI size={iconSize} />;
case 2: // Midjourney Proxy
case 5: // Midjourney Proxy Plus
return <Midjourney size={iconSize} />;
case 36: // Suno API
return <Suno size={iconSize} />;
case 4: // Ollama
return <Ollama size={iconSize} />;
case 14: // Anthropic Claude
case 33: // AWS Claude
return <Claude.Color size={iconSize} />;
case 41: // Vertex AI
return <Gemini.Color size={iconSize} />;
case 34: // Cohere
return <Cohere.Color size={iconSize} />;
case 39: // Cloudflare
return <Cloudflare.Color size={iconSize} />;
case 43: // DeepSeek
return <DeepSeek.Color size={iconSize} />;
case 15: // 百度文心千帆
case 46: // 百度文心千帆V2
return <Wenxin.Color size={iconSize} />;
case 17: // 阿里通义千问
return <Qwen.Color size={iconSize} />;
case 18: // 讯飞星火认知
return <Spark.Color size={iconSize} />;
case 16: // 智谱 ChatGLM
case 26: // 智谱 GLM-4V
return <Zhipu.Color size={iconSize} />;
case 24: // Google Gemini
case 11: // Google PaLM2
return <Gemini.Color size={iconSize} />;
case 47: // Xinference
return <Xinference.Color size={iconSize} />;
case 25: // Moonshot
return <Moonshot size={iconSize} />;
case 20: // OpenRouter
return <OpenRouter size={iconSize} />;
case 19: // 360 智脑
return <Ai360.Color size={iconSize} />;
case 23: // 腾讯混元
return <Hunyuan.Color size={iconSize} />;
case 31: // 零一万物
return <Yi.Color size={iconSize} />;
case 35: // MiniMax
return <Minimax.Color size={iconSize} />;
case 37: // Dify
return <Dify.Color size={iconSize} />;
case 38: // Jina
return <Jina size={iconSize} />;
case 40: // SiliconCloud
return <SiliconCloud.Color size={iconSize} />;
case 42: // Mistral AI
return <Mistral.Color size={iconSize} />;
case 45: // 字节火山方舟、豆包通用
return <Doubao.Color size={iconSize} />;
case 48: // xAI
return <XAI size={iconSize} />;
case 49: // Coze
return <Coze size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;
case 21: // 知识库AI Proxy
case 44: // 嵌入模型MokaAI M3E
default:
return null; // 未知类型或自定义渠道不显示图标
}
}
// 颜色列表
const colors = [
'amber',
@@ -519,7 +609,7 @@ export function renderGroup(group) {
showSuccess(i18next.t('已复制:') + group);
} else {
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
title: i18next.t('无法复制到剪贴板,请手动复制'),
content: group,
});
}
@@ -956,23 +1046,23 @@ export function renderModelPrice(
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
},
)
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
},
)
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
},
)
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
},
)
: '',
].join('');
@@ -1156,10 +1246,10 @@ export function renderAudioModelPrice(
let audioPrice =
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
(audioCompletionTokens / 1000000) *
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
inputRatioPrice *
audioRatio *
audioCompletionRatio *
groupRatio;
let price = textPrice + audioPrice;
return (
<>
@@ -1215,27 +1305,27 @@ export function renderAudioModelPrice(
<p>
{cacheTokens > 0
? i18next.t(
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)
: i18next.t(
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
total: textPrice.toFixed(6),
},
)}
</p>
<p>
{i18next.t(
@@ -1372,33 +1462,33 @@ export function renderClaudeModelPrice(
<p>
{cacheTokens > 0 || cacheCreationTokens > 0
? i18next.t(
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
nonCacheInput: nonCachedTokens,
cacheInput: cacheTokens,
cacheRatio: cacheRatio,
cacheCreationInput: cacheCreationTokens,
cacheCreationRatio: cacheCreationRatio,
cachePrice: cacheRatioPrice,
cacheCreationPrice: cacheCreationRatioPrice,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)
: i18next.t(
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)}
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>

View File

@@ -1567,5 +1567,24 @@
"使用统计": "Usage Statistics",
"资源消耗": "Resource Consumption",
"性能指标": "Performance Indicators",
"模型数据分析": "Model Data Analysis"
"模型数据分析": "Model Data Analysis",
"搜索无结果": "No results found",
"仪表盘配置": "Dashboard Configuration",
"API信息管理可以配置多个API地址用于状态展示和负载均衡": "API information management, you can configure multiple API addresses for status display and load balancing",
"线路描述": "Route description",
"颜色": "Color",
"标识颜色": "Identifier color",
"添加API": "Add API",
"保存配置": "Save Configuration",
"API信息": "API Information",
"暂无API信息配置": "No API information configured",
"暂无API信息": "No API information",
"请输入API地址": "Please enter the API address",
"请输入线路描述": "Please enter the route description",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"请输入说明": "Please enter the description",
"如:香港线路": "e.g. Hong Kong line",
"请联系管理员在系统设置中配置API信息": "Please contact the administrator to configure API information in the system settings.",
"确定要删除此API信息吗": "Are you sure you want to delete this API information?",
"测速": "Speed Test"
}

View File

@@ -73,6 +73,10 @@ code {
.semi-page-item,
.semi-navigation-item,
.semi-tag-closable,
.semi-input-wrapper,
.semi-tabs-tab-button,
.semi-select,
.semi-button,
.semi-datepicker-range-input {
border-radius: 9999px !important;
}
@@ -322,6 +326,24 @@ code {
font-size: 1.1em;
}
/* API信息卡片样式 */
.api-info-container {
position: relative;
}
.api-info-fade-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(transparent, var(--semi-color-bg-1));
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.3s ease;
}
/* ==================== 调试面板特定样式 ==================== */
.debug-panel .semi-tabs {
height: 100% !important;
@@ -378,6 +400,7 @@ code {
}
/* 隐藏模型设置区域的滚动条 */
.api-info-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar,
.thinking-content-scroll::-webkit-scrollbar,
.custom-request-textarea .semi-input::-webkit-scrollbar,
@@ -385,6 +408,7 @@ code {
display: none;
}
.api-info-scroll,
.model-settings-scroll,
.thinking-content-scroll,
.custom-request-textarea .semi-input,

View File

@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import '@douyinfe/semi-ui/dist/css/semi.css';
import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';

View File

@@ -194,6 +194,24 @@ const EditTagModal = (props) => {
}, [originModelOptions, inputs.models]);
useEffect(() => {
const fetchTagModels = async () => {
if (!tag) return;
setLoading(true);
try {
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
if (res?.data?.success) {
const models = res.data.data ? res.data.data.split(',') : [];
setInputs((inputs) => ({ ...inputs, models: models }));
} else {
showError(res.data.message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
setInputs({
...originInputs,
tag: tag,
@@ -201,7 +219,8 @@ const EditTagModal = (props) => {
});
fetchModels().then();
fetchGroups().then();
}, [visible]);
fetchTagModels().then(); // Call the new function
}, [visible, tag]); // Add tag to dependency array
const addCustomModels = () => {
if (customModel.trim() === '') return;
@@ -347,6 +366,11 @@ const EditTagModal = (props) => {
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('模型')}</Text>
<Banner
type="info"
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
className="!rounded-lg mb-4"
/>
<Select
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
name='models'
@@ -388,19 +412,19 @@ const EditTagModal = (props) => {
/>
<Space className="mt-2">
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
</Text>
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
>
{t('清空重定向')}
</Text>
<Text
className="text-blue-500 cursor-pointer"
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', '')}
>
{t('不更改')}

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,7 @@ import { API, showError, isMobile } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { IconGithubLogo } from '@douyinfe/semi-icons';
import exampleImage from '/example.png';
import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import NoticeModal from '../../components/layout/NoticeModal';
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
@@ -20,6 +19,7 @@ const Home = () => {
const [noticeVisible, setNoticeVisible] = useState(false);
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
useEffect(() => {
const checkNoticeAndShow = async () => {
@@ -85,132 +85,123 @@ const Home = () => {
{homePageContentLoaded && homePageContent === '' ? (
<div className="w-full overflow-x-hidden">
{/* Banner 部分 */}
<div className="w-full border-b border-semi-color-border min-h-[500px] md:h-[650px] lg:h-[750px] relative overflow-x-hidden">
<div className="flex flex-col md:flex-row items-center justify-center h-full px-4 py-8 md:py-0">
{/* 左侧内容区 */}
<div className="flex-shrink-0 w-full md:w-[480px] md:mr-[60px] lg:mr-[120px] mb-8 md:mb-0">
<div className="flex items-center gap-2 justify-center md:justify-start">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-semibold text-semi-color-text-0 w-auto leading-normal md:leading-[67px]">
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
<div className="flex items-center justify-center h-full px-4 py-12 md:py-16 lg:py-20">
{/* 居中内容区 */}
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
<h1 className="text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-semibold text-semi-color-text-0 leading-tight">
{statusState?.status?.system_name || 'New API'}
</h1>
{statusState?.status?.version && (
<Tag color='light-blue' size='large' shape='circle' className="ml-1">
{statusState.status.version}
</Tag>
)}
</div>
<p className="text-base md:text-lg text-semi-color-text-0 mt-4 md:mt-8 w-full md:w-[480px] leading-7 md:leading-8 text-center md:text-left">
<p className="text-base md:text-lg lg:text-xl text-semi-color-text-0 leading-7 md:leading-8 lg:leading-9 max-w-2xl px-4">
{t('新一代大模型网关与AI资产管理系统一键接入主流大模型轻松管理您的AI资产')}
</p>
{/* 操作按钮 */}
<div className="mt-6 md:mt-10 flex flex-wrap gap-4 justify-center md:justify-start">
<div className="mt-8 md:mt-10 lg:mt-12 flex flex-row gap-4 justify-center items-center">
<Link to="/console">
<Button theme="solid" type="primary" size="large" className="!rounded-3xl">
<Button theme="solid" type="primary" size="large" className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
{t('开始使用')}
</Button>
</Link>
{isDemoSiteMode && (
{isDemoSiteMode && statusState?.status?.version ? (
<Button
size="large"
className="flex items-center !rounded-3xl"
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconGithubLogo />}
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
>
GitHub
{statusState.status.version}
</Button>
) : (
docsLink && (
<Button
size="large"
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconFile />}
onClick={() => window.open(docsLink, '_blank')}
>
{t('文档')}
</Button>
)
)}
</div>
{/* 框架兼容性图标 */}
<div className="mt-8 md:mt-16">
<div className="flex items-center mb-3 justify-center md:justify-start">
<Text type="tertiary" className="text-lg md:text-xl font-light">
<div className="mt-12 md:mt-16 lg:mt-20 w-full">
<div className="flex items-center mb-6 md:mb-8 justify-center">
<Text type="tertiary" className="text-lg md:text-xl lg:text-2xl font-light">
{t('支持众多的大模型供应商')}
</Text>
</div>
<div className="flex flex-wrap items-center relative mt-6 md:mt-8 gap-6 md:gap-8 justify-center md:justify-start">
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Moonshot size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<OpenAI size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<XAI size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Zhipu.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Volcengine.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Cohere.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Claude.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Gemini.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Suno size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Minimax.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Wenxin.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Spark.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qingyan.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<DeepSeek.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Qwen.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Midjourney size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Grok size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<AzureAI.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Hunyuan.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Xinference.Color size={40} />
</div>
<div className="relative w-8 md:w-10 h-8 md:h-10 flex items-center justify-center">
<Typography.Text className="!text-2xl font-bold">30+</Typography.Text>
<div className="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center">
<Typography.Text className="!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold">30+</Typography.Text>
</div>
</div>
</div>
</div>
{/* 右侧图片区域 - 在小屏幕上隐藏或调整位置 */}
<div className="flex-shrink-0 relative md:mr-[-200px] lg:mr-[-400px] hidden md:block lg:min-w-[1100px]">
<div className="absolute w-[320px] md:w-[500px] lg:w-[640px] h-[320px] md:h-[500px] lg:h-[640px] left-[-25px] md:left-[-40px] lg:left-[-50px] top-[-10px] md:top-[-15px] lg:top-[-20px] opacity-60"
style={{ filter: 'blur(120px)' }}>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] top-[80px] md:top-[100px] lg:top-[132px] bg-semi-color-primary rounded-full opacity-30"></div>
<div className="absolute w-[320px] md:w-[400px] lg:w-[474px] h-[320px] md:h-[400px] lg:h-[474px] left-[80px] md:left-[120px] lg:left-[166px] bg-semi-color-tertiary rounded-full opacity-30"></div>
</div>
<img
src={exampleImage}
alt="application demo"
className="relative h-[400px] md:h-[600px] lg:h-[721px] ml-[-15px] md:ml-[-20px] lg:ml-[-30px] mt-[-15px] md:mt-[-20px] lg:mt-[-30px]"
/>
</div>
</div>
</div>
</div>
@@ -223,7 +214,7 @@ const Home = () => {
/>
) : (
<div
className="text-base md:text-lg p-4 md:p-6 overflow-x-hidden"
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
@@ -234,3 +225,4 @@ const Home = () => {
};
export default Home;

View File

@@ -0,0 +1,399 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Space,
Table,
Form,
Typography,
Empty,
Divider,
Avatar,
Modal,
Tag
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import {
Plus,
Edit,
Trash2,
Save,
Settings
} from 'lucide-react';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const SettingsAPIInfo = ({ options, refresh }) => {
const { t } = useTranslation();
const [apiInfoList, setApiInfoList] = useState([]);
const [showApiModal, setShowApiModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingApi, setDeletingApi] = useState(null);
const [editingApi, setEditingApi] = useState(null);
const [modalLoading, setModalLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [apiForm, setApiForm] = useState({
url: '',
description: '',
route: '',
color: 'blue'
});
const colorOptions = [
{ value: 'blue', label: 'blue' },
{ value: 'green', label: 'green' },
{ value: 'cyan', label: 'cyan' },
{ value: 'purple', label: 'purple' },
{ value: 'pink', label: 'pink' },
{ value: 'red', label: 'red' },
{ value: 'orange', label: 'orange' },
{ value: 'amber', label: 'amber' },
{ value: 'yellow', label: 'yellow' },
{ value: 'lime', label: 'lime' },
{ value: 'light-green', label: 'light-green' },
{ value: 'teal', label: 'teal' },
{ value: 'light-blue', label: 'light-blue' },
{ value: 'indigo', label: 'indigo' },
{ value: 'violet', label: 'violet' },
{ value: 'grey', label: 'grey' }
];
const updateOption = async (key, value) => {
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
showSuccess('API信息已更新');
if (refresh) refresh();
} else {
showError(message);
}
};
const submitApiInfo = async () => {
try {
setLoading(true);
const apiInfoJson = JSON.stringify(apiInfoList);
await updateOption('ApiInfo', apiInfoJson);
setHasChanges(false);
} catch (error) {
console.error('API信息更新失败', error);
showError('API信息更新失败');
} finally {
setLoading(false);
}
};
const handleAddApi = () => {
setEditingApi(null);
setApiForm({
url: '',
description: '',
route: '',
color: 'blue'
});
setShowApiModal(true);
};
const handleEditApi = (api) => {
setEditingApi(api);
setApiForm({
url: api.url,
description: api.description,
route: api.route,
color: api.color
});
setShowApiModal(true);
};
const handleDeleteApi = (api) => {
setDeletingApi(api);
setShowDeleteModal(true);
};
const confirmDeleteApi = () => {
if (deletingApi) {
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
setApiInfoList(newList);
setHasChanges(true);
showSuccess('API信息已删除请及时点击“保存配置”进行保存');
}
setShowDeleteModal(false);
setDeletingApi(null);
};
const handleSaveApi = async () => {
if (!apiForm.url || !apiForm.route || !apiForm.description) {
showError('请填写完整的API信息');
return;
}
try {
setModalLoading(true);
let newList;
if (editingApi) {
newList = apiInfoList.map(api =>
api.id === editingApi.id
? { ...api, ...apiForm }
: api
);
} else {
const newId = Math.max(...apiInfoList.map(api => api.id), 0) + 1;
const newApi = {
id: newId,
...apiForm
};
newList = [...apiInfoList, newApi];
}
setApiInfoList(newList);
setHasChanges(true);
setShowApiModal(false);
showSuccess(editingApi ? 'API信息已更新请及时点击“保存配置”进行保存' : 'API信息已添加请及时点击“保存配置”进行保存');
} catch (error) {
showError('操作失败: ' + error.message);
} finally {
setModalLoading(false);
}
};
const parseApiInfo = (apiInfoStr) => {
if (!apiInfoStr) {
setApiInfoList([]);
return;
}
try {
const parsed = JSON.parse(apiInfoStr);
setApiInfoList(Array.isArray(parsed) ? parsed : []);
} catch (error) {
console.error('解析API信息失败:', error);
setApiInfoList([]);
}
};
useEffect(() => {
if (options.ApiInfo !== undefined) {
parseApiInfo(options.ApiInfo);
}
}, [options.ApiInfo]);
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('API地址'),
dataIndex: 'url',
render: (text, record) => (
<Tag
color={record.color}
className="!rounded-full"
style={{ maxWidth: '280px' }}
>
{text}
</Tag>
),
},
{
title: t('线路描述'),
dataIndex: 'route',
render: (text, record) => (
<Tag shape='circle'>
{text}
</Tag>
),
},
{
title: t('说明'),
dataIndex: 'description',
ellipsis: true,
render: (text, record) => (
<Tag shape='circle'>
{text || '-'}
</Tag>
),
},
{
title: t('颜色'),
dataIndex: 'color',
render: (color) => (
<Avatar
size="extra-extra-small"
color={color}
/>
),
},
{
title: t('操作'),
fixed: 'right',
render: (_, record) => (
<Space>
<Button
icon={<Edit size={14} />}
theme='light'
type='tertiary'
size='small'
className="!rounded-full"
onClick={() => handleEditApi(record)}
>
{t('编辑')}
</Button>
<Button
icon={<Trash2 size={14} />}
type='danger'
theme='light'
size='small'
className="!rounded-full"
onClick={() => handleDeleteApi(record)}
>
{t('删除')}
</Button>
</Space>
),
},
];
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<Settings size={16} className="mr-2" />
<Text>{t('API信息管理可以配置多个API地址用于状态展示和负载均衡')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<Plus size={14} />}
className="!rounded-full w-full md:w-auto"
onClick={handleAddApi}
>
{t('添加API')}
</Button>
<Button
icon={<Save size={14} />}
onClick={submitApiInfo}
loading={loading}
disabled={!hasChanges}
type='secondary'
className="!rounded-full w-full md:w-auto"
>
{t('保存配置')}
</Button>
</div>
</div>
</div>
);
return (
<>
<Form.Section text={renderHeader()}>
<Table
columns={columns}
dataSource={apiInfoList}
scroll={{ x: 'max-content' }}
pagination={false}
size='middle'
loading={loading}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无API信息')}
style={{ padding: 30 }}
/>
}
className="rounded-xl overflow-hidden"
/>
</Form.Section>
<Modal
title={editingApi ? t('编辑API') : t('添加API')}
visible={showApiModal}
onOk={handleSaveApi}
onCancel={() => setShowApiModal(false)}
okText={t('保存')}
cancelText={t('取消')}
className="rounded-xl"
confirmLoading={modalLoading}
>
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
<Form.Input
field='url'
label={t('API地址')}
placeholder='https://api.example.com'
rules={[{ required: true, message: t('请输入API地址') }]}
onChange={(value) => setApiForm({ ...apiForm, url: value })}
/>
<Form.Input
field='route'
label={t('线路描述')}
placeholder={t('如:香港线路')}
rules={[{ required: true, message: t('请输入线路描述') }]}
onChange={(value) => setApiForm({ ...apiForm, route: value })}
/>
<Form.Input
field='description'
label={t('说明')}
placeholder={t('如:大带宽批量分析图片推荐')}
rules={[{ required: true, message: t('请输入说明') }]}
onChange={(value) => setApiForm({ ...apiForm, description: value })}
/>
<Form.Select
field='color'
label={t('标识颜色')}
optionList={colorOptions}
onChange={(value) => setApiForm({ ...apiForm, color: value })}
render={(option) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar
size="extra-extra-small"
color={option.value}
/>
{option.label}
</div>
)}
/>
</Form>
</Modal>
<Modal
title={t('确认删除')}
visible={showDeleteModal}
onOk={confirmDeleteApi}
onCancel={() => {
setShowDeleteModal(false);
setDeletingApi(null);
}}
okText={t('确认删除')}
cancelText={t('取消')}
type="warning"
className="rounded-xl"
okButtonProps={{
type: 'danger',
theme: 'solid'
}}
>
<Text>{t('确定要删除此API信息吗')}</Text>
</Modal>
</>
);
};
export default SettingsAPIInfo;

View File

@@ -6,10 +6,10 @@ import { useTranslation } from 'react-i18next';
import SystemSetting from '../../components/settings/SystemSetting.js';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/settings/OtherSetting';
import PersonalSetting from '../../components/settings/PersonalSetting.js';
import OperationSetting from '../../components/settings/OperationSetting.js';
import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
import ModelSetting from '../../components/settings/ModelSetting.js';
import DashboardSetting from '../../components/settings/DashboardSetting.js';
const Setting = () => {
const { t } = useTranslation();
@@ -44,6 +44,11 @@ const Setting = () => {
content: <OtherSetting />,
itemKey: 'other',
});
panes.push({
tab: t('仪表盘配置'),
content: <DashboardSetting />,
itemKey: 'dashboard',
});
}
const onChangeTab = (key) => {
setTabActiveKey(key);

View File

@@ -133,7 +133,7 @@ const Setup = () => {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center px-4 py-8">

View File

@@ -219,9 +219,15 @@ const EditToken = (props) => {
let successCount = 0; // 记录成功创建的令牌数量
for (let i = 0; i < tokenCount; i++) {
let localInputs = { ...inputs };
if (i !== 0) {
// 如果用户想要创建多个令牌,则给每个令牌一个序号后缀
localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;
// 检查用户是否填写了令牌名称
const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
if (i !== 0 || inputs.name.trim() === '') {
// 如果创建多个令牌i !== 0或者用户没有填写名称则添加随机后缀
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
} else {
localInputs.name = baseName;
}
localInputs.remain_quota = parseInt(localInputs.remain_quota);

View File

@@ -55,6 +55,7 @@ const TopUp = () => {
const [amountLoading, setAmountLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// 邀请相关状态
const [affLink, setAffLink] = useState('');
@@ -256,6 +257,32 @@ const TopUp = () => {
showSuccess(t('邀请链接已复制到剪切板'));
};
// 检测暗色模式
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
setIsDarkMode(isDark);
};
checkDarkMode();
// 监听主题变化
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener(checkDarkMode);
return () => {
observer.disconnect();
mediaQuery.removeListener(checkDarkMode);
};
}, []);
useEffect(() => {
if (userState?.user?.id) {
setUserDataLoading(false);
@@ -398,48 +425,45 @@ const TopUp = () => {
<div className="w-full">
<Card className="!rounded-2xl shadow-lg border-0">
<Card
className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
className="!rounded-2xl !border-0 !shadow-lg overflow-hidden"
style={{
background: 'linear-gradient(135deg, #1e3a8a 0%, #1e40af 25%, #2563eb 50%, #3b82f6 75%, #60a5fa 100%)',
background: isDarkMode
? 'linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%)'
: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #cbd5e1 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
<div className="absolute -top-10 -right-10 w-40 h-40 bg-slate-400 opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-slate-300 opacity-8 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-slate-400 opacity-6 rounded-full"></div>
</div>
<div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
<div className="relative p-4 sm:p-6 md:p-8 text-gray-600 dark:text-gray-300">
<div className="flex justify-between items-start mb-4 sm:mb-6">
<div className="flex-1 min-w-0">
{userDataLoading ? (
<Skeleton.Title style={{ width: '200px', height: '20px' }} />
) : (
<div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
<div className="text-base sm:text-lg font-semibold truncate text-gray-800 dark:text-gray-100">
{t('尊敬的')} {getUsername()}
</div>
)}
</div>
<div
className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
style={{
background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
}}
>
<IconCreditCard size="default" style={{ color: 'white' }} />
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-md flex-shrink-0 ml-2 bg-slate-400 dark:bg-slate-500">
<IconCreditCard size="default" className="text-white" />
</div>
</div>
<div className="mb-4 sm:mb-6">
<div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
<div className="text-xs sm:text-sm mb-1 sm:mb-2 text-gray-500 dark:text-gray-400">
{t('当前余额')}
</div>
{userDataLoading ? (
<Skeleton.Title style={{ width: '180px', height: '32px' }} />
) : (
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
<div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide text-gray-900 dark:text-gray-100">
{renderQuota(userState?.user?.quota || userQuota)}
</div>
)}
@@ -448,37 +472,37 @@ const TopUp = () => {
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
<div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('历史消耗')}
</div>
{userDataLoading ? (
<Skeleton.Title style={{ width: '60px', height: '14px' }} />
) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{renderQuota(userState?.user?.used_quota || 0)}
</div>
)}
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户分组')}
</div>
{userDataLoading ? (
<Skeleton.Title style={{ width: '50px', height: '14px' }} />
) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{userState?.user?.group || t('默认')}
</div>
)}
</div>
<div className="text-center sm:text-left">
<div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
<div className="text-xs text-gray-400 dark:text-gray-500">
{t('用户角色')}
</div>
{userDataLoading ? (
<Skeleton.Title style={{ width: '60px', height: '14px' }} />
) : (
<div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
<div className="text-xs sm:text-sm font-medium truncate text-gray-600 dark:text-gray-300">
{getUserRole()}
</div>
)}
@@ -489,32 +513,187 @@ const TopUp = () => {
{userDataLoading ? (
<Skeleton.Title style={{ width: '50px', height: '24px' }} />
) : (
<div
className="px-2 py-1 sm:px-3 rounded-md text-xs sm:text-sm font-medium inline-block"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(10px)'
}}
>
<div className="px-2 py-1 sm:px-3 rounded-md text-xs sm:text-sm font-medium inline-block bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-600">
ID: {userState?.user?.id || '---'}
</div>
)}
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-slate-300 via-slate-400 to-slate-500 dark:from-slate-600 dark:via-slate-500 dark:to-slate-400 opacity-40"></div>
</div>
</Card>
<div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 邀请信息部分 */}
<div>
{/* 左侧:在线充值和兑换余额 */}
<div className="lg:col-span-2 space-y-8">
{/* 在线充值部分 */}
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconPlus size="large" className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Text className="text-xl font-semibold">{t('在线充值')}</Text>
<div className="text-gray-500 text-sm">{t('支持多种支付方式')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '14px' }} />
) : (
<Text type="tertiary">{t('实付金额:') + ' ' + renderAmount()}</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
if (value && value >= 1) {
setTopUpCount(value);
await getAmount(value);
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (!value || value < 1) {
setTopUpCount(1);
getAmount(1);
}
}}
size="large"
className="!rounded-lg w-full"
prefix={<IconCreditCard />}
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('zfb');
}}
size="large"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiAlipay size={20} />}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('wx');
}}
size="large"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiWechat size={20} />}
>
<span className="ml-2">{t('微信')}</span>
</Button>
</div>
{!enableOnlineTopUp && (
<Banner
fullMode={false}
type="warning"
icon={null}
closeIcon={null}
className="!rounded-lg"
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('在线充值功能未开启')}
</div>
}
description={
<div>
{t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
</div>
}
/>
)}
</div>
</div>
{/* 兑换余额部分 */}
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconGift size="large" className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<Text className="text-xl font-semibold">{t('兑换余额')}</Text>
<div className="text-gray-500 text-sm">{t('使用兑换码充值余额')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('兑换码')}</Text>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
size="large"
className="!rounded-lg"
prefix={<IconGift />}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{topUpLink && (
<Button
type="primary"
theme="solid"
onClick={openTopUpLink}
size="large"
className="!rounded-lg flex-1"
icon={<IconLink />}
>
{t('获取兑换码')}
</Button>
)}
<Button
type="warning"
theme="solid"
onClick={topUp}
disabled={isSubmitting}
loading={isSubmitting}
size="large"
className="!rounded-lg flex-1"
>
{isSubmitting ? t('兑换中...') : t('兑换')}
</Button>
</div>
</div>
</div>
</div>
{/* 右侧:邀请信息部分 */}
<div className="lg:col-span-1">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center mr-4">
<IconLink size="large" className="text-orange-500" />
<div className="w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4">
<IconLink size="large" className="text-slate-600 dark:text-slate-300" />
</div>
<div>
<div className="flex items-center gap-3">
@@ -524,7 +703,7 @@ const TopUp = () => {
theme="solid"
onClick={() => setOpenTransfer(true)}
size="small"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600"
className="!rounded-lg !bg-slate-600 hover:!bg-slate-700"
icon={<IconCreditCard />}
>
{t('划转')}
@@ -536,7 +715,7 @@ const TopUp = () => {
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3">
<Card
className="!rounded-2xl text-center"
bodyStyle={{ padding: '16px' }}
@@ -546,7 +725,6 @@ const TopUp = () => {
<div className="text-gray-900 text-lg font-bold mt-1">
{renderQuota(userState?.user?.aff_quota)}
</div>
</Card>
<Card
className="!rounded-2xl text-center"
@@ -583,162 +761,6 @@ const TopUp = () => {
</div>
</div>
</div>
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mr-4">
<IconGift size="large" className="text-green-500" />
</div>
<div>
<Text className="text-xl font-semibold">{t('兑换余额')}</Text>
<div className="text-gray-500 text-sm">{t('使用兑换码充值余额')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<Text strong className="block mb-2">{t('兑换码')}</Text>
<Input
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
size="large"
className="!rounded-lg"
prefix={<IconGift />}
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
{topUpLink && (
<Button
type="primary"
theme="solid"
onClick={openTopUpLink}
size="large"
className="!rounded-lg flex-1"
icon={<IconLink />}
>
{t('获取兑换码')}
</Button>
)}
<Button
type="warning"
theme="solid"
onClick={topUp}
disabled={isSubmitting}
loading={isSubmitting}
size="large"
className="!rounded-lg flex-1"
>
{isSubmitting ? t('兑换中...') : t('兑换')}
</Button>
</div>
</div>
</div>
<div>
<div className="flex items-center mb-6">
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4">
<IconPlus size="large" className="text-blue-500" />
</div>
<div>
<Text className="text-xl font-semibold">{t('在线充值')}</Text>
<div className="text-gray-500 text-sm">{t('支持多种支付方式')}</div>
</div>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between mb-2">
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title style={{ width: '80px', height: '14px' }} />
) : (
<Text type="tertiary">{t('实付金额:') + ' ' + renderAmount()}</Text>
)}
</div>
<InputNumber
disabled={!enableOnlineTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
if (value && value >= 1) {
setTopUpCount(value);
await getAmount(value);
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (!value || value < 1) {
setTopUpCount(1);
getAmount(1);
}
}}
size="large"
className="!rounded-lg w-full"
prefix={<IconCreditCard />}
formatter={(value) => value ? `${value}` : ''}
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('zfb');
}}
size="large"
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiAlipay size={20} />}
>
<span className="ml-2">{t('支付宝')}</span>
</Button>
<Button
type="primary"
theme="solid"
onClick={async () => {
preTopUp('wx');
}}
size="large"
className="!rounded-lg !bg-green-500 hover:!bg-green-600 h-14"
disabled={!enableOnlineTopUp}
loading={paymentLoading}
icon={<SiWechat size={20} />}
>
<span className="ml-2">{t('微信')}</span>
</Button>
</div>
{!enableOnlineTopUp && (
<Banner
fullMode={false}
type="warning"
icon={null}
closeIcon={null}
className="!rounded-lg"
title={
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
{t('在线充值功能未开启')}
</div>
}
description={
<div>
{t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
</div>
}
/>
)}
</div>
</div>
</div>
</div>
</Card>