Merge branch 'alpha' into feature/simple_stripe

This commit is contained in:
wzxjohn
2025-07-16 10:39:11 +08:00
committed by GitHub
170 changed files with 6242 additions and 3319 deletions

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
@@ -7,22 +7,28 @@ import Loading from '../common/Loading';
const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const [, userDispatch] = useContext(UserContext);
const navigate = useNavigate();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState(t('处理中...'));
// 最大重试次数
const MAX_RETRIES = 3;
let navigate = useNavigate();
const sendCode = async (code, state, retry = 0) => {
try {
const { data: resData } = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = resData;
if (!success) {
throw new Error(message || 'OAuth2 callback error');
}
const sendCode = async (code, state, count) => {
const res = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/setting');
navigate('/console/personal');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
@@ -31,27 +37,34 @@ const OAuth2Callback = (props) => {
showSuccess(t('登录成功!'));
navigate('/console/token');
}
} else {
showError(message);
if (count === 0) {
setPrompt(t('操作失败,重定向至登录界面中...'));
navigate('/console/setting'); // in case this is failed to bind GitHub
return;
} catch (error) {
if (retry < MAX_RETRIES) {
// 递增的退避等待
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
return sendCode(code, state, retry + 1);
}
count++;
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, state, count);
// 重试次数耗尽,提示错误并返回设置页面
showError(error.message || t('授权失败'));
navigate('/console/personal');
}
};
useEffect(() => {
let code = searchParams.get('code');
let state = searchParams.get('state');
sendCode(code, state, 0).then();
const code = searchParams.get('code');
const state = searchParams.get('state');
// 参数缺失直接返回
if (!code) {
showError(t('未获取到授权码'));
navigate('/console/personal');
return;
}
sendCode(code, state);
}, []);
return <Loading prompt={prompt} />;
return <Loading />;
};
export default OAuth2Callback;

View File

@@ -1,22 +1,14 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const Loading = ({ prompt: name = '', size = 'large' }) => {
const { t } = useTranslation();
const Loading = ({ size = 'small' }) => {
return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size={size}
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('{{name}}', { name }) : t('加载中...')}
</span>
</div>
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
<Spin
size={size}
spinning={true}
/>
</div>
);
};

View File

@@ -22,7 +22,7 @@ const FooterBar = () => {
const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => (
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<footer className="relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
@@ -31,13 +31,15 @@ import {
Badge,
} from '@douyinfe/semi-ui';
import { StatusContext } from '../../context/Status/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
const HeaderBar = () => {
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState, dispatch: styleDispatch } = useStyle();
const isMobile = useIsMobile();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [isLoading, setIsLoading] = useState(true);
let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
@@ -45,6 +47,7 @@ const HeaderBar = () => {
const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const loadingStartRef = useRef(Date.now());
const systemName = getSystemName();
const logo = getLogo();
@@ -194,11 +197,15 @@ const HeaderBar = () => {
}, [i18n]);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
if (statusState?.status !== undefined) {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const timer = setTimeout(() => {
setIsLoading(false);
}, remaining);
return () => clearTimeout(timer);
}
}, [statusState?.status]);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
@@ -207,7 +214,7 @@ const HeaderBar = () => {
const handleNavLinkClick = (itemKey) => {
if (itemKey === 'home') {
styleDispatch(styleActions.setSider(false));
// styleDispatch(styleActions.setSider(false)); // This line is removed
}
setMobileMenuOpen(false);
};
@@ -221,7 +228,16 @@ const HeaderBar = () => {
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobileView ? 100 : 60, height: 16 }}
/>
}
/>
</div>
));
}
@@ -272,9 +288,22 @@ const HeaderBar = () => {
if (isLoading) {
return (
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
<Skeleton
loading={true}
active
placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
/>
<div className="ml-1.5 mr-1">
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
active
style={{ width: isMobile ? 15 : 50, height: 12 }}
/>
}
/>
</div>
</div>
);
@@ -366,7 +395,7 @@ const HeaderBar = () => {
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
if (showRegisterButton) {
if (styleState.isMobile) {
if (isMobile) {
loginButtonClasses += " !rounded-full";
} else {
loginButtonClasses += " !rounded-l-full !rounded-r-none";
@@ -414,7 +443,7 @@ const HeaderBar = () => {
<NoticeModal
visible={noticeVisible}
onClose={handleNoticeClose}
isMobile={styleState.isMobile}
isMobile={isMobile}
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
unreadKeys={getUnreadKeys()}
/>
@@ -425,18 +454,18 @@ const HeaderBar = () => {
<Button
icon={
isConsoleRoute
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
? ((isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
}
aria-label={
isConsoleRoute
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
? ((isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏'))
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
}
onClick={() => {
if (isConsoleRoute) {
// 控制侧边栏的显示/隐藏,无论是否移动设备
styleDispatch(styleActions.toggleSider());
isMobile ? onMobileMenuToggle() : toggleCollapsed();
} else {
// 控制HeaderBar自己的移动菜单
setMobileMenuOpen(!mobileMenuOpen);
@@ -448,22 +477,35 @@ const HeaderBar = () => {
/>
</div>
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
{isLoading ? (
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
) : (
<Skeleton
loading={isLoading}
active
placeholder={
<Skeleton.Image
active
className="h-7 md:h-8 !rounded-full"
style={{ width: 32, height: 32 }}
/>
}
>
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
)}
</Skeleton>
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2">
{isLoading ? (
<Skeleton.Title style={{ width: 120, height: 24 }} />
) : (
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
bg-clip-text text-transparent">
<Skeleton
loading={isLoading}
active
placeholder={
<Skeleton.Title
active
style={{ width: 120, height: 24 }}
/>
}
>
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
{systemName}
</Typography.Title>
)}
</Skeleton>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}

View File

@@ -4,8 +4,9 @@ import SiderBar from './SiderBar.js';
import App from '../../App.js';
import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react';
import { useStyle } from '../../context/Style/index.js';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { UserContext } from '../../context/User/index.js';
@@ -14,9 +15,11 @@ import { useLocation } from 'react-router-dom';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState } = useStyle();
const [, userDispatch] = useContext(UserContext);
const [, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, , setCollapsed] = useSidebarCollapsed();
const [drawerOpen, setDrawerOpen] = useState(false);
const { i18n } = useTranslation();
const location = useLocation();
@@ -26,6 +29,15 @@ const PageLayout = () => {
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
setCollapsed(false);
}
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
@@ -63,7 +75,6 @@ const PageLayout = () => {
linkElement.href = logo;
}
}
// 从localStorage获取上次使用的语言
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
i18n.changeLanguage(savedLang);
@@ -76,7 +87,7 @@ const PageLayout = () => {
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: styleState.isMobile ? 'visible' : 'hidden',
overflow: isMobile ? 'visible' : 'hidden',
}}
>
<Header
@@ -90,16 +101,16 @@ const PageLayout = () => {
zIndex: 100,
}}
>
<HeaderBar />
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
</Header>
<Layout
style={{
overflow: styleState.isMobile ? 'visible' : 'auto',
overflow: isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{styleState.showSider && (
{showSider && (
<Sider
style={{
position: 'fixed',
@@ -109,21 +120,15 @@ const PageLayout = () => {
border: 'none',
paddingRight: '0',
height: 'calc(100vh - 64px)',
width: 'var(--sidebar-current-width)',
}}
>
<SiderBar />
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
</Sider>
)}
<Layout
style={{
marginLeft: styleState.isMobile
? '0'
: styleState.showSider
? styleState.siderCollapsed
? '60px'
: '180px'
: '0',
transition: 'margin-left 0.3s ease',
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
@@ -132,9 +137,9 @@ const PageLayout = () => {
<Content
style={{
flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'hidden',
overflowY: isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (styleState.isMobile ? '5px' : '24px') : '0',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
}}
>

View File

@@ -3,7 +3,7 @@ import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react';
import { useStyle, styleActions } from '../../context/Style/index.js';
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
import {
isAdmin,
isRoot,
@@ -13,7 +13,7 @@ import {
import {
Nav,
Divider,
Tooltip,
Button,
} from '@douyinfe/semi-ui';
const routerMap = {
@@ -34,12 +34,11 @@ const routerMap = {
personal: '/console/personal',
};
const SiderBar = () => {
const SiderBar = ({ onNavigate = () => { } }) => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
@@ -217,10 +216,14 @@ const SiderBar = () => {
}
}, [location.pathname, routerMapState]);
// 同步折叠状态
// 监控折叠状态变化以更新 body class
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
if (collapsed) {
document.body.classList.add('sidebar-collapsed');
} else {
document.body.classList.remove('sidebar-collapsed');
}
}, [collapsed]);
// 获取菜单项对应的颜色
const getItemColor = (itemKey) => {
@@ -323,32 +326,13 @@ const SiderBar = () => {
return (
<div
className="sidebar-container"
style={{ width: isCollapsed ? '60px' : '180px' }}
style={{ width: 'var(--sidebar-current-width)' }}
>
<Nav
className="sidebar-nav custom-sidebar-nav"
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
className="sidebar-nav"
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
@@ -363,6 +347,7 @@ const SiderBar = () => {
<Link
style={{ textDecoration: 'none' }}
to={to}
onClick={onNavigate}
>
{itemElement}
</Link>
@@ -383,7 +368,7 @@ const SiderBar = () => {
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
@@ -392,7 +377,7 @@ const SiderBar = () => {
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
@@ -403,7 +388,7 @@ const SiderBar = () => {
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
@@ -414,7 +399,7 @@ const SiderBar = () => {
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
{!collapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
@@ -422,24 +407,25 @@ const SiderBar = () => {
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
<div className="sidebar-collapse-button">
<Button
theme="outline"
type="tertiary"
size="small"
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color="var(--semi-color-text-2)"
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
}
onClick={toggleCollapsed}
iconOnly={collapsed}
style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
</div>
</div>
);

View File

@@ -34,7 +34,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.temperature}
</Tag>
</div>
@@ -70,7 +70,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.top_p}
</Tag>
</div>
@@ -106,7 +106,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.frequency_penalty}
</Tag>
</div>
@@ -142,7 +142,7 @@ const ParameterControl = ({
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
<Tag size="small" shape='circle'>
{inputs.presence_penalty}
</Tag>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { isMobile } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Modal,
Table,
@@ -26,6 +26,7 @@ const ChannelSelectorModal = forwardRef(({
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
const [filteredData, setFilteredData] = useState([]);
@@ -118,25 +119,25 @@ const ChannelSelectorModal = forwardRef(({
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -186,7 +187,7 @@ const ChannelSelectorModal = forwardRef(({
onCancel={onCancel}
onOk={onOk}
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
size={isMobile() ? 'full-width' : 'large'}
size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsChats from '../../pages/Setting/Chat/SettingsChats.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const ChatsSetting = () => {
let [inputs, setInputs] = useState({
@@ -21,7 +21,7 @@ const ChatsSetting = () => {
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
@@ -45,7 +45,7 @@ const DashboardSetting = () => {
}
if (item.key.endsWith('Enabled') &&
(item.key === 'DataExportEnabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
}
});
setInputs(newInputs);

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const DrawingSetting = () => {
let [inputs, setInputs] = useState({
@@ -23,7 +23,7 @@ const DrawingSetting = () => {
let newInputs = {};
data.forEach((item) => {
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../helpers';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
@@ -44,7 +44,7 @@ const ModelSetting = () => {
}
}
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -5,7 +5,7 @@ import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensit
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const OperationSetting = () => {
let [inputs, setInputs] = useState({
@@ -54,7 +54,7 @@ const OperationSetting = () => {
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -3,7 +3,7 @@ import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
const PaymentSetting = () => {
@@ -51,7 +51,7 @@ const PaymentSetting = () => {
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers/index.js';
import { API, showError, toBoolean } from '../../helpers/index.js';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
@@ -28,7 +28,7 @@ const RateLimitSetting = () => {
}
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -8,7 +8,7 @@ import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVi
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js';
import { API, showError } from '../../helpers';
import { API, showError, toBoolean } from '../../helpers';
const RatioSetting = () => {
const { t } = useTranslation();
@@ -51,7 +51,7 @@ const RatioSetting = () => {
}
}
if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) {
newInputs[item.key] = item.value === 'true' ? true : false;
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}

View File

@@ -17,10 +17,13 @@ import {
removeTrailingSlash,
showError,
showSuccess,
toBoolean,
} from '../../helpers';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
@@ -57,13 +60,13 @@ const SystemSetting = () => {
EmailAliasRestrictionEnabled: '',
SMTPSSLEnabled: '',
EmailDomainWhitelist: [],
// telegram login
TelegramOAuthEnabled: '',
TelegramBotToken: '',
TelegramBotName: '',
LinuxDOOAuthEnabled: '',
LinuxDOClientId: '',
LinuxDOClientSecret: '',
ServerAddress: '',
});
const [originInputs, setOriginInputs] = useState({});
@@ -104,7 +107,7 @@ const SystemSetting = () => {
case 'LinuxDOOAuthEnabled':
case 'oidc.enabled':
case 'WorkerAllowHttpImageRequestEnabled':
item.value = item.value === 'true';
item.value = toBoolean(item.value);
break;
case 'Price':
case 'MinTopUp':
@@ -173,7 +176,7 @@ const SystemSetting = () => {
});
}
showSuccess('更新成功');
showSuccess(t('更新成功'));
// 更新本地状态
const newInputs = { ...inputs };
options.forEach((opt) => {
@@ -181,7 +184,7 @@ const SystemSetting = () => {
});
setInputs(newInputs);
} catch (error) {
showError('更新失败');
showError(t('更新失败'));
}
setLoading(false);
};
@@ -205,6 +208,11 @@ const SystemSetting = () => {
await updateOptions(options);
};
const submitServerAddress = async () => {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]);
};
const submitSMTP = async () => {
const options = [];
@@ -244,7 +252,7 @@ const SystemSetting = () => {
},
]);
} else {
showError('邮箱域名白名单格式不正确');
showError(t('邮箱域名白名单格式不正确'));
}
};
@@ -256,19 +264,19 @@ const SystemSetting = () => {
const domainRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
if (!domainRegex.test(domain)) {
showError('邮箱域名格式不正确,请输入有效的域名,如 gmail.com');
showError(t('邮箱域名格式不正确,请输入有效的域名,如 gmail.com'));
return;
}
// 检查是否已存在
if (emailDomainWhitelist.includes(domain)) {
showError('该域名已存在于白名单中');
showError(t('该域名已存在于白名单中'));
return;
}
setEmailDomainWhitelist([...emailDomainWhitelist, domain]);
setEmailToAdd('');
showSuccess('已添加到白名单');
showSuccess(t('已添加到白名单'));
}
};
@@ -332,7 +340,7 @@ const SystemSetting = () => {
!inputs['oidc.well_known'].startsWith('http://') &&
!inputs['oidc.well_known'].startsWith('https://')
) {
showError('Well-Known URL 必须以 http:// 或 https:// 开头');
showError(t('Well-Known URL 必须以 http:// 或 https:// 开头'));
return;
}
try {
@@ -341,11 +349,11 @@ const SystemSetting = () => {
res.data['authorization_endpoint'];
inputs['oidc.token_endpoint'] = res.data['token_endpoint'];
inputs['oidc.user_info_endpoint'] = res.data['userinfo_endpoint'];
showSuccess('获取 OIDC 配置成功!');
showSuccess(t('获取 OIDC 配置成功!'));
} catch (err) {
console.error(err);
showError(
'获取 OIDC 配置失败,请检查网络状况和 Well-Known URL 是否正确',
t('获取 OIDC 配置失败,请检查网络状况和 Well-Known URL 是否正确'),
);
return;
}
@@ -487,7 +495,25 @@ const SystemSetting = () => {
}}
>
<Card>
<Form.Section text='代理设置'>
<Form.Section text={t('通用设置')}>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Input
field='ServerAddress'
label={t('服务器地址')}
placeholder='https://yourdomain.com'
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
/>
</Col>
</Row>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('代理设置')}>
<Text>
支持{' '}
<a
@@ -505,14 +531,14 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WorkerUrl'
label='Worker地址'
label={t('Worker地址')}
placeholder='例如https://workername.yourdomain.workers.dev'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WorkerValidKey'
label='Worker密钥'
label={t('Worker密钥')}
placeholder='敏感信息不会发送到前端显示'
type='password'
/>
@@ -522,14 +548,14 @@ const SystemSetting = () => {
field='WorkerAllowHttpImageRequestEnabled'
noLabel
>
允许 HTTP 协议图片请求适用于自部署代理
{t('允许 HTTP 协议图片请求适用于自部署代理)')}
</Form.Checkbox>
<Button onClick={submitWorker}>更新Worker设置</Button>
<Button onClick={submitWorker}>{t('更新Worker设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置登录注册'>
<Form.Section text={t('配置登录注册')}>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
@@ -541,7 +567,7 @@ const SystemSetting = () => {
handleCheckboxChange('PasswordLoginEnabled', e)
}
>
允许通过密码进行登录
{t('允许通过密码进行登录')}
</Form.Checkbox>
<Form.Checkbox
field='PasswordRegisterEnabled'
@@ -550,7 +576,7 @@ const SystemSetting = () => {
handleCheckboxChange('PasswordRegisterEnabled', e)
}
>
允许通过密码进行注册
{t('允许通过密码进行注册')}
</Form.Checkbox>
<Form.Checkbox
field='EmailVerificationEnabled'
@@ -559,7 +585,7 @@ const SystemSetting = () => {
handleCheckboxChange('EmailVerificationEnabled', e)
}
>
通过密码注册时需要进行邮箱验证
{t('通过密码注册时需要进行邮箱验证')}
</Form.Checkbox>
<Form.Checkbox
field='RegisterEnabled'
@@ -568,7 +594,7 @@ const SystemSetting = () => {
handleCheckboxChange('RegisterEnabled', e)
}
>
允许新用户注册
{t('允许新用户注册')}
</Form.Checkbox>
<Form.Checkbox
field='TurnstileCheckEnabled'
@@ -577,7 +603,7 @@ const SystemSetting = () => {
handleCheckboxChange('TurnstileCheckEnabled', e)
}
>
启用 Turnstile 用户校验
{t('允许 Turnstile 用户校验')}
</Form.Checkbox>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
@@ -588,7 +614,7 @@ const SystemSetting = () => {
handleCheckboxChange('GitHubOAuthEnabled', e)
}
>
允许通过 GitHub 账户登录 & 注册
{t('允许通过 GitHub 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='LinuxDOOAuthEnabled'
@@ -597,7 +623,7 @@ const SystemSetting = () => {
handleCheckboxChange('LinuxDOOAuthEnabled', e)
}
>
允许通过 Linux DO 账户登录 & 注册
{t('允许通过 Linux DO 账户登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='WeChatAuthEnabled'
@@ -606,7 +632,7 @@ const SystemSetting = () => {
handleCheckboxChange('WeChatAuthEnabled', e)
}
>
允许通过微信登录 & 注册
{t('允许通过微信登录 & 注册')}
</Form.Checkbox>
<Form.Checkbox
field='TelegramOAuthEnabled'
@@ -615,7 +641,7 @@ const SystemSetting = () => {
handleCheckboxChange('TelegramOAuthEnabled', e)
}
>
允许通过 Telegram 进行登录
{t('允许通过 Telegram 进行登录')}
</Form.Checkbox>
<Form.Checkbox
field="['oidc.enabled']"
@@ -624,7 +650,7 @@ const SystemSetting = () => {
handleCheckboxChange('oidc.enabled', e)
}
>
允许通过 OIDC 进行登录
{t('允许通过 OIDC 进行登录')}
</Form.Checkbox>
</Col>
</Row>
@@ -632,8 +658,8 @@ const SystemSetting = () => {
</Card>
<Card>
<Form.Section text='配置邮箱域名白名单'>
<Text>用以防止恶意用户利用临时邮箱批量注册</Text>
<Form.Section text={t('配置邮箱域名白名单')}>
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
@@ -669,11 +695,11 @@ const SystemSetting = () => {
<TagInput
value={emailDomainWhitelist}
onChange={setEmailDomainWhitelist}
placeholder='输入域名后回车'
placeholder={t('输入域名后回车')}
style={{ width: '100%', marginTop: 16 }}
/>
<Form.Input
placeholder='输入要添加的邮箱域名'
placeholder={t('输入要添加的邮箱域名')}
value={emailToAdd}
onChange={(value) => setEmailToAdd(value)}
style={{ marginTop: 16 }}
@@ -683,7 +709,7 @@ const SystemSetting = () => {
type='primary'
onClick={handleAddEmail}
>
添加
{t('添加')}
</Button>
}
onEnterPress={handleAddEmail}
@@ -692,24 +718,24 @@ const SystemSetting = () => {
onClick={submitEmailDomainWhitelist}
style={{ marginTop: 10 }}
>
保存邮箱域名白名单设置
{t('保存邮箱域名白名单设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 SMTP'>
<Text>用以支持系统的邮件发送</Text>
<Form.Section text={t('配置 SMTP')}>
<Text>{t('用以支持系统的邮件发送')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPServer' label='SMTP 服务器地址' />
<Form.Input field='SMTPServer' label={t('SMTP 服务器地址')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPPort' label='SMTP 端口' />
<Form.Input field='SMTPPort' label={t('SMTP 端口')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPAccount' label='SMTP 账户' />
<Form.Input field='SMTPAccount' label={t('SMTP 账户')} />
</Col>
</Row>
<Row
@@ -717,12 +743,12 @@ const SystemSetting = () => {
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input field='SMTPFrom' label='SMTP 发送者邮箱' />
<Form.Input field='SMTPFrom' label={t('SMTP 发送者邮箱')} />
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='SMTPToken'
label='SMTP 访问凭证'
label={t('SMTP 访问凭证')}
type='password'
placeholder='敏感信息不会发送到前端显示'
/>
@@ -735,27 +761,25 @@ const SystemSetting = () => {
handleCheckboxChange('SMTPSSLEnabled', e)
}
>
启用SMTP SSL
{t('启用SMTP SSL')}
</Form.Checkbox>
</Col>
</Row>
<Button onClick={submitSMTP}>保存 SMTP 设置</Button>
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 OIDC'>
<Form.Section text={t('配置 OIDC')}>
<Text>
用以支持通过 OIDC 登录例如 OktaAuth0 等兼容 OIDC 协议的
IdP
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')}
</Text>
<Banner
type='info'
description={`主页链接填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}重定向 URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/oidc`}
description={`${t('主页链接填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}${t('重定向 URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/oidc`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Text>
若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写
OIDC Well-Known URL系统会自动获取 OIDC 配置
{t('若你的 OIDC Provider 支持 Discovery Endpoint你可以仅填写 OIDC Well-Known URL系统会自动获取 OIDC 配置')}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
@@ -763,15 +787,15 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.well_known']"
label='Well-Known URL'
placeholder='请输入 OIDC 的 Well-Known URL'
label={t('Well-Known URL')}
placeholder={t('请输入 OIDC 的 Well-Known URL')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.client_id']"
label='Client ID'
placeholder='输入 OIDC 的 Client ID'
label={t('Client ID')}
placeholder={t('输入 OIDC 的 Client ID')}
/>
</Col>
</Row>
@@ -781,16 +805,16 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.client_secret']"
label='Client Secret'
label={t('Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.authorization_endpoint']"
label='Authorization Endpoint'
placeholder='输入 OIDC 的 Authorization Endpoint'
label={t('Authorization Endpoint')}
placeholder={t('输入 OIDC 的 Authorization Endpoint')}
/>
</Col>
</Row>
@@ -800,28 +824,28 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.token_endpoint']"
label='Token Endpoint'
placeholder='输入 OIDC 的 Token Endpoint'
label={t('Token Endpoint')}
placeholder={t('输入 OIDC 的 Token Endpoint')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field="['oidc.user_info_endpoint']"
label='User Info Endpoint'
placeholder='输入 OIDC 的 Userinfo Endpoint'
label={t('User Info Endpoint')}
placeholder={t('输入 OIDC 的 Userinfo Endpoint')}
/>
</Col>
</Row>
<Button onClick={submitOIDCSettings}>保存 OIDC 设置</Button>
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 GitHub OAuth App'>
<Text>用以支持通过 GitHub 进行登录注册</Text>
<Form.Section text={t('配置 GitHub OAuth App')}>
<Text>{t('用以支持通过 GitHub 进行登录注册')}</Text>
<Banner
type='info'
description={`Homepage URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}Authorization callback URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/github`}
description={`${t('Homepage URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}${t('Authorization callback URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/github`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
@@ -830,27 +854,27 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='GitHubClientId'
label='GitHub Client ID'
label={t('GitHub Client ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='GitHubClientSecret'
label='GitHub Client Secret'
label={t('GitHub Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitGitHubOAuth}>
保存 GitHub OAuth 设置
{t('保存 GitHub OAuth 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Linux DO OAuth'>
<Form.Section text={t('配置 Linux DO OAuth')}>
<Text>
用以支持通过 Linux DO 进行登录注册
{t('用以支持通过 Linux DO 进行登录注册')}
<a
href='https://connect.linux.do/'
target='_blank'
@@ -861,13 +885,13 @@ const SystemSetting = () => {
marginRight: 4,
}}
>
点击此处
{t('点击此处')}
</a>
管理你的 LinuxDO OAuth App
{t('管理你的 LinuxDO OAuth App')}
</Text>
<Banner
type='info'
description={`回调 URL 填 ${inputs.ServerAddress ? inputs.ServerAddress : '网站地址'}/oauth/linuxdo`}
description={`${t('回调 URL 填')} ${inputs.ServerAddress ? inputs.ServerAddress : t('网站地址')}/oauth/linuxdo`}
style={{ marginBottom: 20, marginTop: 16 }}
/>
<Row
@@ -876,122 +900,122 @@ const SystemSetting = () => {
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='LinuxDOClientId'
label='Linux DO Client ID'
placeholder='输入你注册的 LinuxDO OAuth APP 的 ID'
label={t('Linux DO Client ID')}
placeholder={t('输入你注册的 LinuxDO OAuth APP 的 ID')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='LinuxDOClientSecret'
label='Linux DO Client Secret'
label={t('Linux DO Client Secret')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitLinuxDOOAuth}>
保存 Linux DO OAuth 设置
{t('保存 Linux DO OAuth 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 WeChat Server'>
<Text>用以支持通过微信进行登录注册</Text>
<Form.Section text={t('配置 WeChat Server')}>
<Text>{t('用以支持通过微信进行登录注册')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatServerAddress'
label='WeChat Server 服务器地址'
label={t('WeChat Server 服务器地址')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatServerToken'
label='WeChat Server 访问凭证'
label={t('WeChat Server 访问凭证')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WeChatAccountQRCodeImageURL'
label='微信公众号二维码图片链接'
label={t('微信公众号二维码图片链接')}
/>
</Col>
</Row>
<Button onClick={submitWeChat}>
保存 WeChat Server 设置
{t('保存 WeChat Server 设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Telegram 登录'>
<Text>用以支持通过 Telegram 进行登录注册</Text>
<Form.Section text={t('配置 Telegram 登录')}>
<Text>{t('用以支持通过 Telegram 进行登录注册')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TelegramBotToken'
label='Telegram Bot Token'
placeholder='敏感信息不会发送到前端显示'
label={t('Telegram Bot Token')}
placeholder={t('敏感信息不会发送到前端显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TelegramBotName'
label='Telegram Bot 名称'
label={t('Telegram Bot 名称')}
/>
</Col>
</Row>
<Button onClick={submitTelegramSettings}>
保存 Telegram 登录设置
{t('保存 Telegram 登录设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text='配置 Turnstile'>
<Text>用以支持用户校验</Text>
<Form.Section text={t('配置 Turnstile')}>
<Text>{t('用以支持用户校验')}</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TurnstileSiteKey'
label='Turnstile Site Key'
label={t('Turnstile Site Key')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='TurnstileSecretKey'
label='Turnstile Secret Key'
label={t('Turnstile Secret Key')}
type='password'
placeholder='敏感信息不会发送到前端显示'
placeholder={t('敏感信息不会发送到前端显示')}
/>
</Col>
</Row>
<Button onClick={submitTurnstile}>保存 Turnstile 设置</Button>
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button>
</Form.Section>
</Card>
<Modal
title='确认取消密码登录'
title={t('确认取消密码登录')}
visible={showPasswordLoginConfirmModal}
onOk={handlePasswordLoginConfirm}
onCancel={() => {
setShowPasswordLoginConfirmModal(false);
formApiRef.current.setValue('PasswordLoginEnabled', true);
}}
okText='确认'
cancelText='取消'
okText={t('确认')}
cancelText={t('取消')}
>
<p>您确定要取消密码登录功能吗这可能会影响用户的登录方式</p>
<p>{t('您确定要取消密码登录功能吗这可能会影响用户的登录方式。')}</p>
</Modal>
</div>
)}

View File

@@ -42,18 +42,22 @@ import {
IconTreeTriangleDown,
IconSearch,
IconMore,
IconDescend2
} from '@douyinfe/semi-icons';
import { loadChannelModels, isMobile, copy } from '../../helpers';
import { loadChannelModels, copy } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import EditTagModal from '../../pages/Channel/EditTagModal.js';
import { useTranslation } from 'react-i18next';
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
import { FaRandom } from 'react-icons/fa';
const ChannelsTable = () => {
const { t } = useTranslation();
const isMobile = useIsMobile();
let type2label = undefined;
const renderType = (type) => {
const renderType = (type, channelInfo = undefined) => {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
@@ -61,12 +65,30 @@ const ChannelsTable = () => {
}
type2label[0] = { value: 0, label: t('未知类型'), color: 'grey' };
}
let icon = getChannelIcon(type);
if (channelInfo?.is_multi_key) {
icon = (
channelInfo?.multi_key_mode === 'random' ? (
<div className="flex items-center gap-1">
<FaRandom className="text-blue-500" />
{icon}
</div>
) : (
<div className="flex items-center gap-1">
<IconDescend2 className="text-blue-500" />
{icon}
</div>
)
)
}
return (
<Tag
size='large'
color={type2label[type]?.color}
shape='circle'
prefixIcon={getChannelIcon(type)}
prefixIcon={icon}
>
{type2label[type]?.label}
</Tag>
@@ -77,7 +99,6 @@ const ChannelsTable = () => {
return (
<Tag
color='light-blue'
size='large'
shape='circle'
type='light'
>
@@ -86,65 +107,107 @@ const ChannelsTable = () => {
);
};
const renderStatus = (status) => {
const renderStatus = (status, channelInfo = undefined) => {
if (channelInfo) {
if (channelInfo.is_multi_key) {
let keySize = channelInfo.multi_key_size;
let enabledKeySize = keySize;
if (channelInfo.multi_key_status_list) {
// multi_key_status_list is a map, key is key, value is status
// get multi_key_status_list length
enabledKeySize = keySize - Object.keys(channelInfo.multi_key_status_list).length;
}
return renderMultiKeyStatus(status, keySize, enabledKeySize);
}
}
switch (status) {
case 1:
return (
<Tag size='large' color='green' shape='circle'>
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
case 2:
return (
<Tag size='large' color='red' shape='circle'>
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const renderMultiKeyStatus = (status, keySize, enabledKeySize) => {
switch (status) {
case 1:
return (
<Tag color='green' shape='circle'>
{t('已启用')} {enabledKeySize}/{keySize}
</Tag>
);
case 2:
return (
<Tag color='red' shape='circle'>
{t('已禁用')} {enabledKeySize}/{keySize}
</Tag>
);
case 3:
return (
<Tag color='yellow' shape='circle'>
{t('自动禁用')} {enabledKeySize}/{keySize}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
{t('未知状态')} {enabledKeySize}/{keySize}
</Tag>
);
}
}
const renderResponseTime = (responseTime) => {
let time = responseTime / 1000;
time = time.toFixed(2) + t(' 秒');
if (responseTime === 0) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未测试')}
</Tag>
);
} else if (responseTime <= 1000) {
return (
<Tag size='large' color='green' shape='circle'>
<Tag color='green' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 3000) {
return (
<Tag size='large' color='lime' shape='circle'>
<Tag color='lime' shape='circle'>
{time}
</Tag>
);
} else if (responseTime <= 5000) {
return (
<Tag size='large' color='yellow' shape='circle'>
<Tag color='yellow' shape='circle'>
{time}
</Tag>
);
} else {
return (
<Tag size='large' color='red' shape='circle'>
<Tag color='red' shape='circle'>
{time}
</Tag>
);
@@ -281,6 +344,11 @@ const ChannelsTable = () => {
dataIndex: 'type',
render: (text, record, index) => {
if (record.children === undefined) {
if (record.channel_info) {
if (record.channel_info.is_multi_key) {
return <>{renderType(text, record.channel_info)}</>;
}
}
return <>{renderType(text)}</>;
} else {
return <>{renderTagType()}</>;
@@ -304,12 +372,12 @@ const ChannelsTable = () => {
<Tooltip
content={t('原因:') + reason + t(',时间:') + timestamp2string(time)}
>
{renderStatus(text)}
{renderStatus(text, record.channel_info)}
</Tooltip>
</div>
);
} else {
return renderStatus(text);
return renderStatus(text, record.channel_info);
}
},
},
@@ -331,7 +399,7 @@ const ChannelsTable = () => {
<div>
<Space spacing={1}>
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -339,7 +407,6 @@ const ChannelsTable = () => {
<Tag
color='white'
type='ghost'
size='large'
shape='circle'
onClick={() => updateChannelBalance(record)}
>
@@ -352,7 +419,7 @@ const ChannelsTable = () => {
} else {
return (
<Tooltip content={t('已用额度')}>
<Tag color='white' type='ghost' size='large' shape='circle'>
<Tag color='white' type='ghost' shape='circle'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
@@ -482,9 +549,15 @@ const ChannelsTable = () => {
title: t('确定是否要删除此渠道?'),
content: t('此修改将不可逆'),
onOk: () => {
manageChannel(record.id, 'delete', record).then(() => {
removeRecord(record);
});
(async () => {
await manageChannel(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
@@ -492,7 +565,7 @@ const ChannelsTable = () => {
{
node: 'item',
name: t('复制'),
type: 'primary',
type: 'tertiary',
onClick: () => {
Modal.confirm({
title: t('确定是否要复制此渠道?'),
@@ -510,15 +583,15 @@ const ChannelsTable = () => {
aria-label={t('测试单个渠道操作项目组')}
>
<Button
theme='light'
size="small"
type='tertiary'
onClick={() => testChannel(record, '')}
>
{t('测试')}
</Button>
<Button
theme='light'
size="small"
type='tertiary'
icon={<IconTreeTriangleDown />}
onClick={() => {
setCurrentTestChannel(record);
@@ -527,28 +600,66 @@ const ChannelsTable = () => {
/>
</SplitButtonGroup>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
{record.channel_info?.is_multi_key ? (
<SplitButtonGroup
aria-label={t('多密钥渠道操作项目组')}
>
{t('禁用')}
</Button>
{
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('禁用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
}
<Dropdown
trigger='click'
position='bottomRight'
menu={[
{
node: 'item',
name: t('启用全部密钥'),
onClick: () => manageChannel(record.id, 'enable_all', record),
}
]}
>
<Button
type='tertiary'
size="small"
icon={<IconTreeTriangleDown />}
/>
</Dropdown>
</SplitButtonGroup>
) : (
<Button
theme='light'
type='secondary'
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('用')}
</Button>
record.status === 1 ? (
<Button
type='danger'
size="small"
onClick={() => manageChannel(record.id, 'disable', record)}
>
{t('用')}
</Button>
) : (
<Button
size="small"
onClick={() => manageChannel(record.id, 'enable', record)}
>
{t('启用')}
</Button>
)
)}
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -566,7 +677,6 @@ const ChannelsTable = () => {
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
/>
@@ -578,23 +688,20 @@ const ChannelsTable = () => {
return (
<Space wrap>
<Button
theme='light'
type='secondary'
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'enable')}
>
{t('启用全部')}
</Button>
<Button
theme='light'
type='warning'
type='tertiary'
size="small"
onClick={() => manageTag(record.key, 'disable')}
>
{t('禁用全部')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -666,22 +773,13 @@ const ChannelsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -868,42 +966,29 @@ const ChannelsTable = () => {
};
const copySelectedChannel = async (record) => {
const channelToCopy = { ...record };
channelToCopy.name += t('_复制');
channelToCopy.created_time = null;
channelToCopy.balance = 0;
channelToCopy.used_quota = 0;
delete channelToCopy.test_time;
delete channelToCopy.response_time;
if (!channelToCopy) {
showError(t('渠道未找到,请刷新页面后重试。'));
return;
}
try {
const newChannel = { ...channelToCopy, id: undefined };
const response = await API.post('/api/channel/', newChannel);
if (response.data.success) {
const res = await API.post(`/api/channel/copy/${record.id}`);
if (res?.data?.success) {
showSuccess(t('渠道复制成功'));
await refresh();
} else {
showError(response.data.message);
showError(res?.data?.message || t('渠道复制失败'));
}
} catch (error) {
showError(t('渠道复制失败: ') + error.message);
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
}
};
const refresh = async () => {
const refresh = async (page = activePage) => {
const { searchKeyword, searchGroup, searchModel } = getFormValues();
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
await loadChannels(activePage, pageSize, idSort, enableTagMode);
await loadChannels(page, pageSize, idSort, enableTagMode);
} else {
await searchChannels(enableTagMode, activeTypeKey, statusFilter, activePage, pageSize, idSort);
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
}
};
useEffect(() => {
// console.log('default effect')
const localIdSort = localStorage.getItem('id-sort') === 'true';
const localPageSize =
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
@@ -954,6 +1039,11 @@ const ChannelsTable = () => {
}
res = await API.put('/api/channel/', data);
break;
case 'enable_all':
data.channel_info = record.channel_info;
data.channel_info.multi_key_status_list = {};
res = await API.put('/api/channel/', data);
break;
}
const { success, message } = res.data;
if (success) {
@@ -1240,7 +1330,7 @@ const ChannelsTable = () => {
tab={
<span className="flex items-center gap-2">
{t('全部')}
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} size='small' shape='circle'>
<Tag color={activeTypeKey === 'all' ? 'red' : 'grey'} shape='circle'>
{channelTypeCounts['all'] || 0}
</Tag>
</span>
@@ -1258,7 +1348,7 @@ const ChannelsTable = () => {
<span className="flex items-center gap-2">
{getChannelIcon(option.value)}
{option.label}
<Tag color={activeTypeKey === key ? 'red' : 'grey'} size='small' shape='circle'>
<Tag color={activeTypeKey === key ? 'red' : 'grey'} shape='circle'>
{count}
</Tag>
</span>
@@ -1453,6 +1543,11 @@ const ChannelsTable = () => {
if (success) {
showSuccess(t('已删除 ${data} 个通道!').replace('${data}', data));
await refresh();
setTimeout(() => {
if (channels.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
} else {
showError(message);
}
@@ -1461,7 +1556,7 @@ const ChannelsTable = () => {
const fixChannelsAbilities = async () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
const { success, message, data } = res.data;
if (success) {
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
await refresh();
@@ -1478,7 +1573,6 @@ const ChannelsTable = () => {
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='danger'
className="w-full md:w-auto"
onClick={() => {
@@ -1495,8 +1589,7 @@ const ChannelsTable = () => {
<Button
size='small'
disabled={!enableBatchDelete}
theme='light'
type='primary'
type='tertiary'
onClick={() => setShowBatchSetTag(true)}
className="w-full md:w-auto"
>
@@ -1511,8 +1604,7 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
type='warning'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
@@ -1530,7 +1622,23 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
type='secondary'
className="w-full"
onClick={() => {
@@ -1549,7 +1657,6 @@ const ChannelsTable = () => {
<Dropdown.Item>
<Button
size='small'
theme='light'
type='danger'
className="w-full"
onClick={() => {
@@ -1565,25 +1672,6 @@ const ChannelsTable = () => {
{t('删除禁用通道')}
</Button>
</Dropdown.Item>
<Dropdown.Item>
<Button
size='small'
theme='light'
type='tertiary'
className="w-full"
onClick={() => {
Modal.confirm({
title: t('确定是否要修复数据库一致性?'),
content: t('进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'),
onOk: () => fixChannelsAbilities(),
size: 'sm',
centered: true,
});
}}
>
{t('修复数据库一致性')}
</Button>
</Dropdown.Item>
</Dropdown.Menu>
}
>
@@ -1594,8 +1682,7 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
>
@@ -1698,8 +1785,7 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='primary'
type='tertiary'
className="w-full md:w-auto"
onClick={refresh}
>
@@ -1708,7 +1794,6 @@ const ChannelsTable = () => {
<Button
size='small'
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
className="w-full md:w-auto"
@@ -1771,7 +1856,7 @@ const ChannelsTable = () => {
</div>
<Button
size='small'
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="w-full md:w-auto"
@@ -1780,7 +1865,7 @@ const ChannelsTable = () => {
</Button>
<Button
size='small'
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -1885,7 +1970,6 @@ const ChannelsTable = () => {
placeholder={t('请输入标签名称')}
value={batchSetTagValue}
onChange={(v) => setBatchSetTagValue(v)}
size='large'
/>
<div className="mt-4">
<Typography.Text type='secondary'>
@@ -1907,61 +1991,6 @@ const ChannelsTable = () => {
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text>
</div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button
theme='light'
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
</div>
)
}
@@ -1971,15 +2000,13 @@ const ChannelsTable = () => {
<div className="flex justify-end">
{isBatchTesting ? (
<Button
theme='light'
type='warning'
type='danger'
onClick={handleCloseModal}
>
{t('停止测试')}
</Button>
) : (
<Button
theme='light'
type='tertiary'
onClick={handleCloseModal}
>
@@ -1987,8 +2014,6 @@ const ChannelsTable = () => {
</Button>
)}
<Button
theme='light'
type='primary'
onClick={batchTestModels}
loading={isBatchTesting}
disabled={isBatchTesting}
@@ -2008,11 +2033,63 @@ const ChannelsTable = () => {
}
maskClosable={!isBatchTesting}
className="!rounded-lg"
size={isMobile() ? 'full-width' : 'large'}
size={isMobile ? 'full-width' : 'large'}
>
<div className="model-test-scroll">
{currentTestChannel && (
<div>
{/* 搜索与操作按钮 */}
<div className="flex items-center justify-end gap-2 w-full mb-2">
<Input
placeholder={t('搜索模型...')}
value={modelSearchKeyword}
onChange={(v) => {
setModelSearchKeyword(v);
setModelTablePage(1);
}}
className="!w-full"
prefix={<IconSearch />}
showClear
/>
<Button
onClick={() => {
if (selectedModelKeys.length === 0) {
showError(t('请先选择模型!'));
return;
}
copy(selectedModelKeys.join(',')).then((ok) => {
if (ok) {
showSuccess(t('已复制 ${count} 个模型').replace('${count}', selectedModelKeys.length));
} else {
showError(t('复制失败,请手动复制'));
}
});
}}
>
{t('复制已选')}
</Button>
<Button
type='tertiary'
onClick={() => {
if (!currentTestChannel) return;
const successKeys = currentTestChannel.models
.split(',')
.filter((m) => m.toLowerCase().includes(modelSearchKeyword.toLowerCase()))
.filter((m) => {
const result = modelTestResults[`${currentTestChannel.id}-${m}`];
return result && result.success;
});
if (successKeys.length === 0) {
showInfo(t('暂无成功模型'));
}
setSelectedModelKeys(successKeys);
}}
>
{t('选择成功')}
</Button>
</div>
<Table
columns={[
{
@@ -2033,7 +2110,7 @@ const ChannelsTable = () => {
if (isTesting) {
return (
<Tag size='large' color='blue' shape='circle'>
<Tag color='blue' shape='circle'>
{t('测试中')}
</Tag>
);
@@ -2041,7 +2118,7 @@ const ChannelsTable = () => {
if (!testResult) {
return (
<Tag size='large' color='grey' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未开始')}
</Tag>
);
@@ -2050,7 +2127,6 @@ const ChannelsTable = () => {
return (
<div className="flex items-center gap-2">
<Tag
size='large'
color={testResult.success ? 'green' : 'red'}
shape='circle'
>
@@ -2072,8 +2148,7 @@ const ChannelsTable = () => {
const isTesting = testingModels.has(record.model);
return (
<Button
theme='light'
type='primary'
type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model)}
loading={isTesting}
size='small'

View File

@@ -20,7 +20,7 @@ import {
renderQuota,
stringToColor,
getLogOther,
renderModelTag,
renderModelTag
} from '../../helpers';
import {
@@ -78,37 +78,37 @@ const LogsTable = () => {
switch (type) {
case 1:
return (
<Tag color='cyan' size='large' shape='circle'>
<Tag color='cyan' shape='circle'>
{t('充值')}
</Tag>
);
case 2:
return (
<Tag color='lime' size='large' shape='circle'>
<Tag color='lime' shape='circle'>
{t('消费')}
</Tag>
);
case 3:
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{t('管理')}
</Tag>
);
case 4:
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' shape='circle'>
{t('系统')}
</Tag>
);
case 5:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{t('错误')}
</Tag>
);
default:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' shape='circle'>
{t('未知')}
</Tag>
);
@@ -118,13 +118,13 @@ const LogsTable = () => {
function renderIsStream(bool) {
if (bool) {
return (
<Tag color='blue' size='large' shape='circle'>
<Tag color='blue' shape='circle'>
{t('流')}
</Tag>
);
} else {
return (
<Tag color='purple' size='large' shape='circle'>
<Tag color='purple' shape='circle'>
{t('非流')}
</Tag>
);
@@ -135,21 +135,21 @@ const LogsTable = () => {
const time = parseInt(type);
if (time < 101) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 300) {
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -162,21 +162,21 @@ const LogsTable = () => {
time = time.toFixed(1);
if (time < 3) {
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 10) {
return (
<Tag color='orange' size='large' shape='circle'>
<Tag color='orange' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -356,28 +356,34 @@ const LogsTable = () => {
dataIndex: 'channel',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
record.type === 0 || record.type === 2 || record.type === 5 ? (
<div>
{
<Tooltip content={record.channel_name || '[未知]'}>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
>
{' '}
{text}{' '}
</Tag>
</Tooltip>
}
</div>
) : (
<></>
)
) : (
<></>
);
let isMultiKey = false
let multiKeyIndex = -1;
let other = getLogOther(record.other);
if (other?.admin_info) {
let adminInfo = other.admin_info;
if (adminInfo?.is_multi_key) {
isMultiKey = true;
multiKeyIndex = adminInfo.multi_key_index;
}
}
return isAdminUser && (record.type === 0 || record.type === 2 || record.type === 5) ? (
<Space>
<Tooltip content={record.channel_name || t('未知渠道')}>
<Tag
color={colors[parseInt(text) % colors.length]}
shape='circle'
>
{text}
</Tag>
</Tooltip>
{isMultiKey && (
<Tag color='white' shape='circle'>
{multiKeyIndex}
</Tag>
)}
</Space>
) : null;
},
},
{
@@ -389,7 +395,7 @@ const LogsTable = () => {
return isAdminUser ? (
<div>
<Avatar
size='small'
size='extra-small'
color={stringToColor(text)}
style={{ marginRight: 4 }}
onClick={(event) => {
@@ -415,7 +421,6 @@ const LogsTable = () => {
<div>
<Tag
color='grey'
size='large'
shape='circle'
onClick={(event) => {
//cancel the row click event
@@ -567,7 +572,6 @@ const LogsTable = () => {
<Tooltip content={text}>
<Tag
color='orange'
size='large'
shape='circle'
onClick={(event) => {
copyText(event, text);
@@ -693,22 +697,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className='flex justify-end'>
<Button
theme='light'
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme='light'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -1215,11 +1210,10 @@ const LogsTable = () => {
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: 13,
}}
className='!rounded-lg'
>
@@ -1227,11 +1221,10 @@ const LogsTable = () => {
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: 13,
}}
className='!rounded-lg'
>
@@ -1239,12 +1232,11 @@ const LogsTable = () => {
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
fontWeight: 500,
padding: 13,
}}
className='!rounded-lg'
>
@@ -1253,10 +1245,10 @@ const LogsTable = () => {
</Space>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -1287,6 +1279,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -1297,6 +1290,7 @@ const LogsTable = () => {
placeholder={t('令牌名称')}
showClear
pure
size="small"
/>
<Form.Input
@@ -1305,6 +1299,7 @@ const LogsTable = () => {
placeholder={t('模型名称')}
showClear
pure
size="small"
/>
<Form.Input
@@ -1313,6 +1308,7 @@ const LogsTable = () => {
placeholder={t('分组')}
showClear
pure
size="small"
/>
{isAdminUser && (
@@ -1323,6 +1319,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
<Form.Input
field='username'
@@ -1330,6 +1327,7 @@ const LogsTable = () => {
placeholder={t('用户名称')}
showClear
pure
size="small"
/>
</>
)}
@@ -1351,6 +1349,7 @@ const LogsTable = () => {
refresh();
}, 0);
}}
size="small"
>
<Form.Select.Option value='0'>
{t('全部')}
@@ -1375,14 +1374,15 @@ const LogsTable = () => {
<div className='flex gap-2 w-full sm:w-auto justify-end'>
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -1392,13 +1392,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -185,115 +185,115 @@ const LogsTable = () => {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Palette size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Palette size={14} />}>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<ZoomIn size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<ZoomIn size={14} />}>
{t('放大')}
</Tag>
);
case 'VIDEO':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('视频')}
</Tag>
);
case 'EDITS':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
{t('编辑')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Shuffle size={14} />}>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle' prefixIcon={<Move size={14} />}>
<Tag color='cyan' shape='circle' prefixIcon={<Move size={14} />}>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<FileText size={14} />}>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle' prefixIcon={<Blend size={14} />}>
<Tag color='lime' shape='circle' prefixIcon={<Blend size={14} />}>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Upload size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Upload size={14} />}>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle' prefixIcon={<Minimize2 size={14} />}>
<Tag color='pink' shape='circle' prefixIcon={<Minimize2 size={14} />}>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle' prefixIcon={<RotateCcw size={14} />}>
<Tag color='indigo' shape='circle' prefixIcon={<RotateCcw size={14} />}>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle' prefixIcon={<PaintBucket size={14} />}>
<Tag color='violet' shape='circle' prefixIcon={<PaintBucket size={14} />}>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle' prefixIcon={<Focus size={14} />}>
<Tag color='teal' shape='circle' prefixIcon={<Focus size={14} />}>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle' prefixIcon={<Move3D size={14} />}>
<Tag color='teal' shape='circle' prefixIcon={<Move3D size={14} />}>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<Monitor size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<Monitor size={14} />}>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle' prefixIcon={<UserCheck size={14} />}>
<Tag color='light-green' shape='circle' prefixIcon={<UserCheck size={14} />}>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -304,31 +304,31 @@ const LogsTable = () => {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='lime' shape='circle' prefixIcon={<Clock size={14} />}>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Copy size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Copy size={14} />}>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<FileX size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<FileX size={14} />}>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -339,43 +339,43 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Loader size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<AlertCircle size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -405,7 +405,7 @@ const LogsTable = () => {
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec} {t('秒')}
</Tag>
);
@@ -439,7 +439,6 @@ const LogsTable = () => {
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
@@ -523,6 +522,7 @@ const LogsTable = () => {
}
return (
<Button
size="small"
onClick={() => {
setModalImageUrl(text);
setIsModalOpenurl(true);
@@ -741,22 +741,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -831,10 +822,10 @@ const LogsTable = () => {
)}
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -864,6 +855,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -874,6 +866,7 @@ const LogsTable = () => {
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
@@ -884,6 +877,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
)}
</div>
@@ -893,14 +887,15 @@ const LogsTable = () => {
<div></div>
<div className="flex gap-2">
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -910,13 +905,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -76,13 +76,13 @@ const ModelPricing = () => {
switch (type) {
case 1:
return (
<Tag color='teal' size='large' shape='circle'>
<Tag color='teal' shape='circle'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large' shape='circle'>
<Tag color='violet' shape='circle'>
{t('按量计费')}
</Tag>
);
@@ -116,7 +116,6 @@ const ModelPricing = () => {
<Tag
key={endpoint}
color={stringToColor(endpoint)}
size='large'
shape='circle'
>
{endpoint}
@@ -179,7 +178,7 @@ const ModelPricing = () => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
<Tag color='blue' shape='circle' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
@@ -187,7 +186,6 @@ const ModelPricing = () => {
return (
<Tag
color='blue'
size='large'
shape='circle'
onClick={() => {
setSelectedGroup(group);
@@ -392,7 +390,6 @@ const ModelPricing = () => {
{category.label}
<Tag
color={activeKey === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
@@ -436,7 +433,6 @@ const ModelPricing = () => {
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
size="large"
/>
</div>
<Button
@@ -446,7 +442,6 @@ const ModelPricing = () => {
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!bg-blue-500 hover:!bg-blue-600 text-white"
size="large"
>
{t('复制选中模型')}
</Button>

View File

@@ -53,31 +53,31 @@ const RedemptionsTable = () => {
const renderStatus = (status, record) => {
if (isExpired(record)) {
return (
<Tag color='orange' size='large' shape='circle'>{t('已过期')}</Tag>
<Tag color='orange' shape='circle'>{t('已过期')}</Tag>
);
}
switch (status) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
<Tag color='green' shape='circle'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large' shape='circle'>
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large' shape='circle'>
<Tag color='grey' shape='circle'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle'>
<Tag color='black' shape='circle'>
{t('未知状态')}
</Tag>
);
@@ -107,7 +107,7 @@ const RedemptionsTable = () => {
render: (text, record, index) => {
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle'>
<Tag color='grey' shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
</div>
@@ -139,6 +139,7 @@ const RedemptionsTable = () => {
title: '',
dataIndex: 'operate',
fixed: 'right',
width: 205,
render: (text, record, index) => {
// 创建更多操作的下拉菜单项
const moreMenuItems = [
@@ -151,9 +152,15 @@ const RedemptionsTable = () => {
title: t('确定是否要删除此兑换码?'),
content: t('此修改将不可逆'),
onOk: () => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
(async () => {
await manageRedemption(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (redemptions.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
@@ -185,7 +192,6 @@ const RedemptionsTable = () => {
<Space>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button
theme='light'
type='tertiary'
size="small"
>
@@ -193,8 +199,6 @@ const RedemptionsTable = () => {
</Button>
</Popover>
<Button
theme='light'
type='secondary'
size="small"
onClick={async () => {
await copyText(record.key);
@@ -203,7 +207,6 @@ const RedemptionsTable = () => {
{t('复制')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -220,7 +223,6 @@ const RedemptionsTable = () => {
menu={moreMenuItems}
>
<Button
theme='light'
type='tertiary'
size="small"
icon={<IconMore />}
@@ -320,8 +322,13 @@ const RedemptionsTable = () => {
});
}, [pageSize]);
const refresh = async () => {
await loadRedemptions(activePage - 1, pageSize);
const refresh = async (page = activePage) => {
const { searchKeyword } = getFormValues();
if (searchKeyword === '') {
await loadRedemptions(page, pageSize);
} else {
await searchRedemptions(searchKeyword, page, pageSize);
}
};
const manageRedemption = async (id, action, record) => {
@@ -424,10 +431,10 @@ const RedemptionsTable = () => {
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -440,7 +447,6 @@ const RedemptionsTable = () => {
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
<div className="flex gap-2 w-full sm:w-auto">
<Button
theme='light'
type='primary'
className="w-full sm:w-auto"
onClick={() => {
@@ -449,11 +455,12 @@ const RedemptionsTable = () => {
});
setShowEdit(true);
}}
size="small"
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
type='tertiary'
className="w-full sm:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
@@ -467,6 +474,7 @@ const RedemptionsTable = () => {
}
await copyText(keys);
}}
size="small"
>
{t('复制所选兑换码到剪贴板')}
</Button>
@@ -492,6 +500,7 @@ const RedemptionsTable = () => {
},
});
}}
size="small"
>
{t('清除失效兑换码')}
</Button>
@@ -519,23 +528,24 @@ const RedemptionsTable = () => {
placeholder={t('关键字(id或者名称)')}
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
theme="light"
type="tertiary"
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadRedemptions(1, pageSize);
@@ -543,6 +553,7 @@ const RedemptionsTable = () => {
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>

View File

@@ -34,7 +34,6 @@ import {
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography
@@ -106,7 +105,7 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large' prefixIcon={<Clock size={14} />}>
<Tag color={color} prefixIcon={<Clock size={14} />}>
{durationSec}
</Tag>
);
@@ -198,31 +197,31 @@ const LogsTable = () => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Music size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Music size={14} />}>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle' prefixIcon={<FileText size={14} />}>
<Tag color='pink' shape='circle' prefixIcon={<FileText size={14} />}>
{t('生成歌词')}
</Tag>
);
case TASK_ACTION_GENERATE:
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('图生视频')}
</Tag>
);
case TASK_ACTION_TEXT_GENERATE:
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Sparkles size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('文生视频')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -233,25 +232,25 @@ const LogsTable = () => {
switch (platform) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
Suno
</Tag>
);
case 'kling':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Video size={14} />}>
Kling
</Tag>
);
case 'jimeng':
return (
<Tag color='purple' size='large' shape='circle' prefixIcon={<Video size={14} />}>
<Tag color='purple' shape='circle' prefixIcon={<Video size={14} />}>
Jimeng
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -262,55 +261,55 @@ const LogsTable = () => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Pause size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Pause size={14} />}>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<Clock size={14} />}>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<Play size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<Play size={14} />}>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<List size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<List size={14} />}>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle' prefixIcon={<Loader size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<Loader size={14} />}>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知')}
</Tag>
);
@@ -596,22 +595,13 @@ const LogsTable = () => {
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
>
<Button onClick={() => initDefaultColumns()}>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
>
<Button onClick={() => setShowColumnSelector(false)}>
{t('确定')}
</Button>
</div>
@@ -665,23 +655,13 @@ const LogsTable = () => {
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>{t('任务记录')}</Text>
)}
<Text>{t('任务记录')}</Text>
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -711,6 +691,7 @@ const LogsTable = () => {
placeholder={[t('开始时间'), t('结束时间')]}
showClear
pure
size="small"
/>
</div>
@@ -721,6 +702,7 @@ const LogsTable = () => {
placeholder={t('任务 ID')}
showClear
pure
size="small"
/>
{/* 渠道 ID - 仅管理员可见 */}
@@ -731,6 +713,7 @@ const LogsTable = () => {
placeholder={t('渠道 ID')}
showClear
pure
size="small"
/>
)}
</div>
@@ -740,14 +723,15 @@ const LogsTable = () => {
<div></div>
<div className="flex gap-2">
<Button
type='primary'
type='tertiary'
htmlType='submit'
loading={loading}
size="small"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -757,13 +741,14 @@ const LogsTable = () => {
}, 100);
}
}}
size="small"
>
{t('重置')}
</Button>
<Button
theme='light'
type='tertiary'
onClick={() => setShowColumnSelector(true)}
size="small"
>
{t('列设置')}
</Button>

View File

@@ -7,7 +7,7 @@ import {
timestamp2string,
renderGroup,
renderQuota,
getQuotaPerUnit
getModelCategories
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import {
@@ -22,6 +22,12 @@ import {
SplitButtonGroup,
Table,
Tag,
AvatarGroup,
Avatar,
Tooltip,
Progress,
Switch,
Input,
Typography
} from '@douyinfe/semi-ui';
import {
@@ -31,7 +37,9 @@ import {
import {
IconSearch,
IconTreeTriangleDown,
IconMore,
IconCopy,
IconEyeOpened,
IconEyeClosed,
} from '@douyinfe/semi-icons';
import { Key } from 'lucide-react';
import EditToken from '../../pages/Token/EditToken';
@@ -47,49 +55,6 @@ function renderTimestamp(timestamp) {
const TokensTable = () => {
const { t } = useTranslation();
const renderStatus = (status, model_limits_enabled = false) => {
switch (status) {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large' shape='circle' >
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large' shape='circle' >
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large' shape='circle' >
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large' shape='circle' >
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large' shape='circle' >
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large' shape='circle' >
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: t('名称'),
@@ -99,64 +64,253 @@ const TokensTable = () => {
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: (text, record, index) => {
return (
<div>
<Space>
{renderStatus(text, record.model_limits_enabled)}
{renderGroup(record.group)}
</Space>
</div>
);
},
},
{
title: t('已用额度'),
dataIndex: 'used_quota',
render: (text, record, index) => {
return (
<div>
<Tag size={'large'} color={'grey'} shape='circle' >
{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';
render: (text, record) => {
const enabled = text === 1;
const handleToggle = (checked) => {
if (checked) {
manageToken(record.id, 'enable', record);
} else {
return 'green';
manageToken(record.id, 'disable', record);
}
};
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'} shape='circle' >
{t('无限制')}
</Tag>
) : (
<Tag
size={'large'}
color={getQuotaColor(parseInt(text))}
shape='circle'
>
{renderQuota(parseInt(text))}
</Tag>
)}
let tagColor = 'black';
let tagText = t('未知状态');
if (enabled) {
tagColor = 'green';
tagText = t('已启用');
} else if (text === 2) {
tagColor = 'red';
tagText = t('已禁用');
} else if (text === 3) {
tagColor = 'yellow';
tagText = t('已过期');
} else if (text === 4) {
tagColor = 'grey';
tagText = t('已耗尽');
}
const used = parseInt(record.used_quota) || 0;
const remain = parseInt(record.remain_quota) || 0;
const total = used + remain;
const percent = total > 0 ? (remain / total) * 100 : 0;
const getProgressColor = (pct) => {
if (pct === 100) return 'var(--semi-color-success)';
if (pct <= 10) return 'var(--semi-color-danger)';
if (pct <= 30) return 'var(--semi-color-warning)';
return undefined;
};
const quotaSuffix = record.unlimited_quota ? (
<div className='text-xs'>{t('无限额度')}</div>
) : (
<div className='flex flex-col items-end'>
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
<Progress
percent={percent}
stroke={getProgressColor(percent)}
aria-label='quota usage'
format={() => `${percent.toFixed(0)}%`}
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
/>
</div>
);
const content = (
<Tag
color={tagColor}
shape='circle'
size='large'
prefixIcon={
<Switch
size='small'
checked={enabled}
onChange={handleToggle}
aria-label='token status switch'
/>
}
suffixIcon={quotaSuffix}
>
{tagText}
</Tag>
);
if (record.unlimited_quota) {
return content;
}
return (
<Tooltip
content={
<div className='text-xs'>
<div>{t('已用额度')}: {renderQuota(used)}</div>
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
<div>{t('总额度')}: {renderQuota(total)}</div>
</div>
}
>
{content}
</Tooltip>
);
},
},
{
title: t('分组'),
dataIndex: 'group',
key: 'group',
render: (text) => {
if (text === 'auto') {
return (
<Tooltip
content={t('当前分组为 auto会自动选择最优分组当一个组不可用时自动降级到下一个组熔断机制')}
position='top'
>
<Tag color='white' shape='circle'> {t('智能熔断')} </Tag>
</Tooltip>
);
}
return renderGroup(text);
},
},
{
title: t('密钥'),
key: 'token_key',
render: (text, record) => {
const fullKey = 'sk-' + record.key;
const maskedKey = 'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
const revealed = !!showKeys[record.id];
return (
<div className='w-[200px]'>
<Input
readOnly
value={revealed ? fullKey : maskedKey}
size='small'
suffix={
<div className='flex items-center'>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
aria-label='toggle token visibility'
onClick={(e) => {
e.stopPropagation();
setShowKeys(prev => ({ ...prev, [record.id]: !revealed }));
}}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyText(fullKey);
}}
/>
</div>
}
/>
</div>
);
},
},
{
title: t('可用模型'),
dataIndex: 'model_limits',
render: (text, record) => {
if (record.model_limits_enabled && text) {
const models = text.split(',').filter(Boolean);
const categories = getModelCategories(t);
const vendorAvatars = [];
const matchedModels = new Set();
Object.entries(categories).forEach(([key, category]) => {
if (key === 'all') return;
if (!category.icon || !category.filter) return;
const vendorModels = models.filter((m) => category.filter({ model_name: m }));
if (vendorModels.length > 0) {
vendorAvatars.push(
<Tooltip key={key} content={vendorModels.join(', ')} position='top' showArrow>
<Avatar size='extra-extra-small' alt={category.label} color='transparent'>
{category.icon}
</Avatar>
</Tooltip>
);
vendorModels.forEach((m) => matchedModels.add(m));
}
});
const unmatchedModels = models.filter((m) => !matchedModels.has(m));
if (unmatchedModels.length > 0) {
vendorAvatars.push(
<Tooltip key='unknown' content={unmatchedModels.join(', ')} position='top' showArrow>
<Avatar size='extra-extra-small' alt='unknown'>
{t('其他')}
</Avatar>
</Tooltip>
);
}
return (
<AvatarGroup size='extra-extra-small'>
{vendorAvatars}
</AvatarGroup>
);
} else {
return (
<Tag color='white' shape='circle'>
{t('无限制')}
</Tag>
);
}
},
},
{
title: t('IP限制'),
dataIndex: 'allow_ips',
render: (text) => {
if (!text || text.trim() === '') {
return (
<Tag color='white' shape='circle'>
{t('无限制')}
</Tag>
);
}
const ips = text
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean);
const displayIps = ips.slice(0, 1);
const extraCount = ips.length - displayIps.length;
const ipTags = displayIps.map((ip, idx) => (
<Tag key={idx} shape='circle'>
{ip}
</Tag>
));
if (extraCount > 0) {
ipTags.push(
<Tooltip
key='extra'
content={ips.slice(1).join(', ')}
position='top'
showArrow
>
<Tag shape='circle'>
{'+' + extraCount}
</Tag>
</Tooltip>
);
}
return <Space wrap>{ipTags}</Space>;
},
},
{
@@ -211,58 +365,6 @@ const TokensTable = () => {
}
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('查看'),
onClick: () => {
Modal.info({
title: t('令牌详情'),
content: 'sk-' + record.key,
size: 'large',
});
},
},
{
node: 'item',
name: t('删除'),
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
manageToken(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
type: 'warning',
onClick: () => {
manageToken(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
type: 'secondary',
onClick: () => {
manageToken(record.id, 'enable', record);
},
});
}
return (
<Space wrap>
<SplitButtonGroup
@@ -270,9 +372,8 @@ const TokensTable = () => {
aria-label={t('项目操作按钮组')}
>
<Button
theme='light'
size="small"
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
type='tertiary'
onClick={() => {
if (chatsArray.length === 0) {
showError(t('请联系管理员配置聊天链接'));
@@ -293,11 +394,7 @@ const TokensTable = () => {
menu={chatsArray}
>
<Button
style={{
padding: '4px 4px',
color: 'rgba(var(--semi-teal-7), 1)',
}}
type='primary'
type='tertiary'
icon={<IconTreeTriangleDown />}
size="small"
></Button>
@@ -305,18 +402,6 @@ const TokensTable = () => {
</SplitButtonGroup>
<Button
theme='light'
type='secondary'
size="small"
onClick={async (text) => {
await copyText('sk-' + record.key);
}}
>
{t('复制')}
</Button>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -327,18 +412,24 @@ const TokensTable = () => {
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
<Button
type='danger'
size="small"
onClick={() => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
(async () => {
await manageToken(record.id, 'delete', record);
await refresh();
})();
},
});
}}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
/>
</Dropdown>
{t('删除')}
</Button>
</Space>
);
},
@@ -357,6 +448,7 @@ const TokensTable = () => {
id: undefined,
});
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
const [showKeys, setShowKeys] = useState({});
// Form 初始值
const formInitValues = {
@@ -405,8 +497,8 @@ const TokensTable = () => {
setLoading(false);
};
const refresh = async () => {
await loadTokens(1);
const refresh = async (page = activePage) => {
await loadTokens(page);
setSelectedKeys([]);
};
@@ -582,6 +674,11 @@ const TokensTable = () => {
const count = res.data.data || 0;
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
await refresh();
setTimeout(() => {
if (tokens.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
} else {
showError(res?.data?.message || t('删除失败'));
}
@@ -601,10 +698,10 @@ const TokensTable = () => {
<Text>{t('令牌用于API访问认证可以设置额度限制和模型权限。')}</Text>
</div>
<Button
theme="light"
type="secondary"
type="tertiary"
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -616,7 +713,6 @@ const TokensTable = () => {
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme="light"
type="primary"
className="flex-1 md:flex-initial"
onClick={() => {
@@ -625,12 +721,12 @@ const TokensTable = () => {
});
setShowEdit(true);
}}
size="small"
>
{t('添加令牌')}
</Button>
<Button
theme="light"
type="warning"
type='tertiary'
className="flex-1 md:flex-initial"
onClick={() => {
if (selectedKeys.length === 0) {
@@ -644,8 +740,7 @@ const TokensTable = () => {
footer: (
<Space>
<Button
type="primary"
theme="solid"
type='tertiary'
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
@@ -659,7 +754,6 @@ const TokensTable = () => {
{t('名称+密钥')}
</Button>
<Button
theme="light"
onClick={async () => {
let content = '';
for (let i = 0; i < selectedKeys.length; i++) {
@@ -675,12 +769,12 @@ const TokensTable = () => {
),
});
}}
size="small"
>
{t('复制所选令牌')}
</Button>
<Button
theme="light"
type="danger"
type='danger'
className="w-full md:w-auto"
onClick={() => {
if (selectedKeys.length === 0) {
@@ -697,6 +791,7 @@ const TokensTable = () => {
onOk: () => batchDeleteTokens(),
});
}}
size="small"
>
{t('删除所选令牌')}
</Button>
@@ -721,6 +816,7 @@ const TokensTable = () => {
placeholder={t('搜索关键字')}
showClear
pure
size="small"
/>
</div>
<div className="relative w-full md:w-56">
@@ -730,19 +826,21 @@ const TokensTable = () => {
placeholder={t('密钥')}
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
theme="light"
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
@@ -753,6 +851,7 @@ const TokensTable = () => {
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>

View File

@@ -54,25 +54,25 @@ const UsersTable = () => {
switch (role) {
case 1:
return (
<Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
<Tag color='blue' shape='circle' prefixIcon={<User size={14} />}>
{t('普通用户')}
</Tag>
);
case 10:
return (
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
<Tag color='yellow' shape='circle' prefixIcon={<Shield size={14} />}>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
<Tag color='orange' shape='circle' prefixIcon={<Crown size={14} />}>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知身份')}
</Tag>
);
@@ -82,16 +82,16 @@ const UsersTable = () => {
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
return <Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
{t('未知状态')}
</Tag>
);
@@ -117,7 +117,7 @@ const UsersTable = () => {
<Space spacing={2}>
<span>{text}</span>
<Tooltip content={remark} position="top" showArrow>
<Tag color='white' size='large' shape='circle' className="!text-xs">
<Tag color='white' shape='circle' className="!text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 flex-shrink-0 rounded-full" style={{ backgroundColor: '#10b981' }} />
{displayRemark}
@@ -142,13 +142,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
<Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
{t('剩余')}: {renderQuota(record.quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
<Tag color='white' 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" prefixIcon={<Activity size={14} />}>
<Tag color='white' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
{t('调用')}: {renderNumber(record.request_count)}
</Tag>
</Space>
@@ -163,13 +163,13 @@ const UsersTable = () => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
<Tag color='white' 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" prefixIcon={<DollarSign size={14} />}>
<Tag color='white' 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" prefixIcon={<UserPlus size={14} />}>
<Tag color='white' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag>
</Space>
@@ -247,9 +247,15 @@ const UsersTable = () => {
title: t('确定是否要注销此用户?'),
content: t('相当于删除用户,此修改将不可逆'),
onOk: () => {
manageUser(record.id, 'delete', record).then(() => {
removeRecord(record.id);
});
(async () => {
await manageUser(record.id, 'delete', record);
await refresh();
setTimeout(() => {
if (users.length === 0 && activePage > 1) {
refresh(activePage - 1);
}
}, 100);
})();
},
});
},
@@ -281,7 +287,6 @@ const UsersTable = () => {
return (
<Space>
<Button
theme='light'
type='tertiary'
size="small"
onClick={() => {
@@ -297,7 +302,6 @@ const UsersTable = () => {
menu={moreMenuItems}
>
<Button
theme='light'
type='tertiary'
size="small"
icon={<IconMore />}
@@ -459,13 +463,12 @@ const UsersTable = () => {
});
};
const refresh = async () => {
setActivePage(1);
const refresh = async (page = activePage) => {
const { searchKeyword, searchGroup } = getFormValues();
if (searchKeyword === '' && searchGroup === '') {
await loadUsers(1, pageSize);
await loadUsers(page, pageSize);
} else {
await searchUsers(1, pageSize, searchKeyword, searchGroup);
await searchUsers(page, pageSize, searchKeyword, searchGroup);
}
};
@@ -520,10 +523,10 @@ const UsersTable = () => {
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</div>
<Button
theme='light'
type='secondary'
type='tertiary'
className="w-full md:w-auto"
onClick={() => setCompactMode(!compactMode)}
size="small"
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
@@ -535,12 +538,11 @@ const UsersTable = () => {
<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'
className="w-full md:w-auto"
onClick={() => {
setShowAddUser(true);
}}
size="small"
>
{t('添加用户')}
</Button>
@@ -568,6 +570,7 @@ const UsersTable = () => {
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
showClear
pure
size="small"
/>
</div>
<div className="w-full md:w-48">
@@ -585,23 +588,24 @@ const UsersTable = () => {
className="w-full"
showClear
pure
size="small"
/>
</div>
<div className="flex gap-2 w-full md:w-auto">
<Button
type="primary"
type="tertiary"
htmlType="submit"
loading={loading || searching}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('查询')}
</Button>
<Button
theme="light"
type='tertiary'
onClick={() => {
if (formApi) {
formApi.reset();
// 重置后立即查询使用setTimeout确保表单重置完成
setTimeout(() => {
setActivePage(1);
loadUsers(1, pageSize);
@@ -609,6 +613,7 @@ const UsersTable = () => {
}
}}
className="flex-1 md:flex-initial md:w-auto"
size="small"
>
{t('重置')}
</Button>

View File

@@ -1,227 +0,0 @@
// contexts/Style/index.js
import React, { useReducer, useEffect, useMemo, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import { isMobile as getIsMobile } from '../../helpers';
// Action Types
const ACTION_TYPES = {
TOGGLE_SIDER: 'TOGGLE_SIDER',
SET_SIDER: 'SET_SIDER',
SET_MOBILE: 'SET_MOBILE',
SET_SIDER_COLLAPSED: 'SET_SIDER_COLLAPSED',
BATCH_UPDATE: 'BATCH_UPDATE',
};
// Constants
const STORAGE_KEYS = {
SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
};
const ROUTE_PATTERNS = {
CONSOLE: '/console',
};
/**
* 判断路径是否为控制台路由
* @param {string} pathname - 路由路径
* @returns {boolean} 是否为控制台路由
*/
const isConsoleRoute = (pathname) => {
return pathname === ROUTE_PATTERNS.CONSOLE ||
pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
};
/**
* 获取初始状态
* @param {string} pathname - 当前路由路径
* @returns {Object} 初始状态对象
*/
const getInitialState = (pathname) => {
const isMobile = getIsMobile();
const isConsole = isConsoleRoute(pathname);
const isCollapsed = localStorage.getItem(STORAGE_KEYS.SIDEBAR_COLLAPSED) === 'true';
return {
isMobile,
showSider: isConsole && !isMobile,
siderCollapsed: isCollapsed,
isManualSiderControl: false,
};
};
/**
* Style reducer
* @param {Object} state - 当前状态
* @param {Object} action - action 对象
* @returns {Object} 新状态
*/
const styleReducer = (state, action) => {
switch (action.type) {
case ACTION_TYPES.TOGGLE_SIDER:
return {
...state,
showSider: !state.showSider,
isManualSiderControl: true,
};
case ACTION_TYPES.SET_SIDER:
return {
...state,
showSider: action.payload,
isManualSiderControl: action.isManualControl ?? false,
};
case ACTION_TYPES.SET_MOBILE:
return {
...state,
isMobile: action.payload,
};
case ACTION_TYPES.SET_SIDER_COLLAPSED:
// 自动保存到 localStorage
localStorage.setItem(STORAGE_KEYS.SIDEBAR_COLLAPSED, action.payload.toString());
return {
...state,
siderCollapsed: action.payload,
};
case ACTION_TYPES.BATCH_UPDATE:
return {
...state,
...action.payload,
};
default:
return state;
}
};
// Context (内部使用,不导出)
const StyleContext = createContext(null);
/**
* 自定义 Hook - 处理窗口大小变化
* @param {Function} dispatch - dispatch 函数
* @param {Object} state - 当前状态
* @param {string} pathname - 当前路径
*/
const useWindowResize = (dispatch, state, pathname) => {
useEffect(() => {
const handleResize = () => {
const isMobile = getIsMobile();
dispatch({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile });
// 只有在非手动控制的情况下,才根据屏幕大小自动调整侧边栏
if (!state.isManualSiderControl && isConsoleRoute(pathname)) {
dispatch({
type: ACTION_TYPES.SET_SIDER,
payload: !isMobile,
isManualControl: false
});
}
};
let timeoutId;
const debouncedResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleResize, 150);
};
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', debouncedResize);
clearTimeout(timeoutId);
};
}, [dispatch, state.isManualSiderControl, pathname]);
};
/**
* 自定义 Hook - 处理路由变化
* @param {Function} dispatch - dispatch 函数
* @param {string} pathname - 当前路径
*/
const useRouteChange = (dispatch, pathname) => {
useEffect(() => {
const isMobile = getIsMobile();
const isConsole = isConsoleRoute(pathname);
dispatch({
type: ACTION_TYPES.BATCH_UPDATE,
payload: {
showSider: isConsole && !isMobile,
isManualSiderControl: false,
},
});
}, [pathname, dispatch]);
};
/**
* 自定义 Hook - 处理移动设备侧边栏自动收起
* @param {Object} state - 当前状态
* @param {Function} dispatch - dispatch 函数
*/
const useMobileSiderAutoHide = (state, dispatch) => {
useEffect(() => {
// 移动设备上,如果不是手动控制且侧边栏是打开的,则自动关闭
if (state.isMobile && state.showSider && !state.isManualSiderControl) {
dispatch({ type: ACTION_TYPES.SET_SIDER, payload: false });
}
}, [state.isMobile, state.showSider, state.isManualSiderControl, dispatch]);
};
/**
* Style Provider 组件
*/
export const StyleProvider = ({ children }) => {
const location = useLocation();
const pathname = location.pathname;
const [state, dispatch] = useReducer(
styleReducer,
pathname,
getInitialState
);
useWindowResize(dispatch, state, pathname);
useRouteChange(dispatch, pathname);
useMobileSiderAutoHide(state, dispatch);
const contextValue = useMemo(
() => ({ state, dispatch }),
[state]
);
return (
<StyleContext.Provider value={contextValue}>
{children}
</StyleContext.Provider>
);
};
/**
* 自定义 Hook - 使用 StyleContext
* @returns {{state: Object, dispatch: Function}} context value
*/
export const useStyle = () => {
const context = React.useContext(StyleContext);
if (!context) {
throw new Error('useStyle must be used within StyleProvider');
}
return context;
};
// 导出 action creators 以便外部使用
export const styleActions = {
toggleSider: () => ({ type: ACTION_TYPES.TOGGLE_SIDER }),
setSider: (show, isManualControl = false) => ({
type: ACTION_TYPES.SET_SIDER,
payload: show,
isManualControl
}),
setMobile: (isMobile) => ({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile }),
setSiderCollapsed: (collapsed) => ({
type: ACTION_TYPES.SET_SIDER_COLLAPSED,
payload: collapsed
}),
};

View File

@@ -59,7 +59,12 @@ export function updateAPI() {
API.interceptors.response.use(
(response) => response,
(error) => {
// 如果请求配置中显式要求跳过全局错误处理,则不弹出默认错误提示
if (error.config && error.config.skipErrorHandler) {
return Promise.reject(error);
}
showError(error);
return Promise.reject(error);
},
);
@@ -82,8 +87,9 @@ export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled
const payload = {
model: inputs.model,
group: inputs.group,
messages: processedMessages,
group: inputs.group,
group: inputs.group,
stream: inputs.stream,
};

View File

@@ -0,0 +1,10 @@
export const toBoolean = (value) => {
// 兼容字符串、数字以及布尔原生类型
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value === 1;
if (typeof value === 'string') {
const v = value.toLowerCase();
return v === 'true' || v === '1';
}
return false;
};

View File

@@ -6,3 +6,4 @@ export * from './render';
export * from './log';
export * from './data';
export * from './token';
export * from './boolean';

View File

@@ -1,6 +1,7 @@
import i18next from 'i18next';
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
import { copy, isMobile, showSuccess } from './utils';
import { copy, showSuccess } from './utils';
import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
import { visit } from 'unist-util-visit';
import {
OpenAI,
@@ -31,6 +32,8 @@ import {
Coze,
SiliconCloud,
FastGPT,
Kling,
Jimeng,
} from '@lobehub/icons';
import {
@@ -51,18 +54,18 @@ import {
// 侧边栏图标颜色映射
export const sidebarIconColors = {
dashboard: '#4F46E5', // 紫蓝
dashboard: '#10B981', // 绿
terminal: '#10B981', // 绿色
message: '#06B6D4', // 青色
key: '#3B82F6', // 蓝色
chart: '#8B5CF6', //
chart: '#F59E0B', // 琥珀
image: '#EC4899', // 粉色
check: '#F59E0B', // 琥珀色
credit: '#F97316', // 橙色
layers: '#EF4444', // 红色
gift: '#F43F5E', // 玫红色
user: '#6366F1', // 靛蓝
settings: '#6B7280', //
user: '#10B981', // 绿
settings: '#F97316', //
};
// 获取侧边栏Lucide图标组件
@@ -386,6 +389,10 @@ export function getChannelIcon(channelType) {
return <XAI size={iconSize} />;
case 49: // Coze
return <Coze size={iconSize} />;
case 50: // 可灵 Kling
return <Kling.Color size={iconSize} />;
case 51: // 即梦 Jimeng
return <Jimeng.Color size={iconSize} />;
case 8: // 自定义渠道
case 22: // 知识库FastGPT
return <FastGPT.Color size={iconSize} />;
@@ -533,7 +540,7 @@ export function stringToColor(str) {
export function renderModelTag(modelName, options = {}) {
const {
color,
size = 'large',
size = 'default',
shape = 'circle',
onClick,
suffixIcon,
@@ -578,7 +585,7 @@ export function renderText(text, limit) {
export function renderGroup(group) {
if (group === '') {
return (
<Tag size='large' key='default' color='orange' shape='circle'>
<Tag key='default' color='white' shape='circle'>
{i18next.t('用户分组')}
</Tag>
);
@@ -597,7 +604,6 @@ export function renderGroup(group) {
<span key={group}>
{groups.map((group) => (
<Tag
size='large'
color={tagColors[group] || stringToColor(group)}
key={group}
shape='circle'
@@ -664,7 +670,8 @@ const measureTextWidth = (
};
export function truncateText(text, maxWidth = 200) {
if (!isMobile()) {
const isMobileScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
if (!isMobileScreen) {
return text;
}
if (!text) return text;

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
import { TABLE_COMPACT_MODES_KEY } from '../constants';
import { MOBILE_BREAKPOINT } from '../hooks/useIsMobile.js';
const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -67,9 +68,7 @@ export async function copy(text) {
return okay;
}
export function isMobile() {
return window.innerWidth <= 600;
}
// isMobile 函数已移除,请改用 useIsMobile Hook
let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
@@ -77,7 +76,8 @@ let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
let showNoticeOptions = { autoClose: false };
if (isMobile()) {
const isMobileScreen = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
if (isMobileScreen) {
showErrorOptions.position = 'top-center';
// showErrorOptions.transition = 'flip';

View File

@@ -0,0 +1,16 @@
export const MOBILE_BREAKPOINT = 768;
import { useSyncExternalStore } from 'react';
export const useIsMobile = () => {
const query = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
return useSyncExternalStore(
(callback) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
},
() => window.matchMedia(query).matches,
() => false,
);
};

View File

@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react';
const KEY = 'default_collapse_sidebar';
export const useSidebarCollapsed = () => {
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(KEY) === 'true');
const toggle = useCallback(() => {
setCollapsed(prev => {
const next = !prev;
localStorage.setItem(KEY, next.toString());
return next;
});
}, []);
const set = useCallback((value) => {
setCollapsed(value);
localStorage.setItem(KEY, value.toString());
}, []);
return [collapsed, toggle, set];
};

View File

@@ -179,7 +179,6 @@
"注销": "Logout",
"登录": "Sign in",
"注册": "Sign up",
"加载{name}中...": "Loading {name}...",
"未登录或登录已过期,请重新登录!": "Not logged in or session expired. Please login again!",
"用户登录": "User Login",
"密码": "Password",
@@ -373,6 +372,9 @@
"搜索令牌的名称 ...": "Search for the name of the token...",
"已用额度": "Quota used",
"剩余额度": "Remaining quota",
"总额度": "Total quota",
"智能熔断": "Smart fallback",
"当前分组为 auto会自动选择最优分组当一个组不可用时自动降级到下一个组熔断机制": "The current group is auto, it will automatically select the optimal group, and automatically downgrade to the next group when a group is unavailable (breakage mechanism)",
"过期时间": "Expiration time",
"无": "None",
"无限制": "Unlimited",
@@ -421,6 +423,8 @@
"微信身份验证": "WeChat Authentication",
"Turnstile 用户校验": "Turnstile User Verification",
"创建新的渠道": "Create New Channel",
"是否自动禁用": "Whether to automatically disable",
"仅当自动禁用开启时有效,关闭后不会自动禁用该渠道": "Only effective when automatic disabling is enabled, after closing, the channel will not be automatically disabled",
"镜像": "Mirror",
"请输入镜像站地址格式为https://domain.com可不填不填则使用渠道默认值": "Please enter the mirror site address, the format is: https://domain.com, it can be left blank, if left blank, the default value of the channel will be used",
"模型": "Model",
@@ -507,6 +511,7 @@
"创建新的兑换码": "Create a new redemption code",
"未找到所请求的页面": "The requested page was not found",
"过期时间格式错误!": "Expiration time format error!",
"过期时间不能早于当前时间!": "Expiration time cannot be earlier than the current time!",
"请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制": "Please enter the expiration time, the format is yyyy-MM-dd HH:mm:ss, -1 means no limit",
"此项可选,为一个 JSON 文本,键为用户请求的模型名称,值为要替换的模型名称,例如:": "This is optional, it's a JSON text, the key is the model name requested by the user, and the value is the model name to be replaced, for example:",
"此项可选,输入镜像站地址,格式为:": "This is optional, enter the mirror site address, the format is:",
@@ -927,7 +932,6 @@
"更新令牌后需等待几分钟生效": "It will take a few minutes to take effect after updating the token.",
"一小时": "One hour",
"新建数量": "New quantity",
"加载失败,请稍后重试": "Loading failed, please try again later",
"未设置": "Not set",
"API文档": "API documentation",
"不是合法的 JSON 字符串": "Not a valid JSON string",
@@ -960,6 +964,7 @@
"启用突发备用号池(建议勾选,极大降低故障率)": "Enable burst backup number pool (it is recommended to check this box to greatly reduce the failure rate)",
"查看说明": "View instructions",
"添加令牌": "Create token",
"IP限制": "IP restrictions",
"令牌纬度控制 Midjouney 配置,设置优先级:令牌 > 路径参数 > 系统默认": "Token latitude controls Midjouney configuration, setting priority: token > path parameter > system default",
"启用速率限制": "Enable rate limiting",
"复制BaseURL": "Copy BaseURL",
@@ -1142,7 +1147,7 @@
"鉴权json": "Authentication JSON",
"请输入鉴权json": "Please enter authentication JSON",
"组织": "Organization",
"组织,可选,不填则为默认组织": "Organization (optional), default if empty",
"组织,不填则为默认组织": "Organization, default if empty",
"请输入组织org-xxx": "Please enter organization org-xxx",
"默认测试模型": "Default Test Model",
"不填则为模型列表第一个": "First model in list if empty",
@@ -1623,6 +1628,7 @@
"请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings.",
"确定要删除此API信息吗": "Are you sure you want to delete this API information?",
"测速": "Speed Test",
"跳转": "Jump",
"批量删除": "Batch Delete",
"常见问答": "FAQ",
"进行中": "Ongoing",
@@ -1754,7 +1760,23 @@
"批量创建时会在名称后自动添加随机后缀": "When creating in batches, a random suffix will be automatically added to the name",
"额度必须大于0": "Quota must be greater than 0",
"生成数量必须大于0": "Generation quantity must be greater than 0",
"创建后可在编辑渠道时获取上游模型列表": "After creation, you can get the upstream model list when editing the channel",
"可用端点类型": "Supported endpoint types",
"未登录,使用默认分组倍率:": "Not logged in, using default group ratio: "
"未登录,使用默认分组倍率:": "Not logged in, using default group ratio: ",
"该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置": "This server address will affect the payment callback address and the address displayed on the default homepage, please ensure correct configuration",
"密钥聚合模式": "Key aggregation mode",
"随机": "Random",
"轮询": "Polling",
"密钥文件 (.json)": "Key file (.json)",
"点击上传文件或拖拽文件到这里": "Click to upload file or drag and drop file here",
"仅支持 JSON 文件": "Only JSON files are supported",
"仅支持 JSON 文件,支持多文件": "Only JSON files are supported, multiple files are supported",
"请上传密钥文件": "Please upload the key file",
"请填写部署地区": "Please fill in the deployment region",
"请输入部署地区例如us-central1\n支持使用模型映射格式\n{\n \"default\": \"us-central1\",\n \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}": "Please enter the deployment region, for example: us-central1\nSupports using model mapping format\n{\n \"default\": \"us-central1\",\n \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}",
"其他": "Other",
"未知渠道": "Unknown channel",
"切换为单密钥模式": "Switch to single key mode",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
"自定义模型名称": "Custom model name",
"启用全部密钥": "Enable all keys"
}

View File

@@ -14,6 +14,22 @@
}
/* ==================== 全局基础样式 ==================== */
/* 侧边栏宽度相关的 CSS 变量,配合 .sidebar-collapsed 类和媒体查询实现响应式布局 */
:root {
--sidebar-width: 180px;
/* 展开时宽度 */
--sidebar-width-collapsed: 60px; /* 折叠后宽度,显示图标栏 */
/* 折叠后宽度 */
--sidebar-current-width: var(--sidebar-width);
}
/* 当 body 上存在 .sidebar-collapsed 类时,使用折叠宽度 */
body.sidebar-collapsed {
--sidebar-current-width: var(--sidebar-width-collapsed);
}
/* 移除了在移动端强制设为 0 的限制,改由 React 控制是否渲染侧边栏以实现显示/隐藏 */
body {
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei', sans-serif;
color: var(--semi-color-text-0);
@@ -59,6 +75,11 @@ code {
.semi-navigation-item {
margin-bottom: 4px !important;
padding: 4px 12px !important;
}
.semi-navigation-sub-title {
padding: 0 !important;
}
.semi-navigation-item-icon {
@@ -70,6 +91,7 @@ code {
margin-right: 0;
}
.semi-navigation-item,
.semi-navigation-sub-title {
height: 100% !important;
}

View File

@@ -6,11 +6,18 @@ import { UserProvider } from './context/User';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
import { ThemeProvider } from './context/Theme';
import { StyleProvider } from './context/Style/index.js';
import PageLayout from './components/layout/PageLayout.js';
import './i18n/i18n.js';
import './index.css';
// 欢迎信息(二次开发者不准将此移除)
// Welcome message (Secondary developers are not allowed to remove this)
if (typeof window !== 'undefined') {
console.log('%cWe ❤ NewAPI%c Github: https://github.com/QuantumNous/new-api',
'color: #10b981; font-weight: bold; font-size: 24px;',
'color: inherit; font-size: 14px;');
}
// initialization
const root = ReactDOM.createRoot(document.getElementById('root'));
@@ -18,11 +25,14 @@ root.render(
<React.StrictMode>
<StatusProvider>
<UserProvider>
<BrowserRouter>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<ThemeProvider>
<StyleProvider>
<PageLayout />
</StyleProvider>
<PageLayout />
</ThemeProvider>
</BrowserRouter>
</UserProvider>

View File

@@ -105,7 +105,7 @@ const About = () => {
);
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
{aboutLoaded && about === '' ? (
<div className="flex justify-center items-center h-screen p-8">
<Empty

View File

@@ -1,14 +1,14 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
API,
isMobile,
showError,
showInfo,
showSuccess,
verifyJSON,
} from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { CHANNEL_OPTIONS } from '../../constants';
import {
SideSheet,
@@ -27,7 +27,7 @@ import {
Row,
Col,
} from '@douyinfe/semi-ui';
import { getChannelModels, copy } from '../../helpers';
import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
import {
IconSave,
IconClose,
@@ -35,6 +35,7 @@ import {
IconSetting,
IconCode,
IconGlobe,
IconBolt,
} from '@douyinfe/semi-icons';
const { Text, Title } = Typography;
@@ -80,6 +81,7 @@ const EditChannel = (props) => {
const channelId = props.editingChannel.id;
const isEdit = channelId !== undefined;
const [loading, setLoading] = useState(isEdit);
const isMobile = useIsMobile();
const handleCancel = () => {
props.handleClose();
};
@@ -100,10 +102,12 @@ const EditChannel = (props) => {
priority: 0,
weight: 0,
tag: '',
multi_key_mode: 'random',
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
const [multiKeyMode, setMultiKeyMode] = useState('random');
const [autoBan, setAutoBan] = useState(true);
// const [autoBan, setAutoBan] = useState(true);
const [inputs, setInputs] = useState(originInputs);
const [originModelOptions, setOriginModelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
@@ -114,6 +118,10 @@ const EditChannel = (props) => {
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const formApiRef = useRef(null);
const [vertexKeys, setVertexKeys] = useState([]);
const [vertexFileList, setVertexFileList] = useState([]);
const vertexErroredNames = useRef(new Set()); // 避免重复报错
const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false);
const getInitValues = () => ({ ...originInputs });
const handleInputChange = (name, value) => {
if (formApiRef.current) {
@@ -211,6 +219,19 @@ const EditChannel = (props) => {
2,
);
}
const chInfo = data.channel_info || {};
const isMulti = chInfo.is_multi_key === true;
setIsMultiKeyChannel(isMulti);
if (isMulti) {
setBatch(true);
setMultiToSingle(true);
const modeVal = chInfo.multi_key_mode || 'random';
setMultiKeyMode(modeVal);
data.multi_key_mode = modeVal;
} else {
setBatch(false);
setMultiToSingle(false);
}
setInputs(data);
if (formApiRef.current) {
formApiRef.current.setValues(data);
@@ -238,9 +259,9 @@ const EditChannel = (props) => {
let err = false;
if (isEdit) {
// 如果是编辑模式使用已有的channel id获取模型列表
const res = await API.get('/api/channel/fetch_models/' + channelId);
if (res.data && res.data.success) {
// 如果是编辑模式,使用已有的 channelId 获取模型列表
const res = await API.get('/api/channel/fetch_models/' + channelId, { skipErrorHandler: true });
if (res && res.data && res.data.success) {
models.push(...res.data.data);
} else {
err = true;
@@ -252,13 +273,17 @@ const EditChannel = (props) => {
err = true;
} else {
try {
const res = await API.post('/api/channel/fetch_models', {
base_url: inputs['base_url'],
type: inputs['type'],
key: inputs['key'],
});
const res = await API.post(
'/api/channel/fetch_models',
{
base_url: inputs['base_url'],
type: inputs['type'],
key: inputs['key'],
},
{ skipErrorHandler: true },
);
if (res.data && res.data.success) {
if (res && res.data && res.data.success) {
models.push(...res.data.data);
} else {
err = true;
@@ -324,14 +349,14 @@ const EditChannel = (props) => {
useEffect(() => {
const modelMap = new Map();
originModelOptions.forEach(option => {
originModelOptions.forEach((option) => {
const v = (option.value || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, option);
}
});
inputs.models.forEach(model => {
inputs.models.forEach((model) => {
const v = (model || '').trim();
if (!modelMap.has(v)) {
modelMap.set(v, {
@@ -342,8 +367,29 @@ const EditChannel = (props) => {
}
});
setModelOptions(Array.from(modelMap.values()));
}, [originModelOptions, inputs.models]);
const categories = getModelCategories(t);
const optionsWithIcon = Array.from(modelMap.values()).map((opt) => {
const modelName = opt.value;
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: modelName })) {
icon = category.icon;
break;
}
}
return {
...opt,
label: (
<span className="flex items-center gap-1">
{icon}
{modelName}
</span>
),
};
});
setModelOptions(optionsWithIcon);
}, [originModelOptions, inputs.models, t]);
useEffect(() => {
fetchModels().then();
@@ -377,10 +423,96 @@ const EditChannel = (props) => {
}
}, [props.visible, channelId]);
const handleVertexUploadChange = ({ fileList }) => {
vertexErroredNames.current.clear();
(async () => {
let validFiles = [];
let keys = [];
const errorNames = [];
for (const item of fileList) {
const fileObj = item.fileInstance;
if (!fileObj) continue;
try {
const txt = await fileObj.text();
keys.push(JSON.parse(txt));
validFiles.push(item);
} catch (err) {
if (!vertexErroredNames.current.has(item.name)) {
errorNames.push(item.name);
vertexErroredNames.current.add(item.name);
}
}
}
// 非批量模式下只保留一个文件(最新选择的),避免重复叠加
if (!batch && validFiles.length > 1) {
validFiles = [validFiles[validFiles.length - 1]];
keys = [keys[keys.length - 1]];
}
setVertexKeys(keys);
setVertexFileList(validFiles);
if (formApiRef.current) {
formApiRef.current.setValue('vertex_files', validFiles);
}
setInputs((prev) => ({ ...prev, vertex_files: validFiles }));
if (errorNames.length > 0) {
showError(t('以下文件解析失败,已忽略:{{list}}', { list: errorNames.join(', ') }));
}
})();
};
const submit = async () => {
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
let localInputs = { ...formValues };
if (localInputs.type === 41) {
let keys = vertexKeys;
// 若当前未选择文件,尝试从已上传文件列表解析(异步读取)
if (keys.length === 0 && vertexFileList.length > 0) {
try {
const parsed = await Promise.all(
vertexFileList.map(async (item) => {
const fileObj = item.fileInstance;
if (!fileObj) return null;
const txt = await fileObj.text();
return JSON.parse(txt);
})
);
keys = parsed.filter(Boolean);
} catch (err) {
showError(t('解析密钥文件失败: {{msg}}', { msg: err.message }));
return;
}
}
// 创建模式必须上传密钥;编辑模式可选
if (keys.length === 0) {
if (!isEdit) {
showInfo(t('请上传密钥文件!'));
return;
} else {
// 编辑模式且未上传新密钥,不修改 key
delete localInputs.key;
}
} else {
// 有新密钥,则覆盖
if (batch) {
localInputs.key = JSON.stringify(keys);
} else {
localInputs.key = JSON.stringify(keys[0]);
}
}
}
// 如果是编辑模式且 key 为空字符串,避免提交空值覆盖旧密钥
if (isEdit && (!localInputs.key || localInputs.key.trim() === '')) {
delete localInputs.key;
}
delete localInputs.vertex_files;
if (!isEdit && (!localInputs.name || !localInputs.key)) {
showInfo(t('请填写渠道名称和渠道密钥!'));
return;
@@ -406,13 +538,23 @@ const EditChannel = (props) => {
localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
localInputs.models = localInputs.models.join(',');
localInputs.group = (localInputs.groups || []).join(',');
let mode = 'single';
if (batch) {
mode = multiToSingle ? 'multi_to_single' : 'batch';
}
if (isEdit) {
res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else {
res = await API.post(`/api/channel/`, localInputs);
res = await API.post(`/api/channel/`, {
mode: mode,
multi_key_mode: mode === 'multi_to_single' ? multiKeyMode : undefined,
channel: localInputs,
});
}
const { success, message } = res.data;
if (success) {
@@ -465,11 +607,79 @@ const EditChannel = (props) => {
}
};
const batchAllowed = !isEdit && inputs.type !== 41;
const batchAllowed = !isEdit || isMultiKeyChannel;
const batchExtra = batchAllowed ? (
<Checkbox checked={batch} onChange={() => setBatch(!batch)}>{t('批量创建')}</Checkbox>
<Space>
<Checkbox
disabled={isEdit}
checked={batch}
onChange={(e) => {
const checked = e.target.checked;
if (!checked && vertexFileList.length > 1) {
Modal.confirm({
title: t('切换为单密钥模式'),
content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'),
onOk: () => {
const firstFile = vertexFileList[0];
const firstKey = vertexKeys[0] ? [vertexKeys[0]] : [];
setVertexFileList([firstFile]);
setVertexKeys(firstKey);
formApiRef.current?.setValue('vertex_files', [firstFile]);
setInputs((prev) => ({ ...prev, vertex_files: [firstFile] }));
setBatch(false);
setMultiToSingle(false);
setMultiKeyMode('random');
},
onCancel: () => {
setBatch(true);
},
centered: true,
});
return;
}
setBatch(checked);
if (!checked) {
setMultiToSingle(false);
setMultiKeyMode('random');
}
}}
>{t('批量创建')}</Checkbox>
{batch && (
<Checkbox disabled={isEdit} checked={multiToSingle} onChange={() => {
setMultiToSingle(prev => !prev);
setInputs(prev => {
const newInputs = { ...prev };
if (!multiToSingle) {
newInputs.multi_key_mode = multiKeyMode;
} else {
delete newInputs.multi_key_mode;
}
return newInputs;
});
}}>{t('密钥聚合模式')}</Checkbox>
)}
</Space>
) : null;
const channelOptionList = useMemo(
() =>
CHANNEL_OPTIONS.map((opt) => ({
...opt,
label: (
<span className="flex items-center gap-2">
{getChannelIcon(opt.value)}
{opt.label}
</span>
),
})),
[],
);
return (
<>
<SideSheet
@@ -484,7 +694,7 @@ const EditChannel = (props) => {
}
bodyStyle={{ padding: '0' }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
width={isMobile ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<Space>
@@ -535,7 +745,7 @@ const EditChannel = (props) => {
label={t('类型')}
placeholder={t('请选择渠道类型')}
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={CHANNEL_OPTIONS}
optionList={channelOptionList}
style={{ width: '100%' }}
filter
searchPosition='dropdown'
@@ -553,56 +763,170 @@ const EditChannel = (props) => {
/>
{batch ? (
<Form.TextArea
field='key'
label={t('密钥')}
placeholder={t('请输入密钥,一行一个')}
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
autosize={{ minRows: 6, maxRows: 6 }}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={batchExtra}
/>
inputs.type === 41 ? (
<Form.Upload
field='vertex_files'
label={t('密钥文件 (.json)')}
accept='.json'
multiple
draggable
dragIcon={<IconBolt />}
dragMainText={t('点击上传文件或拖拽文件到这里')}
dragSubText={t('仅支持 JSON 文件,支持多文件')}
style={{ marginTop: 10 }}
uploadTrigger='custom'
beforeUpload={() => false}
onChange={handleVertexUploadChange}
fileList={vertexFileList}
rules={isEdit ? [] : [{ required: true, message: t('请上传密钥文件') }]}
extraText={batchExtra}
/>
) : (
<Form.TextArea
field='key'
label={t('密钥')}
placeholder={t('请输入密钥,一行一个')}
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
autosize
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={batchExtra}
showClear
/>
)
) : (
<>
{inputs.type === 41 ? (
<Form.TextArea
field='key'
label={t('密钥')}
placeholder={
'{\n' +
' "type": "service_account",\n' +
' "project_id": "abc-bcd-123-456",\n' +
' "private_key_id": "123xxxxx456",\n' +
' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' +
' "client_email": "xxx@developer.gserviceaccount.com",\n' +
' "client_id": "111222333",\n' +
' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' +
' "token_uri": "https://oauth2.googleapis.com/token",\n' +
' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' +
' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' +
' "universe_domain": "googleapis.com"\n' +
'}'
}
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
autosize={{ minRows: 10 }}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
<Form.Upload
field='vertex_files'
label={t('密钥文件 (.json)')}
accept='.json'
draggable
dragIcon={<IconBolt />}
dragMainText={t('点击上传文件或拖拽文件到这里')}
dragSubText={t('仅支持 JSON 文件')}
style={{ marginTop: 10 }}
uploadTrigger='custom'
beforeUpload={() => false}
onChange={handleVertexUploadChange}
fileList={vertexFileList}
rules={isEdit ? [] : [{ required: true, message: t('请上传密钥文件') }]}
extraText={batchExtra}
/>
) : (
<Form.Input
field='key'
label={t('密钥')}
label={isEdit ? t('密钥(编辑模式下,保存的密钥不会显示)') : t('密钥')}
placeholder={t(type2secretPrompt(inputs.type))}
rules={isEdit ? [] : [{ required: true, message: t('请输入密钥') }]}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
extraText={batchExtra}
showClear
/>
)}
</>
)}
{batch && multiToSingle && (
<>
<Form.Select
field='multi_key_mode'
label={t('密钥聚合模式')}
placeholder={t('请选择多密钥使用策略')}
optionList={[
{ label: t('随机'), value: 'random' },
{ label: t('轮询'), value: 'polling' },
]}
style={{ width: '100%' }}
value={inputs.multi_key_mode || 'random'}
onChange={(value) => {
setMultiKeyMode(value);
handleInputChange('multi_key_mode', value);
}}
/>
{inputs.multi_key_mode === 'polling' && (
<Banner
type='warning'
description={t('轮询模式必须搭配Redis和内存缓存功能使用否则性能将大幅降低并且无法实现轮询功能')}
className='!rounded-lg mt-2'
/>
)}
</>
)}
{inputs.type === 18 && (
<Form.Input
field='other'
label={t('模型版本')}
placeholder={'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 41 && (
<Form.TextArea
field='other'
label={t('部署地区')}
placeholder={t(
'请输入部署地区例如us-central1\n支持使用模型映射格式\n{\n "default": "us-central1",\n "claude-3-5-sonnet-20240620": "europe-west1"\n}'
)}
autosize
onChange={(value) => handleInputChange('other', value)}
rules={[{ required: true, message: t('请填写部署地区') }]}
extraText={
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
>
{t('填入模板')}
</Text>
}
showClear
/>
)}
{inputs.type === 21 && (
<Form.Input
field='other'
label={t('知识库 ID')}
placeholder={'请输入知识库 ID例如123456'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 39 && (
<Form.Input
field='other'
label='Account ID'
placeholder={'请输入Account ID例如d6b5da8hk1awo8nap34ube6gh'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 49 && (
<Form.Input
field='other'
label={t('智能体ID')}
placeholder={'请输入智能体ID例如7342866812345'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 1 && (
<Form.Input
field='openai_organization'
label={t('组织')}
placeholder={t('请输入组织org-xxx')}
showClear
helpText={t('组织,不填则为默认组织')}
onChange={(value) => handleInputChange('openai_organization', value)}
/>
)}
</Card>
{/* API Configuration Card */}
@@ -747,7 +1071,7 @@ const EditChannel = (props) => {
<Form.Select
field='models'
label={t('模型')}
placeholder={isEdit ? t('请选择该渠道所支持的模型') : t('创建后可在编辑渠道时获取上游模型列表')}
placeholder={t('请选择该渠道所支持的模型')}
rules={[{ required: true, message: t('请选择模型') }]}
multiple
filter
@@ -763,11 +1087,9 @@ const EditChannel = (props) => {
<Button size='small' type='secondary' onClick={() => handleInputChange('models', fullModels)}>
{t('填入所有模型')}
</Button>
{isEdit && (
<Button size='small' type='tertiary' onClick={() => fetchUpstreamModelList('models')}>
{t('获取模型列表')}
</Button>
)}
<Button size='small' type='tertiary' onClick={() => fetchUpstreamModelList('models')}>
{t('获取模型列表')}
</Button>
<Button size='small' type='warning' onClick={() => handleInputChange('models', [])}>
{t('清除所有模型')}
</Button>
@@ -860,77 +1182,6 @@ const EditChannel = (props) => {
onChange={(value) => handleInputChange('groups', value)}
/>
{inputs.type === 18 && (
<Form.Input
field='other'
label={t('模型版本')}
placeholder={'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 41 && (
<Form.TextArea
field='other'
label={t('部署地区')}
placeholder={t(
'请输入部署地区例如us-central1\n支持使用模型映射格式\n{\n "default": "us-central1",\n "claude-3-5-sonnet-20240620": "europe-west1"\n}'
)}
autosize={{ minRows: 2 }}
onChange={(value) => handleInputChange('other', value)}
extraText={
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
>
{t('填入模板')}
</Text>
}
/>
)}
{inputs.type === 21 && (
<Form.Input
field='other'
label={t('知识库 ID')}
placeholder={'请输入知识库 ID例如123456'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 39 && (
<Form.Input
field='other'
label='Account ID'
placeholder={'请输入Account ID例如d6b5da8hk1awo8nap34ube6gh'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 49 && (
<Form.Input
field='other'
label={t('智能体ID')}
placeholder={'请输入智能体ID例如7342866812345'}
onChange={(value) => handleInputChange('other', value)}
showClear
/>
)}
{inputs.type === 1 && (
<Form.Input
field='openai_organization'
label={t('组织')}
placeholder={t('请输入组织org-xxx')}
showClear
helpText={t('组织,可选,不填则为默认组织')}
onChange={(value) => handleInputChange('openai_organization', value)}
/>
)}
<Form.Input
field='tag'
label={t('渠道标签')}

View File

@@ -3,7 +3,7 @@ import ChannelsTable from '../../components/table/ChannelsTable';
const File = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<ChannelsTable />
</div>
);

View File

@@ -17,7 +17,7 @@ const chat2page = () => {
}
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<h3>正在加载请稍候...</h3>
</div>
);

View File

@@ -1,14 +1,14 @@
import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
import { useNavigate } from 'react-router-dom';
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle } from 'lucide-react';
import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle, ExternalLink } from 'lucide-react';
import { marked } from 'marked';
import {
Card,
Form,
Spin,
IconButton,
Button,
Modal,
Avatar,
Tabs,
@@ -18,14 +18,14 @@ import {
Timeline,
Collapse,
Progress,
Divider
Divider,
Skeleton
} from '@douyinfe/semi-ui';
import {
IconRefresh,
IconSearch,
IconMoneyExchangeStroked,
IconHistogram,
IconRotate,
IconCoinMoneyStroked,
IconTextStroked,
IconPulse,
@@ -33,15 +33,17 @@ import {
IconTypograph,
IconPieChart2Stroked,
IconPlus,
IconMinus
IconMinus,
IconSend
} from '@douyinfe/semi-icons';
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
import { VChart } from '@visactor/react-vchart';
import {
API,
isAdmin,
isMobile,
showError,
showSuccess,
showWarning,
timestamp2string,
timestamp2string1,
getQuotaWithUnit,
@@ -50,9 +52,9 @@ import {
renderQuota,
modelToColor,
copy,
showSuccess,
getRelativeTime
} from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js';
import { useTranslation } from 'react-i18next';
@@ -65,6 +67,7 @@ const Detail = (props) => {
// ========== Hooks - Navigation & Translation ==========
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
// ========== Hooks - Refs ==========
const formRef = useRef();
@@ -192,6 +195,7 @@ const Detail = (props) => {
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
const [loading, setLoading] = useState(false);
const [greetingVisible, setGreetingVisible] = useState(false);
const [quotaData, setQuotaData] = useState([]);
const [consumeQuota, setConsumeQuota] = useState(0);
const [consumeTokens, setConsumeTokens] = useState(0);
@@ -449,7 +453,7 @@ const Detail = (props) => {
// ========== Hooks - Memoized Values ==========
const performanceMetrics = useMemo(() => {
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
const avgRPM = (times / timeDiff).toFixed(3);
const avgRPM = isNaN(times / timeDiff) ? '0' : (times / timeDiff).toFixed(3);
const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
return { avgRPM, avgTPM, timeDiff };
@@ -518,7 +522,7 @@ const Detail = (props) => {
{
title: t('当前余额'),
value: renderQuota(userState?.user?.quota),
icon: <IconMoneyExchangeStroked size="large" />,
icon: <IconMoneyExchangeStroked />,
avatarColor: 'blue',
onClick: () => navigate('/console/topup'),
trendData: [],
@@ -527,7 +531,7 @@ const Detail = (props) => {
{
title: t('历史消耗'),
value: renderQuota(userState?.user?.used_quota),
icon: <IconHistogram size="large" />,
icon: <IconHistogram />,
avatarColor: 'purple',
trendData: [],
trendColor: '#8b5cf6'
@@ -541,7 +545,7 @@ const Detail = (props) => {
{
title: t('请求次数'),
value: userState.user?.request_count,
icon: <IconRotate size="large" />,
icon: <IconSend />,
avatarColor: 'green',
trendData: [],
trendColor: '#10b981'
@@ -549,7 +553,7 @@ const Detail = (props) => {
{
title: t('统计次数'),
value: times,
icon: <IconPulse size="large" />,
icon: <IconPulse />,
avatarColor: 'cyan',
trendData: trendData.times,
trendColor: '#06b6d4'
@@ -563,7 +567,7 @@ const Detail = (props) => {
{
title: t('统计额度'),
value: renderQuota(consumeQuota),
icon: <IconCoinMoneyStroked size="large" />,
icon: <IconCoinMoneyStroked />,
avatarColor: 'yellow',
trendData: trendData.consumeQuota,
trendColor: '#f59e0b'
@@ -571,7 +575,7 @@ const Detail = (props) => {
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
icon: <IconTextStroked size="large" />,
icon: <IconTextStroked />,
avatarColor: 'pink',
trendData: trendData.tokens,
trendColor: '#ec4899'
@@ -585,7 +589,7 @@ const Detail = (props) => {
{
title: t('平均RPM'),
value: performanceMetrics.avgRPM,
icon: <IconStopwatchStroked size="large" />,
icon: <IconStopwatchStroked />,
avatarColor: 'indigo',
trendData: trendData.rpm,
trendColor: '#6366f1'
@@ -593,7 +597,7 @@ const Detail = (props) => {
{
title: t('平均TPM'),
value: performanceMetrics.avgTPM,
icon: <IconTypograph size="large" />,
icon: <IconTypograph />,
avatarColor: 'orange',
trendData: trendData.tpm,
trendColor: '#f97316'
@@ -614,7 +618,7 @@ const Detail = (props) => {
const handleSpeedTest = useCallback((apiUrl) => {
const encodedUrl = encodeURIComponent(apiUrl);
const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
window.open(speedTestUrl, '_blank');
window.open(speedTestUrl, '_blank', 'noopener,noreferrer');
}, []);
const handleInputChange = useCallback((value, name) => {
@@ -627,6 +631,7 @@ const Detail = (props) => {
const loadQuotaData = useCallback(async () => {
setLoading(true);
const startTime = Date.now();
try {
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
@@ -654,7 +659,11 @@ const Detail = (props) => {
showError(message);
}
} finally {
setLoading(false);
const elapsed = Date.now() - startTime;
const remainingTime = Math.max(0, 500 - elapsed);
setTimeout(() => {
setLoading(false);
}, remainingTime);
}
}, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
@@ -746,6 +755,13 @@ const Detail = (props) => {
return () => clearTimeout(timer);
}, [uptimeData, activeUptimeTab]);
useEffect(() => {
const timer = setTimeout(() => {
setGreetingVisible(true);
}, 100);
return () => clearTimeout(timer);
}, []);
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -1104,16 +1120,23 @@ const Detail = (props) => {
}, []);
return (
<div className="bg-gray-50 h-full mt-[64px]">
<div className="bg-gray-50 h-full mt-[64px] px-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
<h2
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
style={{ opacity: greetingVisible ? 1 : 0 }}
>
{getGreeting}
</h2>
<div className="flex gap-3">
<IconButton
<Button
type='tertiary'
icon={<IconSearch />}
onClick={showSearchModal}
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
/>
<IconButton
<Button
type='tertiary'
icon={<IconRefresh />}
onClick={refresh}
loading={loading}
@@ -1129,7 +1152,7 @@ const Detail = (props) => {
onOk={handleSearchConfirm}
onCancel={handleCloseModal}
closeOnEsc={true}
size={isMobile() ? 'full-width' : 'small'}
size={isMobile ? 'full-width' : 'small'}
centered
>
<Form ref={formRef} layout='vertical' className="w-full">
@@ -1174,143 +1197,159 @@ const Detail = (props) => {
</Form>
</Modal>
<Spin spinning={loading}>
<div className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedStatsData.map((group, idx) => (
<Card
key={idx}
{...CARD_PROPS}
className={`${group.color} border-0 !rounded-2xl w-full`}
title={group.title}
>
<div className="space-y-4">
{group.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-center justify-between cursor-pointer"
onClick={item.onClick}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="small"
color={item.avatarColor}
>
{item.icon}
</Avatar>
<div>
<div className="text-xs text-gray-500">{item.title}</div>
<div className="text-lg font-semibold">{item.value}</div>
<div className="mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedStatsData.map((group, idx) => (
<Card
key={idx}
{...CARD_PROPS}
className={`${group.color} border-0 !rounded-2xl w-full`}
title={group.title}
>
<div className="space-y-4">
{group.items.map((item, itemIdx) => (
<div
key={itemIdx}
className="flex items-center justify-between cursor-pointer"
onClick={item.onClick}
>
<div className="flex items-center">
<Avatar
className="mr-3"
size="small"
color={item.avatarColor}
>
{item.icon}
</Avatar>
<div>
<div className="text-xs text-gray-500">{item.title}</div>
<div className="text-lg font-semibold">
<Skeleton
loading={loading}
active
placeholder={
<Skeleton.Paragraph
active
rows={1}
style={{ width: '65px', height: '24px', marginTop: '4px' }}
/>
}
>
{item.value}
</Skeleton>
</div>
</div>
{item.trendData && item.trendData.length > 0 && (
<div className="w-24 h-10">
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={CHART_CONFIG}
/>
</div>
)}
</div>
))}
</div>
</Card>
))}
</div>
</div>
<div className="mb-4">
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<Card
{...CARD_PROPS}
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
<div className={FLEX_CENTER_GAP2}>
<PieChart size={16} />
{t('模型数据分析')}
{(loading || (item.trendData && item.trendData.length > 0)) && (
<div className="w-24 h-10">
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={CHART_CONFIG}
/>
</div>
)}
</div>
<Tabs
type="button"
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
<TabPane tab={
<span>
<IconHistogram />
{t('消耗分布')}
</span>
} itemKey="1" />
<TabPane tab={
<span>
<IconPulse />
{t('消耗趋势')}
</span>
} itemKey="2" />
<TabPane tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
} itemKey="3" />
<TabPane tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
} itemKey="4" />
</Tabs>
</div>
}
>
<div style={{ height: 400 }}>
{activeChartTab === '1' && (
<VChart
spec={spec_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '2' && (
<VChart
spec={spec_model_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '3' && (
<VChart
spec={spec_pie}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '4' && (
<VChart
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
)}
))}
</div>
</Card>
))}
</div>
</div>
{hasApiInfoPanel && (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
title={
<div className={FLEX_CENTER_GAP2}>
<Server size={16} />
{t('API信息')}
</div>
}
>
<div className="card-content-container">
<div
ref={apiScrollRef}
className="space-y-3 max-h-96 overflow-y-auto card-content-scroll"
onScroll={handleApiScroll}
>
{apiInfoData.length > 0 ? (
apiInfoData.map((api) => (
<div className="mb-4">
<div className={`grid grid-cols-1 gap-4 ${hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
<Card
{...CARD_PROPS}
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
<div className={FLEX_CENTER_GAP2}>
<PieChart size={16} />
{t('模型数据分析')}
</div>
<Tabs
type="button"
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
<TabPane tab={
<span>
<IconHistogram />
{t('消耗分布')}
</span>
} itemKey="1" />
<TabPane tab={
<span>
<IconPulse />
{t('消耗趋势')}
</span>
} itemKey="2" />
<TabPane tab={
<span>
<IconPieChart2Stroked />
{t('调用次数分布')}
</span>
} itemKey="3" />
<TabPane tab={
<span>
<IconHistogram />
{t('调用次数排行')}
</span>
} itemKey="4" />
</Tabs>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="h-96 p-2">
{activeChartTab === '1' && (
<VChart
spec={spec_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '2' && (
<VChart
spec={spec_model_line}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '3' && (
<VChart
spec={spec_pie}
option={CHART_CONFIG}
/>
)}
{activeChartTab === '4' && (
<VChart
spec={spec_rank_bar}
option={CHART_CONFIG}
/>
)}
</div>
</Card>
{hasApiInfoPanel && (
<Card
{...CARD_PROPS}
className="bg-gray-50 border-0 !rounded-2xl"
title={
<div className={FLEX_CENTER_GAP2}>
<Server size={16} />
{t('API信息')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="card-content-container">
<div
ref={apiScrollRef}
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
onScroll={handleApiScroll}
>
{apiInfoData.length > 0 ? (
apiInfoData.map((api) => (
<>
<div key={api.id} className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
<div className="flex-shrink-0 mr-3">
<Avatar
@@ -1321,18 +1360,32 @@ const Detail = (props) => {
</Avatar>
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 mb-1 !font-bold flex items-center gap-2">
<Tag
prefixIcon={<Gauge size={12} />}
size="small"
color="white"
shape='circle'
onClick={() => handleSpeedTest(api.url)}
className="cursor-pointer hover:opacity-80 text-xs"
>
{t('测速')}
</Tag>
{api.route}
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
{api.route}
</span>
<div className="flex items-center gap-1 mt-1 lg:mt-0">
<Tag
prefixIcon={<Gauge size={12} />}
size="small"
color="white"
shape='circle'
onClick={() => handleSpeedTest(api.url)}
className="cursor-pointer hover:opacity-80 text-xs"
>
{t('测速')}
</Tag>
<Tag
prefixIcon={<ExternalLink size={12} />}
size="small"
color="white"
shape='circle'
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
className="cursor-pointer hover:opacity-80 text-xs"
>
{t('跳转')}
</Tag>
</div>
</div>
<div
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
@@ -1345,30 +1398,33 @@ const Detail = (props) => {
</div>
</div>
</div>
))
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
/>
</div>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showApiScrollHint ? 1 : 0 }}
/>
<Divider />
</>
))
) : (
<div className="flex justify-center items-center py-8">
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
/>
</div>
)}
</div>
</Card>
)}
</div>
<div
className="card-content-fade-indicator"
style={{ opacity: showApiScrollHint ? 1 : 0 }}
/>
</div>
</Card>
)}
</div>
</div>
{/* 系统公告和常见问答卡片 */}
{hasInfoPanels && (
{/* 系统公告和常见问答卡片 */}
{
hasInfoPanels && (
<div className="mb-4">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
{/* 公告卡片 */}
@@ -1381,7 +1437,7 @@ const Detail = (props) => {
<div className="flex items-center gap-2">
<Bell size={16} />
{t('系统公告')}
<Tag size="small" color="grey" shape="circle">
<Tag color="white" shape="circle">
{t('显示最新20条')}
</Tag>
</div>
@@ -1405,6 +1461,7 @@ const Detail = (props) => {
</div>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className="card-content-container">
<div
@@ -1513,19 +1570,20 @@ const Detail = (props) => {
{uptimeEnabled && (
<Card
{...CARD_PROPS}
className="shadow-sm !rounded-2xl lg:col-span-1 flex flex-col"
className="shadow-sm !rounded-2xl lg:col-span-1"
title={
<div className="flex items-center justify-between w-full gap-2">
<div className="flex items-center gap-2">
<Gauge size={16} />
{t('服务可用性')}
</div>
<IconButton
<Button
icon={<IconRefresh />}
onClick={loadUptimeData}
loading={uptimeLoading}
size="small"
theme="borderless"
type='tertiary'
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
/>
</div>
@@ -1533,7 +1591,7 @@ const Detail = (props) => {
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className="flex-1 relative">
<div className="relative">
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
@@ -1613,9 +1671,9 @@ const Detail = (props) => {
</Spin>
</div>
{/* 固定在底部的图例 */}
{/* 图例 */}
{uptimeData.length > 0 && (
<div className="p-3 mt-auto bg-gray-50 rounded-b-2xl">
<div className="p-3 bg-gray-50 rounded-b-2xl">
<div className="flex flex-wrap gap-3 text-xs justify-center">
{uptimeLegendData.map((legend, index) => (
<div key={index} className="flex items-center gap-1">
@@ -1633,9 +1691,9 @@ const Detail = (props) => {
)}
</div>
</div>
)}
</Spin>
</div>
)
}
</div >
);
};

View File

@@ -1,6 +1,7 @@
import React, { useContext, useEffect, useState } from 'react';
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
import { API, showError, copy, showSuccess } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import { API_ENDPOINTS } from '../../constants/common.constant';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
@@ -18,6 +19,7 @@ const Home = () => {
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const [noticeVisible, setNoticeVisible] = useState(false);
const isMobile = useIsMobile();
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
const serverAddress = statusState?.status?.server_address || window.location.origin;
@@ -98,7 +100,7 @@ const Home = () => {
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={isMobile()}
isMobile={isMobile}
/>
{homePageContentLoaded && homePageContent === '' ? (
<div className="w-full overflow-x-hidden">
@@ -133,7 +135,7 @@ const Home = () => {
readonly
value={serverAddress}
className="flex-1 !rounded-full"
size={isMobile() ? 'default' : 'large'}
size={isMobile ? 'default' : 'large'}
suffix={
<div className="flex items-center gap-2">
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
@@ -160,13 +162,13 @@ const Home = () => {
{/* 操作按钮 */}
<div className="flex flex-row gap-4 justify-center items-center">
<Link to="/console">
<Button theme="solid" type="primary" size={isMobile() ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
<Button theme="solid" type="primary" size={isMobile ? "default" : "large"} className="!rounded-3xl px-8 py-2" icon={<IconPlay />}>
{t('获取密钥')}
</Button>
</Link>
{isDemoSiteMode && statusState?.status?.version ? (
<Button
size={isMobile() ? "default" : "large"}
size={isMobile ? "default" : "large"}
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconGithubLogo />}
onClick={() => window.open('https://github.com/QuantumNous/new-api', '_blank')}
@@ -176,7 +178,7 @@ const Home = () => {
) : (
docsLink && (
<Button
size={isMobile() ? "default" : "large"}
size={isMobile ? "default" : "large"}
className="flex items-center !rounded-3xl px-6 py-2"
icon={<IconFile />}
onClick={() => window.open(docsLink, '_blank')}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import LogsTable from '../../components/table/LogsTable';
const Token = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<LogsTable />
</div>
);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import MjLogsTable from '../../components/table/MjLogsTable';
const Midjourney = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<MjLogsTable />
</div>
);

View File

@@ -5,7 +5,7 @@ import { Layout, Toast, Modal } from '@douyinfe/semi-ui';
// Context
import { UserContext } from '../../context/User/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
import { useIsMobile } from '../../hooks/useIsMobile.js';
// hooks
import { usePlaygroundState } from '../../hooks/usePlaygroundState.js';
@@ -59,7 +59,8 @@ const generateAvatarDataUrl = (username) => {
const Playground = () => {
const { t } = useTranslation();
const [userState] = useContext(UserContext);
const { state: styleState, dispatch: styleDispatch } = useStyle();
const isMobile = useIsMobile();
const styleState = { isMobile };
const [searchParams] = useSearchParams();
const state = usePlaygroundState();
@@ -321,19 +322,7 @@ const Playground = () => {
}
}, [searchParams, t]);
// 处理窗口大小变化
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 768;
if (styleState.isMobile !== mobile) {
styleDispatch(styleActions.setMobile(mobile));
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [styleState.isMobile, styleDispatch]);
// Playground 组件无需再监听窗口变化isMobile 由 useIsMobile Hook 自动更新
// 构建预览payload
useEffect(() => {
@@ -365,26 +354,26 @@ const Playground = () => {
return (
<div className="h-full bg-gray-50 mt-[64px]">
<Layout style={{ height: '100%', background: 'transparent' }} className="flex flex-col md:flex-row">
{(showSettings || !styleState.isMobile) && (
{(showSettings || !isMobile) && (
<Layout.Sider
style={{
background: 'transparent',
borderRight: 'none',
flexShrink: 0,
minWidth: styleState.isMobile ? '100%' : 320,
maxWidth: styleState.isMobile ? '100%' : 320,
height: styleState.isMobile ? 'auto' : 'calc(100vh - 66px)',
minWidth: isMobile ? '100%' : 320,
maxWidth: isMobile ? '100%' : 320,
height: isMobile ? 'auto' : 'calc(100vh - 66px)',
overflow: 'auto',
position: styleState.isMobile ? 'fixed' : 'relative',
zIndex: styleState.isMobile ? 1000 : 1,
position: isMobile ? 'fixed' : 'relative',
zIndex: isMobile ? 1000 : 1,
width: '100%',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
width={styleState.isMobile ? '100%' : 320}
className={styleState.isMobile ? 'bg-white shadow-lg' : ''}
width={isMobile ? '100%' : 320}
className={isMobile ? 'bg-white shadow-lg' : ''}
>
<OptimizedSettingsPanel
inputs={inputs}
@@ -432,7 +421,7 @@ const Playground = () => {
</div>
{/* 调试面板 - 桌面端 */}
{showDebugPanel && !styleState.isMobile && (
{showDebugPanel && !isMobile && (
<div className="w-96 flex-shrink-0 h-full">
<OptimizedDebugPanel
debugData={debugData}
@@ -446,7 +435,7 @@ const Playground = () => {
</div>
{/* 调试面板 - 移动端覆盖层 */}
{showDebugPanel && styleState.isMobile && (
{showDebugPanel && isMobile && (
<div
style={{
position: 'fixed',

View File

@@ -2,7 +2,7 @@ import React from 'react';
import ModelPricing from '../../components/table/ModelPricing.js';
const Pricing = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<ModelPricing />
</div>
);

View File

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
import {
API,
downloadTextAsFile,
isMobile,
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
} from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Button,
Modal,
@@ -36,6 +36,7 @@ const EditRedemption = (props) => {
const { t } = useTranslation();
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const getInitValues = () => ({
@@ -155,7 +156,7 @@ const EditRedemption = (props) => {
}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
width={isMobile ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<Space>

View File

@@ -3,7 +3,7 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
const Redemption = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<RedemptionsTable />
</div>
);

View File

@@ -65,6 +65,7 @@ export default function SettingsGeneralPayment(props) {
label={t('服务器地址')}
placeholder={'https://yourdomain.com'}
style={{ width: '100%' }}
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
/>
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
</Form.Section>

View File

@@ -18,7 +18,8 @@ import {
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
import { API, showError, showSuccess, showWarning, stringToColor } from '../../../helpers';
import { useIsMobile } from '../../../hooks/useIsMobile.js';
import { DEFAULT_ENDPOINT } from '../../../constants';
import { useTranslation } from 'react-i18next';
import {
@@ -28,6 +29,7 @@ import {
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
const isMobile = useIsMobile();
const columns = [
{ title: t('渠道'), dataIndex: 'channel' },
{ title: t('模型'), dataIndex: 'model' },
@@ -49,7 +51,7 @@ function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
visible={visible}
onCancel={onCancel}
onOk={onOk}
size={isMobile() ? 'full-width' : 'large'}
size={isMobile ? 'full-width' : 'large'}
>
<Table columns={columns} dataSource={items} pagination={false} size="small" />
</Modal>
@@ -61,6 +63,7 @@ export default function UpstreamRatioSync(props) {
const [modalVisible, setModalVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const isMobile = useIsMobile();
// 渠道选择相关
const [allChannels, setAllChannels] = useState([]);

View File

@@ -150,7 +150,7 @@ const Setting = () => {
}
}, [location.search]);
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<Layout>
<Layout.Content>
<Tabs

View File

@@ -183,7 +183,7 @@ const Setup = () => {
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库警告')}</span>
<Tag color='orange' size='small' className="ml-2 !rounded-full">
<Tag color='orange' shape='circle' className="ml-2">
SQLite
</Tag>
</div>
@@ -222,7 +222,7 @@ const Setup = () => {
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库信息')}</span>
<Tag color='blue' size='small' className="ml-2 !rounded-full">
<Tag color='blue' shape='circle' className="ml-2">
MySQL
</Tag>
</div>
@@ -256,7 +256,7 @@ const Setup = () => {
title={
<div className="flex items-center">
<span className="font-medium">{t('数据库信息')}</span>
<Tag color='green' size='small' className="ml-2 !rounded-full">
<Tag color='green' shape='circle' className="ml-2">
PostgreSQL
</Tag>
</div>
@@ -425,7 +425,7 @@ const Setup = () => {
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('对外运营模式')}</div>
<div className="text-sm text-gray-500">{t('适用于为多个用户提供服务的场景')}</div>
<Tag color='blue' size='small' className="!rounded-full mt-2">
<Tag color='blue' shape='circle' className="mt-2">
{t('默认模式')}
</Tag>
</div>
@@ -443,7 +443,7 @@ const Setup = () => {
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('自用模式')}</div>
<div className="text-sm text-gray-500">{t('适用于个人使用的场景,不需要设置模型价格')}</div>
<Tag color='green' size='small' className="!rounded-full mt-2">
<Tag color='green' shape='circle' className="mt-2">
{t('无需计费')}
</Tag>
</div>
@@ -461,7 +461,7 @@ const Setup = () => {
<div className="flex-1">
<div className="font-medium text-gray-900 mb-1">{t('演示站点模式')}</div>
<div className="text-sm text-gray-500">{t('适用于展示系统功能的场景,提供基础功能演示')}</div>
<Tag color='purple' size='small' className="!rounded-full mt-2">
<Tag color='purple' shape='circle' className="mt-2">
{t('演示体验')}
</Tag>
</div>
@@ -522,8 +522,8 @@ const Setup = () => {
<p>{t('默认模式,适用于为多个用户提供服务的场景。')}</p>
<p>{t('此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。')}</p>
<div className="mt-3">
<Tag color='blue' className="!rounded-full mr-2">{t('计费模式')}</Tag>
<Tag color='blue' className="!rounded-full">{t('多用户支持')}</Tag>
<Tag color='blue' shape='circle' className="mr-2">{t('计费模式')}</Tag>
<Tag color='blue' shape='circle'>{t('多用户支持')}</Tag>
</div>
</div>
</div>
@@ -542,8 +542,8 @@ const Setup = () => {
<p>{t('适用于个人使用的场景。')}</p>
<p>{t('不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。')}</p>
<div className="mt-3">
<Tag color='green' className="!rounded-full mr-2">{t('无需计费')}</Tag>
<Tag color='green' className="!rounded-full">{t('个人使用')}</Tag>
<Tag color='green' shape='circle' className="mr-2">{t('无需计费')}</Tag>
<Tag color='green' shape='circle'>{t('个人使用')}</Tag>
</div>
</div>
</div>
@@ -562,8 +562,8 @@ const Setup = () => {
<p>{t('适用于展示系统功能的场景。')}</p>
<p>{t('提供基础功能演示,方便用户了解系统特性。')}</p>
<div className="mt-3">
<Tag color='purple' className="!rounded-full mr-2">{t('功能演示')}</Tag>
<Tag color='purple' className="!rounded-full">{t('体验试用')}</Tag>
<Tag color='purple' shape='circle' className="mr-2">{t('功能演示')}</Tag>
<Tag color='purple' shape='circle'>{t('体验试用')}</Tag>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
const Task = () => (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<TaskLogsTable />
</div>
);

View File

@@ -1,13 +1,14 @@
import React, { useEffect, useState, useContext, useRef } from 'react';
import {
API,
isMobile,
showError,
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt,
getModelCategories,
} from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Button,
SideSheet,
@@ -37,6 +38,7 @@ const EditToken = (props) => {
const { t } = useTranslation();
const [statusState, statusDispatch] = useContext(StatusContext);
const [loading, setLoading] = useState(false);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
@@ -78,10 +80,25 @@ const EditToken = (props) => {
let res = await API.get(`/api/user/models`);
const { success, message, data } = res.data;
if (success) {
let localModelOptions = data.map((model) => ({
label: model,
value: model,
}));
const categories = getModelCategories(t);
let localModelOptions = data.map((model) => {
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
icon = category.icon;
break;
}
}
return {
label: (
<span className="flex items-center gap-1">
{icon}
{model}
</span>
),
value: model,
};
});
setModels(localModelOptions);
} else {
showError(t(message));
@@ -261,7 +278,7 @@ const EditToken = (props) => {
}
bodyStyle={{ padding: '0' }}
visible={props.visiable}
width={isMobile() ? '100%' : 600}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>
@@ -345,7 +362,23 @@ const EditToken = (props) => {
label={t('过期时间')}
type='dateTime'
placeholder={t('请选择过期时间')}
rules={[{ required: true, message: t('请选择过期时间') }]}
rules={[
{ required: true, message: t('请选择过期时间') },
{
validator: (rule, value) => {
// 允许 -1 表示永不过期,也允许空值在必填校验时被拦截
if (value === -1 || !value) return Promise.resolve();
const time = Date.parse(value);
if (isNaN(time)) {
return Promise.reject(t('过期时间格式错误!'));
}
if (time <= Date.now()) {
return Promise.reject(t('过期时间不能早于当前时间!'));
}
return Promise.resolve();
},
},
]}
showClear
style={{ width: '100%' }}
/>
@@ -453,6 +486,20 @@ const EditToken = (props) => {
</div>
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
multiple
optionList={models}
extraText={t('非必要,不建议启用模型限制')}
filter
searchPosition='dropdown'
showClear
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.TextArea
field='allow_ips'
@@ -465,19 +512,6 @@ const EditToken = (props) => {
style={{ width: '100%' }}
/>
</Col>
<Col span={24}>
<Form.Select
field='model_limits'
label={t('模型限制列表')}
placeholder={t('请选择该令牌支持的模型,留空支持所有模型')}
multiple
optionList={models}
maxTagCount={3}
extraText={t('非必要,不建议启用模型限制')}
showClear
style={{ width: '100%' }}
/>
</Col>
</Row>
</Card>
</div>

View File

@@ -3,7 +3,7 @@ import TokensTable from '../../components/table/TokensTable';
const Token = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<TokensTable />
</div>
);

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef } from 'react';
import { API, isMobile, showError, showSuccess } from '../../helpers';
import { API, showError, showSuccess } from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Button,
SideSheet,
@@ -26,6 +27,7 @@ const AddUser = (props) => {
const { t } = useTranslation();
const formApiRef = useRef(null);
const [loading, setLoading] = useState(false);
const isMobile = useIsMobile();
const getInitValues = () => ({
username: '',
@@ -67,7 +69,7 @@ const AddUser = (props) => {
}
bodyStyle={{ padding: '0' }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
width={isMobile ? '100%' : 600}
footer={
<div className="flex justify-end bg-white">
<Space>

View File

@@ -2,12 +2,12 @@ import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
isMobile,
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
} from '../../helpers';
import { useIsMobile } from '../../hooks/useIsMobile.js';
import {
Button,
Modal,
@@ -41,6 +41,7 @@ const EditUser = (props) => {
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const isMobile = useIsMobile();
const [groupOptions, setGroupOptions] = useState([]);
const formApiRef = useRef(null);
@@ -137,7 +138,7 @@ const EditUser = (props) => {
}
bodyStyle={{ padding: 0 }}
visible={props.visible}
width={isMobile() ? '100%' : 600}
width={isMobile ? '100%' : 600}
footer={
<div className='flex justify-end bg-white'>
<Space>

View File

@@ -3,7 +3,7 @@ import UsersTable from '../../components/table/UsersTable';
const User = () => {
return (
<div className="mt-[64px]">
<div className="mt-[64px] px-2">
<UsersTable />
</div>
);