Merge branch 'alpha' into mutil_key_channel
# Conflicts: # controller/channel.go # docker-compose.yml # web/src/components/table/ChannelsTable.js # web/src/pages/Channel/EditChannel.js
This commit is contained in:
@@ -34,20 +34,20 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password } = inputs;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const { username, password } = inputs;
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
let navigate = useNavigate();
|
||||
const [status, setStatus] = useState({});
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
@@ -59,7 +59,6 @@ const LoginForm = () => {
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -69,19 +68,22 @@ const LoginForm = () => {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const [status] = useState(() => {
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
return savedStatus ? JSON.parse(savedStatus) : {};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
@@ -356,9 +358,19 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -387,7 +399,6 @@ const LoginForm = () => {
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name="username"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
@@ -399,7 +410,6 @@ const LoginForm = () => {
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
@@ -451,9 +461,19 @@ const LoginForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -499,8 +519,11 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -78,8 +78,11 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
@@ -110,7 +113,6 @@ const PasswordResetConfirm = () => {
|
||||
label={t('邮箱')}
|
||||
name="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
@@ -122,7 +124,6 @@ const PasswordResetConfirm = () => {
|
||||
label={t('新密码')}
|
||||
name="newPassword"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
suffix={
|
||||
|
||||
@@ -78,8 +78,11 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
@@ -99,7 +102,6 @@ const PasswordResetForm = () => {
|
||||
placeholder={t('请输入您的邮箱地址')}
|
||||
name="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
prefix={<IconMail />}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { UserContext } from '../../context/User/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const RegisterForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
@@ -45,15 +46,12 @@ const RegisterForm = () => {
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailRegister, setShowEmailRegister] = useState(false);
|
||||
const [status, setStatus] = useState({});
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
@@ -63,7 +61,6 @@ const RegisterForm = () => {
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -73,18 +70,22 @@ const RegisterForm = () => {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
const [status] = useState(() => {
|
||||
const savedStatus = localStorage.getItem('status');
|
||||
return savedStatus ? JSON.parse(savedStatus) : {};
|
||||
});
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(() => {
|
||||
return status.email_verification ?? false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
setShowEmailVerification(status.email_verification);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
setShowEmailVerification(status.email_verification);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}, []);
|
||||
}, [status]);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
@@ -393,7 +394,6 @@ const RegisterForm = () => {
|
||||
placeholder={t('请输入用户名')}
|
||||
name="username"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
@@ -405,7 +405,6 @@ const RegisterForm = () => {
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
@@ -417,7 +416,6 @@ const RegisterForm = () => {
|
||||
name="password2"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password2', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
@@ -431,7 +429,6 @@ const RegisterForm = () => {
|
||||
name="email"
|
||||
type="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
prefix={<IconMail />}
|
||||
suffix={
|
||||
@@ -439,7 +436,6 @@ const RegisterForm = () => {
|
||||
onClick={sendVerificationCode}
|
||||
loading={verificationCodeLoading}
|
||||
size="small"
|
||||
className="!rounded-md mr-2"
|
||||
>
|
||||
{t('获取验证码')}
|
||||
</Button>
|
||||
@@ -451,7 +447,6 @@ const RegisterForm = () => {
|
||||
placeholder={t('输入验证码')}
|
||||
name="verification_code"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('verification_code', value)}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
@@ -541,8 +536,11 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tag,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
@@ -43,6 +44,7 @@ const HeaderBar = () => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
@@ -53,9 +55,44 @@ const HeaderBar = () => {
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
|
||||
|
||||
const calculateUnreadCount = () => {
|
||||
if (!announcements.length) return 0;
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
|
||||
};
|
||||
|
||||
const getUnreadKeys = () => {
|
||||
if (!announcements.length) return [];
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadCount(calculateUnreadCount());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [announcements]);
|
||||
|
||||
const mainNavLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
@@ -106,6 +143,25 @@ const HeaderBar = () => {
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleNoticeOpen = () => {
|
||||
setNoticeVisible(true);
|
||||
};
|
||||
|
||||
const handleNoticeClose = () => {
|
||||
setNoticeVisible(false);
|
||||
if (announcements.length) {
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
|
||||
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
|
||||
}
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.body.setAttribute('theme-mode', 'dark');
|
||||
@@ -353,15 +409,14 @@ const HeaderBar = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前路由是否以/console开头
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
onClose={handleNoticeClose}
|
||||
isMobile={styleState.isMobile}
|
||||
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
||||
unreadKeys={getUnreadKeys()}
|
||||
/>
|
||||
<div className="w-full px-2">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
@@ -462,14 +517,27 @@ const HeaderBar = () => {
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={() => setNoticeVisible(true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
{unreadCount > 0 ? (
|
||||
<Badge count={unreadCount} type="danger" overflowCount={99}>
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { API, showError, getRelativeTime } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { Bell, Megaphone } from 'lucide-react';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||
|
||||
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
|
||||
const processedAnnouncements = useMemo(() => {
|
||||
return (announcements || []).slice(0, 20).map(item => ({
|
||||
key: getKeyForItem(item),
|
||||
type: item.type || 'default',
|
||||
time: getRelativeTime(item.publishDate),
|
||||
content: item.content,
|
||||
extra: item.extra,
|
||||
isUnread: unreadSet.has(getKeyForItem(item))
|
||||
}));
|
||||
}, [announcements, unreadSet]);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
@@ -44,7 +66,13 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const renderContent = () => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setActiveTab(defaultTab);
|
||||
}
|
||||
}, [defaultTab, visible]);
|
||||
|
||||
const renderMarkdownNotice = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
}
|
||||
@@ -64,25 +92,91 @@ const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="notice-content-scroll max-h-[60vh] overflow-y-auto pr-2"
|
||||
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAnnouncementTimeline = () => {
|
||||
if (processedAnnouncements.length === 0) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无系统公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
|
||||
<Timeline mode="alternate">
|
||||
{processedAnnouncements.map((item, idx) => {
|
||||
const htmlContent = marked.parse(item.content || '');
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type}
|
||||
time={item.time}
|
||||
className={item.isUnread ? '' : ''}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={item.isUnread ? 'shine-text' : ''}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
{item.extra && (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (activeTab === 'inApp') {
|
||||
return renderMarkdownNotice();
|
||||
}
|
||||
return renderAnnouncementTimeline();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('系统公告')}
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{t('系统公告')}</span>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type='card'
|
||||
size='small'
|
||||
>
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
|
||||
<Button type='secondary' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" onClick={onClose}>{t('关闭公告')}</Button>
|
||||
</div>
|
||||
)}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderContent()}
|
||||
{renderBody()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { API, getLogo, getSystemName, showError, setStatusData } from '../../hel
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
const PageLayout = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
@@ -94,8 +94,6 @@ const PageLayout = () => {
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
marginTop: '64px',
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: styleState.isMobile ? 'visible' : 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
235
web/src/components/settings/ChannelSelectorModal.js
Normal file
235
web/src/components/settings/ChannelSelectorModal.js
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { isMobile } from '../../helpers';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Input,
|
||||
Space,
|
||||
Highlight,
|
||||
Select,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
const ChannelSelectorModal = forwardRef(({
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
t,
|
||||
}, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
setFilteredData(matched);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const total = filteredData.length;
|
||||
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder="/your/endpoint"
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag size='large' color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey="key"
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: total,
|
||||
}),
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChannelSelectorModal;
|
||||
63
web/src/components/settings/ChatsSetting.js
Normal file
63
web/src/components/settings/ChatsSetting.js
Normal file
@@ -0,0 +1,63 @@
|
||||
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';
|
||||
|
||||
const ChatsSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
/* 聊天设置 */
|
||||
Chats: '[]',
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key.endsWith('Enabled') ||
|
||||
['DefaultCollapseSidebar'].includes(item.key)
|
||||
) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 聊天设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsChats options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsSetting;
|
||||
@@ -5,6 +5,7 @@ import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
|
||||
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
|
||||
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
|
||||
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard.js';
|
||||
|
||||
const DashboardSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
@@ -23,6 +24,11 @@ const DashboardSetting = () => {
|
||||
FAQ: '',
|
||||
UptimeKumaUrl: '',
|
||||
UptimeKumaSlug: '',
|
||||
|
||||
/* 数据看板 */
|
||||
DataExportEnabled: false,
|
||||
DataExportDefaultTime: 'hour',
|
||||
DataExportInterval: 5,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -37,6 +43,10 @@ const DashboardSetting = () => {
|
||||
if (item.key in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
if (item.key.endsWith('Enabled') &&
|
||||
(item.key === 'DataExportEnabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
}
|
||||
});
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
@@ -106,9 +116,9 @@ const DashboardSetting = () => {
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* API信息管理 */}
|
||||
{/* 数据看板设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
|
||||
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 系统公告管理 */}
|
||||
@@ -116,6 +126,11 @@ const DashboardSetting = () => {
|
||||
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* API信息管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
|
||||
{/* 常见问答管理 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsFAQ options={inputs} refresh={onRefresh} />
|
||||
|
||||
65
web/src/components/settings/DrawingSetting.js
Normal file
65
web/src/components/settings/DrawingSetting.js
Normal file
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
|
||||
const DrawingSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
/* 绘图设置 */
|
||||
DrawingEnabled: false,
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 绘图设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingSetting;
|
||||
@@ -1,66 +1,44 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
|
||||
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
|
||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
|
||||
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
|
||||
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
|
||||
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
|
||||
const OperationSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
/* 额度相关 */
|
||||
QuotaForNewUser: 0,
|
||||
PreConsumedQuota: 0,
|
||||
QuotaForInviter: 0,
|
||||
QuotaForInvitee: 0,
|
||||
QuotaRemindThreshold: 0,
|
||||
PreConsumedQuota: 0,
|
||||
StreamCacheQueueLength: 0,
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ModelPrice: '',
|
||||
GroupRatio: '',
|
||||
GroupGroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
|
||||
/* 通用设置 */
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
// ChatLink2: '', // 添加的新状态变量
|
||||
QuotaPerUnit: 0,
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
ChannelDisableThreshold: 0,
|
||||
LogConsumeEnabled: false,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: false,
|
||||
DisplayTokenStatEnabled: false,
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
CheckSensitiveOnCompletionEnabled: '',
|
||||
StopOnSensitiveEnabled: '',
|
||||
SensitiveWords: '',
|
||||
MjNotifyEnabled: false,
|
||||
MjAccountFilterEnabled: false,
|
||||
MjModeClearEnabled: false,
|
||||
MjForwardUrlEnabled: false,
|
||||
MjActionCheckSuccessEnabled: false,
|
||||
DrawingEnabled: false,
|
||||
DataExportEnabled: false,
|
||||
DataExportDefaultTime: 'hour',
|
||||
DataExportInterval: 5,
|
||||
DefaultCollapseSidebar: false, // 默认折叠侧边栏
|
||||
RetryTimes: 0,
|
||||
Chats: '[]',
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
|
||||
/* 敏感词设置 */
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
SensitiveWords: '',
|
||||
|
||||
/* 日志设置 */
|
||||
LogConsumeEnabled: false,
|
||||
|
||||
/* 监控设置 */
|
||||
ChannelDisableThreshold: 0,
|
||||
QuotaRemindThreshold: 0,
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
});
|
||||
|
||||
@@ -72,17 +50,6 @@ const OperationSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key === 'ModelRatio' ||
|
||||
item.key === 'GroupRatio' ||
|
||||
item.key === 'GroupGroupRatio' ||
|
||||
item.key === 'UserUsableGroups' ||
|
||||
item.key === 'CompletionRatio' ||
|
||||
item.key === 'ModelPrice' ||
|
||||
item.key === 'CacheRatio'
|
||||
) {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
if (
|
||||
item.key.endsWith('Enabled') ||
|
||||
['DefaultCollapseSidebar'].includes(item.key)
|
||||
@@ -121,10 +88,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneral options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 绘图设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 屏蔽词过滤设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
|
||||
@@ -133,10 +96,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsLog options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 数据看板 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 监控设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsMonitoring options={inputs} refresh={onRefresh} />
|
||||
@@ -145,28 +104,6 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsCreditLimit options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 聊天设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsChats options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 分组倍率设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 合并模型倍率设置和可视化倍率设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='line'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
|
||||
88
web/src/components/settings/PaymentSetting.js
Normal file
88
web/src/components/settings/PaymentSetting.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
|
||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PaymentSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
ServerAddress: '',
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
switch (item.key) {
|
||||
case 'TopupGroupRatio':
|
||||
try {
|
||||
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (error) {
|
||||
console.error('解析TopupGroupRatio出错:', error);
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
newInputs[item.key] = parseFloat(item.value);
|
||||
break;
|
||||
default:
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
};
|
||||
|
||||
async function onRefresh() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError(t('刷新失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSetting;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, showSuccess } from '../../helpers/index.js';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { API, showError } from '../../helpers/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||
|
||||
@@ -24,14 +23,14 @@ const RateLimitSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (item.key === 'ModelRequestRateLimitGroup') {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
if (item.key === 'ModelRequestRateLimitGroup') {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
}
|
||||
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
122
web/src/components/settings/RatioSetting.js
Normal file
122
web/src/components/settings/RatioSetting.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js';
|
||||
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
|
||||
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js';
|
||||
|
||||
import { API, showError } from '../../helpers';
|
||||
|
||||
const RatioSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let [inputs, setInputs] = useState({
|
||||
ModelPrice: '',
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
GroupRatio: '',
|
||||
GroupGroupRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
ExposeRatioEnabled: false,
|
||||
UserUsableGroups: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key === 'ModelRatio' ||
|
||||
item.key === 'GroupRatio' ||
|
||||
item.key === 'GroupGroupRatio' ||
|
||||
item.key === 'AutoGroups' ||
|
||||
item.key === 'UserUsableGroups' ||
|
||||
item.key === 'CompletionRatio' ||
|
||||
item.key === 'ModelPrice' ||
|
||||
item.key === 'CacheRatio'
|
||||
) {
|
||||
try {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (e) {
|
||||
// 如果后端返回的不是合法 JSON,直接展示
|
||||
}
|
||||
}
|
||||
if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) {
|
||||
newInputs[item.key] = item.value === 'true' ? true : false;
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
setInputs(newInputs);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await getOptions();
|
||||
} catch (error) {
|
||||
showError('刷新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及可视化编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<GroupRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
|
||||
<UpstreamRatioSync
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatioSetting;
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON
|
||||
} from '../../helpers';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -42,17 +41,9 @@ const SystemSetting = () => {
|
||||
SMTPAccount: '',
|
||||
SMTPFrom: '',
|
||||
SMTPToken: '',
|
||||
ServerAddress: '',
|
||||
WorkerUrl: '',
|
||||
WorkerValidKey: '',
|
||||
WorkerAllowHttpImageRequestEnabled: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
PayAddress: '',
|
||||
CustomCallbackAddress: '',
|
||||
Footer: '',
|
||||
WeChatAuthEnabled: '',
|
||||
WeChatServerAddress: '',
|
||||
@@ -199,11 +190,6 @@ const SystemSetting = () => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitServerAddress = async () => {
|
||||
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
|
||||
await updateOptions([{ key: 'ServerAddress', value: ServerAddress }]);
|
||||
};
|
||||
|
||||
const submitWorker = async () => {
|
||||
let WorkerUrl = removeTrailingSlash(inputs.WorkerUrl);
|
||||
const options = [
|
||||
@@ -219,47 +205,6 @@ const SystemSetting = () => {
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const submitPayAddress = async () => {
|
||||
if (inputs.ServerAddress === '') {
|
||||
showError('请先填写服务器地址');
|
||||
return;
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
if (!verifyJSON(inputs.TopupGroupRatio)) {
|
||||
showError('充值分组倍率不是合法的 JSON 字符串');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
|
||||
];
|
||||
|
||||
if (inputs.EpayId !== '') {
|
||||
options.push({ key: 'EpayId', value: inputs.EpayId });
|
||||
}
|
||||
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
|
||||
options.push({ key: 'EpayKey', value: inputs.EpayKey });
|
||||
}
|
||||
if (inputs.Price !== '') {
|
||||
options.push({ key: 'Price', value: inputs.Price.toString() });
|
||||
}
|
||||
if (inputs.MinTopUp !== '') {
|
||||
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
|
||||
}
|
||||
if (inputs.CustomCallbackAddress !== '') {
|
||||
options.push({
|
||||
key: 'CustomCallbackAddress',
|
||||
value: inputs.CustomCallbackAddress,
|
||||
});
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
|
||||
}
|
||||
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const submitSMTP = async () => {
|
||||
const options = [];
|
||||
|
||||
@@ -541,17 +486,6 @@ const SystemSetting = () => {
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<Form.Section text='通用设置'>
|
||||
<Form.Input
|
||||
field='ServerAddress'
|
||||
label='服务器地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={submitServerAddress}>更新服务器地址</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
<Card>
|
||||
<Form.Section text='代理设置'>
|
||||
<Text>
|
||||
@@ -594,74 +528,6 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text='支付设置'>
|
||||
<Text>
|
||||
(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)
|
||||
</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='PayAddress'
|
||||
label='支付地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayId'
|
||||
label='易支付商户ID'
|
||||
placeholder='例如:0001'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayKey'
|
||||
label='易支付商户密钥'
|
||||
placeholder='敏感信息不会发送到前端显示'
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CustomCallbackAddress'
|
||||
label='回调地址'
|
||||
placeholder='例如:https://yourdomain.com'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='Price'
|
||||
precision={2}
|
||||
label='充值价格(x元/美金)'
|
||||
placeholder='例如:7,就是7元/美金'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='MinTopUp'
|
||||
label='最低充值美元数量'
|
||||
placeholder='例如:2,就是最低充值2$'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.TextArea
|
||||
field='TopupGroupRatio'
|
||||
label='充值分组倍率'
|
||||
placeholder='为一个 JSON 文本,键为组名称,值为倍率'
|
||||
autosize
|
||||
/>
|
||||
<Button onClick={submitPayAddress}>更新支付设置</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text='配置登录注册'>
|
||||
<Row
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,8 +47,9 @@ import {
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||
import { IconSetting, IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { IconSearch, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Route } from 'lucide-react';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -192,7 +193,7 @@ const LogsTable = () => {
|
||||
if (!modelMapped) {
|
||||
return renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -209,7 +210,7 @@ const LogsTable = () => {
|
||||
</Text>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
@@ -220,7 +221,7 @@ const LogsTable = () => {
|
||||
{renderModelTag(other.upstream_model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => {},
|
||||
(r) => { },
|
||||
);
|
||||
},
|
||||
})}
|
||||
@@ -231,7 +232,7 @@ const LogsTable = () => {
|
||||
>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
suffixIcon: (
|
||||
<Route
|
||||
@@ -636,23 +637,23 @@ const LogsTable = () => {
|
||||
}
|
||||
let content = other?.claude
|
||||
? renderClaudeModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
return (
|
||||
<Paragraph
|
||||
ellipsis={{
|
||||
@@ -695,21 +696,18 @@ const LogsTable = () => {
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => initDefaultColumns()}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
@@ -985,27 +983,27 @@ const LogsTable = () => {
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
@@ -1145,7 +1143,7 @@ const LogsTable = () => {
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize).then((r) => {}); // 不传入logType,让其从表单获取最新值
|
||||
loadLogs(page, pageSize).then((r) => { }); // 不传入logType,让其从表单获取最新值
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
@@ -1203,6 +1201,8 @@ const LogsTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('logs');
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderColumnSelector()}
|
||||
@@ -1211,45 +1211,56 @@ const LogsTable = () => {
|
||||
title={
|
||||
<div className='flex flex-col w-full'>
|
||||
<Spin spinning={loadingStat}>
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Divider margin='12px' />
|
||||
@@ -1284,7 +1295,6 @@ const LogsTable = () => {
|
||||
field='token_name'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('令牌名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -1293,7 +1303,6 @@ const LogsTable = () => {
|
||||
field='model_name'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -1302,7 +1311,6 @@ const LogsTable = () => {
|
||||
field='group'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('分组')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -1313,7 +1321,6 @@ const LogsTable = () => {
|
||||
field='channel'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -1321,7 +1328,6 @@ const LogsTable = () => {
|
||||
field='username'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('用户名称')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -1336,7 +1342,7 @@ const LogsTable = () => {
|
||||
<Form.Select
|
||||
field='logType'
|
||||
placeholder={t('日志类型')}
|
||||
className='!rounded-full w-full sm:w-auto min-w-[120px]'
|
||||
className='w-full sm:w-auto min-w-[120px]'
|
||||
showClear
|
||||
pure
|
||||
onChange={() => {
|
||||
@@ -1372,7 +1378,6 @@ const LogsTable = () => {
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -1382,22 +1387,18 @@ const LogsTable = () => {
|
||||
if (formApi) {
|
||||
formApi.reset();
|
||||
setLogType(0);
|
||||
// 重置后立即查询,使用setTimeout确保表单重置完成
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
@@ -1411,7 +1412,7 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
{...(hasExpandableRows() && {
|
||||
expandedRowRender: expandRowRender,
|
||||
expandRowByClick: true,
|
||||
@@ -1421,7 +1422,7 @@ const LogsTable = () => {
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
empty={
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
XCircle,
|
||||
Loader,
|
||||
AlertCircle,
|
||||
Hash
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
@@ -59,8 +59,8 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -107,6 +107,7 @@ const LogsTable = () => {
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const isAdminUser = isAdmin();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('mjLogs');
|
||||
|
||||
// 加载保存的列偏好设置
|
||||
useEffect(() => {
|
||||
@@ -194,6 +195,18 @@ const LogsTable = () => {
|
||||
{t('放大')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VIDEO':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
{t('视频')}
|
||||
</Tag>
|
||||
);
|
||||
case 'EDITS':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
{t('编辑')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Shuffle size={14} />}>
|
||||
@@ -514,7 +527,6 @@ const LogsTable = () => {
|
||||
setModalImageUrl(text);
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查看图片')}
|
||||
</Button>
|
||||
@@ -732,21 +744,18 @@ const LogsTable = () => {
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => initDefaultColumns()}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
@@ -802,7 +811,7 @@ const LogsTable = () => {
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<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 ? (
|
||||
@@ -821,6 +830,14 @@ const LogsTable = () => {
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
@@ -855,7 +872,6 @@ const LogsTable = () => {
|
||||
field='mj_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -866,7 +882,6 @@ const LogsTable = () => {
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -881,7 +896,6 @@ const LogsTable = () => {
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -896,16 +910,13 @@ const LogsTable = () => {
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
@@ -919,11 +930,11 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
|
||||
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag, stringToColor } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Card,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Dropdown,
|
||||
Empty
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -107,6 +106,26 @@ const ModelPricing = () => {
|
||||
) : null;
|
||||
}
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag
|
||||
key={endpoint}
|
||||
color={stringToColor(endpoint)}
|
||||
size='large'
|
||||
shape='circle'
|
||||
>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('可用性'),
|
||||
@@ -121,6 +140,13 @@ const ModelPricing = () => {
|
||||
},
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
@@ -162,6 +188,7 @@ const ModelPricing = () => {
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={() => {
|
||||
setSelectedGroup(group);
|
||||
showInfo(
|
||||
@@ -171,7 +198,7 @@ const ModelPricing = () => {
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{group}
|
||||
</Tag>
|
||||
@@ -257,7 +284,7 @@ const ModelPricing = () => {
|
||||
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
const [groupRatio, setGroupRatio] = useState({});
|
||||
const [usableGroup, setUsableGroup] = useState({});
|
||||
|
||||
@@ -334,57 +361,6 @@ const ModelPricing = () => {
|
||||
return counts;
|
||||
}, [models, modelCategories]);
|
||||
|
||||
const renderArrow = (items, pos, handleArrowClick) => {
|
||||
const style = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '100%',
|
||||
background: 'rgba(var(--semi-grey-1), 1)',
|
||||
color: 'var(--semi-color-text)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
return (
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
const key = item.itemKey;
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
onClick={() => setActiveKey(item.itemKey)}
|
||||
icon={modelCategories[item.itemKey]?.icon}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelCategories[item.itemKey]?.label || item.itemKey}
|
||||
<Tag
|
||||
color={activeKey === item.itemKey ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<div style={style} onClick={handleArrowClick}>
|
||||
{pos === 'start' ? '←' : '→'}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否有对应的模型
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!models.length) return ['all'];
|
||||
|
||||
@@ -394,11 +370,9 @@ const ModelPricing = () => {
|
||||
}).map(([key]) => key);
|
||||
}, [models]);
|
||||
|
||||
// 渲染标签页
|
||||
const renderTabs = () => {
|
||||
return (
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
activeKey={activeKey}
|
||||
type="card"
|
||||
collapsible
|
||||
@@ -434,16 +408,13 @@ const ModelPricing = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 优化过滤逻辑
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
// 先按分类过滤
|
||||
if (activeKey !== 'all') {
|
||||
result = result.filter(model => modelCategories[activeKey].filter(model));
|
||||
}
|
||||
|
||||
// 再按搜索词过滤
|
||||
if (filteredValue.length > 0) {
|
||||
const searchTerm = filteredValue[0].toLowerCase();
|
||||
result = result.filter(model =>
|
||||
@@ -454,7 +425,6 @@ const ModelPricing = () => {
|
||||
return result;
|
||||
}, [activeKey, models, filteredValue]);
|
||||
|
||||
// 搜索和操作区组件
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<Card className="!rounded-xl mb-6" bordered={false}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
@@ -462,7 +432,6 @@ const ModelPricing = () => {
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
className="!rounded-lg"
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
@@ -476,7 +445,7 @@ const ModelPricing = () => {
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
className="!bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
size="large"
|
||||
>
|
||||
{t('复制选中模型')}
|
||||
@@ -485,7 +454,6 @@ const ModelPricing = () => {
|
||||
</Card>
|
||||
), [selectedRowKeys, t]);
|
||||
|
||||
// 表格组件
|
||||
const ModelTable = useMemo(() => (
|
||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||
<Table
|
||||
@@ -523,10 +491,10 @@ const ModelPricing = () => {
|
||||
<div className="bg-gray-50">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center p-4 sm:p-6 md:p-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full">
|
||||
{/* 主卡片容器 */}
|
||||
<Card className="!rounded-2xl shadow-lg border-0">
|
||||
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部状态卡片 */}
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
|
||||
@@ -536,13 +504,6 @@ const ModelPricing = () => {
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
|
||||
<div className="flex items-start">
|
||||
@@ -565,7 +526,7 @@ const ModelPricing = () => {
|
||||
<div className="flex items-center">
|
||||
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t('未登录,使用默认分组倍率')}: {groupRatio['default']}
|
||||
{t('未登录,使用默认分组倍率:')}{groupRatio['default']}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,14 +8,7 @@ import {
|
||||
renderQuota
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Minus,
|
||||
HelpCircle,
|
||||
Coins,
|
||||
Ticket
|
||||
} from 'lucide-react';
|
||||
import { Ticket } from 'lucide-react';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
@@ -37,18 +30,12 @@ import {
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconPlus,
|
||||
IconCopy,
|
||||
IconSearch,
|
||||
IconEyeOpened,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore
|
||||
IconMore,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditRedemption from '../../pages/Redemption/EditRedemption';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -66,31 +53,31 @@ const RedemptionsTable = () => {
|
||||
const renderStatus = (status, record) => {
|
||||
if (isExpired(record)) {
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Minus size={14} />}>{t('已过期')}</Tag>
|
||||
<Tag color='orange' size='large' shape='circle'>{t('已过期')}</Tag>
|
||||
);
|
||||
}
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('未使用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Minus size={14} />}>
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('已使用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
<Tag color='black' size='large' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -120,7 +107,7 @@ const RedemptionsTable = () => {
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
<Tag size={'large'} color={'grey'} shape='circle'>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -158,7 +145,6 @@ const RedemptionsTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
@@ -178,7 +164,6 @@ const RedemptionsTable = () => {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, 'disable', record);
|
||||
@@ -188,7 +173,6 @@ const RedemptionsTable = () => {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, 'enable', record);
|
||||
@@ -201,21 +185,17 @@ const RedemptionsTable = () => {
|
||||
<Space>
|
||||
<Popover content={record.key} style={{ padding: 20 }} position='top'>
|
||||
<Button
|
||||
icon={<IconEyeOpened />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查看')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
theme='light'
|
||||
type='secondary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={async () => {
|
||||
await copyText(record.key);
|
||||
}}
|
||||
@@ -223,11 +203,9 @@ const RedemptionsTable = () => {
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingRedemption(record);
|
||||
setShowEdit(true);
|
||||
@@ -242,11 +220,10 @@ const RedemptionsTable = () => {
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconMore />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@@ -266,16 +243,14 @@ const RedemptionsTable = () => {
|
||||
id: undefined,
|
||||
});
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('redemptions');
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
};
|
||||
|
||||
// Form API 引用
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// 获取表单值的辅助函数
|
||||
const getFormValues = () => {
|
||||
const formValues = formApi ? formApi.getValues() : {};
|
||||
return {
|
||||
@@ -296,14 +271,15 @@ const RedemptionsTable = () => {
|
||||
setRedemptions(redeptions);
|
||||
};
|
||||
|
||||
const loadRedemptions = async (startIdx, pageSize) => {
|
||||
const loadRedemptions = async (page = 1, pageSize) => {
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
|
||||
`/api/redemption/?p=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setActivePage(data.page <= 0 ? 1 : data.page);
|
||||
setTokenCount(data.total);
|
||||
setRedemptionFormat(newPageData);
|
||||
} else {
|
||||
@@ -336,17 +312,8 @@ const RedemptionsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
|
||||
await loadRedemptions(activePage - 1, pageSize);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRedemptions(0, pageSize)
|
||||
loadRedemptions(1, pageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
@@ -417,20 +384,6 @@ const RedemptionsTable = () => {
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const sortRedemption = (key) => {
|
||||
if (redemptions.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedRedemptions = [...redemptions];
|
||||
sortedRedemptions.sort((a, b) => {
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
});
|
||||
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||||
sortedRedemptions.reverse();
|
||||
}
|
||||
setRedemptions(sortedRedemptions);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
const { searchKeyword } = getFormValues();
|
||||
@@ -465,9 +418,19 @@ const RedemptionsTable = () => {
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-orange-500">
|
||||
<Ticket size={16} className="mr-2" />
|
||||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||||
<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">
|
||||
<Ticket size={16} className="mr-2" />
|
||||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -479,8 +442,7 @@ const RedemptionsTable = () => {
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full sm:w-auto"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
@@ -492,8 +454,7 @@ const RedemptionsTable = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type='warning'
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full w-full sm:w-auto"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个兑换码!'));
|
||||
@@ -512,8 +473,7 @@ const RedemptionsTable = () => {
|
||||
</div>
|
||||
<Button
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
className="!rounded-full w-full sm:w-auto"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定清除所有失效兑换码?'),
|
||||
@@ -557,7 +517,6 @@ const RedemptionsTable = () => {
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -567,7 +526,7 @@ const RedemptionsTable = () => {
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -583,7 +542,7 @@ const RedemptionsTable = () => {
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
@@ -610,9 +569,9 @@ const RedemptionsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
|
||||
dataSource={pageData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
XCircle,
|
||||
Loader,
|
||||
List,
|
||||
Hash
|
||||
Hash,
|
||||
Video,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
API,
|
||||
@@ -45,8 +47,9 @@ import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
import { TASK_ACTION_GENERATE, TASK_ACTION_TEXT_GENERATE } from '../../constants/common.constant';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -80,6 +83,7 @@ const COLUMN_KEYS = {
|
||||
TASK_STATUS: 'task_status',
|
||||
PROGRESS: 'progress',
|
||||
FAIL_REASON: 'fail_reason',
|
||||
RESULT_URL: 'result_url',
|
||||
};
|
||||
|
||||
const renderTimestamp = (timestampInSeconds) => {
|
||||
@@ -96,20 +100,8 @@ const renderTimestamp = (timestampInSeconds) => {
|
||||
};
|
||||
|
||||
function renderDuration(submit_time, finishTime) {
|
||||
// 确保startTime和finishTime都是有效的时间戳
|
||||
if (!submit_time || !finishTime) return 'N/A';
|
||||
|
||||
// 将时间戳转换为Date对象
|
||||
const start = new Date(submit_time);
|
||||
const finish = new Date(finishTime);
|
||||
|
||||
// 计算时间差(毫秒)
|
||||
const durationMs = finish - start;
|
||||
|
||||
// 将时间差转换为秒,并保留一位小数
|
||||
const durationSec = (durationMs / 1000).toFixed(1);
|
||||
|
||||
// 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
|
||||
const durationSec = finishTime - submit_time;
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
// 返回带有样式的颜色标签
|
||||
@@ -162,6 +154,7 @@ const LogsTable = () => {
|
||||
[COLUMN_KEYS.TASK_STATUS]: true,
|
||||
[COLUMN_KEYS.PROGRESS]: true,
|
||||
[COLUMN_KEYS.FAIL_REASON]: true,
|
||||
[COLUMN_KEYS.RESULT_URL]: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -215,6 +208,18 @@ const LogsTable = () => {
|
||||
{t('生成歌词')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' size='large' 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} />}>
|
||||
{t('文生视频')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
@@ -224,14 +229,26 @@ const LogsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderPlatform = (type) => {
|
||||
switch (type) {
|
||||
const renderPlatform = (platform) => {
|
||||
switch (platform) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
Suno
|
||||
</Tag>
|
||||
);
|
||||
case 'kling':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
Kling
|
||||
</Tag>
|
||||
);
|
||||
case 'jimeng':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle' prefixIcon={<Video size={14} />}>
|
||||
Jimeng
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
@@ -423,10 +440,21 @@ const LogsTable = () => {
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FAIL_REASON,
|
||||
title: t('失败原因'),
|
||||
title: t('详情'),
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
||||
const isVideoTask = record.action === TASK_ACTION_GENERATE || record.action === TASK_ACTION_TEXT_GENERATE;
|
||||
const isSuccess = record.status === 'SUCCESS';
|
||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
return (
|
||||
<a href={text} target="_blank" rel="noopener noreferrer">
|
||||
{t('点击预览视频')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
@@ -456,6 +484,8 @@ const LogsTable = () => {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('taskLogs');
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
@@ -569,21 +599,18 @@ const LogsTable = () => {
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => initDefaultColumns()}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
@@ -635,7 +662,7 @@ const LogsTable = () => {
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<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 ? (
|
||||
@@ -650,6 +677,14 @@ const LogsTable = () => {
|
||||
<Text>{t('任务记录')}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
@@ -684,7 +719,6 @@ const LogsTable = () => {
|
||||
field='task_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -695,7 +729,6 @@ const LogsTable = () => {
|
||||
field='channel_id'
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -710,7 +743,6 @@ const LogsTable = () => {
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -725,16 +757,13 @@ const LogsTable = () => {
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
@@ -748,11 +777,11 @@ const LogsTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
columns={compactMode ? getVisibleColumns().map(({ fixed, ...rest }) => rest) : getVisibleColumns()}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
empty={
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
renderQuota,
|
||||
getQuotaPerUnit
|
||||
} from '../../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
@@ -29,33 +28,15 @@ import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Shield,
|
||||
XCircle,
|
||||
Clock,
|
||||
Gauge,
|
||||
HelpCircle,
|
||||
Infinity,
|
||||
Coins,
|
||||
Key
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
IconPlus,
|
||||
IconCopy,
|
||||
IconSearch,
|
||||
IconTreeTriangleDown,
|
||||
IconEyeOpened,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore
|
||||
IconMore,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Key } from 'lucide-react';
|
||||
import EditToken from '../../pages/Token/EditToken';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -71,38 +52,38 @@ const TokensTable = () => {
|
||||
case 1:
|
||||
if (model_limits_enabled) {
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
|
||||
<Tag color='green' size='large' shape='circle' >
|
||||
{t('已启用:限制模型')}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
<Tag color='green' size='large' shape='circle' >
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
<Tag color='red' size='large' shape='circle' >
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
<Tag color='yellow' size='large' shape='circle' >
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle' prefixIcon={<Gauge size={14} />}>
|
||||
<Tag color='grey' size='large' shape='circle' >
|
||||
{t('已耗尽')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
<Tag color='black' size='large' shape='circle' >
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -135,7 +116,7 @@ const TokensTable = () => {
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
<Tag size={'large'} color={'grey'} shape='circle' prefixIcon={<Coins size={14} />}>
|
||||
<Tag size={'large'} color={'grey'} shape='circle' >
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
</div>
|
||||
@@ -162,7 +143,7 @@ const TokensTable = () => {
|
||||
return (
|
||||
<div>
|
||||
{record.unlimited_quota ? (
|
||||
<Tag size={'large'} color={'white'} shape='circle' prefixIcon={<Infinity size={14} />}>
|
||||
<Tag size={'large'} color={'white'} shape='circle' >
|
||||
{t('无限制')}
|
||||
</Tag>
|
||||
) : (
|
||||
@@ -170,7 +151,6 @@ const TokensTable = () => {
|
||||
size={'large'}
|
||||
color={getQuotaColor(parseInt(text))}
|
||||
shape='circle'
|
||||
prefixIcon={<Coins size={14} />}
|
||||
>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
@@ -236,7 +216,6 @@ const TokensTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('查看'),
|
||||
icon: <IconEyeOpened />,
|
||||
onClick: () => {
|
||||
Modal.info({
|
||||
title: t('令牌详情'),
|
||||
@@ -248,7 +227,6 @@ const TokensTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
@@ -269,7 +247,6 @@ const TokensTable = () => {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageToken(record.id, 'disable', record);
|
||||
@@ -279,7 +256,6 @@ const TokensTable = () => {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageToken(record.id, 'enable', record);
|
||||
@@ -290,7 +266,7 @@ const TokensTable = () => {
|
||||
return (
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
className="!rounded-full overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
aria-label={t('项目操作按钮组')}
|
||||
>
|
||||
<Button
|
||||
@@ -329,11 +305,9 @@ const TokensTable = () => {
|
||||
</SplitButtonGroup>
|
||||
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
theme='light'
|
||||
type='secondary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={async (text) => {
|
||||
await copyText('sk-' + record.key);
|
||||
}}
|
||||
@@ -342,11 +316,9 @@ const TokensTable = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingToken(record);
|
||||
setShowEdit(true);
|
||||
@@ -365,7 +337,6 @@ const TokensTable = () => {
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@@ -385,6 +356,7 @@ const TokensTable = () => {
|
||||
const [editingToken, setEditingToken] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('tokens');
|
||||
|
||||
// Form 初始值
|
||||
const formInitValues = {
|
||||
@@ -435,6 +407,7 @@ const TokensTable = () => {
|
||||
|
||||
const refresh = async () => {
|
||||
await loadTokens(1);
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
@@ -583,24 +556,56 @@ const TokensTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const batchDeleteTokens = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请先选择要删除的令牌!'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const ids = selectedKeys.map((token) => token.id);
|
||||
const res = await API.post('/api/token/batch', { ids });
|
||||
if (res?.data?.success) {
|
||||
const count = res.data.data || 0;
|
||||
showSuccess(t('已删除 {{count}} 个令牌!', { count }));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(res?.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<Key size={16} className="mr-2" />
|
||||
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
|
||||
<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-blue-500">
|
||||
<Key size={16} className="mr-2" />
|
||||
<Text>{t('令牌用于API访问认证,可以设置额度限制和模型权限。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme="light"
|
||||
type="secondary"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
setEditingToken({
|
||||
id: undefined,
|
||||
@@ -613,22 +618,74 @@ const TokensTable = () => {
|
||||
<Button
|
||||
theme="light"
|
||||
type="warning"
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={async () => {
|
||||
className="flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
Modal.info({
|
||||
title: t('复制令牌'),
|
||||
icon: null,
|
||||
content: t('请选择你的复制方式'),
|
||||
footer: (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('名称+密钥')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={async () => {
|
||||
let content = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
content += 'sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(content);
|
||||
Modal.destroyAll();
|
||||
}}
|
||||
>
|
||||
{t('仅密钥')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('复制所选令牌到剪贴板')}
|
||||
{t('复制所选令牌')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="danger"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: t('批量删除令牌'),
|
||||
content: (
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
|
||||
</div>
|
||||
),
|
||||
onOk: () => batchDeleteTokens(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除所选令牌')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -649,7 +706,6 @@ const TokensTable = () => {
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索关键字')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -659,7 +715,6 @@ const TokensTable = () => {
|
||||
field="searchToken"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('密钥')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -669,7 +724,7 @@ const TokensTable = () => {
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -684,7 +739,7 @@ const TokensTable = () => {
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
@@ -711,9 +766,15 @@ const TokensTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(col => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
}) : columns}
|
||||
dataSource={tokens}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Activity,
|
||||
Users,
|
||||
DollarSign,
|
||||
UserPlus
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -33,26 +34,21 @@ import {
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
IconUserAdd,
|
||||
IconArrowUp,
|
||||
IconArrowDown
|
||||
IconMore,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import AddUser from '../../pages/User/AddUser';
|
||||
import EditUser from '../../pages/User/EditUser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTableCompactMode } from '../../hooks/useTableCompactMode';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UsersTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('users');
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
@@ -110,6 +106,27 @@ const UsersTable = () => {
|
||||
{
|
||||
title: t('用户名'),
|
||||
dataIndex: 'username',
|
||||
render: (text, record) => {
|
||||
const remark = record.remark;
|
||||
if (!remark) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
const maxLen = 10;
|
||||
const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
|
||||
return (
|
||||
<Space spacing={2}>
|
||||
<span>{text}</span>
|
||||
<Tooltip content={remark} position="top" showArrow>
|
||||
<Tag color='white' size='large' 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}
|
||||
</div>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
@@ -196,7 +213,6 @@ const UsersTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('提升'),
|
||||
icon: <IconArrowUp />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
@@ -211,7 +227,6 @@ const UsersTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('降级'),
|
||||
icon: <IconArrowDown />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
@@ -226,7 +241,6 @@ const UsersTable = () => {
|
||||
{
|
||||
node: 'item',
|
||||
name: t('注销'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
@@ -247,7 +261,6 @@ const UsersTable = () => {
|
||||
moreMenuItems.splice(-1, 0, {
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageUser(record.id, 'disable', record);
|
||||
@@ -257,7 +270,6 @@ const UsersTable = () => {
|
||||
moreMenuItems.splice(-1, 0, {
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageUser(record.id, 'enable', record);
|
||||
@@ -269,11 +281,9 @@ const UsersTable = () => {
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingUser(record);
|
||||
setShowEditUser(true);
|
||||
@@ -287,11 +297,10 @@ const UsersTable = () => {
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
icon={<IconMore />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@@ -505,9 +514,19 @@ const UsersTable = () => {
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<IconUserAdd className="mr-2" />
|
||||
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
|
||||
<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-blue-500">
|
||||
<IconUserAdd className="mr-2" />
|
||||
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,8 +537,7 @@ const UsersTable = () => {
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
}}
|
||||
@@ -548,7 +566,6 @@ const UsersTable = () => {
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -565,7 +582,7 @@ const UsersTable = () => {
|
||||
searchUsers(1, pageSize);
|
||||
}, 100);
|
||||
}}
|
||||
className="!rounded-full w-full"
|
||||
className="w-full"
|
||||
showClear
|
||||
pure
|
||||
/>
|
||||
@@ -575,7 +592,7 @@ const UsersTable = () => {
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
@@ -591,7 +608,7 @@ const UsersTable = () => {
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="!rounded-full flex-1 md:flex-initial md:w-auto"
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
@@ -623,9 +640,9 @@ const UsersTable = () => {
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
|
||||
dataSource={users}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
@@ -653,7 +670,7 @@ const UsersTable = () => {
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -125,4 +125,16 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'blue',
|
||||
label: 'Coze',
|
||||
},
|
||||
{
|
||||
value: 50,
|
||||
color: 'green',
|
||||
label: '可灵',
|
||||
},
|
||||
{
|
||||
value: 51,
|
||||
color: 'blue',
|
||||
label: '即梦',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
|
||||
|
||||
export const DEFAULT_ENDPOINT = '/api/ratio_config';
|
||||
|
||||
export const TABLE_COMPACT_MODES_KEY = 'table_compact_modes';
|
||||
|
||||
export const API_ENDPOINTS = [
|
||||
'/v1/chat/completions',
|
||||
'/v1/responses',
|
||||
'/v1/messages',
|
||||
'/v1beta/models',
|
||||
'/v1/embeddings',
|
||||
'/v1/rerank',
|
||||
'/v1/images/generations',
|
||||
'/v1/images/edits',
|
||||
'/v1/images/variations',
|
||||
'/v1/audio/speech',
|
||||
'/v1/audio/transcriptions',
|
||||
'/v1/audio/translations'
|
||||
];
|
||||
|
||||
export const TASK_ACTION_GENERATE = 'generate';
|
||||
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
|
||||
@@ -84,6 +84,7 @@ export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled
|
||||
model: inputs.model,
|
||||
group: inputs.group,
|
||||
messages: processedMessages,
|
||||
group: inputs.group,
|
||||
stream: inputs.stream,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export function setStatusData(data) {
|
||||
localStorage.setItem('enable_task', data.enable_task);
|
||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||
localStorage.setItem('chats', JSON.stringify(data.chats));
|
||||
localStorage.setItem('pay_methods', JSON.stringify(data.pay_methods));
|
||||
localStorage.setItem(
|
||||
'data_export_default_time',
|
||||
data.data_export_default_time,
|
||||
|
||||
@@ -883,7 +883,7 @@ function getEffectiveRatio(groupRatio, user_group_ratio) {
|
||||
? i18next.t('专属倍率')
|
||||
: i18next.t('分组倍率');
|
||||
const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio;
|
||||
|
||||
|
||||
return {
|
||||
ratio: effectiveRatio,
|
||||
label: ratioLabel,
|
||||
@@ -1074,25 +1074,25 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
].join('');
|
||||
|
||||
@@ -1281,10 +1281,10 @@ export function renderAudioModelPrice(
|
||||
let audioPrice =
|
||||
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
|
||||
(audioCompletionTokens / 1000000) *
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
groupRatio;
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
groupRatio;
|
||||
let price = textPrice + audioPrice;
|
||||
return (
|
||||
<>
|
||||
@@ -1340,27 +1340,27 @@ export function renderAudioModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0
|
||||
? i18next.t(
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cachePrice: inputRatioPrice * cacheRatio,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
},
|
||||
)
|
||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cachePrice: inputRatioPrice * cacheRatio,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
},
|
||||
)}
|
||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
@@ -1397,7 +1397,7 @@ export function renderQuotaWithPrompt(quota, digits) {
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
return (
|
||||
' | ' + i18next.t('等价金额') + ': ' + renderQuota(quota, digits) + ''
|
||||
i18next.t('等价金额:') + renderQuota(quota, digits)
|
||||
);
|
||||
}
|
||||
return '';
|
||||
@@ -1499,35 +1499,35 @@ export function renderClaudeModelPrice(
|
||||
<p>
|
||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||
? i18next.t(
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
{
|
||||
nonCacheInput: nonCachedTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cacheRatio: cacheRatio,
|
||||
cacheCreationInput: cacheCreationTokens,
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
cachePrice: cacheRatioPrice,
|
||||
cacheCreationPrice: cacheCreationRatioPrice,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
},
|
||||
)
|
||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
{
|
||||
nonCacheInput: nonCachedTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cacheRatio: cacheRatio,
|
||||
cacheCreationInput: cacheCreationTokens,
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
cachePrice: cacheRatioPrice,
|
||||
cacheCreationPrice: cacheCreationRatioPrice,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
},
|
||||
)}
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
||||
</article>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toastConstants } from '../constants';
|
||||
import React from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
||||
|
||||
const HTMLToastContent = ({ htmlContent }) => {
|
||||
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
|
||||
@@ -509,3 +510,31 @@ export const formatDateTimeString = (date) => {
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
function readTableCompactModes() {
|
||||
try {
|
||||
const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
|
||||
return json ? JSON.parse(json) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeTableCompactModes(modes) {
|
||||
try {
|
||||
localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function getTableCompactMode(tableKey = 'global') {
|
||||
const modes = readTableCompactModes();
|
||||
return !!modes[tableKey];
|
||||
}
|
||||
|
||||
export function setTableCompactMode(compact, tableKey = 'global') {
|
||||
const modes = readTableCompactModes();
|
||||
modes[tableKey] = compact;
|
||||
writeTableCompactModes(modes);
|
||||
}
|
||||
|
||||
34
web/src/hooks/useTableCompactMode.js
Normal file
34
web/src/hooks/useTableCompactMode.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getTableCompactMode, setTableCompactMode } from '../helpers';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:管理表格紧凑/自适应模式
|
||||
* 返回 [compactMode, setCompactMode]。
|
||||
* 内部使用 localStorage 保存状态,并监听 storage 事件保持多标签页同步。
|
||||
*/
|
||||
export function useTableCompactMode(tableKey = 'global') {
|
||||
const [compactMode, setCompactModeState] = useState(() => getTableCompactMode(tableKey));
|
||||
|
||||
const setCompactMode = useCallback((value) => {
|
||||
setCompactModeState(value);
|
||||
setTableCompactMode(value, tableKey);
|
||||
}, [tableKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (e) => {
|
||||
if (e.key === TABLE_COMPACT_MODES_KEY) {
|
||||
try {
|
||||
const modes = JSON.parse(e.newValue || '{}');
|
||||
setCompactModeState(!!modes[tableKey]);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, [tableKey]);
|
||||
|
||||
return [compactMode, setCompactMode];
|
||||
}
|
||||
@@ -139,7 +139,7 @@
|
||||
"已成功开始测试所有已启用通道,请刷新页面查看结果。": "Successfully started testing all enabled channels. Please refresh page to view results.",
|
||||
"通道 ${name} 余额更新成功!": "Channel ${name} quota updated successfully!",
|
||||
"已更新完毕所有已启用通道余额!": "Updated quota for all enabled channels!",
|
||||
"搜索渠道的 ID,名称,密钥和API地址 ...": "Search channel ID, name, key and Base URL...",
|
||||
"渠道ID,名称,密钥,API地址": "Channel ID, name, key, Base URL",
|
||||
"名称": "Name",
|
||||
"分组": "Group",
|
||||
"类型": "Type",
|
||||
@@ -397,7 +397,7 @@
|
||||
"删除用户": "Delete User",
|
||||
"添加新的用户": "Add New User",
|
||||
"自定义": "Custom",
|
||||
"等价金额": "Equivalent Amount",
|
||||
"等价金额:": "Equivalent Amount: ",
|
||||
"未登录或登录已过期,请重新登录": "Not logged in or login has expired, please log in again",
|
||||
"请求次数过多,请稍后再试": "Too many requests, please try again later",
|
||||
"服务器内部错误,请联系管理员": "Server internal error, please contact the administrator",
|
||||
@@ -428,6 +428,7 @@
|
||||
"填入基础模型": "Fill in the basic model",
|
||||
"填入所有模型": "Fill in all models",
|
||||
"清除所有模型": "Clear all models",
|
||||
"复制所有模型": "Copy all models",
|
||||
"密钥": "Key",
|
||||
"请输入密钥": "Please enter the key",
|
||||
"批量创建": "Batch Create",
|
||||
@@ -455,8 +456,8 @@
|
||||
"创建新的令牌": "Create New Token",
|
||||
"令牌分组,默认为用户的分组": "Token group, default is the your's group",
|
||||
"IP白名单": "IP whitelist",
|
||||
"注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。": "Note that the quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account.",
|
||||
"设为无限额度": "Set to unlimited quota",
|
||||
"令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制": "The quota of the token is only used to limit the maximum quota usage of the token itself, and the actual usage is limited by the remaining quota of the account",
|
||||
"无限额度": "Unlimited quota",
|
||||
"更新令牌信息": "Update Token Information",
|
||||
"请输入充值码!": "Please enter the recharge code!",
|
||||
"请输入名称": "Please enter a name",
|
||||
@@ -470,10 +471,11 @@
|
||||
"请输入新的密码": "Please enter a new password",
|
||||
"显示名称": "Display Name",
|
||||
"请输入新的显示名称": "Please enter a new display name",
|
||||
"已绑定的 GitHub 账户": "GitHub Account Bound",
|
||||
"此项只读,要用户通过个人设置页面的相关绑<EFBFBD><EFBFBD>按钮进<EFBFBD><EFBFBD>绑<EFBFBD><EFBFBD><EFBFBD>,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
|
||||
"已绑定的微信账户": "WeChat Account Bound",
|
||||
"已绑定的邮箱账户": "Email Account Bound",
|
||||
"已绑定的 GITHUB 账户": "Bound GitHub Account",
|
||||
"已绑定的 WECHAT 账户": "Bound WeChat Account",
|
||||
"已绑定的 EMAIL 账户": "Bound Email Account",
|
||||
"已绑定的 TELEGRAM 账户": "Bound Telegram Account",
|
||||
"此项只读,要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "This item is read-only. Users need to bind through the relevant binding button on the personal settings page, and cannot be modified directly",
|
||||
"用户信息更新成功!": "User information updated successfully!",
|
||||
"使用明细(总消耗额度:{renderQuota(stat.quota)})": "Usage Details (Total Consumption Quota: {renderQuota(stat.quota)})",
|
||||
"用户名称": "User Name",
|
||||
@@ -515,7 +517,6 @@
|
||||
"注意,系统请求的时模型名称中的点会被剔除,例如:gpt-4.1会请求为gpt-41,所以在Azure部署的时候,部署模型名称需要手动改为gpt-41": "Note that the dot in the model name requested by the system will be removed, for example: gpt-4.1 will be requested as gpt-41, so when deploying on Azure, the deployment model name needs to be manually changed to gpt-41",
|
||||
"2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
|
||||
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
|
||||
"取消无限额度": "Cancel unlimited quota",
|
||||
"取消": "Cancel",
|
||||
"重置": "Reset",
|
||||
"请输入新的剩余额度": "Please enter the new remaining quota",
|
||||
@@ -800,6 +801,7 @@
|
||||
"获取无水印": "Get no watermark",
|
||||
"生成图片": "Generate pictures",
|
||||
"可灵": "Kling",
|
||||
"即梦": "Jimeng",
|
||||
"正在提交": "Submitting",
|
||||
"执行中": "processing",
|
||||
"平台": "platform",
|
||||
@@ -813,7 +815,16 @@
|
||||
"复制所选令牌": "Copy selected token",
|
||||
"请至少选择一个令牌!": "Please select at least one token!",
|
||||
"管理员未设置查询页链接": "The administrator has not set the query page link",
|
||||
"复制所选令牌到剪贴板": "Copy selected token to clipboard",
|
||||
"批量删除令牌": "Batch delete token",
|
||||
"确定要删除所选的 {{count}} 个令牌吗?": "Are you sure you want to delete the selected {{count}} tokens?",
|
||||
"删除所选令牌": "Delete selected token",
|
||||
"请先选择要删除的令牌!": "Please select the token to be deleted!",
|
||||
"已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
|
||||
"删除失败": "Delete failed",
|
||||
"复制令牌": "Copy token",
|
||||
"请选择你的复制方式": "Please select your copy method",
|
||||
"名称+密钥": "Name + key",
|
||||
"仅密钥": "Only key",
|
||||
"查看API地址": "View API address",
|
||||
"打开查询页": "Open query page",
|
||||
"时间(仅显示近3天)": "Time (only displays the last 3 days)",
|
||||
@@ -865,7 +876,7 @@
|
||||
"加载token失败": "Failed to load token",
|
||||
"配置聊天": "Configure chat",
|
||||
"模型消耗分布": "Model consumption distribution",
|
||||
"模型调用次数占比": "Proportion of model calls",
|
||||
"模型调用次数占比": "Model call ratio",
|
||||
"用户消耗分布": "User consumption distribution",
|
||||
"时间粒度": "Time granularity",
|
||||
"天": "day",
|
||||
@@ -1108,6 +1119,10 @@
|
||||
"平均TPM": "Average TPM",
|
||||
"消耗分布": "Consumption distribution",
|
||||
"调用次数分布": "Models call distribution",
|
||||
"消耗趋势": "Consumption trend",
|
||||
"模型消耗趋势": "Model consumption trend",
|
||||
"调用次数排行": "Models call ranking",
|
||||
"模型调用次数排行": "Model call ranking",
|
||||
"添加渠道": "Add channel",
|
||||
"测试所有通道": "Test all channels",
|
||||
"删除禁用通道": "Delete disabled channels",
|
||||
@@ -1132,8 +1147,8 @@
|
||||
"默认测试模型": "Default Test Model",
|
||||
"不填则为模型列表第一个": "First model in list if empty",
|
||||
"是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道": "Auto-disable (only effective when auto-disable is enabled). When turned off, this channel will not be automatically disabled",
|
||||
"状态码复写(仅影响本地判断,不修改返回到上游的状态码)": "Status Code Override (only affects local judgment, does not modify status code returned upstream)",
|
||||
"此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Optional, used to override returned status codes, e.g. rewriting Claude channel's 400 error to 500 (for retry). Do not abuse this feature. Example:",
|
||||
"状态码复写": "Status Code Override",
|
||||
"此项可选,用于复写返回的状态码,仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Optional, used to override returned status codes, only affects local judgment, does not modify status code returned upstream, e.g. rewriting Claude channel's 400 error to 500 (for retry). Do not abuse this feature. Example:",
|
||||
"渠道标签": "Channel Tag",
|
||||
"渠道优先级": "Channel Priority",
|
||||
"渠道权重": "Channel Weight",
|
||||
@@ -1188,7 +1203,7 @@
|
||||
"添加用户": "Add user",
|
||||
"角色": "Role",
|
||||
"已绑定的 Telegram 账户": "Bound Telegram account",
|
||||
"新额度": "New quota",
|
||||
"新额度:": "New quota: ",
|
||||
"需要添加的额度(支持负数)": "Need to add quota (supports negative numbers)",
|
||||
"此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改": "Read-only, user's personal settings, and cannot be modified directly",
|
||||
"请输入新的密码,最短 8 位": "Please enter a new password, at least 8 characterss",
|
||||
@@ -1206,7 +1221,7 @@
|
||||
"默认折叠侧边栏": "Default collapse sidebar",
|
||||
"聊天链接功能已经弃用,请使用下方聊天设置功能": "Chat link function has been deprecated, please use the chat settings below",
|
||||
"你似乎并没有修改什么": "You seem to have not modified anything",
|
||||
"令牌聊天设置": "Chat settings",
|
||||
"聊天设置": "Chat settings",
|
||||
"必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能": "Must set all chat links above to empty to use the chat settings below",
|
||||
"链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1": "The {key} in the link will be automatically replaced with sk-xxxx, the {address} will be automatically replaced with the server address in system settings, and the end will not have / and /v1",
|
||||
"聊天配置": "Chat configuration",
|
||||
@@ -1263,7 +1278,7 @@
|
||||
" 吗?": "?",
|
||||
"修改子渠道优先级": "Modify sub-channel priority",
|
||||
"确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ",
|
||||
"分组设置": "Group settings",
|
||||
"分组倍率设置": "Group ratio settings",
|
||||
"用户可选分组": "User selectable groups",
|
||||
"保存分组倍率设置": "Save group ratio settings",
|
||||
"模型倍率设置": "Model ratio settings",
|
||||
@@ -1373,6 +1388,12 @@
|
||||
"示例": "Example",
|
||||
"缺省 MaxTokens": "Default MaxTokens",
|
||||
"启用Claude思考适配(-thinking后缀)": "Enable Claude thinking adaptation (-thinking suffix)",
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用,": "Unlike Claude, Gemini's thinking model automatically decides whether to think by default, and can be used normally even without enabling the adaptation model.",
|
||||
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。": "If you need billing, it is recommended to set the no-suffix model price according to the thinking price.",
|
||||
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。": "Supports using gemini-2.5-pro-preview-06-05-thinking-128 format to precisely pass thinking budget.",
|
||||
"启用Gemini思考后缀适配": "Enable Gemini thinking suffix adaptation",
|
||||
"适配-thinking、-thinking-预算数字和-nothinking后缀": "Adapt -thinking, -thinking-budgetNumber, and -nothinking suffixes",
|
||||
"思考预算占比": "Thinking budget ratio",
|
||||
"Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage",
|
||||
"思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage",
|
||||
"0.1-1之间的小数": "Decimal between 0.1 and 1",
|
||||
@@ -1406,8 +1427,8 @@
|
||||
"初始化系统": "Initialize system",
|
||||
"支持众多的大模型供应商": "Supporting various LLM providers",
|
||||
"统一的大模型接口网关": "The Unified LLMs API Gateway",
|
||||
"更好的价格,更好的稳定性,无需订阅": "Better price, better stability, no subscription required",
|
||||
"开始使用": "Get Started",
|
||||
"更好的价格,更好的稳定性,只需要将模型基址替换为:": "Better price, better stability, no subscription required, just replace the model BASE URL with: ",
|
||||
"获取密钥": "Get Key",
|
||||
"关于我们": "About Us",
|
||||
"关于项目": "About Project",
|
||||
"联系我们": "Contact Us",
|
||||
@@ -1443,7 +1464,8 @@
|
||||
"访问限制": "Access Restrictions",
|
||||
"设置令牌的访问限制": "Set token access restrictions",
|
||||
"请勿过度信任此功能,IP可能被伪造": "Do not over-trust this feature, IP can be spoofed",
|
||||
"勾选启用模型限制后可选择": "Select after checking to enable model restrictions",
|
||||
"模型限制列表": "Model restrictions list",
|
||||
"请选择该令牌支持的模型,留空支持所有模型": "Select models supported by the token, leave blank to support all models",
|
||||
"非必要,不建议启用模型限制": "Not necessary, model restrictions are not recommended",
|
||||
"分组信息": "Group Information",
|
||||
"设置令牌的分组": "Set token grouping",
|
||||
@@ -1582,7 +1604,7 @@
|
||||
"性能指标": "Performance Indicators",
|
||||
"模型数据分析": "Model Data Analysis",
|
||||
"搜索无结果": "No results found",
|
||||
"仪表盘配置": "Dashboard Configuration",
|
||||
"仪表盘设置": "Dashboard Settings",
|
||||
"API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API information management, you can configure multiple API addresses for status display and load balancing (maximum 50)",
|
||||
"线路描述": "Route description",
|
||||
"颜色": "Color",
|
||||
@@ -1609,6 +1631,7 @@
|
||||
"编辑公告": "Edit Notice",
|
||||
"公告内容": "Notice Content",
|
||||
"请输入公告内容": "Please enter the notice content",
|
||||
"请输入公告内容(支持 Markdown/HTML)": "Please enter the notice content (supports Markdown/HTML)",
|
||||
"发布日期": "Publish Date",
|
||||
"请选择发布日期": "Please select the publish date",
|
||||
"发布时间": "Publish Time",
|
||||
@@ -1624,6 +1647,7 @@
|
||||
"请输入问题标题": "Please enter the question title",
|
||||
"回答内容": "Answer Content",
|
||||
"请输入回答内容": "Please enter the answer content",
|
||||
"请输入回答内容(支持 Markdown/HTML)": "Please enter the answer content (supports Markdown/HTML)",
|
||||
"确定要删除此问答吗?": "Are you sure you want to delete this FAQ?",
|
||||
"系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)",
|
||||
"常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)",
|
||||
@@ -1658,5 +1682,79 @@
|
||||
"清除失效兑换码": "Clear invalid redemption codes",
|
||||
"确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
|
||||
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.",
|
||||
"选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)"
|
||||
"选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)",
|
||||
"请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)",
|
||||
"上游倍率同步": "Upstream ratio synchronization",
|
||||
"获取渠道失败:": "Failed to get channels: ",
|
||||
"请至少选择一个渠道": "Please select at least one channel",
|
||||
"获取倍率失败:": "Failed to get ratios: ",
|
||||
"后端请求失败": "Backend request failed",
|
||||
"部分渠道测试失败:": "Some channels failed to test: ",
|
||||
"未找到差异化倍率,无需同步": "No differential ratio found, no synchronization is required",
|
||||
"请求后端接口失败:": "Failed to request the backend interface: ",
|
||||
"同步成功": "Synchronization successful",
|
||||
"部分保存失败": "Some settings failed to save",
|
||||
"保存失败": "Save failed",
|
||||
"选择同步渠道": "Select synchronization channel",
|
||||
"应用同步": "Apply synchronization",
|
||||
"倍率类型": "Ratio type",
|
||||
"当前值": "Current value",
|
||||
"上游值": "Upstream value",
|
||||
"差异": "Difference",
|
||||
"搜索渠道名称或地址": "Search channel name or address",
|
||||
"缓存倍率": "Cache ratio",
|
||||
"暂无差异化倍率显示": "No differential ratio display",
|
||||
"请先选择同步渠道": "Please select the synchronization channel first",
|
||||
"与本地相同": "Same as local",
|
||||
"未找到匹配的模型": "No matching model found",
|
||||
"暴露倍率接口": "Expose ratio API",
|
||||
"支付设置": "Payment Settings",
|
||||
"(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(Currently only supports Epay interface, the default callback address is the server address above!)",
|
||||
"支付地址": "Payment address",
|
||||
"易支付商户ID": "Epay merchant ID",
|
||||
"易支付商户密钥": "Epay merchant key",
|
||||
"回调地址": "Callback address",
|
||||
"充值价格(x元/美金)": "Recharge price (x yuan/dollar)",
|
||||
"最低充值美元数量": "Minimum recharge dollar amount",
|
||||
"充值分组倍率": "Recharge group ratio",
|
||||
"充值方式设置": "Recharge method settings",
|
||||
"更新支付设置": "Update payment settings",
|
||||
"通知": "Notice",
|
||||
"源地址": "Source address",
|
||||
"同步接口": "Synchronization interface",
|
||||
"置信度": "Confidence",
|
||||
"谨慎": "Cautious",
|
||||
"该数据可能不可信,请谨慎使用": "This data may not be reliable, please use with caution",
|
||||
"可信": "Reliable",
|
||||
"所有上游数据均可信": "All upstream data is reliable",
|
||||
"以下上游数据可能不可信:": "The following upstream data may not be reliable: ",
|
||||
"按倍率类型筛选": "Filter by ratio type",
|
||||
"内容": "Content",
|
||||
"放大编辑": "Expand editor",
|
||||
"编辑公告内容": "Edit announcement content",
|
||||
"自适应列表": "Adaptive list",
|
||||
"紧凑列表": "Compact list",
|
||||
"仅显示矛盾倍率": "Only show conflicting ratios",
|
||||
"矛盾": "Conflict",
|
||||
"确认冲突项修改": "Confirm conflict item modification",
|
||||
"该模型存在固定价格与倍率计费方式冲突,请确认选择": "The model has a fixed price and ratio billing method conflict, please confirm the selection",
|
||||
"当前计费": "Current billing",
|
||||
"修改为": "Modify to",
|
||||
"状态筛选": "Status filter",
|
||||
"没有模型可以复制": "No models to copy",
|
||||
"模型列表已复制到剪贴板": "Model list copied to clipboard",
|
||||
"复制失败": "Copy failed",
|
||||
"复制已选": "Copy selected",
|
||||
"选择成功": "Selection successful",
|
||||
"暂无成功模型": "No successful models",
|
||||
"请先选择模型!": "Please select a model first!",
|
||||
"已复制 ${count} 个模型": "Copied ${count} models",
|
||||
"复制失败,请手动复制": "Copy failed, please copy manually",
|
||||
"过期时间快捷设置": "Expiration time quick settings",
|
||||
"批量创建时会在名称后自动添加随机后缀": "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: "
|
||||
}
|
||||
@@ -43,6 +43,7 @@ code {
|
||||
|
||||
/* ==================== 导航和侧边栏样式 ==================== */
|
||||
/* 导航项样式 */
|
||||
.semi-input-textarea-wrapper,
|
||||
.semi-navigation-sub-title,
|
||||
.semi-chat-inputBox-sendButton,
|
||||
.semi-page-item,
|
||||
@@ -53,7 +54,7 @@ code {
|
||||
.semi-select,
|
||||
.semi-button,
|
||||
.semi-datepicker-range-input {
|
||||
border-radius: 9999px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.semi-navigation-item {
|
||||
@@ -375,6 +376,7 @@ code {
|
||||
}
|
||||
|
||||
/* 隐藏卡片内容区域的滚动条 */
|
||||
.model-test-scroll,
|
||||
.card-content-scroll,
|
||||
.model-settings-scroll,
|
||||
.thinking-content-scroll,
|
||||
@@ -385,6 +387,7 @@ code {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.model-test-scroll::-webkit-scrollbar,
|
||||
.card-content-scroll::-webkit-scrollbar,
|
||||
.model-settings-scroll::-webkit-scrollbar,
|
||||
.thinking-content-scroll::-webkit-scrollbar,
|
||||
@@ -432,4 +435,162 @@ code {
|
||||
.semi-table-tbody>.semi-table-row {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 同步倍率 - 渠道选择器 ==================== */
|
||||
|
||||
.components-transfer-source-item,
|
||||
.components-transfer-selected-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.semi-transfer-left-list,
|
||||
.semi-transfer-right-list {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.semi-transfer-left-list::-webkit-scrollbar,
|
||||
.semi-transfer-right-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .semi-checkbox,
|
||||
.components-transfer-selected-item .semi-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .semi-avatar,
|
||||
.components-transfer-selected-item .semi-avatar {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .info,
|
||||
.components-transfer-selected-item .info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .name,
|
||||
.components-transfer-selected-item .name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.components-transfer-source-item .email,
|
||||
.components-transfer-selected-item .email {
|
||||
font-size: 12px;
|
||||
color: var(--semi-color-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.components-transfer-selected-item .semi-icon-close {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--semi-color-text-2);
|
||||
}
|
||||
|
||||
.components-transfer-selected-item .semi-icon-close:hover {
|
||||
color: var(--semi-color-text-0);
|
||||
}
|
||||
|
||||
/* ==================== 未读通知闪光效果 ==================== */
|
||||
@keyframes sweep-shine {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shine-text {
|
||||
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, rgba(255, 255, 255, 0.9) 50%, currentColor 60%, currentColor 100%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: sweep-shine 4s linear infinite;
|
||||
}
|
||||
|
||||
.dark .shine-text {
|
||||
background: linear-gradient(90deg, currentColor 0%, currentColor 40%, #facc15 50%, currentColor 60%, currentColor 100%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* ==================== ScrollList 定制样式 ==================== */
|
||||
.semi-scrolllist,
|
||||
.semi-scrolllist * {
|
||||
-ms-overflow-style: none;
|
||||
/* IE, Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist::-webkit-scrollbar,
|
||||
.semi-scrolllist *::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist-body {
|
||||
padding: 1px !important;
|
||||
}
|
||||
|
||||
.semi-scrolllist-list-outer {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* ==================== Banner 背景模糊球 ==================== */
|
||||
.blur-ball {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.blur-ball-indigo {
|
||||
background: #6366f1;
|
||||
/* indigo-500 */
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.blur-ball-teal {
|
||||
background: #14b8a6;
|
||||
/* teal-400 */
|
||||
top: 200px;
|
||||
left: 30%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 浅色主题下让模糊球更柔和 */
|
||||
html:not(.dark) .blur-ball-indigo {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
html:not(.dark) .blur-ball-teal {
|
||||
opacity: 0.2;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import '@douyinfe/semi-ui/dist/css/semi.css';
|
||||
import { UserProvider } from './context/User';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { StatusProvider } from './context/Status';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import { ThemeProvider } from './context/Theme';
|
||||
import { StyleProvider } from './context/Style/index.js';
|
||||
import PageLayout from './components/layout/PageLayout.js';
|
||||
@@ -15,7 +14,6 @@ import './index.css';
|
||||
// initialization
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StatusProvider>
|
||||
|
||||
@@ -105,7 +105,7 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<Empty
|
||||
@@ -132,7 +132,7 @@ const About = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
@@ -11,14 +11,13 @@ import {
|
||||
SideSheet,
|
||||
Space,
|
||||
Button,
|
||||
Input,
|
||||
Typography,
|
||||
Spin,
|
||||
Select,
|
||||
Banner,
|
||||
TextArea,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
@@ -52,9 +51,14 @@ const EditTagModal = (props) => {
|
||||
models: [],
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(name, value);
|
||||
}
|
||||
if (name === 'type') {
|
||||
let localModels = [];
|
||||
switch (value) {
|
||||
@@ -73,6 +77,8 @@ const EditTagModal = (props) => {
|
||||
localModels = [
|
||||
'swap_face',
|
||||
'mj_imagine',
|
||||
'mj_video',
|
||||
'mj_edits',
|
||||
'mj_variation',
|
||||
'mj_reroll',
|
||||
'mj_blend',
|
||||
@@ -132,27 +138,25 @@ const EditTagModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (values) => {
|
||||
setLoading(true);
|
||||
let data = {
|
||||
tag: tag,
|
||||
};
|
||||
if (inputs.model_mapping !== null && inputs.model_mapping !== '') {
|
||||
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
|
||||
const formVals = values || formApiRef.current?.getValues() || {};
|
||||
let data = { tag };
|
||||
if (formVals.model_mapping) {
|
||||
if (!verifyJSON(formVals.model_mapping)) {
|
||||
showInfo('模型映射必须是合法的 JSON 格式!');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
data.model_mapping = inputs.model_mapping;
|
||||
data.model_mapping = formVals.model_mapping;
|
||||
}
|
||||
if (inputs.groups.length > 0) {
|
||||
data.groups = inputs.groups.join(',');
|
||||
if (formVals.groups && formVals.groups.length > 0) {
|
||||
data.groups = formVals.groups.join(',');
|
||||
}
|
||||
if (inputs.models.length > 0) {
|
||||
data.models = inputs.models.join(',');
|
||||
if (formVals.models && formVals.models.length > 0) {
|
||||
data.models = formVals.models.join(',');
|
||||
}
|
||||
data.new_tag = inputs.new_tag;
|
||||
// check have any change
|
||||
data.new_tag = formVals.new_tag;
|
||||
if (
|
||||
data.model_mapping === undefined &&
|
||||
data.groups === undefined &&
|
||||
@@ -201,7 +205,7 @@ const EditTagModal = (props) => {
|
||||
const res = await API.get(`/api/channel/tag/models?tag=${tag}`);
|
||||
if (res?.data?.success) {
|
||||
const models = res.data.data ? res.data.data.split(',') : [];
|
||||
setInputs((inputs) => ({ ...inputs, models: models }));
|
||||
handleInputChange('models', models);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
@@ -212,19 +216,32 @@ const EditTagModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
}
|
||||
|
||||
setInputs({
|
||||
...originInputs,
|
||||
tag: tag,
|
||||
new_tag: tag,
|
||||
});
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then(); // Call the new function
|
||||
}, [visible, tag]); // Add tag to dependency array
|
||||
}, [visible, tag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(inputs);
|
||||
}
|
||||
}, [inputs]);
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
// 使用逗号分隔字符串,然后去除每个模型名称前后的空格
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
|
||||
let localModels = [...inputs.models];
|
||||
@@ -232,11 +249,9 @@ const EditTagModal = (props) => {
|
||||
const addedModels = [];
|
||||
|
||||
modelArray.forEach((model) => {
|
||||
// 检查模型是否已存在,且模型名称非空
|
||||
if (model && !localModels.includes(model)) {
|
||||
localModels.push(model); // 添加到模型列表
|
||||
localModels.push(model);
|
||||
localModelOptions.push({
|
||||
// 添加到下拉选项
|
||||
key: model,
|
||||
text: model,
|
||||
value: model,
|
||||
@@ -245,7 +260,6 @@ const EditTagModal = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 更新状态值
|
||||
setModelOptions(localModelOptions);
|
||||
setCustomModel('');
|
||||
handleInputChange('models', localModels);
|
||||
@@ -273,14 +287,7 @@ const EditTagModal = (props) => {
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
@@ -289,9 +296,7 @@ const EditTagModal = (props) => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={handleSave}
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
loading={loading}
|
||||
icon={<IconSave />}
|
||||
>
|
||||
@@ -299,8 +304,6 @@ const EditTagModal = (props) => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
@@ -312,173 +315,128 @@ const EditTagModal = (props) => {
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconBookmark size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('标签信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('标签的基本配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
key={tag || 'edit'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={handleSave}
|
||||
>
|
||||
{() => (
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Tag Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconBookmark size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('标签信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('标签的基本配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('标签名称')}</Text>
|
||||
<Input
|
||||
value={inputs.new_tag}
|
||||
onChange={(value) => setInputs({ ...inputs, new_tag: value })}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconCode size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('模型选择和映射设置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('模型')}</Text>
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
type="warning"
|
||||
description={t('所有编辑均为覆盖操作,留空则不更改')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
name='models'
|
||||
multiple
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
value={inputs.models}
|
||||
optionList={modelOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
addonAfter={
|
||||
<Button type='primary' onClick={addCustomModels} className="!rounded-r-lg">
|
||||
{t('填入')}
|
||||
</Button>
|
||||
}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
value={customModel}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Form.Input
|
||||
field='new_tag'
|
||||
label={t('标签名称')}
|
||||
placeholder={t('请输入新标签,留空则解散标签')}
|
||||
onChange={(value) => handleInputChange('new_tag', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('模型重定向')}</Text>
|
||||
<TextArea
|
||||
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
|
||||
name='model_mapping'
|
||||
onChange={(value) => handleInputChange('model_mapping', value)}
|
||||
autosize
|
||||
value={inputs.model_mapping}
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
<Space className="mt-2">
|
||||
<Text
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
|
||||
>
|
||||
{t('填入模板')}
|
||||
</Text>
|
||||
<Text
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}
|
||||
>
|
||||
{t('清空重定向')}
|
||||
</Text>
|
||||
<Text
|
||||
className="!text-semi-color-primary cursor-pointer"
|
||||
onClick={() => handleInputChange('model_mapping', '')}
|
||||
>
|
||||
{t('不更改')}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Model Config */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconCode size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('模型配置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型选择和映射设置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Banner
|
||||
type="info"
|
||||
description={t('当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。')}
|
||||
className="!rounded-lg mb-4"
|
||||
/>
|
||||
<Form.Select
|
||||
field='models'
|
||||
label={t('模型')}
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='custom_model'
|
||||
label={t('自定义模型名称')}
|
||||
placeholder={t('输入自定义模型名称')}
|
||||
onChange={(value) => setCustomModel(value.trim())}
|
||||
suffix={<Button size='small' type='primary' onClick={addCustomModels}>{t('填入')}</Button>}
|
||||
/>
|
||||
|
||||
<Form.TextArea
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改')}
|
||||
autosize
|
||||
onChange={(value) => handleInputChange('model_mapping', value)}
|
||||
extraText={(
|
||||
<Space>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}>{t('填入模板')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', JSON.stringify({}, null, 2))}>{t('清空重定向')}</Text>
|
||||
<Text className="!text-semi-color-primary cursor-pointer" onClick={() => handleInputChange('model_mapping', '')}>{t('不更改')}</Text>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
{/* Header: Group Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('用户分组配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Form.Select
|
||||
field='groups'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
|
||||
optionList={groupOptions}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUser size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('分组设置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('用户分组配置')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('分组')}</Text>
|
||||
<Select
|
||||
placeholder={t('请选择可以使用该渠道的分组,留空则不更改')}
|
||||
name='groups'
|
||||
multiple
|
||||
allowAdditions
|
||||
additionLabel={t('请在系统设置页面编辑分组倍率以添加新的分组:')}
|
||||
onChange={(value) => handleInputChange('groups', value)}
|
||||
value={inputs.groups}
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</Spin>
|
||||
)}
|
||||
</Form>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@ import ChannelsTable from '../../components/table/ChannelsTable';
|
||||
|
||||
const File = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<ChannelsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ const ChatPage = () => {
|
||||
return !isLoading && iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
style={{ width: '100%', height: 'calc(100vh - 64px)', border: 'none', marginTop: '64px' }}
|
||||
title='Token Frame'
|
||||
allow='camera;microphone'
|
||||
/>
|
||||
) : (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000] mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin
|
||||
size="large"
|
||||
|
||||
@@ -17,7 +17,7 @@ const chat2page = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-[64px]">
|
||||
<h3>正在加载,请稍候...</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState, useMemo, useCallback }
|
||||
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 { marked } from 'marked';
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -365,6 +366,86 @@ const Detail = (props) => {
|
||||
},
|
||||
});
|
||||
|
||||
// 模型消耗趋势折线图
|
||||
const [spec_model_line, setSpecModelLine] = useState({
|
||||
type: 'line',
|
||||
data: [
|
||||
{
|
||||
id: 'lineData',
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
xField: 'Time',
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
legends: {
|
||||
visible: true,
|
||||
selectMode: 'single',
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型消耗趋势'),
|
||||
subtext: '',
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => renderNumber(datum['Count']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
// 模型调用次数排行柱状图
|
||||
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||
type: 'bar',
|
||||
data: [
|
||||
{
|
||||
id: 'rankData',
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
xField: 'Model',
|
||||
yField: 'Count',
|
||||
seriesField: 'Model',
|
||||
legends: {
|
||||
visible: true,
|
||||
selectMode: 'single',
|
||||
},
|
||||
title: {
|
||||
visible: true,
|
||||
text: t('模型调用次数排行'),
|
||||
subtext: '',
|
||||
},
|
||||
bar: {
|
||||
state: {
|
||||
hover: {
|
||||
stroke: '#000',
|
||||
lineWidth: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mark: {
|
||||
content: [
|
||||
{
|
||||
key: (datum) => datum['Model'],
|
||||
value: (datum) => renderNumber(datum['Count']),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
color: {
|
||||
specified: modelColorMap,
|
||||
},
|
||||
});
|
||||
|
||||
// ========== Hooks - Memoized Values ==========
|
||||
const performanceMetrics = useMemo(() => {
|
||||
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
||||
@@ -852,6 +933,46 @@ const Detail = (props) => {
|
||||
'barData'
|
||||
);
|
||||
|
||||
// ===== 模型调用次数折线图 =====
|
||||
let modelLineData = [];
|
||||
chartTimePoints.forEach((time) => {
|
||||
const timeData = Array.from(uniqueModels).map((model) => {
|
||||
const key = `${time}-${model}`;
|
||||
const aggregated = aggregatedData.get(key);
|
||||
return {
|
||||
Time: time,
|
||||
Model: model,
|
||||
Count: aggregated?.count || 0,
|
||||
};
|
||||
});
|
||||
modelLineData.push(...timeData);
|
||||
});
|
||||
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||
|
||||
// ===== 模型调用次数排行柱状图 =====
|
||||
const rankData = Array.from(modelTotals)
|
||||
.map(([model, count]) => ({
|
||||
Model: model,
|
||||
Count: count,
|
||||
}))
|
||||
.sort((a, b) => b.Count - a.Count);
|
||||
|
||||
updateChartSpec(
|
||||
setSpecModelLine,
|
||||
modelLineData,
|
||||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||
newModelColors,
|
||||
'lineData'
|
||||
);
|
||||
|
||||
updateChartSpec(
|
||||
setSpecRankBar,
|
||||
rankData,
|
||||
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||
newModelColors,
|
||||
'rankData'
|
||||
);
|
||||
|
||||
setPieData(newPieData);
|
||||
setLineData(newLineData);
|
||||
setConsumeQuota(totalQuota);
|
||||
@@ -983,7 +1104,7 @@ const Detail = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 h-full">
|
||||
<div className="bg-gray-50 h-full mt-[64px]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">{getGreeting}</h2>
|
||||
<div className="flex gap-3">
|
||||
@@ -1121,28 +1242,53 @@ const Detail = (props) => {
|
||||
{t('消耗分布')}
|
||||
</span>
|
||||
} itemKey="1" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPulse />
|
||||
{t('消耗趋势')}
|
||||
</span>
|
||||
} itemKey="2" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconPieChart2Stroked />
|
||||
{t('调用次数分布')}
|
||||
</span>
|
||||
} itemKey="2" />
|
||||
} itemKey="3" />
|
||||
<TabPane tab={
|
||||
<span>
|
||||
<IconHistogram />
|
||||
{t('调用次数排行')}
|
||||
</span>
|
||||
} itemKey="4" />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ height: 400 }}>
|
||||
{activeChartTab === '1' ? (
|
||||
{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>
|
||||
|
||||
@@ -1267,10 +1413,27 @@ const Detail = (props) => {
|
||||
onScroll={() => handleCardScroll(announcementScrollRef, setShowAnnouncementScrollHint)}
|
||||
>
|
||||
{announcementData.length > 0 ? (
|
||||
<Timeline
|
||||
mode="alternate"
|
||||
dataSource={announcementData}
|
||||
/>
|
||||
<Timeline mode="alternate">
|
||||
{announcementData.map((item, idx) => (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type || 'default'}
|
||||
time={item.time}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
|
||||
/>
|
||||
{item.extra && (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
@@ -1321,7 +1484,9 @@ const Detail = (props) => {
|
||||
header={item.question}
|
||||
itemKey={index.toString()}
|
||||
>
|
||||
<p>{item.answer}</p>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile } from '../../helpers';
|
||||
import { Button, Typography, Tag, Input, ScrollList, ScrollItem } from '@douyinfe/semi-ui';
|
||||
import { API, showError, isMobile, copy, showSuccess } from '../../helpers';
|
||||
import { API_ENDPOINTS } from '../../constants/common.constant';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconGithubLogo, IconPlay, IconFile } from '@douyinfe/semi-icons';
|
||||
import { IconGithubLogo, IconPlay, IconFile, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import NoticeModal from '../../components/layout/NoticeModal';
|
||||
import { Moonshot, OpenAI, XAI, Zhipu, Volcengine, Cohere, Claude, Gemini, Suno, Minimax, Wenxin, Spark, Qingyan, DeepSeek, Qwen, Midjourney, Grok, AzureAI, Hunyuan, Xinference } from '@lobehub/icons';
|
||||
@@ -17,29 +18,12 @@ const Home = () => {
|
||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||
const [homePageContent, setHomePageContent] = useState('');
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
const serverAddress = statusState?.status?.server_address || window.location.origin;
|
||||
const endpointItems = API_ENDPOINTS.map((e) => ({ value: e }));
|
||||
const [endpointIndex, setEndpointIndex] = useState(0);
|
||||
const isChinese = i18n.language.startsWith('zh');
|
||||
|
||||
const displayHomePageContent = async () => {
|
||||
setHomePageContent(localStorage.getItem('home_page_content') || '');
|
||||
@@ -71,10 +55,44 @@ const Home = () => {
|
||||
setHomePageContentLoaded(true);
|
||||
};
|
||||
|
||||
const handleCopyBaseURL = async () => {
|
||||
const ok = await copy(serverAddress);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制到剪切板'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkNoticeAndShow = async () => {
|
||||
const lastCloseDate = localStorage.getItem('notice_close_date');
|
||||
const today = new Date().toDateString();
|
||||
if (lastCloseDate !== today) {
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, data } = res.data;
|
||||
if (success && data && data.trim() !== '') {
|
||||
setNoticeVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取公告失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkNoticeAndShow();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
displayHomePageContent().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setEndpointIndex((prev) => (prev + 1) % endpointItems.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<NoticeModal
|
||||
@@ -86,30 +104,64 @@ const Home = () => {
|
||||
<div className="w-full overflow-x-hidden">
|
||||
{/* Banner 部分 */}
|
||||
<div className="w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden">
|
||||
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32">
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" />
|
||||
<div className="blur-ball blur-ball-teal" />
|
||||
<div className="flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10">
|
||||
{/* 居中内容区 */}
|
||||
<div className="flex flex-col items-center justify-center text-center max-w-4xl mx-auto">
|
||||
<div className="flex flex-col items-center justify-center mb-6 md:mb-8">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-semibold text-semi-color-text-0 leading-tight">
|
||||
<h1 className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`}>
|
||||
{i18n.language === 'en' ? (
|
||||
<>
|
||||
The Unified<br />
|
||||
LLMs API Gateway
|
||||
<span className="shine-text">LLMs API Gateway</span>
|
||||
</>
|
||||
) : (
|
||||
t('统一的大模型接口网关')
|
||||
<>
|
||||
统一的<br />
|
||||
<span className="shine-text">大模型接口网关</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-semi-color-text-1 mt-4 md:mt-6">
|
||||
{t('更好的价格,更好的稳定性,无需订阅')}
|
||||
<p className="text-base md:text-lg lg:text-xl text-semi-color-text-1 mt-4 md:mt-6 max-w-xl">
|
||||
{t('更好的价格,更好的稳定性,只需要将模型基址替换为:')}
|
||||
</p>
|
||||
{/* BASE URL 与端点选择 */}
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-4 w-full mt-4 md:mt-6 max-w-md">
|
||||
<Input
|
||||
readonly
|
||||
value={serverAddress}
|
||||
className="flex-1 !rounded-full"
|
||||
size={isMobile() ? 'default' : 'large'}
|
||||
suffix={
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollList bodyHeight={32} style={{ border: 'unset', boxShadow: 'unset' }}>
|
||||
<ScrollItem
|
||||
mode="wheel"
|
||||
cycled={true}
|
||||
list={endpointItems}
|
||||
selectedIndex={endpointIndex}
|
||||
onSelect={({ index }) => setEndpointIndex(index)}
|
||||
/>
|
||||
</ScrollList>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleCopyBaseURL}
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<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 />}>
|
||||
{t('开始使用')}
|
||||
{t('获取密钥')}
|
||||
</Button>
|
||||
</Link>
|
||||
{isDemoSiteMode && statusState?.status?.version ? (
|
||||
@@ -220,10 +272,7 @@ const Home = () => {
|
||||
className="w-full h-screen border-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-base md:text-lg p-4 md:p-6 lg:p-8 overflow-x-hidden max-w-6xl mx-auto"
|
||||
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||
></div>
|
||||
<div className="mt-[64px]" dangerouslySetInnerHTML={{ __html: homePageContent }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import LogsTable from '../../components/table/LogsTable';
|
||||
|
||||
const Token = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<LogsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import MjLogsTable from '../../components/table/MjLogsTable';
|
||||
|
||||
const Midjourney = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<MjLogsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen p-8">
|
||||
<div className="flex justify-center items-center h-screen p-8 mt-[64px]">
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={<IllustrationNotFoundDark style={{ width: 250, height: 250 }} />}
|
||||
|
||||
@@ -363,7 +363,7 @@ const Playground = () => {
|
||||
}, [setMessage, saveMessagesImmediately]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50">
|
||||
<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) && (
|
||||
<Layout.Sider
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import ModelPricing from '../../components/table/ModelPricing.js';
|
||||
|
||||
const Pricing = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<ModelPricing />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
SideSheet,
|
||||
Space,
|
||||
@@ -21,13 +19,14 @@ import {
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
DatePicker,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlusCircle,
|
||||
IconGift,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
@@ -37,30 +36,30 @@ const EditRedemption = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = props.editingRedemption.id !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const originInputs = {
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
quota: 100000,
|
||||
count: 1,
|
||||
expired_time: 0,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const { name, quota, count, expired_time } = inputs;
|
||||
expired_time: null,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const loadRedemption = async () => {
|
||||
setLoading(true);
|
||||
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setInputs(data);
|
||||
if (data.expired_time === 0) {
|
||||
data.expired_time = null;
|
||||
} else {
|
||||
data.expired_time = new Date(data.expired_time * 1000);
|
||||
}
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -68,28 +67,29 @@ const EditRedemption = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadRedemption().then(() => {
|
||||
// console.log(inputs);
|
||||
});
|
||||
} else {
|
||||
setInputs(originInputs);
|
||||
if (formApiRef.current) {
|
||||
if (isEdit) {
|
||||
loadRedemption();
|
||||
} else {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [props.editingRedemption.id]);
|
||||
|
||||
const submit = async () => {
|
||||
let name = inputs.name;
|
||||
if (!isEdit && inputs.name === '') {
|
||||
// set default name
|
||||
name = renderQuota(quota);
|
||||
const submit = async (values) => {
|
||||
let name = values.name;
|
||||
if (!isEdit && (!name || name === '')) {
|
||||
name = renderQuota(values.quota);
|
||||
}
|
||||
setLoading(true);
|
||||
let localInputs = inputs;
|
||||
localInputs.count = parseInt(localInputs.count);
|
||||
localInputs.quota = parseInt(localInputs.quota);
|
||||
let localInputs = { ...values };
|
||||
localInputs.count = parseInt(localInputs.count) || 0;
|
||||
localInputs.quota = parseInt(localInputs.quota) || 0;
|
||||
localInputs.name = name;
|
||||
if (localInputs.expired_time === null || localInputs.expired_time === undefined) {
|
||||
if (!localInputs.expired_time) {
|
||||
localInputs.expired_time = 0;
|
||||
} else {
|
||||
localInputs.expired_time = Math.floor(localInputs.expired_time.getTime() / 1000);
|
||||
}
|
||||
let res;
|
||||
if (isEdit) {
|
||||
@@ -110,8 +110,8 @@ const EditRedemption = (props) => {
|
||||
props.handleClose();
|
||||
} else {
|
||||
showSuccess(t('兑换码创建成功!'));
|
||||
setInputs(originInputs);
|
||||
props.refresh();
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.handleClose();
|
||||
}
|
||||
} else {
|
||||
@@ -131,7 +131,7 @@ const EditRedemption = (props) => {
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
downloadTextAsFile(text, `${inputs.name}.txt`);
|
||||
downloadTextAsFile(text, `${localInputs.name}.txt`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -153,14 +153,7 @@ const EditRedemption = (props) => {
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
@@ -168,9 +161,7 @@ const EditRedemption = (props) => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
@@ -178,8 +169,6 @@ const EditRedemption = (props) => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
@@ -193,123 +182,119 @@ const EditRedemption = (props) => {
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconGift size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置兑换码的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
required={!isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('过期时间')}</Text>
|
||||
<DatePicker
|
||||
type="dateTime"
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
showClear
|
||||
value={expired_time ? new Date(expired_time * 1000) : null}
|
||||
onChange={(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
handleInputChange('expired_time', 0);
|
||||
} else {
|
||||
const timestamp = Math.floor(value.getTime() / 1000);
|
||||
handleInputChange('expired_time', timestamp);
|
||||
}
|
||||
}}
|
||||
size="large"
|
||||
className="!rounded-lg w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconCreditCard size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('额度设置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置兑换码的额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => formApiRef.current = api}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
{/* Header: Basic Info */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconGift size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutoComplete
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('生成数量')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入生成数量')}
|
||||
onChange={(value) => handleInputChange('count', value)}
|
||||
value={count}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconPlusCircle />}
|
||||
/>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
style={{ width: '100%' }}
|
||||
rules={!isEdit ? [] : [{ required: true, message: t('请输入名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('选择过期时间(可选,留空为永久)')}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
{/* Header: Quota Settings */}
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="green" className="mr-2 shadow-md">
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('额度设置')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('设置兑换码的额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.AutoComplete
|
||||
field='quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
style={{ width: '100%' }}
|
||||
type='number'
|
||||
rules={[
|
||||
{ required: true, message: t('请输入额度') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('额度必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
extraText={renderQuotaWithPrompt(Number(values.quota) || 0)}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='count'
|
||||
label={t('生成数量')}
|
||||
min={1}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入生成数量') },
|
||||
{
|
||||
validator: (rule, v) => {
|
||||
const num = parseInt(v, 10);
|
||||
return num > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(t('生成数量必须大于0'));
|
||||
},
|
||||
},
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
@@ -3,9 +3,9 @@ import RedemptionsTable from '../../components/table/RedemptionsTable';
|
||||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<RedemptionsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
@@ -16,7 +13,6 @@ import {
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
verifyJSONPromise,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -80,21 +76,6 @@ export default function SettingsChats(props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetModelRatio() {
|
||||
try {
|
||||
let res = await API.post(`/api/option/rest_model_ratio`);
|
||||
// return {success, message}
|
||||
if (res.data.success) {
|
||||
showSuccess(res.data.message);
|
||||
props.refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -119,13 +100,7 @@ export default function SettingsChats(props) {
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('令牌聊天设置')}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'必须将上方聊天链接全部设置为空,才能使用下方聊天设置功能',
|
||||
)}
|
||||
/>
|
||||
<Form.Section text={t('聊天设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
@@ -230,7 +230,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
render: (text, record) => (
|
||||
<Tag
|
||||
color={record.color}
|
||||
className="!rounded-full"
|
||||
shape='circle'
|
||||
style={{ maxWidth: '280px' }}
|
||||
>
|
||||
{text}
|
||||
@@ -277,7 +277,6 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditApi(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
@@ -287,7 +286,6 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteApi(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
@@ -327,7 +325,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
onClick={handleAddApi}
|
||||
>
|
||||
{t('添加API')}
|
||||
@@ -338,7 +336,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
@@ -348,7 +346,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
@@ -430,7 +428,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
@@ -441,7 +439,6 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
onCancel={() => setShowApiModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form layout='vertical' initValues={apiForm} key={editingApi ? editingApi.id : 'new'}>
|
||||
@@ -495,7 +492,6 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
Divider,
|
||||
Modal,
|
||||
Tag,
|
||||
Switch
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -20,7 +22,8 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
Bell
|
||||
Bell,
|
||||
Maximize2
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, getRelativeTime, formatDateTimeString } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -33,6 +36,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
const [announcementsList, setAnnouncementsList] = useState([]);
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showContentModal, setShowContentModal] = useState(false);
|
||||
const [deletingAnnouncement, setDeletingAnnouncement] = useState(null);
|
||||
const [editingAnnouncement, setEditingAnnouncement] = useState(null);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
@@ -51,6 +55,8 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
// 面板启用状态
|
||||
const [panelEnabled, setPanelEnabled] = useState(true);
|
||||
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'default', label: t('默认') },
|
||||
{ value: 'ongoing', label: t('进行中') },
|
||||
@@ -76,13 +82,16 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
<Tooltip content={text} position='topLeft' showArrow>
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -121,13 +130,17 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
dataIndex: 'extra',
|
||||
key: 'extra',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '200px',
|
||||
wordBreak: 'break-word',
|
||||
color: 'var(--semi-color-text-2)'
|
||||
}}>
|
||||
{text || '-'}
|
||||
</div>
|
||||
<Tooltip content={text || '-'} showArrow>
|
||||
<div style={{
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'var(--semi-color-text-2)'
|
||||
}}>
|
||||
{text || '-'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -142,7 +155,6 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditAnnouncement(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
@@ -152,7 +164,6 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteAnnouncement(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
@@ -352,7 +363,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
onClick={handleAddAnnouncement}
|
||||
>
|
||||
{t('添加公告')}
|
||||
@@ -363,7 +374,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
@@ -373,7 +384,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
@@ -388,11 +399,17 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// 计算当前页显示的数据
|
||||
// 计算当前页显示的数据(按发布时间倒序排序,最新优先显示)
|
||||
const getCurrentPageData = () => {
|
||||
const sortedList = [...announcementsList].sort((a, b) => {
|
||||
const dateA = new Date(a.publishDate).getTime();
|
||||
const dateB = new Date(b.publishDate).getTime();
|
||||
return dateB - dateA; // 倒序,最新的排在前面
|
||||
});
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return announcementsList.slice(startIndex, endIndex);
|
||||
return sortedList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
@@ -452,7 +469,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
@@ -463,19 +480,33 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
onCancel={() => setShowAnnouncementModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
>
|
||||
<Form layout='vertical' initValues={announcementForm} key={editingAnnouncement ? editingAnnouncement.id : 'new'}>
|
||||
<Form
|
||||
layout='vertical'
|
||||
initValues={announcementForm}
|
||||
key={editingAnnouncement ? editingAnnouncement.id : 'new'}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.TextArea
|
||||
field='content'
|
||||
label={t('公告内容')}
|
||||
placeholder={t('请输入公告内容')}
|
||||
placeholder={t('请输入公告内容(支持 Markdown/HTML)')}
|
||||
maxCount={500}
|
||||
rows={3}
|
||||
rules={[{ required: true, message: t('请输入公告内容') }]}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
|
||||
/>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Maximize2 size={14} />}
|
||||
style={{ marginBottom: 16 }}
|
||||
onClick={() => setShowContentModal(true)}
|
||||
>
|
||||
{t('放大编辑')}
|
||||
</Button>
|
||||
<Form.DatePicker
|
||||
field='publishDate'
|
||||
label={t('发布日期')}
|
||||
@@ -509,7 +540,6 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
@@ -517,6 +547,32 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
>
|
||||
<Text>{t('确定要删除此公告吗?')}</Text>
|
||||
</Modal>
|
||||
|
||||
{/* 公告内容放大编辑 Modal */}
|
||||
<Modal
|
||||
title={t('编辑公告内容')}
|
||||
visible={showContentModal}
|
||||
onOk={() => {
|
||||
// 将内容同步到表单
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('content', announcementForm.content);
|
||||
}
|
||||
setShowContentModal(false);
|
||||
}}
|
||||
onCancel={() => setShowContentModal(false)}
|
||||
okText={t('确定')}
|
||||
cancelText={t('取消')}
|
||||
width={800}
|
||||
>
|
||||
<TextArea
|
||||
value={announcementForm.content}
|
||||
placeholder={t('请输入公告内容(支持 Markdown/HTML)')}
|
||||
maxCount={500}
|
||||
rows={15}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setAnnouncementForm({ ...announcementForm, content: value })}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Empty,
|
||||
Divider,
|
||||
Modal,
|
||||
Switch
|
||||
Switch,
|
||||
Tooltip
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -54,13 +55,17 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
dataIndex: 'question',
|
||||
key: 'question',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
<Tooltip content={text} showArrow>
|
||||
<div style={{
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -68,14 +73,17 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
dataIndex: 'answer',
|
||||
key: 'answer',
|
||||
render: (text) => (
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'var(--semi-color-text-1)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
<Tooltip content={text} showArrow>
|
||||
<div style={{
|
||||
maxWidth: '400px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'var(--semi-color-text-1)'
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -90,7 +98,6 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditFaq(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
@@ -100,7 +107,6 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteFaq(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
@@ -289,7 +295,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
onClick={handleAddFaq}
|
||||
>
|
||||
{t('添加问答')}
|
||||
@@ -300,7 +306,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
@@ -310,7 +316,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
@@ -389,7 +395,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
@@ -400,7 +406,6 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
onCancel={() => setShowFaqModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
width={800}
|
||||
>
|
||||
@@ -416,7 +421,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
<Form.TextArea
|
||||
field='answer'
|
||||
label={t('回答内容')}
|
||||
placeholder={t('请输入回答内容')}
|
||||
placeholder={t('请输入回答内容(支持 Markdown/HTML)')}
|
||||
maxCount={1000}
|
||||
rows={6}
|
||||
rules={[{ required: true, message: t('请输入回答内容') }]}
|
||||
@@ -436,7 +441,6 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
|
||||
@@ -101,7 +101,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleEditGroup(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
@@ -111,7 +110,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
type='danger'
|
||||
theme='light'
|
||||
size='small'
|
||||
className="!rounded-full"
|
||||
onClick={() => handleDeleteGroup(record)}
|
||||
>
|
||||
{t('删除')}
|
||||
@@ -314,7 +312,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<Plus size={14} />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('添加分类')}
|
||||
@@ -325,7 +323,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
theme='light'
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('批量删除')} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
@@ -335,7 +333,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
loading={loading}
|
||||
disabled={!hasChanges}
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
@@ -413,7 +411,7 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
className="overflow-hidden"
|
||||
/>
|
||||
</Form.Section>
|
||||
|
||||
@@ -424,7 +422,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
onCancel={() => setShowUptimeModal(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
className="rounded-xl"
|
||||
confirmLoading={modalLoading}
|
||||
width={600}
|
||||
>
|
||||
@@ -467,7 +464,6 @@ const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
okText={t('确认删除')}
|
||||
cancelText={t('取消')}
|
||||
type="warning"
|
||||
className="rounded-xl"
|
||||
okButtonProps={{
|
||||
type: 'danger',
|
||||
theme: 'solid'
|
||||
|
||||
@@ -173,7 +173,8 @@ export default function SettingGeminiModel(props) {
|
||||
<Text>
|
||||
{t(
|
||||
"和Claude不同,默认情况下Gemini的思考模型会自动决定要不要思考,就算不开启适配模型也可以正常使用," +
|
||||
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置"
|
||||
"如果您需要计费,推荐设置无后缀模型价格按思考价格设置。" +
|
||||
"支持使用 gemini-2.5-pro-preview-06-05-thinking-128 格式来精确传递思考预算。"
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
@@ -183,7 +184,7 @@ export default function SettingGeminiModel(props) {
|
||||
<Form.Switch
|
||||
label={t('启用Gemini思考后缀适配')}
|
||||
field={'gemini.thinking_adapter_enabled'}
|
||||
extraText={"适配-thinking和-nothinking后缀"}
|
||||
extraText={t('适配 -thinking、-thinking-预算数字 和 -nothinking 后缀')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
@@ -205,11 +206,11 @@ export default function SettingGeminiModel(props) {
|
||||
<Row>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
label={t('请求模型带-thinking后缀的BudgetTokens数(超出24576的部分将被忽略)')}
|
||||
label={t('思考预算占比')}
|
||||
field={'gemini.thinking_adapter_budget_tokens_percentage'}
|
||||
initValue={''}
|
||||
extraText={t('0.1-1之间的小数')}
|
||||
min={0.1}
|
||||
extraText={t('0.002-1之间的小数')}
|
||||
min={0.002}
|
||||
max={1}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function GroupRatioSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length)
|
||||
return showWarning(t('你似乎并没有修改什么'));
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const value =
|
||||
typeof inputs[item.key] === 'boolean'
|
||||
? String(inputs[item.key])
|
||||
: inputs[item.key];
|
||||
return API.put('/api/option/', { key: item.key, value });
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (res.includes(undefined)) {
|
||||
return showError(
|
||||
requestQueue.length > 1
|
||||
? t('部分保存失败,请重试')
|
||||
: t('保存失败'),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
if (!res[i].data.success) {
|
||||
return showError(res[i].data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
} catch (error) {
|
||||
showError(t('请检查输入'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section text={t('分组设置')}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
|
||||
extraText={t(
|
||||
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1',
|
||||
)}
|
||||
field={'GroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('用户可选分组')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
|
||||
extraText={t(
|
||||
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
|
||||
)}
|
||||
field={'UserUsableGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, UserUsableGroups: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组特殊倍率')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
extraText={t(
|
||||
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
|
||||
)}
|
||||
field={'GroupGroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupGroupRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Collapse,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -92,10 +91,6 @@ export default function GeneralSettings(props) {
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('聊天链接功能已经弃用,请使用下方聊天设置功能')}
|
||||
/>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
|
||||
74
web/src/pages/Setting/Payment/SettingsGeneralPayment.js
Normal file
74
web/src/pages/Setting/Payment/SettingsGeneralPayment.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsGeneralPayment(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
ServerAddress: '',
|
||||
});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = { ServerAddress: props.options.ServerAddress || '' };
|
||||
setInputs(currentInputs);
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitServerAddress = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
|
||||
const res = await API.put('/api/option/', {
|
||||
key: 'ServerAddress',
|
||||
value: ServerAddress,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
props.refresh && props.refresh();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={t('通用设置')}>
|
||||
<Form.Input
|
||||
field='ServerAddress'
|
||||
label={t('服务器地址')}
|
||||
placeholder={'https://yourdomain.com'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
218
web/src/pages/Setting/Payment/SettingsPaymentGateway.js
Normal file
218
web/src/pages/Setting/Payment/SettingsPaymentGateway.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function SettingsPaymentGateway(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
PayAddress: '',
|
||||
EpayId: '',
|
||||
EpayKey: '',
|
||||
Price: 7.3,
|
||||
MinTopUp: 1,
|
||||
TopupGroupRatio: '',
|
||||
CustomCallbackAddress: '',
|
||||
PayMethods: '',
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.options && formApiRef.current) {
|
||||
const currentInputs = {
|
||||
PayAddress: props.options.PayAddress || '',
|
||||
EpayId: props.options.EpayId || '',
|
||||
EpayKey: props.options.EpayKey || '',
|
||||
Price: props.options.Price !== undefined ? parseFloat(props.options.Price) : 7.3,
|
||||
MinTopUp: props.options.MinTopUp !== undefined ? parseFloat(props.options.MinTopUp) : 1,
|
||||
TopupGroupRatio: props.options.TopupGroupRatio || '',
|
||||
CustomCallbackAddress: props.options.CustomCallbackAddress || '',
|
||||
PayMethods: props.options.PayMethods || '',
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
formApiRef.current.setValues(currentInputs);
|
||||
}
|
||||
}, [props.options]);
|
||||
|
||||
const handleFormChange = (values) => {
|
||||
setInputs(values);
|
||||
};
|
||||
|
||||
const submitPayAddress = async () => {
|
||||
if (props.options.ServerAddress === '') {
|
||||
showError(t('请先填写服务器地址'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
if (!verifyJSON(inputs.TopupGroupRatio)) {
|
||||
showError(t('充值分组倍率不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
if (!verifyJSON(inputs.PayMethods)) {
|
||||
showError(t('充值方式设置不是合法的 JSON 字符串'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const options = [
|
||||
{ key: 'PayAddress', value: removeTrailingSlash(inputs.PayAddress) },
|
||||
];
|
||||
|
||||
if (inputs.EpayId !== '') {
|
||||
options.push({ key: 'EpayId', value: inputs.EpayId });
|
||||
}
|
||||
if (inputs.EpayKey !== undefined && inputs.EpayKey !== '') {
|
||||
options.push({ key: 'EpayKey', value: inputs.EpayKey });
|
||||
}
|
||||
if (inputs.Price !== '') {
|
||||
options.push({ key: 'Price', value: inputs.Price.toString() });
|
||||
}
|
||||
if (inputs.MinTopUp !== '') {
|
||||
options.push({ key: 'MinTopUp', value: inputs.MinTopUp.toString() });
|
||||
}
|
||||
if (inputs.CustomCallbackAddress !== '') {
|
||||
options.push({
|
||||
key: 'CustomCallbackAddress',
|
||||
value: inputs.CustomCallbackAddress,
|
||||
});
|
||||
}
|
||||
if (originInputs['TopupGroupRatio'] !== inputs.TopupGroupRatio) {
|
||||
options.push({ key: 'TopupGroupRatio', value: inputs.TopupGroupRatio });
|
||||
}
|
||||
if (originInputs['PayMethods'] !== inputs.PayMethods) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map(opt =>
|
||||
API.put('/api/option/', {
|
||||
key: opt.key,
|
||||
value: opt.value,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(requestQueue);
|
||||
|
||||
// 检查所有请求是否成功
|
||||
const errorResults = results.filter(res => !res.data.success);
|
||||
if (errorResults.length > 0) {
|
||||
errorResults.forEach(res => {
|
||||
showError(res.data.message);
|
||||
});
|
||||
} else {
|
||||
showSuccess(t('更新成功'));
|
||||
// 更新本地存储的原始值
|
||||
setOriginInputs({ ...inputs });
|
||||
props.refresh && props.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('更新失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
initValues={inputs}
|
||||
onValueChange={handleFormChange}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
<Form.Section text={t('支付设置')}>
|
||||
<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='PayAddress'
|
||||
label={t('支付地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayId'
|
||||
label={t('易支付商户ID')}
|
||||
placeholder={t('例如:0001')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='EpayKey'
|
||||
label={t('易支付商户密钥')}
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
type='password'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='CustomCallbackAddress'
|
||||
label={t('回调地址')}
|
||||
placeholder={t('例如:https://yourdomain.com')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='Price'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='MinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.TextArea
|
||||
field='TopupGroupRatio'
|
||||
label={t('充值分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为组名称,值为倍率')}
|
||||
autosize
|
||||
/>
|
||||
<Form.TextArea
|
||||
field='PayMethods'
|
||||
label={t('充值方式设置')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export default function RequestRateLimit(props) {
|
||||
]}
|
||||
extraText={
|
||||
<div>
|
||||
<p style={{ marginBottom: -15 }}>{t('说明:')}</p>
|
||||
<p>{t('说明:')}</p>
|
||||
<ul>
|
||||
<li>{t('使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}')}</li>
|
||||
<li>{t('示例:{"default": [200, 100], "vip": [0, 1000]}。')}</li>
|
||||
|
||||
228
web/src/pages/Setting/Ratio/GroupRatioSettings.js
Normal file
228
web/src/pages/Setting/Ratio/GroupRatioSettings.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
showWarning,
|
||||
verifyJSON,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function GroupRatioSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputs, setInputs] = useState({
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await refForm.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
const updateArray = compareObjects(inputs, inputsRow);
|
||||
if (!updateArray.length)
|
||||
return showWarning(t('你似乎并没有修改什么'));
|
||||
|
||||
const requestQueue = updateArray.map((item) => {
|
||||
const value =
|
||||
typeof inputs[item.key] === 'boolean'
|
||||
? String(inputs[item.key])
|
||||
: inputs[item.key];
|
||||
return API.put('/api/option/', { key: item.key, value });
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
Promise.all(requestQueue)
|
||||
.then((res) => {
|
||||
if (res.includes(undefined)) {
|
||||
return showError(
|
||||
requestQueue.length > 1
|
||||
? t('部分保存失败,请重试')
|
||||
: t('保存失败'),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
if (!res[i].data.success) {
|
||||
return showError(res[i].data.message);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(t('保存成功'));
|
||||
props.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
showError(t('保存失败,请重试'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('请检查输入'));
|
||||
});
|
||||
} catch (error) {
|
||||
showError(t('请检查输入'));
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
if (Object.keys(inputs).includes(key)) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
}, [props.options]);
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为倍率')}
|
||||
extraText={t(
|
||||
'分组倍率设置,可以在此处新增分组或修改现有分组的倍率,格式为 JSON 字符串,例如:{"vip": 0.5, "test": 1},表示 vip 分组的倍率为 0.5,test 分组的倍率为 1',
|
||||
)}
|
||||
field={'GroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('用户可选分组')}
|
||||
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
|
||||
extraText={t(
|
||||
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
|
||||
)}
|
||||
field={'UserUsableGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, UserUsableGroups: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('分组特殊倍率')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
extraText={t(
|
||||
'键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{"vip": {"default": 0.5, "test": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5,使用test分组时倍率为1',
|
||||
)}
|
||||
field={'GroupGroupRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, GroupGroupRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('自动分组auto,从第一个开始选择')}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'AutoGroups'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return true; // Allow empty values
|
||||
}
|
||||
|
||||
// First check if it's valid JSON
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
// Check if it's an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if every element is a string
|
||||
return parsed.every(item => typeof item === 'string');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AutoGroups: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t(
|
||||
'创建令牌默认选择auto分组,初始令牌也将设为auto(否则留空,为用户默认分组)',
|
||||
)}
|
||||
field={'DefaultUseAutoGroup'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, DefaultUseAutoGroup: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export default function ModelRatioSettings(props) {
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -117,96 +118,105 @@ export default function ModelRatioSettings(props) {
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型固定价格')}
|
||||
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀',
|
||||
)}
|
||||
field={'ModelPrice'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ModelPrice: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'ModelRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ModelRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('提示缓存倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CacheRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CacheRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型补全倍率(仅对自定义模型有效)')}
|
||||
extraText={t('仅对自定义模型有效')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CompletionRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型固定价格')}
|
||||
extraText={t('一次调用消耗多少刀,优先级大于模型倍率')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀',
|
||||
)}
|
||||
field={'ModelPrice'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ModelPrice: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'ModelRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ModelRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('提示缓存倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CacheRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CacheRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('模型补全倍率(仅对自定义模型有效)')}
|
||||
extraText={t('仅对自定义模型有效')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率')}
|
||||
field={'CompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, CompletionRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
label={t('暴露倍率接口')}
|
||||
field={'ExposeRatioEnabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ExposeRatioEnabled: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button onClick={onSubmit}>{t('保存模型倍率设置')}</Button>
|
||||
@@ -372,7 +372,7 @@ export default function ModelRatioNotSetEditor(props) {
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Space className='mt-2'>
|
||||
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
Form,
|
||||
Space,
|
||||
RadioGroup,
|
||||
Radio
|
||||
Radio,
|
||||
Checkbox,
|
||||
Tag
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconDelete,
|
||||
@@ -30,6 +32,7 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
|
||||
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
|
||||
const [conflictOnly, setConflictOnly] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const pageSize = 10;
|
||||
const quotaPerUnit = getQuotaPerUnit();
|
||||
@@ -47,13 +50,19 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
...Object.keys(completionRatio),
|
||||
]);
|
||||
|
||||
const modelData = Array.from(modelNames).map((name) => ({
|
||||
name,
|
||||
price: modelPrice[name] === undefined ? '' : modelPrice[name],
|
||||
ratio: modelRatio[name] === undefined ? '' : modelRatio[name],
|
||||
completionRatio:
|
||||
completionRatio[name] === undefined ? '' : completionRatio[name],
|
||||
}));
|
||||
const modelData = Array.from(modelNames).map((name) => {
|
||||
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
|
||||
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
|
||||
const comp = completionRatio[name] === undefined ? '' : completionRatio[name];
|
||||
|
||||
return {
|
||||
name,
|
||||
price,
|
||||
ratio,
|
||||
completionRatio: comp,
|
||||
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
|
||||
};
|
||||
});
|
||||
|
||||
setModels(modelData);
|
||||
} catch (error) {
|
||||
@@ -69,11 +78,13 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
};
|
||||
|
||||
// 在 return 语句之前,先处理过滤和分页逻辑
|
||||
const filteredModels = models.filter((model) =>
|
||||
searchText
|
||||
const filteredModels = models.filter((model) => {
|
||||
const keywordMatch = searchText
|
||||
? model.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
: true;
|
||||
const conflictMatch = conflictOnly ? model.hasConflict : true;
|
||||
return keywordMatch && conflictMatch;
|
||||
});
|
||||
|
||||
// 然后基于过滤后的数据计算分页数据
|
||||
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
|
||||
@@ -152,6 +163,16 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<span>
|
||||
{text}
|
||||
{record.hasConflict && (
|
||||
<Tag color='red' shape='circle' className='ml-2'>
|
||||
{t('矛盾')}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('模型固定价格'),
|
||||
@@ -219,9 +240,13 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return;
|
||||
}
|
||||
setModels((prev) =>
|
||||
prev.map((model) =>
|
||||
model.name === name ? { ...model, [field]: value } : model,
|
||||
),
|
||||
prev.map((model) => {
|
||||
if (model.name !== name) return model;
|
||||
const updated = { ...model, [field]: value };
|
||||
updated.hasConflict =
|
||||
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -296,16 +321,18 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
if (existingModelIndex >= 0) {
|
||||
// Update existing model
|
||||
setModels((prev) =>
|
||||
prev.map((model, index) =>
|
||||
index === existingModelIndex
|
||||
? {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
}
|
||||
: model,
|
||||
),
|
||||
prev.map((model, index) => {
|
||||
if (index !== existingModelIndex) return model;
|
||||
const updated = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
};
|
||||
updated.hasConflict =
|
||||
updated.price !== '' && (updated.ratio !== '' || updated.completionRatio !== '');
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
setVisible(false);
|
||||
showSuccess(t('更新成功'));
|
||||
@@ -317,15 +344,17 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setModels((prev) => [
|
||||
{
|
||||
setModels((prev) => {
|
||||
const newModel = {
|
||||
name: values.name,
|
||||
price: values.price || '',
|
||||
ratio: values.ratio || '',
|
||||
completionRatio: values.completionRatio || '',
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
};
|
||||
newModel.hasConflict =
|
||||
newModel.price !== '' && (newModel.ratio !== '' || newModel.completionRatio !== '');
|
||||
return [newModel, ...prev];
|
||||
});
|
||||
setVisible(false);
|
||||
showSuccess(t('添加成功'));
|
||||
}
|
||||
@@ -404,7 +433,7 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
return (
|
||||
<>
|
||||
<Space vertical align='start' style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Space className='mt-2'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => {
|
||||
@@ -426,7 +455,17 @@ export default function ModelSettingsVisualEditor(props) {
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Checkbox
|
||||
checked={conflictOnly}
|
||||
onChange={(e) => {
|
||||
setConflictOnly(e.target.checked);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{t('仅显示矛盾倍率')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
759
web/src/pages/Setting/Ratio/UpstreamRatioSync.js
Normal file
759
web/src/pages/Setting/Ratio/UpstreamRatioSync.js
Normal file
@@ -0,0 +1,759 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Tag,
|
||||
Empty,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Tooltip,
|
||||
Select,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
RefreshCcw,
|
||||
CheckSquare,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess, showWarning, stringToColor, isMobile } from '../../../helpers';
|
||||
import { DEFAULT_ENDPOINT } from '../../../constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ChannelSelectorModal from '../../../components/settings/ChannelSelectorModal';
|
||||
|
||||
function ConflictConfirmModal({ t, visible, items, onOk, onCancel }) {
|
||||
const columns = [
|
||||
{ title: t('渠道'), dataIndex: 'channel' },
|
||||
{ title: t('模型'), dataIndex: 'model' },
|
||||
{
|
||||
title: t('当前计费'),
|
||||
dataIndex: 'current',
|
||||
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||
},
|
||||
{
|
||||
title: t('修改为'),
|
||||
dataIndex: 'newVal',
|
||||
render: (text) => <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认冲突项修改')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
size={isMobile() ? 'full-width' : 'large'}
|
||||
>
|
||||
<Table columns={columns} dataSource={items} pagination={false} size="small" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpstreamRatioSync(props) {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
|
||||
// 渠道选择相关
|
||||
const [allChannels, setAllChannels] = useState([]);
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState([]);
|
||||
|
||||
// 渠道端点配置
|
||||
const [channelEndpoints, setChannelEndpoints] = useState({}); // { channelId: endpoint }
|
||||
|
||||
// 差异数据和测试结果
|
||||
const [differences, setDifferences] = useState({});
|
||||
const [resolutions, setResolutions] = useState({});
|
||||
|
||||
// 是否已经执行过同步
|
||||
const [hasSynced, setHasSynced] = useState(false);
|
||||
|
||||
// 分页相关状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// 搜索相关状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
// 倍率类型过滤
|
||||
const [ratioTypeFilter, setRatioTypeFilter] = useState('');
|
||||
|
||||
// 冲突确认弹窗相关
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const [conflictItems, setConflictItems] = useState([]); // {channel, model, current, newVal, ratioType}
|
||||
|
||||
const channelSelectorRef = React.useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [ratioTypeFilter, searchKeyword]);
|
||||
|
||||
const fetchAllChannels = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/ratio_sync/channels');
|
||||
|
||||
if (res.data.success) {
|
||||
const channels = res.data.data || [];
|
||||
|
||||
const transferData = channels.map(channel => ({
|
||||
key: channel.id,
|
||||
label: channel.name,
|
||||
value: channel.id,
|
||||
disabled: false,
|
||||
_originalData: channel,
|
||||
}));
|
||||
|
||||
setAllChannels(transferData);
|
||||
|
||||
// 合并已有 endpoints,避免每次打开弹窗都重置
|
||||
setChannelEndpoints(prev => {
|
||||
const merged = { ...prev };
|
||||
transferData.forEach(channel => {
|
||||
if (!merged[channel.key]) {
|
||||
merged[channel.key] = DEFAULT_ENDPOINT;
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取渠道失败:') + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmChannelSelection = () => {
|
||||
const selected = allChannels
|
||||
.filter(ch => selectedChannelIds.includes(ch.value))
|
||||
.map(ch => ch._originalData);
|
||||
|
||||
if (selected.length === 0) {
|
||||
showWarning(t('请至少选择一个渠道'));
|
||||
return;
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchRatiosFromChannels(selected);
|
||||
};
|
||||
|
||||
const fetchRatiosFromChannels = async (channelList) => {
|
||||
setSyncLoading(true);
|
||||
|
||||
const upstreams = channelList.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
base_url: ch.base_url,
|
||||
endpoint: channelEndpoints[ch.id] || DEFAULT_ENDPOINT,
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
upstreams: upstreams,
|
||||
timeout: 10,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/ratio_sync/fetch', payload);
|
||||
|
||||
if (!res.data.success) {
|
||||
showError(res.data.message || t('后端请求失败'));
|
||||
setSyncLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { differences = {}, test_results = [] } = res.data.data;
|
||||
|
||||
const errorResults = test_results.filter(r => r.status === 'error');
|
||||
if (errorResults.length > 0) {
|
||||
showWarning(t('部分渠道测试失败:') + errorResults.map(r => `${r.name}: ${r.error}`).join(', '));
|
||||
}
|
||||
|
||||
setDifferences(differences);
|
||||
setResolutions({});
|
||||
setHasSynced(true);
|
||||
|
||||
if (Object.keys(differences).length === 0) {
|
||||
showSuccess(t('未找到差异化倍率,无需同步'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求后端接口失败:') + e.message);
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function getBillingCategory(ratioType) {
|
||||
return ratioType === 'model_price' ? 'price' : 'ratio';
|
||||
}
|
||||
|
||||
const selectValue = useCallback((model, ratioType, value) => {
|
||||
const category = getBillingCategory(ratioType);
|
||||
|
||||
setResolutions(prev => {
|
||||
const newModelRes = { ...(prev[model] || {}) };
|
||||
|
||||
Object.keys(newModelRes).forEach((rt) => {
|
||||
if (getBillingCategory(rt) !== category) {
|
||||
delete newModelRes[rt];
|
||||
}
|
||||
});
|
||||
|
||||
newModelRes[ratioType] = value;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[model]: newModelRes,
|
||||
};
|
||||
});
|
||||
}, [setResolutions]);
|
||||
|
||||
const applySync = async () => {
|
||||
const currentRatios = {
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
};
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
const getLocalBillingCategory = (model) => {
|
||||
if (currentRatios.ModelPrice[model] !== undefined) return 'price';
|
||||
if (currentRatios.ModelRatio[model] !== undefined ||
|
||||
currentRatios.CompletionRatio[model] !== undefined ||
|
||||
currentRatios.CacheRatio[model] !== undefined) return 'ratio';
|
||||
return null;
|
||||
};
|
||||
|
||||
const findSourceChannel = (model, ratioType, value) => {
|
||||
if (differences[model] && differences[model][ratioType]) {
|
||||
const upMap = differences[model][ratioType].upstreams || {};
|
||||
const entry = Object.entries(upMap).find(([_, v]) => v === value);
|
||||
if (entry) return entry[0];
|
||||
}
|
||||
return t('未知');
|
||||
};
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
const localCat = getLocalBillingCategory(model);
|
||||
const newCat = 'model_price' in ratios ? 'price' : 'ratio';
|
||||
|
||||
if (localCat && localCat !== newCat) {
|
||||
const currentDesc = localCat === 'price'
|
||||
? `${t('固定价格')} : ${currentRatios.ModelPrice[model]}`
|
||||
: `${t('模型倍率')} : ${currentRatios.ModelRatio[model] ?? '-'}\n${t('补全倍率')} : ${currentRatios.CompletionRatio[model] ?? '-'}`;
|
||||
|
||||
let newDesc = '';
|
||||
if (newCat === 'price') {
|
||||
newDesc = `${t('固定价格')} : ${ratios['model_price']}`;
|
||||
} else {
|
||||
const newModelRatio = ratios['model_ratio'] ?? '-';
|
||||
const newCompRatio = ratios['completion_ratio'] ?? '-';
|
||||
newDesc = `${t('模型倍率')} : ${newModelRatio}\n${t('补全倍率')} : ${newCompRatio}`;
|
||||
}
|
||||
|
||||
const channels = Object.entries(ratios)
|
||||
.map(([rt, val]) => findSourceChannel(model, rt, val))
|
||||
.filter((v, idx, arr) => arr.indexOf(v) === idx)
|
||||
.join(', ');
|
||||
|
||||
conflicts.push({
|
||||
channel: channels,
|
||||
model,
|
||||
current: currentDesc,
|
||||
newVal: newDesc,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
setConflictItems(conflicts);
|
||||
setConfirmVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSync(currentRatios);
|
||||
};
|
||||
|
||||
const performSync = useCallback(async (currentRatios) => {
|
||||
const finalRatios = {
|
||||
ModelRatio: { ...currentRatios.ModelRatio },
|
||||
CompletionRatio: { ...currentRatios.CompletionRatio },
|
||||
CacheRatio: { ...currentRatios.CacheRatio },
|
||||
ModelPrice: { ...currentRatios.ModelPrice },
|
||||
};
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
const selectedTypes = Object.keys(ratios);
|
||||
const hasPrice = selectedTypes.includes('model_price');
|
||||
const hasRatio = selectedTypes.some(rt => rt !== 'model_price');
|
||||
|
||||
if (hasPrice) {
|
||||
delete finalRatios.ModelRatio[model];
|
||||
delete finalRatios.CompletionRatio[model];
|
||||
delete finalRatios.CacheRatio[model];
|
||||
}
|
||||
if (hasRatio) {
|
||||
delete finalRatios.ModelPrice[model];
|
||||
}
|
||||
|
||||
Object.entries(ratios).forEach(([ratioType, value]) => {
|
||||
const optionKey = ratioType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
finalRatios[optionKey][model] = parseFloat(value);
|
||||
});
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const updates = Object.entries(finalRatios).map(([key, value]) =>
|
||||
API.put('/api/option/', {
|
||||
key,
|
||||
value: JSON.stringify(value, null, 2),
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
|
||||
if (results.every(res => res.data.success)) {
|
||||
showSuccess(t('同步成功'));
|
||||
props.refresh();
|
||||
|
||||
setDifferences(prevDifferences => {
|
||||
const newDifferences = { ...prevDifferences };
|
||||
|
||||
Object.entries(resolutions).forEach(([model, ratios]) => {
|
||||
Object.keys(ratios).forEach(ratioType => {
|
||||
if (newDifferences[model] && newDifferences[model][ratioType]) {
|
||||
delete newDifferences[model][ratioType];
|
||||
|
||||
if (Object.keys(newDifferences[model]).length === 0) {
|
||||
delete newDifferences[model];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return newDifferences;
|
||||
});
|
||||
|
||||
setResolutions({});
|
||||
} else {
|
||||
showError(t('部分保存失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [resolutions, props.options, props.refresh]);
|
||||
|
||||
const getCurrentPageData = (dataSource) => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return dataSource.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex flex-col md:flex-row gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
icon={<RefreshCcw size={14} />}
|
||||
className="w-full md:w-auto mt-2"
|
||||
onClick={() => {
|
||||
setModalVisible(true);
|
||||
if (allChannels.length === 0) {
|
||||
fetchAllChannels();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('选择同步渠道')}
|
||||
</Button>
|
||||
|
||||
{(() => {
|
||||
const hasSelections = Object.keys(resolutions).length > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<CheckSquare size={14} />}
|
||||
type='secondary'
|
||||
onClick={applySync}
|
||||
disabled={!hasSelections}
|
||||
className="w-full md:w-auto mt-2"
|
||||
>
|
||||
{t('应用同步')}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full md:w-auto mt-2">
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
value={searchKeyword}
|
||||
onChange={setSearchKeyword}
|
||||
className="w-full sm:w-64"
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder={t('按倍率类型筛选')}
|
||||
value={ratioTypeFilter}
|
||||
onChange={setRatioTypeFilter}
|
||||
className="w-full sm:w-48"
|
||||
showClear
|
||||
onClear={() => setRatioTypeFilter('')}
|
||||
>
|
||||
<Select.Option value="model_ratio">{t('模型倍率')}</Select.Option>
|
||||
<Select.Option value="completion_ratio">{t('补全倍率')}</Select.Option>
|
||||
<Select.Option value="cache_ratio">{t('缓存倍率')}</Select.Option>
|
||||
<Select.Option value="model_price">{t('固定价格')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDifferenceTable = () => {
|
||||
const dataSource = useMemo(() => {
|
||||
const tmp = [];
|
||||
|
||||
Object.entries(differences).forEach(([model, ratioTypes]) => {
|
||||
const hasPrice = 'model_price' in ratioTypes;
|
||||
const hasOtherRatio = ['model_ratio', 'completion_ratio', 'cache_ratio'].some(rt => rt in ratioTypes);
|
||||
const billingConflict = hasPrice && hasOtherRatio;
|
||||
|
||||
Object.entries(ratioTypes).forEach(([ratioType, diff]) => {
|
||||
tmp.push({
|
||||
key: `${model}_${ratioType}`,
|
||||
model,
|
||||
ratioType,
|
||||
current: diff.current,
|
||||
upstreams: diff.upstreams,
|
||||
confidence: diff.confidence || {},
|
||||
billingConflict,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return tmp;
|
||||
}, [differences]);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
if (!searchKeyword.trim() && !ratioTypeFilter) {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
return dataSource.filter(item => {
|
||||
const matchesKeyword = !searchKeyword.trim() ||
|
||||
item.model.toLowerCase().includes(searchKeyword.toLowerCase().trim());
|
||||
|
||||
const matchesRatioType = !ratioTypeFilter ||
|
||||
item.ratioType === ratioTypeFilter;
|
||||
|
||||
return matchesKeyword && matchesRatioType;
|
||||
});
|
||||
}, [dataSource, searchKeyword, ratioTypeFilter]);
|
||||
|
||||
const upstreamNames = useMemo(() => {
|
||||
const set = new Set();
|
||||
filteredDataSource.forEach((row) => {
|
||||
Object.keys(row.upstreams || {}).forEach((name) => set.add(name));
|
||||
});
|
||||
return Array.from(set);
|
||||
}, [filteredDataSource]);
|
||||
|
||||
if (filteredDataSource.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={
|
||||
searchKeyword.trim()
|
||||
? t('未找到匹配的模型')
|
||||
: (Object.keys(differences).length === 0 ?
|
||||
(hasSynced ? t('暂无差异化倍率显示') : t('请先选择同步渠道'))
|
||||
: t('请先选择同步渠道'))
|
||||
}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model',
|
||||
fixed: 'left',
|
||||
},
|
||||
{
|
||||
title: t('倍率类型'),
|
||||
dataIndex: 'ratioType',
|
||||
render: (text, record) => {
|
||||
const typeMap = {
|
||||
model_ratio: t('模型倍率'),
|
||||
completion_ratio: t('补全倍率'),
|
||||
cache_ratio: t('缓存倍率'),
|
||||
model_price: t('固定价格'),
|
||||
};
|
||||
const baseTag = <Tag color={stringToColor(text)} shape="circle">{typeMap[text] || text}</Tag>;
|
||||
if (record?.billingConflict) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{baseTag}
|
||||
<Tooltip position="top" content={t('该模型存在固定价格与倍率计费方式冲突,请确认选择')}>
|
||||
<AlertTriangle size={14} className="text-yellow-500" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return baseTag;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('置信度'),
|
||||
dataIndex: 'confidence',
|
||||
render: (_, record) => {
|
||||
const allConfident = Object.values(record.confidence || {}).every(v => v !== false);
|
||||
|
||||
if (allConfident) {
|
||||
return (
|
||||
<Tooltip content={t('所有上游数据均可信')}>
|
||||
<Tag color="green" shape="circle" type="light" prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('可信')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
const untrustedSources = Object.entries(record.confidence || {})
|
||||
.filter(([_, isConfident]) => isConfident === false)
|
||||
.map(([name]) => name)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<Tooltip content={t('以下上游数据可能不可信:') + untrustedSources}>
|
||||
<Tag color="yellow" shape="circle" type="light" prefixIcon={<AlertTriangle size={14} />}>
|
||||
{t('谨慎')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('当前值'),
|
||||
dataIndex: 'current',
|
||||
render: (text) => (
|
||||
<Tag color={text !== null && text !== undefined ? 'blue' : 'default'} shape="circle">
|
||||
{text !== null && text !== undefined ? text : t('未设置')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
...upstreamNames.map((upName) => {
|
||||
const channelStats = (() => {
|
||||
let selectableCount = 0;
|
||||
let selectedCount = 0;
|
||||
|
||||
filteredDataSource.forEach((row) => {
|
||||
const upstreamVal = row.upstreams?.[upName];
|
||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||
selectableCount++;
|
||||
const isSelected = resolutions[row.model]?.[row.ratioType] === upstreamVal;
|
||||
if (isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
selectableCount,
|
||||
selectedCount,
|
||||
allSelected: selectableCount > 0 && selectedCount === selectableCount,
|
||||
partiallySelected: selectedCount > 0 && selectedCount < selectableCount,
|
||||
hasSelectableItems: selectableCount > 0
|
||||
};
|
||||
})();
|
||||
|
||||
const handleBulkSelect = (checked) => {
|
||||
if (checked) {
|
||||
filteredDataSource.forEach((row) => {
|
||||
const upstreamVal = row.upstreams?.[upName];
|
||||
if (upstreamVal !== null && upstreamVal !== undefined && upstreamVal !== 'same') {
|
||||
selectValue(row.model, row.ratioType, upstreamVal);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
filteredDataSource.forEach((row) => {
|
||||
if (newRes[row.model]) {
|
||||
delete newRes[row.model][row.ratioType];
|
||||
if (Object.keys(newRes[row.model]).length === 0) {
|
||||
delete newRes[row.model];
|
||||
}
|
||||
}
|
||||
});
|
||||
return newRes;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
title: channelStats.hasSelectableItems ? (
|
||||
<Checkbox
|
||||
checked={channelStats.allSelected}
|
||||
indeterminate={channelStats.partiallySelected}
|
||||
onChange={(e) => handleBulkSelect(e.target.checked)}
|
||||
>
|
||||
{upName}
|
||||
</Checkbox>
|
||||
) : (
|
||||
<span>{upName}</span>
|
||||
),
|
||||
dataIndex: upName,
|
||||
render: (_, record) => {
|
||||
const upstreamVal = record.upstreams?.[upName];
|
||||
const isConfident = record.confidence?.[upName] !== false;
|
||||
|
||||
if (upstreamVal === null || upstreamVal === undefined) {
|
||||
return <Tag color="default" shape="circle">{t('未设置')}</Tag>;
|
||||
}
|
||||
|
||||
if (upstreamVal === 'same') {
|
||||
return <Tag color="blue" shape="circle">{t('与本地相同')}</Tag>;
|
||||
}
|
||||
|
||||
const isSelected = resolutions[record.model]?.[record.ratioType] === upstreamVal;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const isChecked = e.target.checked;
|
||||
if (isChecked) {
|
||||
selectValue(record.model, record.ratioType, upstreamVal);
|
||||
} else {
|
||||
setResolutions((prev) => {
|
||||
const newRes = { ...prev };
|
||||
if (newRes[record.model]) {
|
||||
delete newRes[record.model][record.ratioType];
|
||||
if (Object.keys(newRes[record.model]).length === 0) {
|
||||
delete newRes[record.model];
|
||||
}
|
||||
}
|
||||
return newRes;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{upstreamVal}
|
||||
</Checkbox>
|
||||
{!isConfident && (
|
||||
<Tooltip position='left' content={t('该数据可能不可信,请谨慎使用')}>
|
||||
<AlertTriangle size={16} className="text-yellow-500" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getCurrentPageData(filteredDataSource)}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredDataSource.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: filteredDataSource.length,
|
||||
}),
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
}
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size='middle'
|
||||
loading={loading || syncLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const updateChannelEndpoint = useCallback((channelId, endpoint) => {
|
||||
setChannelEndpoints(prev => ({ ...prev, [channelId]: endpoint }));
|
||||
}, []);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
if (channelSelectorRef.current) {
|
||||
channelSelectorRef.current.resetPagination();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Section text={renderHeader()}>
|
||||
{renderDifferenceTable()}
|
||||
</Form.Section>
|
||||
|
||||
<ChannelSelectorModal
|
||||
ref={channelSelectorRef}
|
||||
t={t}
|
||||
visible={modalVisible}
|
||||
onCancel={handleModalClose}
|
||||
onOk={confirmChannelSelection}
|
||||
allChannels={allChannels}
|
||||
selectedChannelIds={selectedChannelIds}
|
||||
setSelectedChannelIds={setSelectedChannelIds}
|
||||
channelEndpoints={channelEndpoints}
|
||||
updateChannelEndpoint={updateChannelEndpoint}
|
||||
/>
|
||||
|
||||
<ConflictConfirmModal
|
||||
t={t}
|
||||
visible={confirmVisible}
|
||||
items={conflictItems}
|
||||
onOk={async () => {
|
||||
setConfirmVisible(false);
|
||||
const curRatios = {
|
||||
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
|
||||
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
|
||||
CacheRatio: JSON.parse(props.options.CacheRatio || '{}'),
|
||||
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
|
||||
};
|
||||
await performSync(curRatios);
|
||||
}}
|
||||
onCancel={() => setConfirmVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,18 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
Calculator,
|
||||
Gauge,
|
||||
Shapes,
|
||||
Cog,
|
||||
MoreHorizontal,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
CreditCard
|
||||
} from 'lucide-react';
|
||||
|
||||
import SystemSetting from '../../components/settings/SystemSetting.js';
|
||||
import { isRoot } from '../../helpers';
|
||||
@@ -10,6 +22,10 @@ import OperationSetting from '../../components/settings/OperationSetting.js';
|
||||
import RateLimitSetting from '../../components/settings/RateLimitSetting.js';
|
||||
import ModelSetting from '../../components/settings/ModelSetting.js';
|
||||
import DashboardSetting from '../../components/settings/DashboardSetting.js';
|
||||
import RatioSetting from '../../components/settings/RatioSetting.js';
|
||||
import ChatsSetting from '../../components/settings/ChatsSetting.js';
|
||||
import DrawingSetting from '../../components/settings/DrawingSetting.js';
|
||||
import PaymentSetting from '../../components/settings/PaymentSetting.js';
|
||||
|
||||
const Setting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,35 +36,105 @@ const Setting = () => {
|
||||
|
||||
if (isRoot()) {
|
||||
panes.push({
|
||||
tab: t('运营设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Settings size={18} />
|
||||
{t('运营设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OperationSetting />,
|
||||
itemKey: 'operation',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('速率限制设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<LayoutDashboard size={18} />
|
||||
{t('仪表盘设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DashboardSetting />,
|
||||
itemKey: 'dashboard',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MessageSquare size={18} />
|
||||
{t('聊天设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ChatsSetting />,
|
||||
itemKey: 'chats',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Palette size={18} />
|
||||
{t('绘图设置')}
|
||||
</span>
|
||||
),
|
||||
content: <DrawingSetting />,
|
||||
itemKey: 'drawing',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<CreditCard size={18} />
|
||||
{t('支付设置')}
|
||||
</span>
|
||||
),
|
||||
content: <PaymentSetting />,
|
||||
itemKey: 'payment',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Calculator size={18} />
|
||||
{t('倍率设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RatioSetting />,
|
||||
itemKey: 'ratio',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Gauge size={18} />
|
||||
{t('速率限制设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RateLimitSetting />,
|
||||
itemKey: 'ratelimit',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('模型相关设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Shapes size={18} />
|
||||
{t('模型相关设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ModelSetting />,
|
||||
itemKey: 'models',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('系统设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Cog size={18} />
|
||||
{t('系统设置')}
|
||||
</span>
|
||||
),
|
||||
content: <SystemSetting />,
|
||||
itemKey: 'system',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('其他设置'),
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<MoreHorizontal size={18} />
|
||||
{t('其他设置')}
|
||||
</span>
|
||||
),
|
||||
content: <OtherSetting />,
|
||||
itemKey: 'other',
|
||||
});
|
||||
panes.push({
|
||||
tab: t('仪表盘配置'),
|
||||
content: <DashboardSetting />,
|
||||
itemKey: 'dashboard',
|
||||
});
|
||||
}
|
||||
const onChangeTab = (key) => {
|
||||
setTabActiveKey(key);
|
||||
@@ -64,11 +150,12 @@ const Setting = () => {
|
||||
}
|
||||
}, [location.search]);
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-[64px]">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<Tabs
|
||||
type='line'
|
||||
type='card'
|
||||
collapsible
|
||||
activeKey={tabActiveKey}
|
||||
onChange={(key) => onChangeTab(key)}
|
||||
>
|
||||
|
||||
@@ -133,7 +133,7 @@ const Setup = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50">
|
||||
<div className="bg-gray-50 mt-[64px]">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center px-4 py-8">
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import TaskLogsTable from '../../components/table/TaskLogsTable.js';
|
||||
|
||||
const Task = () => (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<TaskLogsTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Task;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useEffect, useState, useContext, useRef } from 'react';
|
||||
import {
|
||||
API,
|
||||
isMobile,
|
||||
@@ -7,71 +6,53 @@ import {
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
renderGroupOption,
|
||||
renderQuotaWithPrompt
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
AutoComplete,
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Input,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
TextArea,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconClock,
|
||||
IconCalendar,
|
||||
IconCreditCard,
|
||||
IconLink,
|
||||
IconServer,
|
||||
IconUserGroup,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconPlusCircle,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditToken = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const originInputs = {
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const isEdit = props.editingToken.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
remain_quota: isEdit ? 0 : 500000,
|
||||
remain_quota: 500000,
|
||||
expired_time: -1,
|
||||
unlimited_quota: false,
|
||||
model_limits_enabled: false,
|
||||
model_limits: [],
|
||||
allow_ips: '',
|
||||
group: '',
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const {
|
||||
name,
|
||||
remain_quota,
|
||||
expired_time,
|
||||
unlimited_quota,
|
||||
model_limits_enabled,
|
||||
model_limits,
|
||||
allow_ips,
|
||||
group,
|
||||
} = inputs;
|
||||
const [models, setModels] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
tokenCount: 1,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
@@ -84,18 +65,15 @@ const EditToken = (props) => {
|
||||
seconds += day * 24 * 60 * 60;
|
||||
seconds += hour * 60 * 60;
|
||||
seconds += minute * 60;
|
||||
if (!formApiRef.current) return;
|
||||
if (seconds !== 0) {
|
||||
timestamp += seconds;
|
||||
setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
|
||||
formApiRef.current.setValue('expired_time', timestamp2string(timestamp));
|
||||
} else {
|
||||
setInputs({ ...inputs, expired_time: -1 });
|
||||
formApiRef.current.setValue('expired_time', -1);
|
||||
}
|
||||
};
|
||||
|
||||
const setUnlimitedQuota = () => {
|
||||
setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
|
||||
};
|
||||
|
||||
const loadModels = async () => {
|
||||
let res = await API.get(`/api/user/models`);
|
||||
const { success, message, data } = res.data;
|
||||
@@ -119,7 +97,17 @@ const EditToken = (props) => {
|
||||
value: group,
|
||||
ratio: info.ratio,
|
||||
}));
|
||||
if (statusState?.status?.default_use_auto_group) {
|
||||
if (localGroupOptions.some((group) => group.value === 'auto')) {
|
||||
localGroupOptions.sort((a, b) => (a.value === 'auto' ? -1 : 1));
|
||||
} else {
|
||||
localGroupOptions.unshift({ label: t('自动选择'), value: 'auto' });
|
||||
}
|
||||
}
|
||||
setGroups(localGroupOptions);
|
||||
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
|
||||
formApiRef.current.setValue('group', 'auto');
|
||||
}
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
@@ -138,7 +126,9 @@ const EditToken = (props) => {
|
||||
} else {
|
||||
data.model_limits = [];
|
||||
}
|
||||
setInputs(data);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -146,34 +136,27 @@ const EditToken = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsEdit(props.editingToken.id !== undefined);
|
||||
}, [props.editingToken.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setInputs(originInputs);
|
||||
} else {
|
||||
loadToken().then(() => {
|
||||
// console.log(inputs);
|
||||
});
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
loadModels();
|
||||
loadGroups();
|
||||
}, [isEdit]);
|
||||
}, [props.editingToken.id]);
|
||||
|
||||
// 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
|
||||
const [tokenCount, setTokenCount] = useState(1);
|
||||
|
||||
// 新增处理 tokenCount 变化的函数
|
||||
const handleTokenCountChange = (value) => {
|
||||
// 确保用户输入的是正整数
|
||||
const count = parseInt(value, 10);
|
||||
if (!isNaN(count) && count > 0) {
|
||||
setTokenCount(count);
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadToken();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
};
|
||||
}, [props.visiable, props.editingToken.id]);
|
||||
|
||||
// 生成一个随机的四位字母数字字符串
|
||||
const generateRandomSuffix = () => {
|
||||
const characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -186,11 +169,10 @@ const EditToken = (props) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
if (isEdit) {
|
||||
// 编辑令牌的逻辑保持不变
|
||||
let localInputs = { ...inputs };
|
||||
let { tokenCount: _tc, ...localInputs } = values;
|
||||
localInputs.remain_quota = parseInt(localInputs.remain_quota);
|
||||
if (localInputs.expired_time !== -1) {
|
||||
let time = Date.parse(localInputs.expired_time);
|
||||
@@ -202,6 +184,7 @@ const EditToken = (props) => {
|
||||
localInputs.expired_time = Math.ceil(time / 1000);
|
||||
}
|
||||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||||
let res = await API.put(`/api/token/`, {
|
||||
...localInputs,
|
||||
id: parseInt(props.editingToken.id),
|
||||
@@ -215,16 +198,12 @@ const EditToken = (props) => {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
// 处理新增多个令牌的情况
|
||||
let successCount = 0; // 记录成功创建的令牌数量
|
||||
for (let i = 0; i < tokenCount; i++) {
|
||||
let localInputs = { ...inputs };
|
||||
|
||||
// 检查用户是否填写了令牌名称
|
||||
const baseName = inputs.name.trim() === '' ? 'default' : inputs.name;
|
||||
|
||||
if (i !== 0 || inputs.name.trim() === '') {
|
||||
// 如果创建多个令牌(i !== 0)或者用户没有填写名称,则添加随机后缀
|
||||
const count = parseInt(values.tokenCount, 10) || 1;
|
||||
let successCount = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
let { tokenCount: _tc, ...localInputs } = values;
|
||||
const baseName = values.name.trim() === '' ? 'default' : values.name.trim();
|
||||
if (i !== 0 || values.name.trim() === '') {
|
||||
localInputs.name = `${baseName}-${generateRandomSuffix()}`;
|
||||
} else {
|
||||
localInputs.name = baseName;
|
||||
@@ -241,17 +220,16 @@ const EditToken = (props) => {
|
||||
localInputs.expired_time = Math.ceil(time / 1000);
|
||||
}
|
||||
localInputs.model_limits = localInputs.model_limits.join(',');
|
||||
localInputs.model_limits_enabled = localInputs.model_limits.length > 0;
|
||||
let res = await API.post(`/api/token/`, localInputs);
|
||||
const { success, message } = res.data;
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
showError(t(message));
|
||||
break; // 如果创建失败,终止循环
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(t('令牌创建成功,请在列表页面点击复制获取令牌!'));
|
||||
props.refresh();
|
||||
@@ -259,8 +237,7 @@ const EditToken = (props) => {
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setInputs(originInputs); // 重置表单
|
||||
setTokenCount(1); // 重置数量为默认值
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -268,43 +245,39 @@ const EditToken = (props) => {
|
||||
placement={isEdit ? 'right' : 'left'}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ?
|
||||
<Tag color="blue" shape="circle">{t('更新')}</Tag> :
|
||||
<Tag color="green" shape="circle">{t('新建')}</Tag>
|
||||
}
|
||||
<Title heading={4} className="m-0">
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新令牌信息') : t('创建新的令牌')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
@@ -317,285 +290,199 @@ const EditToken = (props) => {
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconPlusCircle size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入名称')}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
value={name}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('过期时间')}</Text>
|
||||
<div className="mb-2">
|
||||
<DatePicker
|
||||
placeholder={t('请选择过期时间')}
|
||||
onChange={(value) => handleInputChange('expired_time', value)}
|
||||
value={expired_time}
|
||||
autoComplete="new-password"
|
||||
type="dateTime"
|
||||
className="w-full !rounded-lg"
|
||||
size="large"
|
||||
prefix={<IconCalendar />}
|
||||
/>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconKey size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
placeholder={t('请输入名称')}
|
||||
rules={[{ required: true, message: t('请输入名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{groups.length > 0 ? (
|
||||
<Form.Select
|
||||
field='group'
|
||||
label={t('令牌分组')}
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
optionList={groups}
|
||||
renderOptionItem={renderGroupOption}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.Select
|
||||
placeholder={t('管理员未设置用户可选分组')}
|
||||
disabled
|
||||
label={t('令牌分组')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
|
||||
<Form.DatePicker
|
||||
field='expired_time'
|
||||
label={t('过期时间')}
|
||||
type='dateTime'
|
||||
placeholder={t('请选择过期时间')}
|
||||
rules={[{ required: true, message: t('请选择过期时间') }]}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
||||
<Form.Slot label={t('过期时间快捷设置')}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={() => setExpiredTime(0, 0, 0, 0)}
|
||||
>
|
||||
{t('永不过期')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(1, 0, 0, 0)}
|
||||
>
|
||||
{t('一个月')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 1, 0, 0)}
|
||||
>
|
||||
{t('一天')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
onClick={() => setExpiredTime(0, 0, 1, 0)}
|
||||
>
|
||||
{t('一小时')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
{!isEdit && (
|
||||
<Col span={24}>
|
||||
<Form.InputNumber
|
||||
field='tokenCount'
|
||||
label={t('新建数量')}
|
||||
min={1}
|
||||
extraText={t('批量创建时会在名称后自动添加随机后缀')}
|
||||
rules={[{ required: true, message: t('请输入新建数量') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
onClick={() => setExpiredTime(0, 0, 0, 0)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('永不过期')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
onClick={() => setExpiredTime(0, 0, 1, 0)}
|
||||
className="!rounded-full"
|
||||
icon={<IconClock />}
|
||||
>
|
||||
{t('一小时')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
onClick={() => setExpiredTime(0, 1, 0, 0)}
|
||||
className="!rounded-full"
|
||||
icon={<IconCalendar />}
|
||||
>
|
||||
{t('一天')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
onClick={() => setExpiredTime(1, 0, 0, 0)}
|
||||
className="!rounded-full"
|
||||
icon={<IconCalendar />}
|
||||
>
|
||||
{t('一个月')}
|
||||
</Button>
|
||||
{/* 额度设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('额度设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌可用额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.AutoComplete
|
||||
field='remain_quota'
|
||||
label={t('额度')}
|
||||
placeholder={t('请输入额度')}
|
||||
type='number'
|
||||
disabled={values.unlimited_quota}
|
||||
extraText={renderQuotaWithPrompt(values.remain_quota)}
|
||||
rules={values.unlimited_quota ? [] : [{ required: true, message: t('请输入额度') }]}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='unlimited_quota'
|
||||
label={t('无限额度')}
|
||||
size='large'
|
||||
extraText={t('令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconCreditCard size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('额度设置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌可用额度和数量')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。')}
|
||||
className="mb-4 !rounded-lg"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(remain_quota)}</Text>
|
||||
{/* 访问限制 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('访问限制')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置令牌的访问限制')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutoComplete
|
||||
placeholder={t('请输入额度')}
|
||||
onChange={(value) => handleInputChange('remain_quota', value)}
|
||||
value={remain_quota}
|
||||
autoComplete="new-password"
|
||||
type="number"
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
data={[
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
{ value: 25000000, label: '50$' },
|
||||
{ value: 50000000, label: '100$' },
|
||||
{ value: 250000000, label: '500$' },
|
||||
{ value: 500000000, label: '1000$' },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('新建数量')}</Text>
|
||||
<AutoComplete
|
||||
placeholder={t('请选择或输入创建令牌的数量')}
|
||||
onChange={(value) => handleTokenCountChange(value)}
|
||||
onSelect={(value) => handleTokenCountChange(value)}
|
||||
value={tokenCount.toString()}
|
||||
autoComplete="off"
|
||||
type="number"
|
||||
className="w-full !rounded-lg"
|
||||
size="large"
|
||||
prefix={<IconPlusCircle />}
|
||||
data={[
|
||||
{ value: 10, label: t('10个') },
|
||||
{ value: 20, label: t('20个') },
|
||||
{ value: 30, label: t('30个') },
|
||||
{ value: 100, label: t('100个') },
|
||||
]}
|
||||
disabled={unlimited_quota}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
theme="light"
|
||||
type={unlimited_quota ? "danger" : "warning"}
|
||||
onClick={setUnlimitedQuota}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{unlimited_quota ? t('取消无限额度') : t('设为无限额度')}
|
||||
</Button>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='allow_ips'
|
||||
label={t('IP白名单')}
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
autosize
|
||||
rows={1}
|
||||
extraText={t('请勿过度信任此功能,IP可能被伪造')}
|
||||
showClear
|
||||
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>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconLink size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('访问限制')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的访问限制')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('IP白名单')}</Text>
|
||||
<TextArea
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
onChange={(value) => handleInputChange('allow_ips', value)}
|
||||
value={inputs.allow_ips}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
className="!rounded-lg"
|
||||
rows={4}
|
||||
/>
|
||||
<Text type="tertiary" className="mt-1 block text-xs">{t('请勿过度信任此功能,IP可能被伪造')}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Checkbox
|
||||
checked={model_limits_enabled}
|
||||
onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
|
||||
>
|
||||
<Text strong>{t('模型限制')}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={model_limits_enabled ? t('请选择该渠道所支持的模型') : t('勾选启用模型限制后可选择')}
|
||||
onChange={(value) => handleInputChange('model_limits', value)}
|
||||
value={inputs.model_limits}
|
||||
multiple
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconServer />}
|
||||
optionList={models}
|
||||
disabled={!model_limits_enabled}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
<Text type="tertiary" className="mt-1 block text-xs">{t('非必要,不建议启用模型限制')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUserGroup size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('分组信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('设置令牌的分组')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('令牌分组')}</Text>
|
||||
{groups.length > 0 ? (
|
||||
<Select
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
onChange={(value) => handleInputChange('group', value)}
|
||||
renderOptionItem={renderGroupOption}
|
||||
value={inputs.group}
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconUserGroup />}
|
||||
optionList={groups}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
placeholder={t('管理员未设置用户可选分组')}
|
||||
disabled={true}
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconUserGroup />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,9 @@ import TokensTable from '../../components/table/TokensTable';
|
||||
|
||||
const Token = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<TokensTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
renderQuota,
|
||||
renderQuotaWithAmount,
|
||||
copy,
|
||||
getQuotaPerUnit
|
||||
getQuotaPerUnit,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Copy,
|
||||
Users,
|
||||
User,
|
||||
Coins
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -49,9 +49,15 @@ const TopUp = () => {
|
||||
const [topUpCode, setTopUpCode] = useState('');
|
||||
const [amount, setAmount] = useState(0.0);
|
||||
const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
|
||||
const [topUpCount, setTopUpCount] = useState(statusState?.status?.min_topup || 1);
|
||||
const [topUpLink, setTopUpLink] = useState(statusState?.status?.top_up_link || '');
|
||||
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(statusState?.status?.enable_online_topup || false);
|
||||
const [topUpCount, setTopUpCount] = useState(
|
||||
statusState?.status?.min_topup || 1,
|
||||
);
|
||||
const [topUpLink, setTopUpLink] = useState(
|
||||
statusState?.status?.top_up_link || '',
|
||||
);
|
||||
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(
|
||||
statusState?.status?.enable_online_topup || false,
|
||||
);
|
||||
const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
|
||||
const [userQuota, setUserQuota] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -61,6 +67,7 @@ const TopUp = () => {
|
||||
const [amountLoading, setAmountLoading] = useState(false);
|
||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [payMethods, setPayMethods] = useState([]);
|
||||
|
||||
// 邀请相关状态
|
||||
const [affLink, setAffLink] = useState('');
|
||||
@@ -76,7 +83,7 @@ const TopUp = () => {
|
||||
{ value: 100 },
|
||||
{ value: 300 },
|
||||
{ value: 500 },
|
||||
{ value: 1000 }
|
||||
{ value: 1000 },
|
||||
]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
|
||||
@@ -126,7 +133,7 @@ const TopUp = () => {
|
||||
if (userState.user) {
|
||||
const updatedUser = {
|
||||
...userState.user,
|
||||
quota: userState.user.quota + data
|
||||
quota: userState.user.quota + data,
|
||||
};
|
||||
userDispatch({ type: 'login', payload: updatedUser });
|
||||
}
|
||||
@@ -283,6 +290,34 @@ const TopUp = () => {
|
||||
}
|
||||
getAffLink().then();
|
||||
setTransferAmount(getQuotaPerUnit());
|
||||
|
||||
let payMethods = localStorage.getItem('pay_methods');
|
||||
try {
|
||||
payMethods = JSON.parse(payMethods);
|
||||
if (payMethods && payMethods.length > 0) {
|
||||
// 检查name和type是否为空
|
||||
payMethods = payMethods.filter((method) => {
|
||||
return method.name && method.type;
|
||||
});
|
||||
// 如果没有color,则设置默认颜色
|
||||
payMethods = payMethods.map((method) => {
|
||||
if (!method.color) {
|
||||
if (method.type === 'zfb') {
|
||||
method.color = 'rgba(var(--semi-blue-5), 1)';
|
||||
} else if (method.type === 'wx') {
|
||||
method.color = 'rgba(var(--semi-green-5), 1)';
|
||||
} else {
|
||||
method.color = 'rgba(var(--semi-primary-5), 1)';
|
||||
}
|
||||
}
|
||||
return method;
|
||||
});
|
||||
setPayMethods(payMethods);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showError(t('支付方式配置错误, 请联系管理员'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -347,12 +382,12 @@ const TopUp = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto relative min-h-screen lg:min-h-0">
|
||||
<div className='mx-auto relative min-h-screen lg:min-h-0 mt-[64px]'>
|
||||
{/* 划转模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="mr-2" size={18} />
|
||||
<div className='flex items-center'>
|
||||
<CreditCard className='mr-2' size={18} />
|
||||
{t('划转邀请额度')}
|
||||
</div>
|
||||
}
|
||||
@@ -360,22 +395,22 @@ const TopUp = () => {
|
||||
onOk={transfer}
|
||||
onCancel={handleTransferCancel}
|
||||
maskClosable={false}
|
||||
size="small"
|
||||
size='small'
|
||||
centered
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('可用邀请额度')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={renderQuota(userState?.user?.aff_quota)}
|
||||
disabled
|
||||
size="large"
|
||||
size='large'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('划转额度')} ({t('最低') + renderQuota(getQuotaPerUnit())})
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
@@ -383,8 +418,8 @@ const TopUp = () => {
|
||||
max={userState?.user?.aff_quota || 0}
|
||||
value={transferAmount}
|
||||
onChange={(value) => setTransferAmount(value)}
|
||||
size="large"
|
||||
className="w-full"
|
||||
size='large'
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,8 +428,8 @@ const TopUp = () => {
|
||||
{/* 充值确认模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="mr-2" size={18} />
|
||||
<div className='flex items-center'>
|
||||
<CreditCard className='mr-2' size={18} />
|
||||
{t('充值确认')}
|
||||
</div>
|
||||
}
|
||||
@@ -402,57 +437,80 @@ const TopUp = () => {
|
||||
onOk={onlineTopUp}
|
||||
onCancel={handleCancel}
|
||||
maskClosable={false}
|
||||
size="small"
|
||||
size='small'
|
||||
centered
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-between items-center py-2'>
|
||||
<Text strong>{t('充值数量')}:</Text>
|
||||
<Text>{renderQuotaWithAmount(topUpCount)}</Text>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<div className='flex justify-between items-center py-2'>
|
||||
<Text strong>{t('实付金额')}:</Text>
|
||||
{amountLoading ? (
|
||||
<Skeleton.Title style={{ width: '60px', height: '16px' }} />
|
||||
) : (
|
||||
<Text type="danger" strong>{renderAmount()}</Text>
|
||||
<Text type='danger' strong>
|
||||
{renderAmount()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<div className='flex justify-between items-center py-2'>
|
||||
<Text strong>{t('支付方式')}:</Text>
|
||||
<Text>
|
||||
{payWay === 'zfb' ? (
|
||||
<div className="flex items-center">
|
||||
<SiAlipay className="mr-1" size={16} />
|
||||
{t('支付宝')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<SiWechat className="mr-1" size={16} />
|
||||
{t('微信')}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const payMethod = payMethods.find(
|
||||
(method) => method.type === payWay,
|
||||
);
|
||||
if (payMethod) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
{payMethod.type === 'zfb' ? (
|
||||
<SiAlipay className='mr-1' size={16} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat className='mr-1' size={16} />
|
||||
) : (
|
||||
<CreditCard className='mr-1' size={16} />
|
||||
)}
|
||||
{payMethod.name}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 默认充值方式
|
||||
return payWay === 'zfb' ? (
|
||||
<div className='flex items-center'>
|
||||
<SiAlipay className='mr-1' size={16} />
|
||||
{t('支付宝')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center'>
|
||||
<SiWechat className='mr-1' size={16} />
|
||||
{t('微信')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧充值区域 */}
|
||||
<div className="lg:col-span-7 space-y-6 w-full">
|
||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
||||
{/* 在线充值卡片 */}
|
||||
<Card
|
||||
className="!rounded-2xl"
|
||||
className='!rounded-2xl'
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
header={
|
||||
<div className="px-5 py-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className='px-5 py-4 pb-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
className="mr-3 shadow-md flex-shrink-0"
|
||||
color="blue"
|
||||
className='mr-3 shadow-md flex-shrink-0'
|
||||
color='blue'
|
||||
>
|
||||
<CreditCard size={24} />
|
||||
</Avatar>
|
||||
@@ -460,21 +518,23 @@ const TopUp = () => {
|
||||
<Title heading={5} style={{ margin: 0 }}>
|
||||
{t('在线充值')}
|
||||
</Title>
|
||||
<Text type="tertiary" className="text-sm">
|
||||
<Text type='tertiary' className='text-sm'>
|
||||
{t('快速方便的充值方式')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
{userDataLoading ? (
|
||||
<Skeleton.Paragraph style={{ width: '120px' }} rows={1} />
|
||||
) : (
|
||||
<Text type="tertiary" className="hidden sm:block">
|
||||
<div className="flex items-center">
|
||||
<User size={14} className="mr-1" />
|
||||
<span className="hidden md:inline">{getUsername()} ({getUserRole()})</span>
|
||||
<span className="md:hidden">{getUsername()}</span>
|
||||
<Text type='tertiary' className='hidden sm:block'>
|
||||
<div className='flex items-center'>
|
||||
<User size={14} className='mr-1' />
|
||||
<span className='hidden md:inline'>
|
||||
{getUsername()} ({getUserRole()})
|
||||
</span>
|
||||
<span className='md:hidden'>{getUsername()}</span>
|
||||
</div>
|
||||
</Text>
|
||||
)}
|
||||
@@ -483,29 +543,33 @@ const TopUp = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 账户余额信息 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
|
||||
<Card className="!rounded-2xl">
|
||||
<Text type="tertiary" className="mb-1">
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 mb-2'>
|
||||
<Card className='!rounded-2xl'>
|
||||
<Text type='tertiary' className='mb-1'>
|
||||
{t('当前余额')}
|
||||
</Text>
|
||||
{userDataLoading ? (
|
||||
<Skeleton.Title style={{ width: '100px', height: '30px' }} />
|
||||
<Skeleton.Title
|
||||
style={{ width: '100px', height: '30px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xl font-semibold mt-2">
|
||||
<div className='text-xl font-semibold mt-2'>
|
||||
{renderQuota(userState?.user?.quota || userQuota)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Card className="!rounded-2xl">
|
||||
<Text type="tertiary" className="mb-1">
|
||||
<Card className='!rounded-2xl'>
|
||||
<Text type='tertiary' className='mb-1'>
|
||||
{t('历史消耗')}
|
||||
</Text>
|
||||
{userDataLoading ? (
|
||||
<Skeleton.Title style={{ width: '100px', height: '30px' }} />
|
||||
<Skeleton.Title
|
||||
style={{ width: '100px', height: '30px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xl font-semibold mt-2">
|
||||
<div className='text-xl font-semibold mt-2'>
|
||||
{renderQuota(userState?.user?.used_quota || 0)}
|
||||
</div>
|
||||
)}
|
||||
@@ -516,8 +580,10 @@ const TopUp = () => {
|
||||
<>
|
||||
{/* 预设充值额度卡片网格 */}
|
||||
<div>
|
||||
<Text strong className="block mb-3">{t('选择充值额度')}</Text>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Text strong className='block mb-3'>
|
||||
{t('选择充值额度')}
|
||||
</Text>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3'>
|
||||
{presetAmounts.map((preset, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -528,35 +594,44 @@ const TopUp = () => {
|
||||
}`}
|
||||
bodyStyle={{ textAlign: 'center' }}
|
||||
>
|
||||
<div className="font-medium text-lg flex items-center justify-center mb-1">
|
||||
<Coins size={16} className="mr-0.5" />
|
||||
<div className='font-medium text-lg flex items-center justify-center mb-1'>
|
||||
<Coins size={16} className='mr-0.5' />
|
||||
{formatLargeNumber(preset.value)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t('实付')} ¥{(preset.value * priceRatio).toFixed(2)}
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('实付')} ¥
|
||||
{(preset.value * priceRatio).toFixed(2)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 桌面端显示的自定义金额和支付按钮 */}
|
||||
<div className="hidden md:block space-y-4">
|
||||
<div className='hidden md:block space-y-4'>
|
||||
<Divider style={{ margin: '24px 0' }}>
|
||||
<Text className="text-sm font-medium">{t('或输入自定义金额')}</Text>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('或输入自定义金额')}
|
||||
</Text>
|
||||
</Divider>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className='flex justify-between mb-2'>
|
||||
<Text strong>{t('充值数量')}</Text>
|
||||
{amountLoading ? (
|
||||
<Skeleton.Title style={{ width: '80px', height: '16px' }} />
|
||||
<Skeleton.Title
|
||||
style={{ width: '80px', height: '16px' }}
|
||||
/>
|
||||
) : (
|
||||
<Text type="tertiary">{t('实付金额:') + renderAmount()}</Text>
|
||||
<Text type='tertiary'>
|
||||
{t('实付金额:') + renderAmount()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<InputNumber
|
||||
disabled={!enableOnlineTopUp}
|
||||
placeholder={t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)}
|
||||
placeholder={
|
||||
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
|
||||
}
|
||||
value={topUpCount}
|
||||
min={minTopUp}
|
||||
max={999999999}
|
||||
@@ -576,36 +651,148 @@ const TopUp = () => {
|
||||
getAmount(1);
|
||||
}
|
||||
}}
|
||||
size="large"
|
||||
className="w-full"
|
||||
formatter={(value) => value ? `${value}` : ''}
|
||||
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
|
||||
size='large'
|
||||
className='w-full'
|
||||
formatter={(value) => (value ? `${value}` : '')}
|
||||
parser={(value) =>
|
||||
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => preTopUp('zfb')}
|
||||
size="large"
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === 'zfb'}
|
||||
icon={<SiAlipay size={18} />}
|
||||
style={{ height: '44px' }}
|
||||
>
|
||||
<span className="ml-2">{t('支付宝')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => preTopUp('wx')}
|
||||
size="large"
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === 'wx'}
|
||||
icon={<SiWechat size={18} />}
|
||||
style={{ height: '44px' }}
|
||||
>
|
||||
<span className="ml-2">{t('微信')}</span>
|
||||
</Button>
|
||||
<div>
|
||||
<Text strong className='block mb-3'>
|
||||
{t('选择支付方式')}
|
||||
</Text>
|
||||
{payMethods.length === 2 ? (
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
type='primary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
size='large'
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
icon={
|
||||
payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={16} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={16} />
|
||||
) : (
|
||||
<CreditCard size={16} />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
height: '40px',
|
||||
color: payMethod.color,
|
||||
}}
|
||||
className='transition-all hover:shadow-md w-full'
|
||||
>
|
||||
<span className='ml-1'>{payMethod.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : payMethods.length === 3 ? (
|
||||
<div className='grid grid-cols-1 sm:grid-cols-3 gap-3'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
type='primary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
size='large'
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
icon={
|
||||
payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={16} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={16} />
|
||||
) : (
|
||||
<CreditCard size={16} />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
height: '40px',
|
||||
color: payMethod.color,
|
||||
}}
|
||||
className='transition-all hover:shadow-md w-full'
|
||||
>
|
||||
<span className='ml-1'>{payMethod.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : payMethods.length > 3 ? (
|
||||
<div className='grid grid-cols-2 sm:grid-cols-4 gap-3'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Card
|
||||
key={payMethod.type}
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={!enableOnlineTopUp}
|
||||
className={`cursor-pointer !rounded-xl p-0 transition-all hover:shadow-md ${paymentLoading && payWay === payMethod.type
|
||||
? 'border-blue-400'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
bodyStyle={{
|
||||
padding: '10px',
|
||||
textAlign: 'center',
|
||||
opacity: !enableOnlineTopUp ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
{paymentLoading && payWay === payMethod.type ? (
|
||||
<div className='flex flex-col items-center justify-center h-full'>
|
||||
<div className='mb-1'>
|
||||
<div className='animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
<div className='text-xs text-gray-500'>{t('处理中')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center justify-center mb-1'>
|
||||
{payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={20} color={payMethod.color} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={20} color={payMethod.color} />
|
||||
) : (
|
||||
<CreditCard size={20} color={payMethod.color} />
|
||||
)}
|
||||
</div>
|
||||
<div className='text-sm font-medium'>{payMethod.name}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 gap-3'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
type='primary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
size='large'
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
icon={
|
||||
payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={16} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={16} />
|
||||
) : (
|
||||
<CreditCard size={16} />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
height: '40px',
|
||||
color: payMethod.color,
|
||||
}}
|
||||
className='transition-all hover:shadow-md w-full'
|
||||
>
|
||||
<span className='ml-1'>{payMethod.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -613,39 +800,41 @@ const TopUp = () => {
|
||||
|
||||
{!enableOnlineTopUp && (
|
||||
<Banner
|
||||
type="warning"
|
||||
description={t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
|
||||
type='warning'
|
||||
description={t(
|
||||
'管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。',
|
||||
)}
|
||||
closeIcon={null}
|
||||
className="!rounded-2xl"
|
||||
className='!rounded-2xl'
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '24px 0' }}>
|
||||
<Text className="text-sm font-medium">{t('兑换码充值')}</Text>
|
||||
<Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
|
||||
</Divider>
|
||||
|
||||
<Card className="!rounded-2xl">
|
||||
<div className="flex items-start mb-4">
|
||||
<Gift size={16} className="mr-2 mt-0.5" />
|
||||
<Card className='!rounded-2xl'>
|
||||
<div className='flex items-start mb-4'>
|
||||
<Gift size={16} className='mr-2 mt-0.5' />
|
||||
<Text strong>{t('使用兑换码快速充值')}</Text>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className='mb-4'>
|
||||
<Input
|
||||
placeholder={t('请输入兑换码')}
|
||||
value={redemptionCode}
|
||||
onChange={(value) => setRedemptionCode(value)}
|
||||
size="large"
|
||||
size='large'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className='flex flex-col sm:flex-row gap-3'>
|
||||
{topUpLink && (
|
||||
<Button
|
||||
type="secondary"
|
||||
type='secondary'
|
||||
onClick={openTopUpLink}
|
||||
size="large"
|
||||
className="flex-1"
|
||||
size='large'
|
||||
className='flex-1'
|
||||
icon={<LinkIcon size={16} />}
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
@@ -653,12 +842,12 @@ const TopUp = () => {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
type='primary'
|
||||
onClick={topUp}
|
||||
disabled={isSubmitting || !redemptionCode}
|
||||
loading={isSubmitting}
|
||||
size="large"
|
||||
className="flex-1"
|
||||
size='large'
|
||||
className='flex-1'
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
{isSubmitting ? t('兑换中...') : t('兑换')}
|
||||
@@ -670,18 +859,18 @@ const TopUp = () => {
|
||||
</div>
|
||||
|
||||
{/* 右侧邀请信息卡片 */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className='lg:col-span-5'>
|
||||
<Card
|
||||
className="!rounded-2xl"
|
||||
className='!rounded-2xl'
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
header={
|
||||
<div className="px-5 py-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className='px-5 py-4 pb-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
className="mr-3 shadow-md flex-shrink-0"
|
||||
color="green"
|
||||
className='mr-3 shadow-md flex-shrink-0'
|
||||
color='green'
|
||||
>
|
||||
<Users size={24} />
|
||||
</Avatar>
|
||||
@@ -689,7 +878,7 @@ const TopUp = () => {
|
||||
<Title heading={5} style={{ margin: 0 }}>
|
||||
{t('邀请奖励')}
|
||||
</Title>
|
||||
<Text type="tertiary" className="text-sm">
|
||||
<Text type='tertiary' className='text-sm'>
|
||||
{t('邀请好友获得额外奖励')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -698,53 +887,56 @@ const TopUp = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Card className="!rounded-2xl">
|
||||
<div className="flex justify-between items-center">
|
||||
<Text type="tertiary">{t('待使用收益')}</Text>
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 gap-4'>
|
||||
<Card className='!rounded-2xl'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text type='tertiary'>{t('待使用收益')}</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
size="small"
|
||||
disabled={!userState?.user?.aff_quota || userState?.user?.aff_quota <= 0}
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='small'
|
||||
disabled={
|
||||
!userState?.user?.aff_quota ||
|
||||
userState?.user?.aff_quota <= 0
|
||||
}
|
||||
onClick={() => setOpenTransfer(true)}
|
||||
>
|
||||
{t('划转到余额')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold mt-2">
|
||||
<div className='text-2xl font-semibold mt-2'>
|
||||
{renderQuota(userState?.user?.aff_quota || 0)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="!rounded-2xl">
|
||||
<Text type="tertiary">{t('总收益')}</Text>
|
||||
<div className="text-xl font-semibold mt-2">
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<Card className='!rounded-2xl'>
|
||||
<Text type='tertiary'>{t('总收益')}</Text>
|
||||
<div className='text-xl font-semibold mt-2'>
|
||||
{renderQuota(userState?.user?.aff_history_quota || 0)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="!rounded-2xl">
|
||||
<Text type="tertiary">{t('邀请人数')}</Text>
|
||||
<div className="text-xl font-semibold mt-2 flex items-center">
|
||||
<Users size={16} className="mr-1" />
|
||||
<Card className='!rounded-2xl'>
|
||||
<Text type='tertiary'>{t('邀请人数')}</Text>
|
||||
<div className='text-xl font-semibold mt-2 flex items-center'>
|
||||
<Users size={16} className='mr-1' />
|
||||
{userState?.user?.aff_count || 0}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
<Title heading={6}>{t('邀请链接')}</Title>
|
||||
<Input
|
||||
value={affLink}
|
||||
readOnly
|
||||
size="large"
|
||||
readonly
|
||||
size='large'
|
||||
suffix={
|
||||
<Button
|
||||
type="primary"
|
||||
theme="light"
|
||||
type='primary'
|
||||
theme='light'
|
||||
onClick={handleAffLinkClick}
|
||||
icon={<Copy size={14} />}
|
||||
>
|
||||
@@ -753,24 +945,24 @@ const TopUp = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Card className="!rounded-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 mr-3 flex-shrink-0"></div>
|
||||
<Text type="tertiary" className="text-sm leading-6">
|
||||
<div className='mt-4'>
|
||||
<Card className='!rounded-2xl'>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-start'>
|
||||
<div className='w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 mr-3 flex-shrink-0'></div>
|
||||
<Text type='tertiary' className='text-sm leading-6'>
|
||||
{t('邀请好友注册,好友充值后您可获得相应奖励')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 mt-2 mr-3 flex-shrink-0"></div>
|
||||
<Text type="tertiary" className="text-sm leading-6">
|
||||
<div className='flex items-start'>
|
||||
<div className='w-1.5 h-1.5 rounded-full bg-green-500 mt-2 mr-3 flex-shrink-0'></div>
|
||||
<Text type='tertiary' className='text-sm leading-6'>
|
||||
{t('通过划转功能将奖励额度转入到您的账户余额中')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-purple-500 mt-2 mr-3 flex-shrink-0"></div>
|
||||
<Text type="tertiary" className="text-sm leading-6">
|
||||
<div className='flex items-start'>
|
||||
<div className='w-1.5 h-1.5 rounded-full bg-purple-500 mt-2 mr-3 flex-shrink-0'></div>
|
||||
<Text type='tertiary' className='text-sm leading-6'>
|
||||
{t('邀请的好友越多,获得的奖励越多')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -785,20 +977,27 @@ const TopUp = () => {
|
||||
|
||||
{/* 移动端底部固定的自定义金额和支付区域 */}
|
||||
{enableOnlineTopUp && (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 shadow-lg z-50" style={{ background: 'var(--semi-color-bg-0)' }}>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className='md:hidden fixed bottom-0 left-0 right-0 p-4 shadow-lg z-50'
|
||||
style={{ background: 'var(--semi-color-bg-0)' }}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className='flex justify-between mb-2'>
|
||||
<Text strong>{t('充值数量')}</Text>
|
||||
{amountLoading ? (
|
||||
<Skeleton.Title style={{ width: '80px', height: '16px' }} />
|
||||
) : (
|
||||
<Text type="tertiary">{t('实付金额:') + renderAmount()}</Text>
|
||||
<Text type='tertiary'>
|
||||
{t('实付金额:') + renderAmount()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<InputNumber
|
||||
disabled={!enableOnlineTopUp}
|
||||
placeholder={t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)}
|
||||
placeholder={
|
||||
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
|
||||
}
|
||||
value={topUpCount}
|
||||
min={minTopUp}
|
||||
max={999999999}
|
||||
@@ -818,31 +1017,79 @@ const TopUp = () => {
|
||||
getAmount(1);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
formatter={(value) => value ? `${value}` : ''}
|
||||
parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
|
||||
className='w-full'
|
||||
formatter={(value) => (value ? `${value}` : '')}
|
||||
parser={(value) =>
|
||||
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => preTopUp('zfb')}
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === 'zfb'}
|
||||
icon={<SiAlipay size={18} />}
|
||||
>
|
||||
<span className="ml-2">{t('支付宝')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => preTopUp('wx')}
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === 'wx'}
|
||||
icon={<SiWechat size={18} />}
|
||||
>
|
||||
<span className="ml-2">{t('微信')}</span>
|
||||
</Button>
|
||||
<div>
|
||||
{payMethods.length === 2 ? (
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Button
|
||||
key={payMethod.type}
|
||||
type='primary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={!enableOnlineTopUp}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
icon={
|
||||
payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={16} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={16} />
|
||||
) : (
|
||||
<CreditCard size={16} />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
color: payMethod.color,
|
||||
}}
|
||||
className='h-10'
|
||||
>
|
||||
<span className='ml-1'>{payMethod.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-4 gap-2'>
|
||||
{payMethods.map((payMethod) => (
|
||||
<Card
|
||||
key={payMethod.type}
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={!enableOnlineTopUp}
|
||||
className={`cursor-pointer !rounded-xl p-0 transition-all ${paymentLoading && payWay === payMethod.type
|
||||
? 'border-blue-400'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
bodyStyle={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
opacity: !enableOnlineTopUp ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
{paymentLoading && payWay === payMethod.type ? (
|
||||
<div className='animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mx-auto'></div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex justify-center'>
|
||||
{payMethod.type === 'zfb' ? (
|
||||
<SiAlipay size={18} color={payMethod.color} />
|
||||
) : payMethod.type === 'wx' ? (
|
||||
<SiWechat size={18} color={payMethod.color} />
|
||||
) : (
|
||||
<CreditCard size={18} color={payMethod.color} />
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs mt-1'>{payMethod.name}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { API, isMobile, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconKey,
|
||||
IconUserAdd,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -23,31 +24,23 @@ const { Text, Title } = Typography;
|
||||
|
||||
const AddUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const originInputs = {
|
||||
const formApiRef = useRef(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getInitValues = () => ({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { username, display_name, password } = inputs;
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
if (inputs.username === '' || inputs.password === '') {
|
||||
setLoading(false);
|
||||
showError(t('用户名和密码不能为空!'));
|
||||
return;
|
||||
}
|
||||
const res = await API.post(`/api/user/`, inputs);
|
||||
const res = await API.post(`/api/user/`, values);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('用户账户创建成功!'));
|
||||
setInputs(originInputs);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
@@ -72,14 +65,7 @@ const AddUser = (props) => {
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
@@ -87,9 +73,7 @@ const AddUser = (props) => {
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
@@ -97,8 +81,6 @@ const AddUser = (props) => {
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
@@ -112,72 +94,68 @@ const AddUser = (props) => {
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUserAdd size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('用户信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('创建新用户账户')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('用户名')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入用户名')}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
required
|
||||
/>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => formApiRef.current = api}
|
||||
onSubmit={submit}
|
||||
onSubmitFail={(errs) => {
|
||||
const first = Object.values(errs)[0];
|
||||
if (first) showError(Array.isArray(first) ? first[0] : first);
|
||||
formApiRef.current?.scrollToError();
|
||||
}}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-2">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconUserAdd size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('用户信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('创建新用户账户')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('显示名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入显示名称')}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconUser />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('密码')}</Text>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('请输入密码')}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconKey />}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
rules={[{ required: true, message: t('请输入用户名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='display_name'
|
||||
label={t('显示名称')}
|
||||
placeholder={t('请输入显示名称')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
type='password'
|
||||
placeholder={t('请输入密码')}
|
||||
rules={[{ required: true, message: t('请输入密码') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='remark'
|
||||
label={t('备注')}
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
|
||||
@@ -1,93 +1,86 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, isMobile, showError, showSuccess, renderQuota, renderQuotaWithPrompt } from '../../helpers';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
isMobile,
|
||||
showError,
|
||||
showSuccess,
|
||||
renderQuota,
|
||||
renderQuotaWithPrompt,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Form,
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconUser,
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconKey,
|
||||
IconCreditCard,
|
||||
IconLink,
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditUser = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const userId = props.editingUser.id;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
|
||||
const [addQuotaLocal, setAddQuotaLocal] = useState('');
|
||||
const [inputs, setInputs] = useState({
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isEdit = Boolean(userId);
|
||||
|
||||
const getInitValues = () => ({
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
github_id: '',
|
||||
oidc_id: '',
|
||||
wechat_id: '',
|
||||
telegram_id: '',
|
||||
email: '',
|
||||
quota: 0,
|
||||
group: 'default',
|
||||
remark: '',
|
||||
});
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const {
|
||||
username,
|
||||
display_name,
|
||||
password,
|
||||
github_id,
|
||||
oidc_id,
|
||||
wechat_id,
|
||||
telegram_id,
|
||||
email,
|
||||
quota,
|
||||
group,
|
||||
} = inputs;
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
res.data.data.map((g) => ({ label: g, value: g }))
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
}
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => props.handleClose();
|
||||
|
||||
const loadUser = async () => {
|
||||
setLoading(true);
|
||||
let res = undefined;
|
||||
if (userId) {
|
||||
res = await API.get(`/api/user/${userId}`);
|
||||
} else {
|
||||
res = await API.get(`/api/user/self`);
|
||||
}
|
||||
const url = userId ? `/api/user/${userId}` : `/api/user/self`;
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
data.password = '';
|
||||
setInputs(data);
|
||||
formApiRef.current?.setValues({ ...getInitValues(), ...data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -95,27 +88,23 @@ const EditUser = (props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUser().then();
|
||||
if (userId) {
|
||||
fetchGroups().then();
|
||||
}
|
||||
loadUser();
|
||||
if (userId) fetchGroups();
|
||||
}, [props.editingUser.id]);
|
||||
|
||||
const submit = async () => {
|
||||
/* ----------------------- submit ----------------------- */
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
let res = undefined;
|
||||
let payload = { ...values };
|
||||
if (typeof payload.quota === 'string') payload.quota = parseInt(payload.quota) || 0;
|
||||
if (userId) {
|
||||
let data = { ...inputs, id: parseInt(userId) };
|
||||
if (typeof data.quota === 'string') {
|
||||
data.quota = parseInt(data.quota);
|
||||
}
|
||||
res = await API.put(`/api/user/`, data);
|
||||
} else {
|
||||
res = await API.put(`/api/user/self`, inputs);
|
||||
payload.id = parseInt(userId);
|
||||
}
|
||||
const url = userId ? `/api/user/` : `/api/user/self`;
|
||||
const res = await API.put(url, payload);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('用户信息更新成功!');
|
||||
showSuccess(t('用户信息更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
@@ -124,58 +113,45 @@ const EditUser = (props) => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
/* --------------------- quota helper -------------------- */
|
||||
const addLocalQuota = () => {
|
||||
let newQuota = parseInt(quota) + parseInt(addQuotaLocal);
|
||||
setInputs((inputs) => ({ ...inputs, quota: newQuota }));
|
||||
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
|
||||
const delta = parseInt(addQuotaLocal) || 0;
|
||||
formApiRef.current?.setValue('quota', current + delta);
|
||||
};
|
||||
|
||||
const openAddQuotaModal = () => {
|
||||
setAddQuotaLocal('0');
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
/* --------------------------- UI --------------------------- */
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={'right'}
|
||||
placement='right'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color="blue" shape="circle">{t('编辑')}</Tag>
|
||||
<Title heading={4} className="m-0">
|
||||
{t('编辑用户')}
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t(isEdit ? '编辑' : '新建')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('编辑用户') : t('创建用户')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
headerStyle={{
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
padding: '24px'
|
||||
}}
|
||||
bodyStyle={{
|
||||
backgroundColor: 'var(--semi-color-bg-0)',
|
||||
padding: '0'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
visible={props.visible}
|
||||
width={isMobile() ? '100%' : 600}
|
||||
footer={
|
||||
<div className="flex justify-end bg-white">
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme="solid"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
onClick={submit}
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
size="large"
|
||||
className="!rounded-full"
|
||||
type="primary"
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
@@ -185,235 +161,153 @@ const EditUser = (props) => {
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className="p-6">
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #1e3a8a 0%, #2563eb 50%, #3b82f6 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUser size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('用户的基本账户信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('用户名')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入新的用户名')}
|
||||
onChange={(value) => handleInputChange('username', value)}
|
||||
value={username}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('密码')}</Text>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
onChange={(value) => handleInputChange('password', value)}
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('显示名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('请输入新的显示名称')}
|
||||
onChange={(value) => handleInputChange('display_name', value)}
|
||||
value={display_name}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{userId && (
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #065f46 0%, #059669 50%, #10b981 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconUserGroup size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('权限设置')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('用户分组和额度管理')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('分组')}</Text>
|
||||
<Select
|
||||
placeholder={t('请选择分组')}
|
||||
search
|
||||
allowAdditions
|
||||
additionLabel={t(
|
||||
'请在系统设置页面编辑分组倍率以添加新的分组:',
|
||||
)}
|
||||
onChange={(value) => handleInputChange('group', value)}
|
||||
value={inputs.group}
|
||||
autoComplete="new-password"
|
||||
optionList={groupOptions}
|
||||
size="large"
|
||||
className="w-full !rounded-lg"
|
||||
prefix={<IconUserGroup />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<Text strong>{t('剩余额度')}</Text>
|
||||
<Text type="tertiary">{renderQuotaWithPrompt(quota)}</Text>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('用户的基本账户信息')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
onChange={(value) => handleInputChange('quota', value)}
|
||||
value={quota}
|
||||
type="number"
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="flex-1 !rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入新的用户名')}
|
||||
rules={[{ required: true, message: t('请输入用户名') }]}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
onClick={openAddQuotaModal}
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
icon={<IconPlus />}
|
||||
>
|
||||
{t('添加额度')}
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入新的密码,最短 8 位')}
|
||||
mode='password'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='display_name'
|
||||
label={t('显示名称')}
|
||||
placeholder={t('请输入新的显示名称')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='remark'
|
||||
label={t('备注')}
|
||||
placeholder={t('请输入备注(仅管理员可见)')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 权限设置 */}
|
||||
{userId && (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconUserGroup size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('权限设置')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('用户分组和额度管理')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='group'
|
||||
label={t('分组')}
|
||||
placeholder={t('请选择分组')}
|
||||
optionList={groupOptions}
|
||||
allowAdditions
|
||||
search
|
||||
rules={[{ required: true, message: t('请选择分组') }]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<Form.InputNumber
|
||||
field='quota'
|
||||
label={t('剩余额度')}
|
||||
placeholder={t('请输入新的剩余额度')}
|
||||
step={500000}
|
||||
extraText={renderQuotaWithPrompt(values.quota || 0)}
|
||||
rules={[{ required: true, message: t('请输入额度') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={14}>
|
||||
<Form.Slot label={t('添加额度')}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 绑定信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('绑定信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('第三方账户绑定状态(只读)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={12}>
|
||||
{['github_id', 'oidc_id', 'wechat_id', 'email', 'telegram_id'].map((field) => (
|
||||
<Col span={24} key={field}>
|
||||
<Form.Input
|
||||
field={field}
|
||||
label={t(`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`)}
|
||||
readonly
|
||||
placeholder={t('此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改')}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4 p-6 rounded-xl" style={{
|
||||
background: 'linear-gradient(135deg, #92400e 0%, #d97706 50%, #f59e0b 100%)',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center mr-4 relative">
|
||||
<IconLink size="large" style={{ color: '#ffffff' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Text style={{ color: '#ffffff' }} className="text-lg font-medium">{t('绑定信息')}</Text>
|
||||
<div style={{ color: '#ffffff' }} className="text-sm opacity-80">{t('第三方账户绑定状态(只读)')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 GitHub 账户')}</Text>
|
||||
<Input
|
||||
value={github_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 OIDC 账户')}</Text>
|
||||
<Input
|
||||
value={oidc_id}
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的微信账户')}</Text>
|
||||
<Input
|
||||
value={wechat_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的邮箱账户')}</Text>
|
||||
<Input
|
||||
value={email}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('已绑定的 Telegram 账户')}</Text>
|
||||
<Input
|
||||
value={telegram_id}
|
||||
autoComplete="new-password"
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
readonly
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
{/* 添加额度模态框 */}
|
||||
<Modal
|
||||
centered={true}
|
||||
centered
|
||||
visible={addQuotaModalOpen}
|
||||
onOk={() => {
|
||||
addLocalQuota();
|
||||
@@ -422,28 +316,31 @@ const EditUser = (props) => {
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<IconPlus className="mr-2" />
|
||||
<div className='flex items-center'>
|
||||
<IconPlus className='mr-2' />
|
||||
{t('添加额度')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Text type="secondary" className="block mb-2">
|
||||
{`${t('新额度')}${renderQuota(quota)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(quota + parseInt(addQuotaLocal || 0))}`}
|
||||
</Text>
|
||||
<div className='mb-4'>
|
||||
{
|
||||
(() => {
|
||||
const current = formApiRef.current?.getValue('quota') || 0;
|
||||
return (
|
||||
<Text type='secondary' className='block mb-2'>
|
||||
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
|
||||
</Text>
|
||||
);
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
<Input
|
||||
<InputNumber
|
||||
placeholder={t('需要添加的额度(支持负数)')}
|
||||
onChange={(value) => {
|
||||
setAddQuotaLocal(value);
|
||||
}}
|
||||
value={addQuotaLocal}
|
||||
type="number"
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
prefix={<IconCreditCard />}
|
||||
onChange={setAddQuotaLocal}
|
||||
style={{ width: '100%' }}
|
||||
showClear
|
||||
step={500000}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -3,9 +3,9 @@ import UsersTable from '../../components/table/UsersTable';
|
||||
|
||||
const User = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-[64px]">
|
||||
<UsersTable />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mj': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/pg': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user