mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-30 12:11:45 +00:00
Merge branch 'main' into feat_subscribe_sp1
This commit is contained in:
@@ -51,6 +51,8 @@ import SetupCheck from './components/layout/SetupCheck';
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
@@ -301,6 +303,22 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user-agreement'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<UserAgreement />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/privacy-policy'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<PrivacyPolicy />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/chat/:id?'
|
||||
element={
|
||||
|
||||
@@ -32,14 +32,22 @@ import {
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
@@ -74,6 +82,11 @@ const LoginForm = () => {
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -93,8 +106,18 @@ const LoginForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
@@ -102,6 +125,10 @@ const LoginForm = () => {
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
@@ -141,6 +168,10 @@ const LoginForm = () => {
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
@@ -192,6 +223,10 @@ const LoginForm = () => {
|
||||
|
||||
// 添加Telegram登录处理函数
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
@@ -228,6 +263,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的GitHub登录点击处理
|
||||
const handleGitHubClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setGithubLoading(true);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id);
|
||||
@@ -239,6 +278,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的OIDC登录点击处理
|
||||
const handleOIDCClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
@@ -250,6 +293,10 @@ const LoginForm = () => {
|
||||
|
||||
// 包装的LinuxDO登录点击处理
|
||||
const handleLinuxDOClick = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id);
|
||||
@@ -266,6 +313,66 @@ const LoginForm = () => {
|
||||
setEmailLoginLoading(false);
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
return;
|
||||
}
|
||||
if (!passkeySupported) {
|
||||
showInfo('当前环境无法使用 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
if (!window.PublicKeyCredential) {
|
||||
showInfo('当前浏览器不支持 Passkey');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/login/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || '无法发起 Passkey 登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKeyOptions = prepareCredentialRequestOptions(
|
||||
data?.options || data?.publicKey || data,
|
||||
);
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
const payload = buildAssertionResult(assertion);
|
||||
if (!payload) {
|
||||
showError('Passkey 验证失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/login/finish',
|
||||
payload,
|
||||
);
|
||||
const finish = finishRes.data;
|
||||
if (finish.success) {
|
||||
userDispatch({ type: 'login', payload: finish.data });
|
||||
setUserData(finish.data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
} else {
|
||||
showError(finish.message || 'Passkey 登录失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo('已取消 Passkey 登录');
|
||||
} else {
|
||||
showError('Passkey 登录失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的重置密码点击处理
|
||||
const handleResetPasswordClick = () => {
|
||||
setResetPasswordLoading(true);
|
||||
@@ -385,6 +492,19 @@ const LoginForm = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
@@ -401,6 +521,44 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='mt-6'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
@@ -437,6 +595,18 @@ const LoginForm = () => {
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='username'
|
||||
@@ -457,6 +627,44 @@ const LoginForm = () => {
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -465,6 +673,7 @@ const LoginForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import {
|
||||
@@ -82,6 +82,9 @@ const RegisterForm = () => {
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
||||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -106,6 +109,10 @@ const RegisterForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -505,6 +512,44 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
@@ -513,6 +558,7 @@ const RegisterForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
>
|
||||
{t('注册')}
|
||||
</Button>
|
||||
|
||||
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
const { Title } = Typography;
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
// 检查是否为 URL
|
||||
const isUrl = (content) => {
|
||||
try {
|
||||
new URL(content.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否为 HTML 内容
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
};
|
||||
|
||||
// 安全地渲染HTML内容
|
||||
const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map(style => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
return { content, styles };
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用文档渲染组件
|
||||
* @param {string} apiEndpoint - API 接口地址
|
||||
* @param {string} title - 文档标题
|
||||
* @param {string} cacheKey - 本地存储缓存键
|
||||
* @param {string} emptyMessage - 空内容时的提示消息
|
||||
*/
|
||||
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [htmlStyles, setHtmlStyles] = useState('');
|
||||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||||
|
||||
const loadContent = async () => {
|
||||
// 先从缓存中获取
|
||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||||
if (cachedContent) {
|
||||
setContent(cachedContent);
|
||||
processContent(cachedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await API.get(apiEndpoint);
|
||||
const { success, message, data } = res.data;
|
||||
if (success && data) {
|
||||
setContent(data);
|
||||
processContent(data);
|
||||
localStorage.setItem(cacheKey, data);
|
||||
} else {
|
||||
if (!cachedContent) {
|
||||
showError(message || emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cachedContent) {
|
||||
showError(emptyMessage);
|
||||
setContent('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (rawContent) => {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||||
setProcessedHtmlContent(htmlContent);
|
||||
setHtmlStyles(styles);
|
||||
} else {
|
||||
setProcessedHtmlContent('');
|
||||
setHtmlStyles('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
}, []);
|
||||
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
|
||||
if (htmlStyles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = styleId;
|
||||
styleEl.type = 'text/css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.innerHTML = htmlStyles;
|
||||
} else {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
return () => {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
};
|
||||
}, [htmlStyles, cacheKey]);
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
className='p-8'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>{title}</Title>
|
||||
<p className='text-gray-600 mb-4'>
|
||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||
</p>
|
||||
<a
|
||||
href={content.trim()}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
title={content.trim()}
|
||||
aria-label={`${t('访问' + title)}: ${content.trim()}`}
|
||||
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
|
||||
>
|
||||
{t('访问' + title)}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div className='prose prose-lg max-w-none'>
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
113
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
113
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../services/secureVerification';
|
||||
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||
|
||||
/**
|
||||
* 渠道密钥查看组件使用示例
|
||||
* 展示如何使用通用安全验证系统
|
||||
*/
|
||||
const ChannelKeyViewExample = ({ channelId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [keyData, setKeyData] = useState('');
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
|
||||
// 使用通用安全验证 Hook
|
||||
const {
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
} = useSecureVerification({
|
||||
onSuccess: (result) => {
|
||||
// 验证成功后处理结果
|
||||
if (result.success && result.data?.key) {
|
||||
setKeyData(result.data.key);
|
||||
setShowKeyModal(true);
|
||||
}
|
||||
},
|
||||
successMessage: t('密钥获取成功'),
|
||||
});
|
||||
|
||||
// 开始查看密钥流程
|
||||
const handleViewKey = async () => {
|
||||
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||
|
||||
await startVerification(apiCall, {
|
||||
title: t('查看渠道密钥'),
|
||||
description: t('为了保护账户安全,请验证您的身份。'),
|
||||
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 查看密钥按钮 */}
|
||||
<Button type='primary' theme='outline' onClick={handleViewKey}>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
|
||||
{/* 安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
verificationMethods={verificationMethods}
|
||||
verificationState={verificationState}
|
||||
onVerify={executeVerification}
|
||||
onCancel={cancelVerification}
|
||||
onCodeChange={setVerificationCode}
|
||||
onMethodSwitch={switchVerificationMethod}
|
||||
title={verificationState.title}
|
||||
description={verificationState.description}
|
||||
/>
|
||||
|
||||
{/* 密钥显示模态框 */}
|
||||
<Modal
|
||||
title={t('渠道密钥信息')}
|
||||
visible={showKeyModal}
|
||||
onCancel={() => setShowKeyModal(false)}
|
||||
footer={
|
||||
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
}
|
||||
width={700}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<ChannelKeyDisplay
|
||||
keyData={keyData}
|
||||
showSuccessIcon={true}
|
||||
successText={t('密钥获取成功')}
|
||||
showWarning={true}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyViewExample;
|
||||
322
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
322
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Input,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Space,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
|
||||
/**
|
||||
* 通用安全验证模态框组件
|
||||
* 配合 useSecureVerification Hook 使用
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示模态框
|
||||
* @param {Object} props.verificationMethods - 可用的验证方式
|
||||
* @param {Object} props.verificationState - 当前验证状态
|
||||
* @param {Function} props.onVerify - 验证回调
|
||||
* @param {Function} props.onCancel - 取消回调
|
||||
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||
* @param {Function} props.onMethodSwitch - 验证方式切换回调
|
||||
* @param {string} props.title - 模态框标题
|
||||
* @param {string} props.description - 验证描述文本
|
||||
*/
|
||||
const SecureVerificationModal = ({
|
||||
visible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
onVerify,
|
||||
onCancel,
|
||||
onCodeChange,
|
||||
onMethodSwitch,
|
||||
title,
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [verifySuccess, setVerifySuccess] = useState(false);
|
||||
|
||||
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
||||
const { method, loading, code } = verificationState;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsAnimating(true);
|
||||
setVerifySuccess(false);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
||||
onVerify(method, code);
|
||||
}
|
||||
if (e.key === 'Escape' && !loading) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// 如果用户没有启用任何验证方式
|
||||
if (visible && !has2FA && !hasPasskey) {
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={<Button onClick={onCancel}>{t('确定')}</Button>}
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className='text-center py-6'>
|
||||
<div className='mb-4'>
|
||||
<svg
|
||||
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title heading={4} className='mb-2'>
|
||||
{t('需要安全验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type='tertiary'>
|
||||
{t('请前往个人设置 → 安全设置进行配置。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('安全验证')}
|
||||
visible={visible}
|
||||
onCancel={loading ? undefined : onCancel}
|
||||
closeOnEsc={!loading}
|
||||
footer={null}
|
||||
width={460}
|
||||
centered
|
||||
style={{
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '20px 24px',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Typography.Paragraph
|
||||
type='tertiary'
|
||||
style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
{/* 验证方式选择 */}
|
||||
<Tabs
|
||||
activeKey={method}
|
||||
onChange={onMethodSwitch}
|
||||
type='line'
|
||||
size='default'
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{has2FA && (
|
||||
<TabPane tab={t('两步验证')} itemKey='2fa'>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Input
|
||||
placeholder={t('请输入6位验证码或8位备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={method === '2fa'}
|
||||
disabled={loading}
|
||||
prefix={
|
||||
<svg
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '20px',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{t('从认证器应用中获取验证码,或使用备用码')}
|
||||
</Typography.Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code.trim() || loading}
|
||||
onClick={() => onVerify(method, code)}
|
||||
>
|
||||
{t('验证')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
|
||||
{hasPasskey && passkeySupported && (
|
||||
<TabPane tab={t('Passkey')} itemKey='passkey'>
|
||||
<div style={{ paddingTop: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 16px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--semi-color-primary-light-default)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
color: 'var(--semi-color-primary)',
|
||||
}}
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Typography.Title
|
||||
heading={5}
|
||||
style={{ margin: '0 0 8px', fontSize: '16px' }}
|
||||
>
|
||||
{t('使用 Passkey 验证')}
|
||||
</Typography.Title>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{t('点击验证按钮,使用您的生物特征或安全密钥')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => onVerify(method)}
|
||||
>
|
||||
{t('验证 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecureVerificationModal;
|
||||
@@ -135,7 +135,9 @@ const TwoFactorAuthModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')}
|
||||
{t(
|
||||
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,14 +142,6 @@ const FooterBar = () => {
|
||||
>
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Deeptrain-Community/chatnio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
chatnio
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/neko-api-key-tool'
|
||||
target='_blank'
|
||||
@@ -163,7 +155,7 @@ const FooterBar = () => {
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('基于New API的项目')}
|
||||
{t('友情链接')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
@@ -174,7 +166,22 @@ const FooterBar = () => {
|
||||
>
|
||||
new-api-horizon
|
||||
</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
<a
|
||||
href='https://github.com/coaidev/coai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
CoAI
|
||||
</a>
|
||||
<a
|
||||
href='https://www.gpt-load.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
GPT-Load
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,15 +207,6 @@ const FooterBar = () => {
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
<span className='!text-semi-color-text-1'> & </span>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -223,10 +221,23 @@ const FooterBar = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{footer ? (
|
||||
<div
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
|
||||
<span>{t('设计与开发由')} </span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
|
||||
@@ -48,9 +48,19 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter =
|
||||
location.pathname.startsWith('/console') ||
|
||||
location.pathname === '/pricing';
|
||||
const cardProPages = [
|
||||
'/console/channel',
|
||||
'/console/log',
|
||||
'/console/redemption',
|
||||
'/console/user',
|
||||
'/console/token',
|
||||
'/console/midjourney',
|
||||
'/console/task',
|
||||
'/console/models',
|
||||
'/pricing',
|
||||
];
|
||||
|
||||
const shouldHideFooter = cardProPages.includes(location.pathname);
|
||||
|
||||
const shouldInnerPadding =
|
||||
location.pathname.includes('/console') &&
|
||||
|
||||
@@ -58,7 +58,7 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
loading: sidebarLoading,
|
||||
} = useSidebar();
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading);
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
import { CN, GB, FR, RU } from 'country-flag-icons/react/3x2';
|
||||
|
||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
return (
|
||||
@@ -42,12 +42,26 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
<GB title='English' className='!w-5 !h-auto' />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('fr')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<FR title='Français' className='!w-5 !h-auto' />
|
||||
<span>Français</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('ru')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'ru' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<RU title='Русский' className='!w-5 !h-auto' />
|
||||
<span>Русский</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<Languages size={18} />}
|
||||
aria-label={t('切换语言')}
|
||||
aria-label={t('common.changeLanguage')}
|
||||
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'
|
||||
|
||||
@@ -35,6 +35,7 @@ const OperationSetting = () => {
|
||||
PreConsumedQuota: 0,
|
||||
QuotaForInviter: 0,
|
||||
QuotaForInvitee: 0,
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
|
||||
/* 通用设置 */
|
||||
TopUpLink: '',
|
||||
@@ -42,7 +43,7 @@ const OperationSetting = () => {
|
||||
QuotaPerUnit: 0,
|
||||
USDExchangeRate: 0,
|
||||
RetryTimes: 0,
|
||||
DisplayInCurrencyEnabled: false,
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
|
||||
@@ -34,10 +34,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const LEGAL_USER_AGREEMENT_KEY = 'legal.user_agreement';
|
||||
const LEGAL_PRIVACY_POLICY_KEY = 'legal.privacy_policy';
|
||||
|
||||
const OtherSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
Notice: '',
|
||||
[LEGAL_USER_AGREEMENT_KEY]: '',
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: '',
|
||||
SystemName: '',
|
||||
Logo: '',
|
||||
Footer: '',
|
||||
@@ -69,6 +74,8 @@ const OtherSetting = () => {
|
||||
|
||||
const [loadingInput, setLoadingInput] = useState({
|
||||
Notice: false,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
SystemName: false,
|
||||
Logo: false,
|
||||
HomePageContent: false,
|
||||
@@ -96,6 +103,50 @@ const OtherSetting = () => {
|
||||
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
|
||||
}
|
||||
};
|
||||
// 通用设置 - UserAgreement
|
||||
const submitUserAgreement = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_USER_AGREEMENT_KEY,
|
||||
inputs[LEGAL_USER_AGREEMENT_KEY],
|
||||
);
|
||||
showSuccess(t('用户协议已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('用户协议更新失败'), error);
|
||||
showError(t('用户协议更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_USER_AGREEMENT_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 通用设置 - PrivacyPolicy
|
||||
const submitPrivacyPolicy = async () => {
|
||||
try {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: true,
|
||||
}));
|
||||
await updateOption(
|
||||
LEGAL_PRIVACY_POLICY_KEY,
|
||||
inputs[LEGAL_PRIVACY_POLICY_KEY],
|
||||
);
|
||||
showSuccess(t('隐私政策已更新'));
|
||||
} catch (error) {
|
||||
console.error(t('隐私政策更新失败'), error);
|
||||
showError(t('隐私政策更新失败'));
|
||||
} finally {
|
||||
setLoadingInput((loadingInput) => ({
|
||||
...loadingInput,
|
||||
[LEGAL_PRIVACY_POLICY_KEY]: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
// 个性化设置
|
||||
const formAPIPersonalization = useRef();
|
||||
// 个性化设置 - SystemName
|
||||
@@ -324,6 +375,40 @@ const OtherSetting = () => {
|
||||
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
|
||||
{t('设置公告')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('用户协议')}
|
||||
placeholder={t(
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_USER_AGREEMENT_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitUserAgreement}
|
||||
loading={loadingInput[LEGAL_USER_AGREEMENT_KEY]}
|
||||
>
|
||||
{t('设置用户协议')}
|
||||
</Button>
|
||||
<Form.TextArea
|
||||
label={t('隐私政策')}
|
||||
placeholder={t(
|
||||
'在此输入隐私政策内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_PRIVACY_POLICY_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitPrivacyPolicy}
|
||||
loading={loadingInput[LEGAL_PRIVACY_POLICY_KEY]}
|
||||
>
|
||||
{t('设置隐私政策')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
</Form>
|
||||
|
||||
@@ -46,6 +46,7 @@ const PaymentSetting = () => {
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -19,7 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
setStatusData,
|
||||
prepareCredentialCreationOptions,
|
||||
buildRegistrationResult,
|
||||
isPasskeySupported,
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -59,6 +70,10 @@ const PersonalSetting = () => {
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [systemToken, setSystemToken] = useState('');
|
||||
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
|
||||
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
|
||||
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
warningType: 'email',
|
||||
warningThreshold: 100000,
|
||||
@@ -66,23 +81,52 @@ const PersonalSetting = () => {
|
||||
webhookSecret: '',
|
||||
notificationEmail: '',
|
||||
barkUrl: '',
|
||||
gotifyUrl: '',
|
||||
gotifyToken: '',
|
||||
gotifyPriority: 5,
|
||||
acceptUnsetModelRatioModel: false,
|
||||
recordIpLog: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
let saved = localStorage.getItem('status');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
setStatus(parsed);
|
||||
if (parsed.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
setTurnstileSiteKey(parsed.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
getUserData().then((res) => {
|
||||
console.log(userState);
|
||||
});
|
||||
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (success && data) {
|
||||
setStatus(data);
|
||||
setStatusData(data);
|
||||
if (data.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(data.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and keep local status
|
||||
}
|
||||
})();
|
||||
|
||||
getUserData();
|
||||
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +152,10 @@ const PersonalSetting = () => {
|
||||
webhookSecret: settings.webhook_secret || '',
|
||||
notificationEmail: settings.notification_email || '',
|
||||
barkUrl: settings.bark_url || '',
|
||||
gotifyUrl: settings.gotify_url || '',
|
||||
gotifyToken: settings.gotify_token || '',
|
||||
gotifyPriority:
|
||||
settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
@@ -131,11 +179,95 @@ const PersonalSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadPasskeyStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/passkey');
|
||||
const { success, data, message } = res.data;
|
||||
if (success) {
|
||||
setPasskeyStatus({
|
||||
enabled: data?.enabled || false,
|
||||
last_used_at: data?.last_used_at || null,
|
||||
backup_eligible: data?.backup_eligible || false,
|
||||
backup_state: data?.backup_state || false,
|
||||
});
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误,保留默认状态
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterPasskey = async () => {
|
||||
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||
showInfo(t('当前设备不支持 Passkey'));
|
||||
return;
|
||||
}
|
||||
setPasskeyRegisterLoading(true);
|
||||
try {
|
||||
const beginRes = await API.post('/api/user/passkey/register/begin');
|
||||
const { success, message, data } = beginRes.data;
|
||||
if (!success) {
|
||||
showError(message || t('无法发起 Passkey 注册'));
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialCreationOptions(
|
||||
data?.options || data?.publicKey || data,
|
||||
);
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
const payload = buildRegistrationResult(credential);
|
||||
if (!payload) {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finishRes = await API.post(
|
||||
'/api/user/passkey/register/finish',
|
||||
payload,
|
||||
);
|
||||
if (finishRes.data.success) {
|
||||
showSuccess(t('Passkey 注册成功'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
showInfo(t('已取消 Passkey 注册'));
|
||||
} else {
|
||||
showError(t('Passkey 注册失败,请重试'));
|
||||
}
|
||||
} finally {
|
||||
setPasskeyRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePasskey = async () => {
|
||||
setPasskeyDeleteLoading(true);
|
||||
try {
|
||||
const res = await API.delete('/api/user/passkey');
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已解绑'));
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
} finally {
|
||||
setPasskeyDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
await loadPasskeyStatus();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -286,6 +418,12 @@ const PersonalSetting = () => {
|
||||
webhook_secret: notificationSettings.webhookSecret,
|
||||
notification_email: notificationSettings.notificationEmail,
|
||||
bark_url: notificationSettings.barkUrl,
|
||||
gotify_url: notificationSettings.gotifyUrl,
|
||||
gotify_token: notificationSettings.gotifyToken,
|
||||
gotify_priority: (() => {
|
||||
const parsed = parseInt(notificationSettings.gotifyPriority);
|
||||
return isNaN(parsed) ? 5 : parsed;
|
||||
})(),
|
||||
accept_unset_model_ratio_model:
|
||||
notificationSettings.acceptUnsetModelRatioModel,
|
||||
record_ip_log: notificationSettings.recordIpLog,
|
||||
@@ -323,6 +461,12 @@ const PersonalSetting = () => {
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
passkeyStatus={passkeyStatus}
|
||||
passkeySupported={passkeySupported}
|
||||
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||
onPasskeyRegister={handleRegisterPasskey}
|
||||
onPasskeyDelete={handleRemovePasskey}
|
||||
/>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Spin,
|
||||
Card,
|
||||
Radio,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
@@ -45,7 +46,6 @@ import { useTranslation } from 'react-i18next';
|
||||
const SystemSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
|
||||
PasswordLoginEnabled: '',
|
||||
PasswordRegisterEnabled: '',
|
||||
EmailVerificationEnabled: '',
|
||||
@@ -77,6 +77,13 @@ const SystemSetting = () => {
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
RegisterEnabled: '',
|
||||
'passkey.enabled': '',
|
||||
'passkey.rp_display_name': '',
|
||||
'passkey.rp_id': '',
|
||||
'passkey.origins': [],
|
||||
'passkey.allow_insecure_origin': '',
|
||||
'passkey.user_verification': 'preferred',
|
||||
'passkey.attachment_preference': '',
|
||||
EmailDomainRestrictionEnabled: '',
|
||||
EmailAliasRestrictionEnabled: '',
|
||||
SMTPSSLEnabled: '',
|
||||
@@ -173,9 +180,25 @@ const SystemSetting = () => {
|
||||
case 'SMTPSSLEnabled':
|
||||
case 'LinuxDOOAuthEnabled':
|
||||
case 'oidc.enabled':
|
||||
case 'passkey.enabled':
|
||||
case 'passkey.allow_insecure_origin':
|
||||
case 'WorkerAllowHttpImageRequestEnabled':
|
||||
item.value = toBoolean(item.value);
|
||||
break;
|
||||
case 'passkey.origins':
|
||||
// origins是逗号分隔的字符串,直接使用
|
||||
item.value = item.value || '';
|
||||
break;
|
||||
case 'passkey.rp_display_name':
|
||||
case 'passkey.rp_id':
|
||||
case 'passkey.attachment_preference':
|
||||
// 确保字符串字段不为null/undefined
|
||||
item.value = item.value || '';
|
||||
break;
|
||||
case 'passkey.user_verification':
|
||||
// 确保有默认值
|
||||
item.value = item.value || 'preferred';
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
item.value = parseFloat(item.value);
|
||||
@@ -188,7 +211,9 @@ const SystemSetting = () => {
|
||||
setInputs(newInputs);
|
||||
setOriginInputs(newInputs);
|
||||
// 同步模式布尔到本地状态
|
||||
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
|
||||
if (
|
||||
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
|
||||
) {
|
||||
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
||||
}
|
||||
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
||||
@@ -582,6 +607,45 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitPasskeySettings = async () => {
|
||||
// 使用formApi直接获取当前表单值
|
||||
const formValues = formApiRef.current?.getValues() || {};
|
||||
|
||||
const options = [];
|
||||
|
||||
options.push({
|
||||
key: 'passkey.rp_display_name',
|
||||
value:
|
||||
formValues['passkey.rp_display_name'] ||
|
||||
inputs['passkey.rp_display_name'] ||
|
||||
'',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.rp_id',
|
||||
value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.user_verification',
|
||||
value:
|
||||
formValues['passkey.user_verification'] ||
|
||||
inputs['passkey.user_verification'] ||
|
||||
'preferred',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.attachment_preference',
|
||||
value:
|
||||
formValues['passkey.attachment_preference'] ||
|
||||
inputs['passkey.attachment_preference'] ||
|
||||
'',
|
||||
});
|
||||
options.push({
|
||||
key: 'passkey.origins',
|
||||
value: formValues['passkey.origins'] || inputs['passkey.origins'] || '',
|
||||
});
|
||||
|
||||
await updateOptions(options);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = async (optionKey, event) => {
|
||||
const value = event.target.checked;
|
||||
|
||||
@@ -641,8 +705,15 @@ const SystemSetting = () => {
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('代理设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'此代理仅用于图片请求转发,Webhook通知发送等,AI API请求仍然由服务器直接发出,可在渠道设置中单独配置代理',
|
||||
)}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Text>
|
||||
(支持{' '}
|
||||
{t('仅支持')}{' '}
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-worker'
|
||||
target='_blank'
|
||||
@@ -650,7 +721,7 @@ const SystemSetting = () => {
|
||||
>
|
||||
new-api-worker
|
||||
</a>
|
||||
)
|
||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
@@ -695,14 +766,17 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('SSRF防护开关详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.enable_ssrf_protection',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -713,14 +787,19 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('私有IP访问详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.allow_private_ip', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.allow_private_ip',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
|
||||
{t(
|
||||
'允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)',
|
||||
)}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -731,7 +810,10 @@ const SystemSetting = () => {
|
||||
noLabel
|
||||
extraText={t('域名IP过滤详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
|
||||
handleCheckboxChange(
|
||||
'fetch_setting.apply_ip_filter_for_domain',
|
||||
e,
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
@@ -740,17 +822,23 @@ const SystemSetting = () => {
|
||||
<Text strong>
|
||||
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('支持通配符格式,如:example.com, *.api.example.com')}
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t(
|
||||
'支持通配符格式,如:example.com, *.api.example.com',
|
||||
)}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const selected =
|
||||
val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setDomainFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_filter_mode': isWhitelist,
|
||||
}));
|
||||
@@ -765,9 +853,9 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setDomainList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_list': value
|
||||
'fetch_setting.domain_list': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入域名后回车,如:example.com')}
|
||||
@@ -784,17 +872,21 @@ const SystemSetting = () => {
|
||||
<Text strong>
|
||||
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const selected =
|
||||
val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setIpFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_filter_mode': isWhitelist,
|
||||
}));
|
||||
@@ -809,9 +901,9 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setIpList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_list': value
|
||||
'fetch_setting.ip_list': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
||||
@@ -826,7 +918,10 @@ const SystemSetting = () => {
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Text strong>{t('允许的端口')}</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
||||
</Text>
|
||||
<TagInput
|
||||
@@ -834,15 +929,18 @@ const SystemSetting = () => {
|
||||
onChange={(value) => {
|
||||
setAllowedPorts(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
'fetch_setting.allowed_ports': value
|
||||
'fetch_setting.allowed_ports': value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
<Text
|
||||
type='secondary'
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
{t('端口配置详细说明')}
|
||||
</Text>
|
||||
</Col>
|
||||
@@ -957,6 +1055,135 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置 Passkey')}>
|
||||
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式',
|
||||
)}
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field="['passkey.enabled']"
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('passkey.enabled', e)
|
||||
}
|
||||
>
|
||||
{t('允许通过 Passkey 登录 & 认证')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field="['passkey.rp_display_name']"
|
||||
label={t('服务显示名称')}
|
||||
placeholder={t('默认使用系统名称')}
|
||||
extraText={t(
|
||||
"用户注册时看到的网站名称,比如'我的网站'",
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Input
|
||||
field="['passkey.rp_id']"
|
||||
label={t('网站域名标识')}
|
||||
placeholder={t('例如:example.com')}
|
||||
extraText={t(
|
||||
'留空则默认使用服务器地址,注意不能携带http://或者https://',
|
||||
)}
|
||||
/>
|
||||
</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={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field="['passkey.user_verification']"
|
||||
label={t('安全验证级别')}
|
||||
placeholder={t('是否要求指纹/面容等生物识别')}
|
||||
optionList={[
|
||||
{
|
||||
label: t('推荐使用(用户可选)'),
|
||||
value: 'preferred',
|
||||
},
|
||||
{ label: t('强制要求'), value: 'required' },
|
||||
{ label: t('不建议使用'), value: 'discouraged' },
|
||||
]}
|
||||
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Select
|
||||
field="['passkey.attachment_preference']"
|
||||
label={t('设备类型偏好')}
|
||||
placeholder={t('选择支持的认证设备类型')}
|
||||
optionList={[
|
||||
{ label: t('不限制'), value: '' },
|
||||
{ label: t('本设备内置'), value: 'platform' },
|
||||
{ label: t('外接设备'), value: 'cross-platform' },
|
||||
]}
|
||||
extraText={t(
|
||||
'本设备:手机指纹/面容,外接:USB安全密钥',
|
||||
)}
|
||||
/>
|
||||
</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={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field="['passkey.allow_insecure_origin']"
|
||||
noLabel
|
||||
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange(
|
||||
'passkey.allow_insecure_origin',
|
||||
e,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('允许不安全的 Origin(HTTP)')}
|
||||
</Form.Checkbox>
|
||||
</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={24} lg={24} xl={24}>
|
||||
<Form.Input
|
||||
field="['passkey.origins']"
|
||||
label={t('允许的 Origins')}
|
||||
placeholder={t('填写带https的域名,逗号分隔')}
|
||||
extraText={t(
|
||||
'为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button
|
||||
onClick={submitPasskeySettings}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{t('保存 Passkey 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置邮箱域名白名单')}>
|
||||
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
Popover,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
@@ -58,6 +59,12 @@ const AccountManagement = ({
|
||||
handleSystemTokenClick,
|
||||
setShowChangePasswordModal,
|
||||
setShowAccountDeleteModal,
|
||||
passkeyStatus,
|
||||
passkeySupported,
|
||||
passkeyRegisterLoading,
|
||||
passkeyDeleteLoading,
|
||||
onPasskeyRegister,
|
||||
onPasskeyDelete,
|
||||
}) => {
|
||||
const renderAccountInfo = (accountId, label) => {
|
||||
if (!accountId || accountId === '') {
|
||||
@@ -83,6 +90,14 @@ const AccountManagement = ({
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] =
|
||||
React.useState(false);
|
||||
const passkeyEnabled = passkeyStatus?.enabled;
|
||||
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||
: t('尚未使用');
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl'>
|
||||
{/* 卡片头部 */}
|
||||
@@ -142,7 +157,7 @@ const AccountManagement = ({
|
||||
size='small'
|
||||
onClick={() => setShowEmailBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.email !== ''
|
||||
{isBound(userState.user?.email)
|
||||
? t('修改绑定')
|
||||
: t('绑定')}
|
||||
</Button>
|
||||
@@ -165,9 +180,11 @@ const AccountManagement = ({
|
||||
{t('微信')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
{!status.wechat_login
|
||||
? t('未启用')
|
||||
: isBound(userState.user?.wechat_id)
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +196,7 @@ const AccountManagement = ({
|
||||
disabled={!status.wechat_login}
|
||||
onClick={() => setShowWeChatBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
{isBound(userState.user?.wechat_id)
|
||||
? t('修改绑定')
|
||||
: status.wechat_login
|
||||
? t('绑定')
|
||||
@@ -220,7 +237,7 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.github_id !== '') ||
|
||||
isBound(userState.user?.github_id) ||
|
||||
!status.github_oauth
|
||||
}
|
||||
>
|
||||
@@ -264,8 +281,7 @@ const AccountManagement = ({
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.oidc_id !== '') ||
|
||||
!status.oidc_enabled
|
||||
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
||||
}
|
||||
>
|
||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||
@@ -298,26 +314,56 @@ const AccountManagement = ({
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{status.telegram_oauth ? (
|
||||
userState.user.telegram_id !== '' ? (
|
||||
<Button disabled={true} size='small'>
|
||||
isBound(userState.user?.telegram_id) ? (
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('已绑定')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='scale-75'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => setShowTelegramBindModal(true)}
|
||||
>
|
||||
{t('绑定')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={true} size='small'>
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('未启用')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Modal
|
||||
title={t('绑定 Telegram')}
|
||||
visible={showTelegramBindModal}
|
||||
onCancel={() => setShowTelegramBindModal(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div className='my-3 text-sm text-gray-600'>
|
||||
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<div className='scale-90'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* LinuxDO绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
@@ -350,7 +396,7 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.linux_do_id !== '') ||
|
||||
isBound(userState.user?.linux_do_id) ||
|
||||
!status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
@@ -443,6 +489,77 @@ const AccountManagement = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Passkey 设置 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconKey size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('Passkey 登录')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{passkeyEnabled
|
||||
? t('已启用 Passkey,无需密码即可登录')
|
||||
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
||||
</Typography.Text>
|
||||
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
||||
<div>
|
||||
{t('最后使用时间')}:{lastUsedLabel}
|
||||
</div>
|
||||
{/*{passkeyEnabled && (*/}
|
||||
{/* <div>*/}
|
||||
{/* {t('备份支持')}:*/}
|
||||
{/* {passkeyStatus?.backup_eligible*/}
|
||||
{/* ? t('支持备份')*/}
|
||||
{/* : t('不支持')}*/}
|
||||
{/* ,{t('备份状态')}:*/}
|
||||
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{!passkeySupported && (
|
||||
<div className='text-amber-600'>
|
||||
{t('当前设备不支持 Passkey')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type={passkeyEnabled ? 'danger' : 'primary'}
|
||||
theme={passkeyEnabled ? 'solid' : 'solid'}
|
||||
onClick={
|
||||
passkeyEnabled
|
||||
? () => {
|
||||
Modal.confirm({
|
||||
title: t('确认解绑 Passkey'),
|
||||
content: t(
|
||||
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
|
||||
),
|
||||
okText: t('确认解绑'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
onOk: onPasskeyDelete,
|
||||
});
|
||||
}
|
||||
: onPasskeyRegister
|
||||
}
|
||||
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
|
||||
icon={<IconKey />}
|
||||
disabled={!passkeySupported && !passkeyEnabled}
|
||||
loading={
|
||||
passkeyEnabled
|
||||
? passkeyDeleteLoading
|
||||
: passkeyRegisterLoading
|
||||
}
|
||||
>
|
||||
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 两步验证设置 */}
|
||||
<TwoFASetting t={t} />
|
||||
|
||||
|
||||
@@ -400,6 +400,7 @@ const NotificationSettings = ({
|
||||
<Radio value='email'>{t('邮件通知')}</Radio>
|
||||
<Radio value='webhook'>{t('Webhook通知')}</Radio>
|
||||
<Radio value='bark'>{t('Bark通知')}</Radio>
|
||||
<Radio value='gotify'>{t('Gotify通知')}</Radio>
|
||||
</Form.RadioGroup>
|
||||
|
||||
<Form.AutoComplete
|
||||
@@ -589,7 +590,108 @@ const NotificationSettings = ({
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Bark 官方文档
|
||||
Bark {t('官方文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Gotify推送设置 */}
|
||||
{notificationSettings.warningType === 'gotify' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='gotifyUrl'
|
||||
label={t('Gotify服务器地址')}
|
||||
placeholder={t(
|
||||
'请输入Gotify服务器地址,例如: https://gotify.example.com',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('gotifyUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t(
|
||||
'支持HTTP和HTTPS,填写Gotify服务器的完整URL地址',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required:
|
||||
notificationSettings.warningType === 'gotify',
|
||||
message: t('请输入Gotify服务器地址'),
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: t(
|
||||
'Gotify服务器地址必须以http://或https://开头',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='gotifyToken'
|
||||
label={t('Gotify应用令牌')}
|
||||
placeholder={t('请输入Gotify应用令牌')}
|
||||
onChange={(val) => handleFormChange('gotifyToken', val)}
|
||||
prefix={<IconKey />}
|
||||
extraText={t(
|
||||
'在Gotify服务器创建应用后获得的令牌,用于发送通知',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required:
|
||||
notificationSettings.warningType === 'gotify',
|
||||
message: t('请输入Gotify应用令牌'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.AutoComplete
|
||||
field='gotifyPriority'
|
||||
label={t('消息优先级')}
|
||||
placeholder={t('请选择消息优先级')}
|
||||
data={[
|
||||
{ value: 0, label: t('0 - 最低') },
|
||||
{ value: 2, label: t('2 - 低') },
|
||||
{ value: 5, label: t('5 - 正常(默认)') },
|
||||
{ value: 8, label: t('8 - 高') },
|
||||
{ value: 10, label: t('10 - 最高') },
|
||||
]}
|
||||
onChange={(val) =>
|
||||
handleFormChange('gotifyPriority', val)
|
||||
}
|
||||
prefix={<IconBell />}
|
||||
extraText={t('消息优先级,范围0-10,默认为5')}
|
||||
style={{ width: '100%', maxWidth: '300px' }}
|
||||
/>
|
||||
|
||||
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
|
||||
<div className='text-sm text-gray-700 mb-3'>
|
||||
<strong>{t('配置说明')}</strong>
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 space-y-2'>
|
||||
<div>
|
||||
1. {t('在Gotify服务器的应用管理中创建新应用')}
|
||||
</div>
|
||||
<div>
|
||||
2.{' '}
|
||||
{t(
|
||||
'复制应用的令牌(Token)并填写到上方的应用令牌字段',
|
||||
)}
|
||||
</div>
|
||||
<div>3. {t('填写Gotify服务器的完整URL地址')}</div>
|
||||
<div className='mt-3 pt-3 border-t border-gray-200'>
|
||||
<span className='text-gray-400'>
|
||||
{t('更多信息请参考')}
|
||||
</span>{' '}
|
||||
<a
|
||||
href='https://gotify.net/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Gotify {t('官方文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,29 +25,55 @@ import { Banner } from '@douyinfe/semi-ui';
|
||||
* 显示当前数据库类型和相关警告信息
|
||||
*/
|
||||
const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
|
||||
// 检测是否在 Electron 环境中运行
|
||||
const isElectron =
|
||||
typeof window !== 'undefined' && window.electron?.isElectron;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 数据库警告 */}
|
||||
{setupStatus.database_type === 'sqlite' && (
|
||||
<Banner
|
||||
type='warning'
|
||||
type={isElectron ? 'info' : 'warning'}
|
||||
closeIcon={null}
|
||||
title={t('数据库警告')}
|
||||
title={isElectron ? t('本地数据存储') : t('数据库警告')}
|
||||
description={
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
<strong>
|
||||
isElectron ? (
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
'您的数据将安全地存储在本地计算机上。所有配置、用户信息和使用记录都会自动保存,关闭应用后不会丢失。',
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
{window.electron?.dataDir && (
|
||||
<p className='mt-2 text-sm opacity-80'>
|
||||
<strong>{t('数据存储位置:')}</strong>
|
||||
<br />
|
||||
<code className='bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded'>
|
||||
{window.electron.dataDir}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<p className='mt-2 text-sm opacity-70'>
|
||||
💡 {t('提示:如需备份数据,只需复制上述目录即可')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
'您正在使用 SQLite 数据库。如果您在容器环境中运行,请确保已正确设置数据库文件的持久化映射,否则容器重启后所有数据将丢失!',
|
||||
)}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
<strong>
|
||||
{t(
|
||||
'建议在生产环境中使用 MySQL 或 PostgreSQL 数据库,或确保 SQLite 数据库文件已映射到宿主机的持久化存储。',
|
||||
)}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
className='!rounded-lg'
|
||||
fullMode={false}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,20 @@ const EditTagModal = (props) => {
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 53:
|
||||
localModels = [
|
||||
'NousResearch/Hermes-4-405B-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Thinking-2507',
|
||||
'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
|
||||
'Qwen/Qwen3-235B-A22B-Instruct-2507',
|
||||
'zai-org/GLM-4.5-FP8',
|
||||
'openai/gpt-oss-120b',
|
||||
'deepseek-ai/DeepSeek-R1-0528',
|
||||
'deepseek-ai/DeepSeek-R1',
|
||||
'deepseek-ai/DeepSeek-V3-0324',
|
||||
'deepseek-ai/DeepSeek-V3.1',
|
||||
];
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
|
||||
@@ -45,6 +46,8 @@ const ModelTestModal = ({
|
||||
testChannel,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
allSelectingRef,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -59,6 +62,23 @@ const ModelTestModal = ({
|
||||
)
|
||||
: [];
|
||||
|
||||
const endpointTypeOptions = [
|
||||
{ value: '', label: t('自动检测') },
|
||||
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
|
||||
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' },
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Gemini (/v1beta/models/{model}:generateContent)',
|
||||
},
|
||||
{ value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
|
||||
{
|
||||
value: 'image-generation',
|
||||
label: t('图像生成') + ' (/v1/images/generations)',
|
||||
},
|
||||
{ value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
|
||||
];
|
||||
|
||||
const handleCopySelected = () => {
|
||||
if (selectedModelKeys.length === 0) {
|
||||
showError(t('请先选择模型!'));
|
||||
@@ -152,7 +172,13 @@ const ModelTestModal = ({
|
||||
return (
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => testChannel(currentTestChannel, record.model)}
|
||||
onClick={() =>
|
||||
testChannel(
|
||||
currentTestChannel,
|
||||
record.model,
|
||||
selectedEndpointType,
|
||||
)
|
||||
}
|
||||
loading={isTesting}
|
||||
size='small'
|
||||
>
|
||||
@@ -228,6 +254,18 @@ const ModelTestModal = ({
|
||||
>
|
||||
{hasChannel && (
|
||||
<div className='model-test-scroll'>
|
||||
{/* 端点类型选择器 */}
|
||||
<div className='flex items-center gap-2 w-full mb-2'>
|
||||
<Typography.Text strong>{t('端点类型')}:</Typography.Text>
|
||||
<Select
|
||||
value={selectedEndpointType}
|
||||
onChange={setSelectedEndpointType}
|
||||
optionList={endpointTypeOptions}
|
||||
className='!w-full'
|
||||
placeholder={t('选择端点类型')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索与操作按钮 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-2'>
|
||||
<Input
|
||||
|
||||
@@ -56,10 +56,10 @@ const MjLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
|
||||
@@ -107,6 +107,7 @@ const SearchActions = memo(
|
||||
optionList={[
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'CNY', label: 'CNY' },
|
||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
} from '../../../constants/common.constant';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ const TaskLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ContentModal = ({
|
||||
isModalOpen,
|
||||
@@ -26,17 +29,138 @@ const ContentModal = ({
|
||||
modalContent,
|
||||
isVideo,
|
||||
}) => {
|
||||
const [videoError, setVideoError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && isVideo) {
|
||||
setVideoError(false);
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isModalOpen, isVideo]);
|
||||
|
||||
const handleVideoError = () => {
|
||||
setVideoError(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(modalContent);
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(modalContent, '_blank');
|
||||
};
|
||||
|
||||
const renderVideoContent = () => {
|
||||
if (videoError) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px' }}
|
||||
>
|
||||
视频无法在当前浏览器中播放,这可能是由于:
|
||||
</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 视频服务商的跨域限制
|
||||
</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}
|
||||
>
|
||||
• 需要特定的请求头或认证
|
||||
</Text>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}
|
||||
>
|
||||
• 防盗链保护机制
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
在新标签页中打开
|
||||
</Button>
|
||||
<Button icon={<IconCopy />} onClick={handleCopyUrl}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
type='tertiary'
|
||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||
>
|
||||
{modalContent}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
autoPlay
|
||||
crossOrigin='anonymous'
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px',
|
||||
}}
|
||||
width={800}
|
||||
>
|
||||
{isVideo ? (
|
||||
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
|
||||
renderVideoContent()
|
||||
) : (
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
)}
|
||||
|
||||
@@ -419,7 +419,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT,
|
||||
title: t('提示'),
|
||||
title: t('输入'),
|
||||
dataIndex: 'prompt_tokens',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
@@ -431,7 +431,7 @@ export const getLogsColumns = ({
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.COMPLETION,
|
||||
title: t('补全'),
|
||||
title: t('输出'),
|
||||
dataIndex: 'completion_tokens',
|
||||
render: (text, record, index) => {
|
||||
return parseInt(text) > 0 &&
|
||||
|
||||
@@ -57,10 +57,10 @@ const LogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
presets={DATE_RANGE_PRESETS.map((preset) => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
end: preset.end(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
Progress,
|
||||
Popover,
|
||||
Typography,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconMore } from '@douyinfe/semi-icons';
|
||||
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
|
||||
|
||||
/**
|
||||
@@ -204,6 +206,8 @@ const renderOperations = (
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -211,6 +215,28 @@ const renderOperations = (
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 Passkey'),
|
||||
onClick: () => showResetPasskeyModal(record),
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 2FA'),
|
||||
onClick: () => showResetTwoFAModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('注销'),
|
||||
type: 'danger',
|
||||
onClick: () => showDeleteModal(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
@@ -253,13 +279,9 @@ const renderOperations = (
|
||||
>
|
||||
{t('降级')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={() => showDeleteModal(record)}
|
||||
>
|
||||
{t('注销')}
|
||||
</Button>
|
||||
<Dropdown menu={moreMenu} trigger='click' position='bottomRight'>
|
||||
<Button type='tertiary' size='small' icon={<IconMore />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
@@ -275,6 +297,8 @@ export const getUsersColumns = ({
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -329,6 +353,8 @@ export const getUsersColumns = ({
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
|
||||
import DemoteUserModal from './modals/DemoteUserModal';
|
||||
import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
|
||||
setShowEditUser,
|
||||
manageUser,
|
||||
refresh,
|
||||
resetUserPasskey,
|
||||
resetUserTwoFA,
|
||||
t,
|
||||
} = usersData;
|
||||
|
||||
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [modalUser, setModalUser] = useState(null);
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
|
||||
// Modal handlers
|
||||
const showPromoteUserModal = (user) => {
|
||||
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const showResetPasskeyUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowResetPasskeyModal(true);
|
||||
};
|
||||
|
||||
const showResetTwoFAUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
const handlePromoteConfirm = () => {
|
||||
manageUser(modalUser.id, 'promote', modalUser);
|
||||
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
|
||||
setShowEnableDisableModal(false);
|
||||
};
|
||||
|
||||
const handleResetPasskeyConfirm = async () => {
|
||||
await resetUserPasskey(modalUser);
|
||||
setShowResetPasskeyModal(false);
|
||||
};
|
||||
|
||||
const handleResetTwoFAConfirm = async () => {
|
||||
await resetUserTwoFA(modalUser);
|
||||
setShowResetTwoFAModal(false);
|
||||
};
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getUsersColumns({
|
||||
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
|
||||
showDemoteModal: showDemoteUserModal,
|
||||
showEnableDisableModal: showEnableDisableUserModal,
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
});
|
||||
}, [t, setEditingUser, setShowEditUser]);
|
||||
}, [
|
||||
t,
|
||||
setEditingUser,
|
||||
setShowEditUser,
|
||||
showPromoteUserModal,
|
||||
showDemoteUserModal,
|
||||
showEnableDisableUserModal,
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
|
||||
manageUser={manageUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ResetPasskeyModal
|
||||
visible={showResetPasskeyModal}
|
||||
onCancel={() => setShowResetPasskeyModal(false)}
|
||||
onConfirm={handleResetPasskeyConfirm}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ResetTwoFAModal
|
||||
visible={showResetTwoFAModal}
|
||||
onCancel={() => setShowResetTwoFAModal(false)}
|
||||
onConfirm={handleResetTwoFAConfirm}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
40
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
40
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认重置 Passkey')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
|
||||
{user?.username
|
||||
? t('目标用户:{{username}}', { username: user.username })
|
||||
: ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasskeyModal;
|
||||
42
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
42
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
|
||||
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('确认重置两步验证')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
type='warning'
|
||||
>
|
||||
{t(
|
||||
'此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。',
|
||||
)}{' '}
|
||||
{user?.username
|
||||
? t('目标用户:{{username}}', { username: user.username })
|
||||
: ''}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetTwoFAModal;
|
||||
@@ -30,12 +30,21 @@ import {
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Spin, Tooltip
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
||||
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
CreditCard,
|
||||
Coins,
|
||||
Wallet,
|
||||
BarChart2,
|
||||
TrendingUp,
|
||||
Receipt,
|
||||
} from 'lucide-react';
|
||||
import { IconGift } from '@douyinfe/semi-icons';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -73,6 +82,7 @@ const RechargeCard = ({
|
||||
renderQuota,
|
||||
statusLoading,
|
||||
topupInfo,
|
||||
onOpenHistory,
|
||||
}) => {
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
@@ -81,16 +91,25 @@ const RechargeCard = ({
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<CreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('账户充值')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<CreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('账户充值')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
icon={<Receipt size={16} />}
|
||||
theme='solid'
|
||||
onClick={onOpenHistory}
|
||||
>
|
||||
{t('账单')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
@@ -270,7 +289,8 @@ const RechargeCard = ({
|
||||
{payMethods && payMethods.length > 0 ? (
|
||||
<Space wrap>
|
||||
{payMethods.map((payMethod) => {
|
||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
||||
const minTopupVal =
|
||||
Number(payMethod.min_topup) || 0;
|
||||
const isStripe = payMethod.type === 'stripe';
|
||||
const disabled =
|
||||
(!enableOnlineTopUp && !isStripe) ||
|
||||
@@ -284,7 +304,9 @@ const RechargeCard = ({
|
||||
type='tertiary'
|
||||
onClick={() => preTopUp(payMethod.type)}
|
||||
disabled={disabled}
|
||||
loading={paymentLoading && payWay === payMethod.type}
|
||||
loading={
|
||||
paymentLoading && payWay === payMethod.type
|
||||
}
|
||||
icon={
|
||||
payMethod.type === 'alipay' ? (
|
||||
<SiAlipay size={18} color='#1677FF' />
|
||||
@@ -295,7 +317,10 @@ const RechargeCard = ({
|
||||
) : (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color={payMethod.color || 'var(--semi-color-text-2)'}
|
||||
color={
|
||||
payMethod.color ||
|
||||
'var(--semi-color-text-2)'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -305,12 +330,22 @@ const RechargeCard = ({
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabled && minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
|
||||
return disabled &&
|
||||
minTopupVal > Number(topUpCount || 0) ? (
|
||||
<Tooltip
|
||||
content={
|
||||
t('此支付方式最低充值金额为') +
|
||||
' ' +
|
||||
minTopupVal
|
||||
}
|
||||
key={payMethod.type}
|
||||
>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
|
||||
<React.Fragment key={payMethod.type}>
|
||||
{buttonEl}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
@@ -325,26 +360,81 @@ const RechargeCard = ({
|
||||
)}
|
||||
|
||||
{(enableOnlineTopUp || enableStripeTopUp) && (
|
||||
<Form.Slot label={t('选择充值额度')}>
|
||||
<Form.Slot
|
||||
label={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{t('选择充值额度')}</span>
|
||||
{(() => {
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
if (type === 'USD') return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
(1 $ = {rate.toFixed(2)} {symbol})
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||
{presetAmounts.map((preset, index) => {
|
||||
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
|
||||
const discount =
|
||||
preset.discount ||
|
||||
topupInfo?.discount?.[preset.value] ||
|
||||
1.0;
|
||||
const originalPrice = preset.value * priceRatio;
|
||||
const discountedPrice = originalPrice * discount;
|
||||
const hasDiscount = discount < 1.0;
|
||||
const actualPay = discountedPrice;
|
||||
const save = originalPrice - discountedPrice;
|
||||
|
||||
|
||||
// 根据当前货币类型换算显示金额和数量
|
||||
const { symbol, rate, type } = getCurrencyConfig();
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 7; // 默认CNY汇率
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let displayValue = preset.value; // 显示的数量
|
||||
let displayActualPay = actualPay;
|
||||
let displaySave = save;
|
||||
|
||||
if (type === 'USD') {
|
||||
// 数量保持USD,价格从CNY转USD
|
||||
displayActualPay = actualPay / usdRate;
|
||||
displaySave = save / usdRate;
|
||||
} else if (type === 'CNY') {
|
||||
// 数量转CNY,价格已是CNY
|
||||
displayValue = preset.value * usdRate;
|
||||
} else if (type === 'CUSTOM') {
|
||||
// 数量和价格都转自定义货币
|
||||
displayValue = preset.value * rate;
|
||||
displayActualPay = (actualPay / usdRate) * rate;
|
||||
displaySave = (save / usdRate) * rate;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
border:
|
||||
selectedPreset === preset.value
|
||||
? '2px solid var(--semi-color-primary)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
width: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: '12px' }}
|
||||
onClick={() => {
|
||||
@@ -356,24 +446,36 @@ const RechargeCard = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
|
||||
<Typography.Title
|
||||
heading={6}
|
||||
style={{ margin: '0 0 8px 0' }}
|
||||
>
|
||||
<Coins size={18} />
|
||||
{formatLargeNumber(preset.value)}
|
||||
{formatLargeNumber(preset.value)} $
|
||||
{hasDiscount && (
|
||||
<Tag style={{ marginLeft: 4 }} color="green">
|
||||
{t('折').includes('off') ?
|
||||
((1 - parseFloat(discount)) * 100).toFixed(1) :
|
||||
(discount * 10).toFixed(1)}{t('折')}
|
||||
</Tag>
|
||||
<Tag style={{ marginLeft: 4 }} color='green'>
|
||||
{t('折').includes('off')
|
||||
? (
|
||||
(1 - parseFloat(discount)) *
|
||||
100
|
||||
).toFixed(1)
|
||||
: (discount * 10).toFixed(1)}
|
||||
{t('折')}
|
||||
</Tag>
|
||||
)}
|
||||
</Typography.Title>
|
||||
<div style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0'
|
||||
}}>
|
||||
{t('实付')} {actualPay.toFixed(2)},
|
||||
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '12px',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{t('实付')} {symbol}
|
||||
{displayActualPay.toFixed(2)},
|
||||
{hasDiscount
|
||||
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
|
||||
: `${t('节省')} ${symbol}0.00`}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -37,6 +37,7 @@ import RechargeCard from './RechargeCard';
|
||||
import InvitationCard from './InvitationCard';
|
||||
import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
|
||||
const TopUp = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -83,14 +84,17 @@ const TopUp = () => {
|
||||
const [openTransfer, setOpenTransfer] = useState(false);
|
||||
const [transferAmount, setTransferAmount] = useState(0);
|
||||
|
||||
// 账单Modal状态
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
|
||||
|
||||
// 充值配置信息
|
||||
const [topupInfo, setTopupInfo] = useState({
|
||||
amount_options: [],
|
||||
discount: {}
|
||||
discount: {},
|
||||
});
|
||||
|
||||
const topUp = async () => {
|
||||
@@ -317,9 +321,9 @@ const TopUp = () => {
|
||||
if (success) {
|
||||
setTopupInfo({
|
||||
amount_options: data.amount_options || [],
|
||||
discount: data.discount || {}
|
||||
discount: data.discount || {},
|
||||
});
|
||||
|
||||
|
||||
// 处理支付方式
|
||||
let payMethods = data.pay_methods || [];
|
||||
try {
|
||||
@@ -335,10 +339,15 @@ const TopUp = () => {
|
||||
payMethods = payMethods.map((method) => {
|
||||
// 规范化最小充值数
|
||||
const normalizedMinTopup = Number(method.min_topup);
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
|
||||
method.min_topup = Number.isFinite(normalizedMinTopup)
|
||||
? normalizedMinTopup
|
||||
: 0;
|
||||
|
||||
// Stripe 的最小充值从后端字段回填
|
||||
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
|
||||
if (
|
||||
method.type === 'stripe' &&
|
||||
(!method.min_topup || method.min_topup <= 0)
|
||||
) {
|
||||
const stripeMin = Number(data.stripe_min_topup);
|
||||
if (Number.isFinite(stripeMin)) {
|
||||
method.min_topup = stripeMin;
|
||||
@@ -369,7 +378,11 @@ const TopUp = () => {
|
||||
const enableStripeTopUp = data.enable_stripe_topup || false;
|
||||
const enableOnlineTopUp = data.enable_online_topup || false;
|
||||
const enableCreemTopUp = data.enable_creem_topup || false;
|
||||
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
|
||||
const minTopUpValue = enableOnlineTopUp
|
||||
? data.min_topup
|
||||
: enableStripeTopUp
|
||||
? data.stripe_min_topup
|
||||
: 1;
|
||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||
setEnableStripeTopUp(enableStripeTopUp);
|
||||
setEnableCreemTopUp(enableCreemTopUp);
|
||||
@@ -397,12 +410,12 @@ const TopUp = () => {
|
||||
console.log('解析支付方式失败:', e);
|
||||
setPayMethods([]);
|
||||
}
|
||||
|
||||
|
||||
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
|
||||
if (data.amount_options && data.amount_options.length > 0) {
|
||||
const customPresets = data.amount_options.map(amount => ({
|
||||
const customPresets = data.amount_options.map((amount) => ({
|
||||
value: amount,
|
||||
discount: data.discount[amount] || 1.0
|
||||
discount: data.discount[amount] || 1.0,
|
||||
}));
|
||||
setPresetAmounts(customPresets);
|
||||
}
|
||||
@@ -546,6 +559,14 @@ const TopUp = () => {
|
||||
setOpenTransfer(false);
|
||||
};
|
||||
|
||||
const handleOpenHistory = () => {
|
||||
setOpenHistory(true);
|
||||
};
|
||||
|
||||
const handleHistoryCancel = () => {
|
||||
setOpenHistory(false);
|
||||
};
|
||||
|
||||
const handleCreemCancel = () => {
|
||||
setCreemOpen(false);
|
||||
setSelectedCreemProduct(null);
|
||||
@@ -555,7 +576,7 @@ const TopUp = () => {
|
||||
const selectPresetAmount = (preset) => {
|
||||
setTopUpCount(preset.value);
|
||||
setSelectedPreset(preset.value);
|
||||
|
||||
|
||||
// 计算实际支付金额,考虑折扣
|
||||
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
|
||||
const discountedAmount = preset.value * priceRatio * discount;
|
||||
@@ -607,6 +628,13 @@ const TopUp = () => {
|
||||
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
|
||||
/>
|
||||
|
||||
{/* 充值账单模态框 */}
|
||||
<TopupHistoryModal
|
||||
visible={openHistory}
|
||||
onCancel={handleHistoryCancel}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Creem 充值确认模态框 */}
|
||||
<Modal
|
||||
title={t('确定要充值 $')}
|
||||
@@ -673,6 +701,7 @@ const TopUp = () => {
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
|
||||
amountNumber,
|
||||
discountRate,
|
||||
}) => {
|
||||
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
|
||||
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
|
||||
const hasDiscount =
|
||||
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
|
||||
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
|
||||
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
|
||||
273
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal file
273
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Badge,
|
||||
Typography,
|
||||
Toast,
|
||||
Empty,
|
||||
Button,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, timestamp2string } from '../../../helpers';
|
||||
import { isAdmin } from '../../../helpers/utils';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 状态映射配置
|
||||
const STATUS_CONFIG = {
|
||||
success: { type: 'success', key: '成功' },
|
||||
pending: { type: 'warning', key: '待支付' },
|
||||
expired: { type: 'danger', key: '已过期' },
|
||||
};
|
||||
|
||||
// 支付方式映射
|
||||
const PAYMENT_METHOD_MAP = {
|
||||
stripe: 'Stripe',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信',
|
||||
};
|
||||
|
||||
const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [topups, setTopups] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const loadTopups = async (currentPage, currentPageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
|
||||
const qs =
|
||||
`p=${currentPage}&page_size=${currentPageSize}` +
|
||||
(keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
|
||||
const endpoint = `${base}?${qs}`;
|
||||
const res = await API.get(endpoint);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setTopups(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} else {
|
||||
Toast.error({ content: message || t('加载失败') });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load topups error:', error);
|
||||
Toast.error({ content: t('加载账单失败') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadTopups(page, pageSize);
|
||||
}
|
||||
}, [visible, page, pageSize, keyword]);
|
||||
|
||||
const handlePageChange = (currentPage) => {
|
||||
setPage(currentPage);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (currentPageSize) => {
|
||||
setPageSize(currentPageSize);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleKeywordChange = (value) => {
|
||||
setKeyword(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 管理员补单
|
||||
const handleAdminComplete = async (tradeNo) => {
|
||||
try {
|
||||
const res = await API.post('/api/user/topup/complete', {
|
||||
trade_no: tradeNo,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
Toast.success({ content: t('补单成功') });
|
||||
await loadTopups(page, pageSize);
|
||||
} else {
|
||||
Toast.error({ content: message || t('补单失败') });
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.error({ content: t('补单失败') });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAdminComplete = (tradeNo) => {
|
||||
Modal.confirm({
|
||||
title: t('确认补单'),
|
||||
content: t('是否将该订单标记为成功并为用户入账?'),
|
||||
onOk: () => handleAdminComplete(tradeNo),
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染状态徽章
|
||||
const renderStatusBadge = (status) => {
|
||||
const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
|
||||
return (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Badge dot type={config.type} />
|
||||
<span>{t(config.key)}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染支付方式
|
||||
const renderPaymentMethod = (pm) => {
|
||||
const displayName = PAYMENT_METHOD_MAP[pm];
|
||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||
};
|
||||
|
||||
// 检查是否为管理员
|
||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
title: t('订单号'),
|
||||
dataIndex: 'trade_no',
|
||||
key: 'trade_no',
|
||||
render: (text) => <Text copyable>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('支付方式'),
|
||||
dataIndex: 'payment_method',
|
||||
key: 'payment_method',
|
||||
render: renderPaymentMethod,
|
||||
},
|
||||
{
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount) => (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('支付金额'),
|
||||
dataIndex: 'money',
|
||||
key: 'money',
|
||||
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: renderStatusBadge,
|
||||
},
|
||||
];
|
||||
|
||||
// 管理员才显示操作列
|
||||
if (userIsAdmin) {
|
||||
baseColumns.push({
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => {
|
||||
if (record.status !== 'pending') return null;
|
||||
return (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => confirmAdminComplete(record.trade_no)}
|
||||
>
|
||||
{t('补单')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
baseColumns.push({
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'create_time',
|
||||
key: 'create_time',
|
||||
render: (time) => timestamp2string(time),
|
||||
});
|
||||
|
||||
return baseColumns;
|
||||
}, [t, userIsAdmin]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('充值账单')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('订单号')}
|
||||
value={keyword}
|
||||
onChange={handleKeywordChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={topups}
|
||||
loading={loading}
|
||||
rowKey='id'
|
||||
pagination={{
|
||||
currentPage: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
onPageChange: handlePageChange,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
}}
|
||||
size='small'
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无充值记录')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopupHistoryModal;
|
||||
@@ -88,6 +88,11 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'purple',
|
||||
label: '智谱 GLM-4V',
|
||||
},
|
||||
{
|
||||
value: 27,
|
||||
color: 'blue',
|
||||
label: 'Perplexity',
|
||||
},
|
||||
{
|
||||
value: 24,
|
||||
color: 'orange',
|
||||
@@ -159,6 +164,21 @@ export const CHANNEL_OPTIONS = [
|
||||
color: 'purple',
|
||||
label: 'Vidu',
|
||||
},
|
||||
{
|
||||
value: 53,
|
||||
color: 'blue',
|
||||
label: 'SubModel',
|
||||
},
|
||||
{
|
||||
value: 54,
|
||||
color: 'blue',
|
||||
label: '豆包视频',
|
||||
},
|
||||
{
|
||||
value: 55,
|
||||
color: 'green',
|
||||
label: 'Sora',
|
||||
},
|
||||
];
|
||||
|
||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||
|
||||
@@ -24,26 +24,26 @@ export const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
text: '今天',
|
||||
start: () => dayjs().startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '近 7 天',
|
||||
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '本周',
|
||||
start: () => dayjs().startOf('week').toDate(),
|
||||
end: () => dayjs().endOf('week').toDate()
|
||||
end: () => dayjs().endOf('week').toDate(),
|
||||
},
|
||||
{
|
||||
text: '近 30 天',
|
||||
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
end: () => dayjs().endOf('day').toDate(),
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
start: () => dayjs().startOf('month').toDate(),
|
||||
end: () => dayjs().endOf('month').toDate()
|
||||
end: () => dayjs().endOf('month').toDate(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -131,13 +131,11 @@ export const buildApiPayload = (
|
||||
seed: 'seed',
|
||||
};
|
||||
|
||||
|
||||
Object.entries(parameterMappings).forEach(([key, param]) => {
|
||||
const enabled = parameterEnabled[key];
|
||||
const value = inputs[param];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
|
||||
if (enabled && hasValue) {
|
||||
payload[param] = value;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ export function setStatusData(data) {
|
||||
localStorage.setItem('logo', data.logo);
|
||||
localStorage.setItem('footer_html', data.footer_html);
|
||||
localStorage.setItem('quota_per_unit', data.quota_per_unit);
|
||||
// 兼容:保留旧字段,同时写入新的额度展示类型
|
||||
localStorage.setItem('display_in_currency', data.display_in_currency);
|
||||
localStorage.setItem('quota_display_type', data.quota_display_type || 'USD');
|
||||
localStorage.setItem('enable_drawing', data.enable_drawing);
|
||||
localStorage.setItem('enable_task', data.enable_task);
|
||||
localStorage.setItem('enable_data_export', data.enable_data_export);
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from './data';
|
||||
export * from './token';
|
||||
export * from './boolean';
|
||||
export * from './dashboard';
|
||||
export * from './passkey';
|
||||
|
||||
177
web/src/helpers/passkey.js
Normal file
177
web/src/helpers/passkey.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function base64UrlToBuffer(base64url) {
|
||||
if (!base64url) return new ArrayBuffer(0);
|
||||
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
|
||||
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const buffer = new ArrayBuffer(rawData.length);
|
||||
const uintArray = new Uint8Array(buffer);
|
||||
for (let i = 0; i < rawData.length; i += 1) {
|
||||
uintArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function bufferToBase64Url(buffer) {
|
||||
if (!buffer) return '';
|
||||
const uintArray = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < uintArray.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(uintArray[i]);
|
||||
}
|
||||
return window
|
||||
.btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function prepareCredentialCreationOptions(payload) {
|
||||
const options =
|
||||
payload?.publicKey ||
|
||||
payload?.PublicKey ||
|
||||
payload?.response ||
|
||||
payload?.Response;
|
||||
if (!options) {
|
||||
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
|
||||
}
|
||||
const publicKey = {
|
||||
...options,
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToBuffer(options.user?.id),
|
||||
},
|
||||
};
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(options.attestationFormats) &&
|
||||
options.attestationFormats.length === 0
|
||||
) {
|
||||
delete publicKey.attestationFormats;
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export function prepareCredentialRequestOptions(payload) {
|
||||
const options =
|
||||
payload?.publicKey ||
|
||||
payload?.PublicKey ||
|
||||
payload?.response ||
|
||||
payload?.Response;
|
||||
if (!options) {
|
||||
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
|
||||
}
|
||||
const publicKey = {
|
||||
...options,
|
||||
challenge: base64UrlToBuffer(options.challenge),
|
||||
};
|
||||
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
publicKey.allowCredentials = options.allowCredentials.map((item) => ({
|
||||
...item,
|
||||
id: base64UrlToBuffer(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
export function buildRegistrationResult(credential) {
|
||||
if (!credential) return null;
|
||||
|
||||
const { response } = credential;
|
||||
const transports =
|
||||
typeof response.getTransports === 'function'
|
||||
? response.getTransports()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports,
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssertionResult(assertion) {
|
||||
if (!assertion) return null;
|
||||
|
||||
const { response } = assertion;
|
||||
|
||||
return {
|
||||
id: assertion.id,
|
||||
rawId: bufferToBase64Url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
authenticatorAttachment: assertion.authenticatorAttachment,
|
||||
response: {
|
||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
signature: bufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle
|
||||
? bufferToBase64Url(response.userHandle)
|
||||
: null,
|
||||
},
|
||||
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function isPasskeySupported() {
|
||||
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof window.PublicKeyCredential.isConditionalMediationAvailable ===
|
||||
'function'
|
||||
) {
|
||||
try {
|
||||
const available =
|
||||
await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||
if (available) return true;
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof window.PublicKeyCredential
|
||||
.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
|
||||
) {
|
||||
try {
|
||||
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
FastGPT,
|
||||
Kling,
|
||||
Jimeng,
|
||||
Perplexity,
|
||||
} from '@lobehub/icons';
|
||||
|
||||
import {
|
||||
@@ -309,6 +310,8 @@ export function getChannelIcon(channelType) {
|
||||
return <Xinference.Color size={iconSize} />;
|
||||
case 25: // Moonshot
|
||||
return <Moonshot size={iconSize} />;
|
||||
case 27: // Perplexity
|
||||
return <Perplexity.Color size={iconSize} />;
|
||||
case 20: // OpenRouter
|
||||
return <OpenRouter size={iconSize} />;
|
||||
case 19: // 360 智脑
|
||||
@@ -337,6 +340,8 @@ export function getChannelIcon(channelType) {
|
||||
return <Kling.Color size={iconSize} />;
|
||||
case 51: // 即梦 Jimeng
|
||||
return <Jimeng.Color size={iconSize} />;
|
||||
case 54: // 豆包视频 Doubao Video
|
||||
return <Doubao.Color size={iconSize} />;
|
||||
case 8: // 自定义渠道
|
||||
case 22: // 知识库:FastGPT
|
||||
return <FastGPT.Color size={iconSize} />;
|
||||
@@ -830,12 +835,25 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return 0;
|
||||
}
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
num = num.toFixed(digits);
|
||||
if (displayInCurrency) {
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + num;
|
||||
} else if (quotaDisplayType === 'USD') {
|
||||
return '$' + num;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + num;
|
||||
} else {
|
||||
return num;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
export function renderNumberWithPoint(num) {
|
||||
@@ -887,33 +905,111 @@ export function getQuotaWithUnit(quota, digits = 6) {
|
||||
}
|
||||
|
||||
export function renderQuotaWithAmount(amount) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
return '$' + amount;
|
||||
} else {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(renderUnitWithQuota(amount));
|
||||
}
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
return '¥' + amount;
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbol = '¤';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || symbol;
|
||||
}
|
||||
} catch (e) {}
|
||||
return symbol + amount;
|
||||
}
|
||||
return '$' + amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前货币配置信息
|
||||
* @returns {Object} - { symbol, rate, type }
|
||||
*/
|
||||
export function getCurrencyConfig() {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
const statusStr = localStorage.getItem('status');
|
||||
|
||||
let symbol = '$';
|
||||
let rate = 1;
|
||||
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
symbol = '¥';
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
rate = s?.usd_exchange_rate || 7;
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
rate = s?.custom_currency_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return { symbol, rate, type: quotaDisplayType };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将美元金额转换为当前选择的货币
|
||||
* @param {number} usdAmount - 美元金额
|
||||
* @param {number} digits - 小数位数
|
||||
* @returns {string} - 格式化后的货币字符串
|
||||
*/
|
||||
export function convertUSDToCurrency(usdAmount, digits = 2) {
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const convertedAmount = usdAmount * rate;
|
||||
return symbol + convertedAmount.toFixed(digits);
|
||||
}
|
||||
|
||||
export function renderQuota(quota, digits = 2) {
|
||||
let quotaPerUnit = localStorage.getItem('quota_per_unit');
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
quotaPerUnit = parseFloat(quotaPerUnit);
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const result = quota / quotaPerUnit;
|
||||
const fixedResult = result.toFixed(digits);
|
||||
|
||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return '$' + minValue.toFixed(digits);
|
||||
}
|
||||
|
||||
return '$' + fixedResult;
|
||||
if (quotaDisplayType === 'TOKENS') {
|
||||
return renderNumber(quota);
|
||||
}
|
||||
return renderNumber(quota);
|
||||
const resultUSD = quota / quotaPerUnit;
|
||||
let symbol = '$';
|
||||
let value = resultUSD;
|
||||
if (quotaDisplayType === 'CNY') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let usdRate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
usdRate = s?.usd_exchange_rate || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * usdRate;
|
||||
symbol = '¥';
|
||||
} else if (quotaDisplayType === 'CUSTOM') {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
let symbolCustom = '¤';
|
||||
let rate = 1;
|
||||
try {
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbolCustom = s?.custom_currency_symbol || symbolCustom;
|
||||
rate = s?.custom_currency_exchange_rate || rate;
|
||||
}
|
||||
} catch (e) {}
|
||||
value = resultUSD * rate;
|
||||
symbol = symbolCustom;
|
||||
}
|
||||
const fixedResult = value.toFixed(digits);
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && value > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return symbol + minValue.toFixed(digits);
|
||||
}
|
||||
return symbol + fixedResult;
|
||||
}
|
||||
|
||||
function isValidGroupRatio(ratio) {
|
||||
@@ -1036,13 +1132,19 @@ export function renderModelPrice(
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
const displayPrice = (modelPrice * rate).toFixed(6);
|
||||
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: displayPrice,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: displayTotal,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1072,25 +1174,30 @@ export function renderModelPrice(
|
||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||||
(imageGenerationCallPrice * groupRatio);
|
||||
imageGenerationCallPrice * groupRatio;
|
||||
|
||||
return (
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
|
||||
price: inputRatioPrice,
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 $${audioInputPrice} / 1M tokens`
|
||||
: '',
|
||||
})}
|
||||
{i18next.t(
|
||||
'输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
|
||||
{
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: audioInputSeperatePrice
|
||||
? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
|
||||
: '',
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1098,10 +1205,11 @@ export function renderModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1110,11 +1218,12 @@ export function renderModelPrice(
|
||||
{image && imageOutputTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
'图片输入价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (图片倍率: {{imageRatio}})',
|
||||
{
|
||||
price: imageRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (imageRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: imageRatioPrice * groupRatio,
|
||||
total: (imageRatioPrice * groupRatio * rate).toFixed(6),
|
||||
imageRatio: imageRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1122,22 +1231,25 @@ export function renderModelPrice(
|
||||
)}
|
||||
{webSearch && webSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('Web搜索价格:${{price}} / 1K 次', {
|
||||
price: webSearchPrice,
|
||||
{i18next.t('Web搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{fileSearch && fileSearchCallCount > 0 && (
|
||||
<p>
|
||||
{i18next.t('文件搜索价格:${{price}} / 1K 次', {
|
||||
price: fileSearchPrice,
|
||||
{i18next.t('文件搜索价格:{{symbol}}{{price}} / 1K 次', {
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||
<p>
|
||||
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||
price: imageGenerationCallPrice,
|
||||
{i18next.t('图片生成调用:{{symbol}}{{price}} / 1次', {
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -1147,50 +1259,55 @@ export function renderModelPrice(
|
||||
let inputDesc = '';
|
||||
if (image && imageOutputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
|
||||
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
nonImageInput: inputTokens - imageOutputTokens,
|
||||
imageInput: imageOutputTokens,
|
||||
imageRatio: imageRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (cacheTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
|
||||
'(输入 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
price: inputRatioPrice,
|
||||
cachePrice: cacheRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
|
||||
'(输入 {{nonAudioInput}} tokens / 1M tokens * {{symbol}}{{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * {{symbol}}{{audioPrice}}',
|
||||
{
|
||||
nonAudioInput: inputTokens - audioInputTokens,
|
||||
audioInput: audioInputTokens,
|
||||
price: inputRatioPrice,
|
||||
audioPrice: audioInputPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
audioPrice: (audioInputPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
inputDesc = i18next.t(
|
||||
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
|
||||
'(输入 {{input}} tokens / 1M tokens * {{symbol}}{{price}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建输出部分描述
|
||||
const outputDesc = i18next.t(
|
||||
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1200,10 +1317,11 @@ export function renderModelPrice(
|
||||
const extraServices = [
|
||||
webSearch && webSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: webSearchCallCount,
|
||||
price: webSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (webSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1211,10 +1329,11 @@ export function renderModelPrice(
|
||||
: '',
|
||||
fileSearch && fileSearchCallCount > 0
|
||||
? i18next.t(
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||
' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
count: fileSearchCallCount,
|
||||
price: fileSearchPrice,
|
||||
symbol: symbol,
|
||||
price: (fileSearchPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1222,9 +1341,10 @@ export function renderModelPrice(
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
' + 图片生成调用 {{symbol}}{{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
price: imageGenerationCallPrice,
|
||||
symbol: symbol,
|
||||
price: (imageGenerationCallPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
@@ -1233,12 +1353,13 @@ export function renderModelPrice(
|
||||
].join('');
|
||||
|
||||
return i18next.t(
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
|
||||
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = {{symbol}}{{total}}',
|
||||
{
|
||||
inputDesc,
|
||||
outputDesc,
|
||||
extraServices,
|
||||
total: price.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
})()}
|
||||
@@ -1270,9 +1391,13 @@ export function renderLogContent(
|
||||
useUserGroupRatio: useUserGroupRatio,
|
||||
} = getEffectiveRatio(groupRatio, user_group_ratio);
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio,
|
||||
});
|
||||
@@ -1365,14 +1490,19 @@ export function renderAudioModelPrice(
|
||||
user_group_ratio,
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
// 1 ratio = $0.002 / 1K tokens
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
);
|
||||
@@ -1407,16 +1537,18 @@ export function renderAudioModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
'补全价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: completionRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
completionRatio: completionRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1424,10 +1556,11 @@ export function renderAudioModelPrice(
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{cacheRatio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{cacheRatio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * cacheRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * cacheRatio * rate).toFixed(6),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1435,20 +1568,27 @@ export function renderAudioModelPrice(
|
||||
)}
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示价格:${{price}} * {{audioRatio}} = ${{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
'音频提示价格:{{symbol}}{{price}} * {{audioRatio}} = {{symbol}}{{total}} / 1M tokens (音频倍率: {{audioRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (inputRatioPrice * audioRatio * rate).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频补全价格:${{price}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
'音频补全价格:{{symbol}}{{price}} * {{audioRatio}} * {{audioCompRatio}} = {{symbol}}{{total}} / 1M tokens (音频补全倍率: {{audioCompRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
total: inputRatioPrice * audioRatio * audioCompletionRatio,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
total: (
|
||||
inputRatioPrice *
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
rate
|
||||
).toFixed(6),
|
||||
audioRatio: audioRatio,
|
||||
audioCompRatio: audioCompletionRatio,
|
||||
},
|
||||
@@ -1457,48 +1597,60 @@ 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}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: inputTokens - cacheTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cachePrice: inputRatioPrice * cacheRatio,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(
|
||||
6,
|
||||
),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||
'文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
total: textPrice.toFixed(6),
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
total: (textPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'音频提示 {{input}} tokens / 1M tokens * ${{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * ${{audioCompPrice}} = ${{total}}',
|
||||
'音频提示 {{input}} tokens / 1M tokens * {{symbol}}{{audioInputPrice}} + 音频补全 {{completion}} tokens / 1M tokens * {{symbol}}{{audioCompPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: audioInputTokens,
|
||||
completion: audioCompletionTokens,
|
||||
audioInputPrice: audioRatio * inputRatioPrice,
|
||||
audioCompPrice:
|
||||
audioRatio * audioCompletionRatio * inputRatioPrice,
|
||||
total: audioPrice.toFixed(6),
|
||||
symbol: symbol,
|
||||
audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(
|
||||
6,
|
||||
),
|
||||
audioCompPrice: (
|
||||
audioRatio *
|
||||
audioCompletionRatio *
|
||||
inputRatioPrice *
|
||||
rate
|
||||
).toFixed(6),
|
||||
total: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = ${{total}}',
|
||||
'总价:文字价格 {{textPrice}} + 音频价格 {{audioPrice}} = {{symbol}}{{total}}',
|
||||
{
|
||||
total: price.toFixed(6),
|
||||
textPrice: textPrice.toFixed(6),
|
||||
audioPrice: audioPrice.toFixed(6),
|
||||
symbol: symbol,
|
||||
total: (price * rate).toFixed(6),
|
||||
textPrice: (textPrice * rate).toFixed(6),
|
||||
audioPrice: (audioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1510,9 +1662,8 @@ export function renderAudioModelPrice(
|
||||
}
|
||||
|
||||
export function renderQuotaWithPrompt(quota, digits) {
|
||||
let displayInCurrency = localStorage.getItem('display_in_currency');
|
||||
displayInCurrency = displayInCurrency === 'true';
|
||||
if (displayInCurrency) {
|
||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||
if (quotaDisplayType !== 'TOKENS') {
|
||||
return i18next.t('等价金额:') + renderQuota(quota, digits);
|
||||
}
|
||||
return '';
|
||||
@@ -1537,14 +1688,18 @@ export function renderClaudeModelPrice(
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t(
|
||||
'模型价格:${{price}} * {{ratioType}}:{{ratio}} = ${{total}}',
|
||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
price: modelPrice,
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
total: modelPrice * groupRatio,
|
||||
total: (modelPrice * groupRatio * rate).toFixed(6),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -1573,28 +1728,31 @@ export function renderClaudeModelPrice(
|
||||
<>
|
||||
<article>
|
||||
<p>
|
||||
{i18next.t('提示价格:${{price}} / 1M tokens', {
|
||||
price: inputRatioPrice,
|
||||
{i18next.t('提示价格:{{symbol}}{{price}} / 1M tokens', {
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18next.t(
|
||||
'补全价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens',
|
||||
'补全价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: completionRatio,
|
||||
total: completionRatioPrice,
|
||||
total: (completionRatioPrice * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
{cacheTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
'缓存价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存倍率: {{cacheRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheRatio,
|
||||
total: cacheRatioPrice,
|
||||
total: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheRatio: cacheRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1603,11 +1761,12 @@ export function renderClaudeModelPrice(
|
||||
{cacheCreationTokens > 0 && (
|
||||
<p>
|
||||
{i18next.t(
|
||||
'缓存创建价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
'缓存创建价格:{{symbol}}{{price}} * {{ratio}} = {{symbol}}{{total}} / 1M tokens (缓存创建倍率: {{cacheCreationRatio}})',
|
||||
{
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
ratio: cacheCreationRatio,
|
||||
total: cacheCreationRatioPrice,
|
||||
total: (cacheCreationRatioPrice * rate).toFixed(6),
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
},
|
||||
)}
|
||||
@@ -1617,33 +1776,37 @@ 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}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
nonCacheInput: nonCachedTokens,
|
||||
cacheInput: cacheTokens,
|
||||
cacheRatio: cacheRatio,
|
||||
cacheCreationInput: cacheCreationTokens,
|
||||
cacheCreationRatio: cacheCreationRatio,
|
||||
cachePrice: cacheRatioPrice,
|
||||
cacheCreationPrice: cacheCreationRatioPrice,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
cachePrice: (cacheRatioPrice * rate).toFixed(2),
|
||||
cacheCreationPrice: (
|
||||
cacheCreationRatioPrice * rate
|
||||
).toFixed(6),
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)
|
||||
: i18next.t(
|
||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||
'提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
|
||||
{
|
||||
input: inputTokens,
|
||||
price: inputRatioPrice,
|
||||
symbol: symbol,
|
||||
price: (inputRatioPrice * rate).toFixed(6),
|
||||
completion: completionTokens,
|
||||
compPrice: completionRatioPrice,
|
||||
compPrice: (completionRatioPrice * rate).toFixed(6),
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
total: price.toFixed(6),
|
||||
total: (price * rate).toFixed(6),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -1669,9 +1832,13 @@ export function renderClaudeLogContent(
|
||||
);
|
||||
groupRatio = effectiveGroupRatio;
|
||||
|
||||
// 获取货币配置
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
|
||||
if (modelPrice !== -1) {
|
||||
return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
|
||||
price: modelPrice,
|
||||
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {
|
||||
symbol: symbol,
|
||||
price: (modelPrice * rate).toFixed(6),
|
||||
ratioType: ratioLabel,
|
||||
ratio: groupRatio,
|
||||
});
|
||||
|
||||
62
web/src/helpers/secureApiCall.js
Normal file
62
web/src/helpers/secureApiCall.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
/**
|
||||
* 安全 API 调用包装器
|
||||
* 自动处理需要验证的 403 错误,透明地触发验证流程
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查错误是否是需要安全验证的错误
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isVerificationRequiredError(error) {
|
||||
if (!error.response) return false;
|
||||
|
||||
const { status, data } = error.response;
|
||||
|
||||
// 检查是否是 403 错误且包含验证相关的错误码
|
||||
if (status === 403 && data) {
|
||||
const verificationCodes = [
|
||||
'VERIFICATION_REQUIRED',
|
||||
'VERIFICATION_EXPIRED',
|
||||
'VERIFICATION_INVALID',
|
||||
];
|
||||
|
||||
return verificationCodes.includes(data.code);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误中提取验证需求信息
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {Object} 验证需求信息
|
||||
*/
|
||||
export function extractVerificationInfo(error) {
|
||||
const data = error.response?.data || {};
|
||||
|
||||
return {
|
||||
code: data.code,
|
||||
message: data.message || '需要安全验证',
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
@@ -646,9 +646,25 @@ export const calculateModelPrice = ({
|
||||
const numCompletion =
|
||||
parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
|
||||
let symbol = '$';
|
||||
if (currency === 'CNY') {
|
||||
symbol = '¥';
|
||||
} else if (currency === 'CUSTOM') {
|
||||
try {
|
||||
const statusStr = localStorage.getItem('status');
|
||||
if (statusStr) {
|
||||
const s = JSON.parse(statusStr);
|
||||
symbol = s?.custom_currency_symbol || '¤';
|
||||
} else {
|
||||
symbol = '¤';
|
||||
}
|
||||
} catch (e) {
|
||||
symbol = '¤';
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
|
||||
inputPrice: `${symbol}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
|
||||
unitLabel,
|
||||
isPerToken: true,
|
||||
usedGroup,
|
||||
|
||||
@@ -83,9 +83,11 @@ export const useChannelsData = () => {
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||
const shouldStopBatchTestingRef = useRef(false);
|
||||
|
||||
// Multi-key management states
|
||||
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
|
||||
@@ -751,125 +753,237 @@ export const useChannelsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Test channel
|
||||
const testChannel = async (record, model) => {
|
||||
setTestQueue((prev) => [...prev, { channel: record, model }]);
|
||||
if (!isProcessingQueue) {
|
||||
setIsProcessingQueue(true);
|
||||
// Test channel - 单个模型测试,参考旧版实现
|
||||
const testChannel = async (record, model, endpointType = '') => {
|
||||
const testKey = `${record.id}-${model}`;
|
||||
|
||||
// 检查是否应该停止批量测试
|
||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Process test queue
|
||||
const processTestQueue = async () => {
|
||||
if (!isProcessingQueue || testQueue.length === 0) return;
|
||||
|
||||
const { channel, model, indexInFiltered } = testQueue[0];
|
||||
|
||||
if (currentTestChannel && currentTestChannel.id === channel.id) {
|
||||
let pageNo;
|
||||
if (indexInFiltered !== undefined) {
|
||||
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
|
||||
} else {
|
||||
const filteredModelsList = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) =>
|
||||
m.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
const modelIdx = filteredModelsList.indexOf(model);
|
||||
pageNo =
|
||||
modelIdx !== -1
|
||||
? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1
|
||||
: 1;
|
||||
}
|
||||
setModelTablePage(pageNo);
|
||||
}
|
||||
// 添加到正在测试的模型集合
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
|
||||
try {
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
const res = await API.get(
|
||||
`/api/channel/test/${channel.id}?model=${model}`,
|
||||
);
|
||||
let url = `/api/channel/test/${record.id}?model=${model}`;
|
||||
if (endpointType) {
|
||||
url += `&endpoint_type=${endpointType}`;
|
||||
}
|
||||
const res = await API.get(url);
|
||||
|
||||
// 检查是否在请求期间被停止
|
||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const { success, message, time } = res.data;
|
||||
|
||||
// 更新测试结果
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[`${channel.id}-${model}`]: { success, time },
|
||||
[testKey]: {
|
||||
success,
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
if (success) {
|
||||
updateChannelProperty(channel.id, (ch) => {
|
||||
ch.response_time = time * 1000;
|
||||
ch.test_time = Date.now() / 1000;
|
||||
// 更新渠道响应时间
|
||||
updateChannelProperty(record.id, (channel) => {
|
||||
channel.response_time = time * 1000;
|
||||
channel.test_time = Date.now() / 1000;
|
||||
});
|
||||
if (!model) {
|
||||
|
||||
if (!model || model === '') {
|
||||
showInfo(
|
||||
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
|
||||
.replace('${name}', channel.name)
|
||||
.replace('${name}', record.name)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
);
|
||||
} else {
|
||||
showInfo(
|
||||
t(
|
||||
'通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。',
|
||||
)
|
||||
.replace('${name}', record.name)
|
||||
.replace('${model}', model)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
showError(`${t('模型')} ${model}: ${message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
// 处理网络错误
|
||||
const testKey = `${record.id}-${model}`;
|
||||
setModelTestResults((prev) => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success: false,
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
} finally {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(model);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
setTestQueue((prev) => prev.slice(1));
|
||||
};
|
||||
|
||||
// Monitor queue changes
|
||||
useEffect(() => {
|
||||
if (testQueue.length > 0 && isProcessingQueue) {
|
||||
processTestQueue();
|
||||
} else if (testQueue.length === 0 && isProcessingQueue) {
|
||||
setIsProcessingQueue(false);
|
||||
setIsBatchTesting(false);
|
||||
}
|
||||
}, [testQueue, isProcessingQueue]);
|
||||
|
||||
// Batch test models
|
||||
// 批量测试单个渠道的所有模型,参考旧版实现
|
||||
const batchTestModels = async () => {
|
||||
if (!currentTestChannel) return;
|
||||
if (!currentTestChannel || !currentTestChannel.models) {
|
||||
showError(t('渠道模型信息不完整'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchTesting(true);
|
||||
setModelTablePage(1);
|
||||
|
||||
const filteredModels = currentTestChannel.models
|
||||
const models = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
setTestQueue(
|
||||
filteredModels.map((model, idx) => ({
|
||||
channel: currentTestChannel,
|
||||
model,
|
||||
indexInFiltered: idx,
|
||||
})),
|
||||
);
|
||||
setIsProcessingQueue(true);
|
||||
if (models.length === 0) {
|
||||
showError(t('没有找到匹配的模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchTesting(true);
|
||||
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||
|
||||
// 清空该渠道之前的测试结果
|
||||
setModelTestResults((prev) => {
|
||||
const newResults = { ...prev };
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
delete newResults[testKey];
|
||||
});
|
||||
return newResults;
|
||||
});
|
||||
|
||||
try {
|
||||
showInfo(
|
||||
t('开始批量测试 ${count} 个模型,已清空上次结果...').replace(
|
||||
'${count}',
|
||||
models.length,
|
||||
),
|
||||
);
|
||||
|
||||
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||
const concurrencyLimit = 5;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < models.length; i += concurrencyLimit) {
|
||||
// 检查是否应该停止
|
||||
if (shouldStopBatchTestingRef.current) {
|
||||
showInfo(t('批量测试已停止'));
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = models.slice(i, i + concurrencyLimit);
|
||||
showInfo(
|
||||
t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
|
||||
const batchPromises = batch.map((model) =>
|
||||
testChannel(currentTestChannel, model, selectedEndpointType),
|
||||
);
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
// 再次检查是否应该停止
|
||||
if (shouldStopBatchTestingRef.current) {
|
||||
showInfo(t('批量测试已停止'));
|
||||
break;
|
||||
}
|
||||
|
||||
// 短暂延迟避免过于频繁的请求
|
||||
if (i + concurrencyLimit < models.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStopBatchTestingRef.current) {
|
||||
// 等待一小段时间确保所有结果都已更新
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 使用当前状态重新计算结果统计
|
||||
setModelTestResults((currentResults) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
models.forEach((model) => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
const result = currentResults[testKey];
|
||||
if (result && result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示完成消息
|
||||
setTimeout(() => {
|
||||
showSuccess(
|
||||
t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
return currentResults; // 不修改状态,只是为了获取最新值
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('批量测试过程中发生错误: ') + error.message);
|
||||
} finally {
|
||||
setIsBatchTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 停止批量测试
|
||||
const stopBatchTesting = () => {
|
||||
shouldStopBatchTestingRef.current = true;
|
||||
setIsBatchTesting(false);
|
||||
setTestingModels(new Set());
|
||||
showInfo(t('已停止批量测试'));
|
||||
};
|
||||
|
||||
// 清空测试结果
|
||||
const clearTestResults = () => {
|
||||
setModelTestResults({});
|
||||
showInfo(t('已清空测试结果'));
|
||||
};
|
||||
|
||||
// Handle close modal
|
||||
const handleCloseModal = () => {
|
||||
// 如果正在批量测试,先停止测试
|
||||
if (isBatchTesting) {
|
||||
setTestQueue([]);
|
||||
setIsProcessingQueue(false);
|
||||
setIsBatchTesting(false);
|
||||
showSuccess(t('已停止测试'));
|
||||
} else {
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
shouldStopBatchTestingRef.current = true;
|
||||
showInfo(t('关闭弹窗,已停止批量测试'));
|
||||
}
|
||||
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setIsBatchTesting(false);
|
||||
setTestingModels(new Set());
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
setSelectedEndpointType('');
|
||||
// 可选择性保留测试结果,这里不清空以便用户查看
|
||||
};
|
||||
|
||||
// Type counts
|
||||
@@ -956,6 +1070,8 @@ export const useChannelsData = () => {
|
||||
isBatchTesting,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
allSelectingRef,
|
||||
|
||||
// Multi-key management states
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
const isLoading = useMinimumLoadingTime(loading);
|
||||
const isLoading = useMinimumLoadingTime(loading, 200);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
|
||||
274
web/src/hooks/common/useSecureVerification.jsx
Normal file
274
web/src/hooks/common/useSecureVerification.jsx
Normal file
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SecureVerificationService } from '../../services/secureVerification';
|
||||
import { showError, showSuccess } from '../../helpers';
|
||||
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||
|
||||
/**
|
||||
* 通用安全验证 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onSuccess - 验证成功回调
|
||||
* @param {Function} options.onError - 验证失败回调
|
||||
* @param {string} options.successMessage - 成功提示消息
|
||||
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
|
||||
*/
|
||||
export const useSecureVerification = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
autoReset = true,
|
||||
} = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 验证方式可用性状态
|
||||
const [verificationMethods, setVerificationMethods] = useState({
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false,
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
// 当前验证状态
|
||||
const [verificationState, setVerificationState] = useState({
|
||||
method: null, // '2fa' | 'passkey'
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null,
|
||||
});
|
||||
|
||||
// 检查可用的验证方式
|
||||
const checkVerificationMethods = useCallback(async () => {
|
||||
const methods =
|
||||
await SecureVerificationService.checkAvailableVerificationMethods();
|
||||
setVerificationMethods(methods);
|
||||
return methods;
|
||||
}, []);
|
||||
|
||||
// 初始化时检查验证方式
|
||||
useEffect(() => {
|
||||
checkVerificationMethods();
|
||||
}, [checkVerificationMethods]);
|
||||
|
||||
// 重置状态
|
||||
const resetState = useCallback(() => {
|
||||
setVerificationState({
|
||||
method: null,
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// 开始验证流程
|
||||
const startVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
onError?.(new Error(errorMessage));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置默认验证方式
|
||||
let defaultMethod = preferredMethod;
|
||||
if (!defaultMethod) {
|
||||
if (methods.hasPasskey && methods.passkeySupported) {
|
||||
defaultMethod = 'passkey';
|
||||
} else if (methods.has2FA) {
|
||||
defaultMethod = '2fa';
|
||||
}
|
||||
}
|
||||
|
||||
setVerificationState((prev) => ({
|
||||
...prev,
|
||||
method: defaultMethod,
|
||||
apiCall,
|
||||
title,
|
||||
description,
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
|
||||
return true;
|
||||
},
|
||||
[checkVerificationMethods, onError, t],
|
||||
);
|
||||
|
||||
// 执行验证
|
||||
const executeVerification = useCallback(
|
||||
async (method, code = '') => {
|
||||
if (!verificationState.apiCall) {
|
||||
showError(t('验证配置错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationState((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
showSuccess(successMessage);
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess?.(result, method);
|
||||
|
||||
// 自动重置状态
|
||||
if (autoReset) {
|
||||
resetState();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error.message || t('验证失败,请重试'));
|
||||
onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setVerificationState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
},
|
||||
[
|
||||
verificationState.apiCall,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
onError,
|
||||
autoReset,
|
||||
resetState,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// 设置验证码
|
||||
const setVerificationCode = useCallback((code) => {
|
||||
setVerificationState((prev) => ({ ...prev, code }));
|
||||
}, []);
|
||||
|
||||
// 切换验证方式
|
||||
const switchVerificationMethod = useCallback((method) => {
|
||||
setVerificationState((prev) => ({ ...prev, method, code: '' }));
|
||||
}, []);
|
||||
|
||||
// 取消验证
|
||||
const cancelVerification = useCallback(() => {
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
// 检查是否可以使用某种验证方式
|
||||
const canUseMethod = useCallback(
|
||||
(method) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verificationMethods.has2FA;
|
||||
case 'passkey':
|
||||
return (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[verificationMethods],
|
||||
);
|
||||
|
||||
// 获取推荐的验证方式
|
||||
const getRecommendedMethod = useCallback(() => {
|
||||
if (
|
||||
verificationMethods.hasPasskey &&
|
||||
verificationMethods.passkeySupported
|
||||
) {
|
||||
return 'passkey';
|
||||
}
|
||||
if (verificationMethods.has2FA) {
|
||||
return '2fa';
|
||||
}
|
||||
return null;
|
||||
}, [verificationMethods]);
|
||||
|
||||
/**
|
||||
* 包装 API 调用,自动处理验证错误
|
||||
* 当 API 返回需要验证的错误时,自动弹出验证模态框
|
||||
* @param {Function} apiCall - API 调用函数
|
||||
* @param {Object} options - 验证选项(同 startVerification)
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
const withVerification = useCallback(
|
||||
async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[startVerification],
|
||||
);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
|
||||
// 方法
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
resetState,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
checkVerificationMethods,
|
||||
|
||||
// 辅助方法
|
||||
canUseMethod,
|
||||
getRecommendedMethod,
|
||||
withVerification, // 新增:自动处理验证的包装函数
|
||||
|
||||
// 便捷属性
|
||||
hasAnyVerificationMethod:
|
||||
verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
isLoading: verificationState.loading,
|
||||
currentMethod: verificationState.method,
|
||||
code: verificationState.code,
|
||||
};
|
||||
};
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { useState, useEffect, useMemo, useContext, useRef } from 'react';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
@@ -29,6 +29,13 @@ export const useSidebar = () => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userConfig, setUserConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const instanceIdRef = useRef(null);
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
|
||||
if (!instanceIdRef.current) {
|
||||
const randomPart = Math.random().toString(16).slice(2);
|
||||
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const defaultAdminConfig = {
|
||||
@@ -74,9 +81,17 @@ export const useSidebar = () => {
|
||||
}, [statusState?.status?.SidebarModulesAdmin]);
|
||||
|
||||
// 加载用户配置的通用方法
|
||||
const loadUserConfig = async () => {
|
||||
const loadUserConfig = async ({ withLoading } = {}) => {
|
||||
const shouldShowLoader =
|
||||
typeof withLoading === 'boolean'
|
||||
? withLoading
|
||||
: !hasLoadedOnceRef.current;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
if (shouldShowLoader) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success && res.data.data.sidebar_modules) {
|
||||
let config;
|
||||
@@ -122,18 +137,25 @@ export const useSidebar = () => {
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (shouldShowLoader) {
|
||||
setLoading(false);
|
||||
}
|
||||
hasLoadedOnceRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新用户配置的方法(供外部调用)
|
||||
const refreshUserConfig = async () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig();
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig({ withLoading: false });
|
||||
}
|
||||
|
||||
// 触发全局刷新事件,通知所有useSidebar实例更新
|
||||
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
|
||||
sidebarEventTarget.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_REFRESH_EVENT, {
|
||||
detail: { sourceId: instanceIdRef.current, skipLoader: true },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 加载用户配置
|
||||
@@ -146,16 +168,25 @@ export const useSidebar = () => {
|
||||
|
||||
// 监听全局刷新事件
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
const handleRefresh = (event) => {
|
||||
if (event?.detail?.sourceId === instanceIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig();
|
||||
loadUserConfig({
|
||||
withLoading: event?.detail?.skipLoader ? false : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
|
||||
return () => {
|
||||
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
sidebarEventTarget.removeEventListener(
|
||||
SIDEBAR_REFRESH_EVENT,
|
||||
handleRefresh,
|
||||
);
|
||||
};
|
||||
}, [adminConfig]);
|
||||
|
||||
|
||||
@@ -64,6 +64,29 @@ export const useModelPricingData = () => {
|
||||
() => statusState?.status?.usd_exchange_rate ?? priceRate,
|
||||
[statusState, priceRate],
|
||||
);
|
||||
const customExchangeRate = useMemo(
|
||||
() => statusState?.status?.custom_currency_exchange_rate ?? 1,
|
||||
[statusState],
|
||||
);
|
||||
const customCurrencySymbol = useMemo(
|
||||
() => statusState?.status?.custom_currency_symbol ?? '¤',
|
||||
[statusState],
|
||||
);
|
||||
|
||||
// 默认货币与站点展示类型同步(USD/CNY),TOKENS 时仍允许切换视图内货币
|
||||
const siteDisplayType = useMemo(
|
||||
() => statusState?.status?.quota_display_type || 'USD',
|
||||
[statusState],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
siteDisplayType === 'USD' ||
|
||||
siteDisplayType === 'CNY' ||
|
||||
siteDisplayType === 'CUSTOM'
|
||||
) {
|
||||
setCurrency(siteDisplayType);
|
||||
}
|
||||
}, [siteDisplayType]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
@@ -156,6 +179,8 @@ export const useModelPricingData = () => {
|
||||
|
||||
if (currency === 'CNY') {
|
||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||
} else if (currency === 'CUSTOM') {
|
||||
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||
}
|
||||
return `$${priceInUSD.toFixed(3)}`;
|
||||
};
|
||||
|
||||
@@ -377,6 +377,12 @@ export const useLogsData = () => {
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
if (logs[i]?.content) {
|
||||
expandDataLocal.push({
|
||||
key: t('其他详情'),
|
||||
value: logs[i].content,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
let modelMapped =
|
||||
@@ -462,6 +468,12 @@ export const useLogsData = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (other?.request_path) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求路径'),
|
||||
value: other.request_path,
|
||||
});
|
||||
}
|
||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,40 @@ export const useUsersData = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const resetUserPasskey = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserTwoFA = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/2fa`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('二步验证已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
@@ -271,6 +305,8 @@ export const useUsersData = () => {
|
||||
loadUsers,
|
||||
searchUsers,
|
||||
manageUser,
|
||||
resetUserPasskey,
|
||||
resetUserTwoFA,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
|
||||
@@ -22,7 +22,9 @@ import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enTranslation from './locales/en.json';
|
||||
import frTranslation from './locales/fr.json';
|
||||
import zhTranslation from './locales/zh.json';
|
||||
import ruTranslation from './locales/ru.json';
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -30,12 +32,10 @@ i18n
|
||||
.init({
|
||||
load: 'languageOnly',
|
||||
resources: {
|
||||
en: {
|
||||
translation: enTranslation,
|
||||
},
|
||||
zh: {
|
||||
translation: zhTranslation,
|
||||
},
|
||||
en: enTranslation,
|
||||
zh: zhTranslation,
|
||||
fr: frTranslation,
|
||||
ru: ruTranslation,
|
||||
},
|
||||
fallbackLng: 'zh',
|
||||
interpolation: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2085
web/src/i18n/locales/fr.json
Normal file
2085
web/src/i18n/locales/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
2094
web/src/i18n/locales/ru.json
Normal file
2094
web/src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const PrivacyPolicy = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint="/api/privacy-policy"
|
||||
title={t('隐私政策')}
|
||||
cacheKey="privacy_policy"
|
||||
emptyMessage={t('加载隐私政策内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
@@ -18,7 +18,26 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Form,
|
||||
Space,
|
||||
Spin,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Table,
|
||||
Modal,
|
||||
Input,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconSearch,
|
||||
IconSaveStroked,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -37,6 +56,52 @@ export default function SettingsChats(props) {
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
const [editMode, setEditMode] = useState('visual');
|
||||
const [chatConfigs, setChatConfigs] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState(null);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const modalFormRef = useRef();
|
||||
|
||||
const jsonToConfigs = (jsonString) => {
|
||||
try {
|
||||
const configs = JSON.parse(jsonString);
|
||||
return Array.isArray(configs)
|
||||
? configs.map((config, index) => ({
|
||||
id: index,
|
||||
name: Object.keys(config)[0] || '',
|
||||
url: Object.values(config)[0] || '',
|
||||
}))
|
||||
: [];
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const configsToJson = (configs) => {
|
||||
const jsonArray = configs.map((config) => ({
|
||||
[config.name]: config.url,
|
||||
}));
|
||||
return JSON.stringify(jsonArray, null, 2);
|
||||
};
|
||||
|
||||
const syncJsonToConfigs = () => {
|
||||
const configs = jsonToConfigs(inputs.Chats);
|
||||
setChatConfigs(configs);
|
||||
};
|
||||
|
||||
const syncConfigsToJson = (configs) => {
|
||||
const jsonString = configsToJson(configs);
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
Chats: jsonString,
|
||||
}));
|
||||
if (refForm.current && editMode === 'json') {
|
||||
refForm.current.setValues({ Chats: jsonString });
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
@@ -103,16 +168,184 @@ export default function SettingsChats(props) {
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
if (refForm.current) {
|
||||
refForm.current.setValues(currentInputs);
|
||||
}
|
||||
|
||||
// 同步到可视化配置
|
||||
const configs = jsonToConfigs(currentInputs.Chats || '[]');
|
||||
setChatConfigs(configs);
|
||||
}, [props.options]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editMode === 'visual') {
|
||||
syncJsonToConfigs();
|
||||
}
|
||||
}, [inputs.Chats, editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refForm.current && editMode === 'json') {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, [editMode, inputs]);
|
||||
|
||||
const handleAddConfig = () => {
|
||||
setEditingConfig({ name: '', url: '' });
|
||||
setIsEdit(false);
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current.setValues({ name: '', url: '' });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleEditConfig = (config) => {
|
||||
setEditingConfig({ ...config });
|
||||
setIsEdit(true);
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current.setValues(config);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleDeleteConfig = (id) => {
|
||||
const newConfigs = chatConfigs.filter((config) => config.id !== id);
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
showSuccess(t('删除成功'));
|
||||
};
|
||||
|
||||
const handleModalOk = () => {
|
||||
if (modalFormRef.current) {
|
||||
modalFormRef.current
|
||||
.validate()
|
||||
.then((values) => {
|
||||
// 检查名称是否重复
|
||||
const isDuplicate = chatConfigs.some(
|
||||
(config) =>
|
||||
config.name === values.name &&
|
||||
(!isEdit || config.id !== editingConfig.id),
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
showError(t('聊天应用名称已存在,请使用其他名称'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const newConfigs = chatConfigs.map((config) =>
|
||||
config.id === editingConfig.id
|
||||
? { ...editingConfig, name: values.name, url: values.url }
|
||||
: config,
|
||||
);
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
} else {
|
||||
const maxId =
|
||||
chatConfigs.length > 0
|
||||
? Math.max(...chatConfigs.map((c) => c.id))
|
||||
: -1;
|
||||
const newConfig = {
|
||||
id: maxId + 1,
|
||||
name: values.name,
|
||||
url: values.url,
|
||||
};
|
||||
const newConfigs = [...chatConfigs, newConfig];
|
||||
setChatConfigs(newConfigs);
|
||||
syncConfigsToJson(newConfigs);
|
||||
}
|
||||
setModalVisible(false);
|
||||
setEditingConfig(null);
|
||||
showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Modal form validation error:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setModalVisible(false);
|
||||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
const filteredConfigs = chatConfigs.filter(
|
||||
(config) =>
|
||||
!searchText ||
|
||||
config.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
const highlightKeywords = (text) => {
|
||||
if (!text) return text;
|
||||
|
||||
const parts = text.split(/(\{address\}|\{key\})/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part === '{address}') {
|
||||
return (
|
||||
<span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else if (part === '{key}') {
|
||||
return (
|
||||
<span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('聊天应用名称'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text) => text || t('未命名'),
|
||||
},
|
||||
{
|
||||
title: t('URL链接'),
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
render: (text) => (
|
||||
<div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
|
||||
{highlightKeywords(text)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconEdit />}
|
||||
size='small'
|
||||
onClick={() => handleEditConfig(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
onClick={() => handleDeleteConfig(record.id)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Form.Section text={t('聊天设置')}>
|
||||
<Banner
|
||||
type='info'
|
||||
@@ -120,34 +353,155 @@ export default function SettingsChats(props) {
|
||||
'链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
|
||||
)}
|
||||
/>
|
||||
<Form.TextArea
|
||||
label={t('聊天配置')}
|
||||
extraText={''}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'Chats'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
Chats: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={{ marginRight: 16, fontWeight: 600 }}>
|
||||
{t('编辑模式')}:
|
||||
</span>
|
||||
<RadioGroup
|
||||
type='button'
|
||||
value={editMode}
|
||||
onChange={(e) => {
|
||||
const newMode = e.target.value;
|
||||
setEditMode(newMode);
|
||||
|
||||
// 确保模式切换时数据正确同步
|
||||
setTimeout(() => {
|
||||
if (newMode === 'json' && refForm.current) {
|
||||
refForm.current.setValues(inputs);
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
<Radio value='visual'>{t('可视化编辑')}</Radio>
|
||||
<Radio value='json'>{t('JSON编辑')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
onClick={handleAddConfig}
|
||||
>
|
||||
{t('添加聊天配置')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconSaveStroked />}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{t('保存聊天设置')}
|
||||
</Button>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索聊天应用名称')}
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
style={{ width: 250 }}
|
||||
showClear
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredConfigs}
|
||||
rowKey='id'
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
|
||||
total,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
>
|
||||
<Form.TextArea
|
||||
label={t('聊天配置')}
|
||||
extraText={''}
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
field={'Chats'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
return verifyJSON(value);
|
||||
},
|
||||
message: t('不是合法的 JSON 字符串'),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
Chats: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Form.Section>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
|
||||
|
||||
{editMode === 'json' && (
|
||||
<Space>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconSaveStroked />}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{t('保存聊天设置')}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
|
||||
visible={modalVisible}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
width={600}
|
||||
>
|
||||
<Form getFormApi={(api) => (modalFormRef.current = api)}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('聊天应用名称')}
|
||||
placeholder={t('请输入聊天应用名称')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入聊天应用名称') },
|
||||
{ min: 1, message: t('名称不能为空') },
|
||||
]}
|
||||
/>
|
||||
<Form.Input
|
||||
field='url'
|
||||
label={t('URL链接')}
|
||||
placeholder={t('请输入完整的URL链接')}
|
||||
rules={[{ required: true, message: t('请输入URL链接') }]}
|
||||
/>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t(
|
||||
'提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址',
|
||||
)}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,9 +202,8 @@ export default function SettingClaudeModel(props) {
|
||||
label={t('思考适配 BudgetTokens 百分比')}
|
||||
field={'claude.thinking_adapter_budget_tokens_percentage'}
|
||||
initValue={''}
|
||||
extraText={t('0.1-1之间的小数')}
|
||||
extraText={t('0.1以上的小数')}
|
||||
min={0.1}
|
||||
max={1}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function SettingGlobalModel(props) {
|
||||
})
|
||||
}
|
||||
extraText={
|
||||
'开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启'
|
||||
t('开启后,所有请求将直接透传给上游,不会进行任何处理(重定向和渠道适配也将失效),请谨慎开启')
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
@@ -116,7 +116,7 @@ export default function SettingGlobalModel(props) {
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
description='警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔'
|
||||
description={t('警告:启用保活后,如果已经写入保活数据后渠道出错,系统无法重试,如果必须开启,推荐设置尽可能大的Ping间隔')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -131,7 +131,7 @@ export default function SettingGlobalModel(props) {
|
||||
'general_setting.ping_interval_enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={'开启后,将定期发送ping数据保持连接活跃'}
|
||||
extraText={t('开启后,将定期发送ping数据保持连接活跃')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function SettingsCreditLimit(props) {
|
||||
PreConsumedQuota: '',
|
||||
QuotaForInviter: '',
|
||||
QuotaForInvitee: '',
|
||||
'quota_setting.enable_free_model_pre_consume': true,
|
||||
});
|
||||
const refForm = useRef();
|
||||
const [inputsRow, setInputsRow] = useState(inputs);
|
||||
@@ -166,6 +167,21 @@ export default function SettingsCreditLimit(props) {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<Form.Switch
|
||||
label={t('对免费模型启用预消耗')}
|
||||
field={'quota_setting.enable_free_model_pre_consume'}
|
||||
extraText={t('开启后,对免费模型(倍率为0,或者价格为0)的模型也会预消耗额度')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'quota_setting.enable_free_model_pre_consume': value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Button size='default' onClick={onSubmit}>
|
||||
|
||||
@@ -17,8 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Banner, Button, Col, Form, Row, Spin, Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
InputGroup,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
API,
|
||||
@@ -35,10 +46,12 @@ export default function GeneralSettings(props) {
|
||||
const [inputs, setInputs] = useState({
|
||||
TopUpLink: '',
|
||||
'general_setting.docs_link': '',
|
||||
'general_setting.quota_display_type': 'USD',
|
||||
'general_setting.custom_currency_symbol': '¤',
|
||||
'general_setting.custom_currency_exchange_rate': '',
|
||||
QuotaPerUnit: '',
|
||||
RetryTimes: '',
|
||||
USDExchangeRate: '',
|
||||
DisplayInCurrencyEnabled: false,
|
||||
DisplayTokenStatEnabled: false,
|
||||
DefaultCollapseSidebar: false,
|
||||
DemoSiteEnabled: false,
|
||||
@@ -88,6 +101,30 @@ export default function GeneralSettings(props) {
|
||||
});
|
||||
}
|
||||
|
||||
// 计算展示在输入框中的“1 USD = X <currency>”中的 X
|
||||
const combinedRate = useMemo(() => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'USD') return '1';
|
||||
if (type === 'CNY') return String(inputs['USDExchangeRate'] || '');
|
||||
if (type === 'TOKENS') return String(inputs['QuotaPerUnit'] || '');
|
||||
if (type === 'CUSTOM')
|
||||
return String(
|
||||
inputs['general_setting.custom_currency_exchange_rate'] || '',
|
||||
);
|
||||
return '';
|
||||
}, [inputs]);
|
||||
|
||||
const onCombinedRateChange = (val) => {
|
||||
const type = inputs['general_setting.quota_display_type'];
|
||||
if (type === 'CNY') {
|
||||
handleFieldChange('USDExchangeRate')(val);
|
||||
} else if (type === 'TOKENS') {
|
||||
handleFieldChange('QuotaPerUnit')(val);
|
||||
} else if (type === 'CUSTOM') {
|
||||
handleFieldChange('general_setting.custom_currency_exchange_rate')(val);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -95,6 +132,28 @@ export default function GeneralSettings(props) {
|
||||
currentInputs[key] = props.options[key];
|
||||
}
|
||||
}
|
||||
// 若旧字段存在且新字段缺失,则做一次兜底映射
|
||||
if (
|
||||
currentInputs['general_setting.quota_display_type'] === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined
|
||||
) {
|
||||
currentInputs['general_setting.quota_display_type'] = props.options
|
||||
.DisplayInCurrencyEnabled
|
||||
? 'USD'
|
||||
: 'TOKENS';
|
||||
}
|
||||
// 回填自定义货币相关字段(如果后端已存在)
|
||||
if (props.options['general_setting.custom_currency_symbol'] !== undefined) {
|
||||
currentInputs['general_setting.custom_currency_symbol'] =
|
||||
props.options['general_setting.custom_currency_symbol'];
|
||||
}
|
||||
if (
|
||||
props.options['general_setting.custom_currency_exchange_rate'] !==
|
||||
undefined
|
||||
) {
|
||||
currentInputs['general_setting.custom_currency_exchange_rate'] =
|
||||
props.options['general_setting.custom_currency_exchange_rate'];
|
||||
}
|
||||
setInputs(currentInputs);
|
||||
setInputsRow(structuredClone(currentInputs));
|
||||
refForm.current.setValues(currentInputs);
|
||||
@@ -130,29 +189,7 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'QuotaPerUnit'}
|
||||
label={t('单位美元额度')}
|
||||
initValue={''}
|
||||
placeholder={t('一单位货币能兑换的额度')}
|
||||
onChange={handleFieldChange('QuotaPerUnit')}
|
||||
showClear
|
||||
onClick={() => setShowQuotaWarning(true)}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'USDExchangeRate'}
|
||||
label={t('美元汇率(非充值汇率,仅用于定价页面换算)')}
|
||||
initValue={''}
|
||||
placeholder={t('美元汇率')}
|
||||
onChange={handleFieldChange('USDExchangeRate')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
{/* 单位美元额度已合入汇率组合控件(TOKENS 模式下编辑),不再单独展示 */}
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'RetryTimes'}
|
||||
@@ -163,18 +200,51 @@ export default function GeneralSettings(props) {
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayInCurrencyEnabled'}
|
||||
label={t('以货币形式显示额度')}
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
onChange={handleFieldChange('DisplayInCurrencyEnabled')}
|
||||
<Form.Slot label={t('站点额度展示类型及汇率')}>
|
||||
<InputGroup style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={'1 USD = '}
|
||||
style={{ width: '50%' }}
|
||||
value={combinedRate}
|
||||
onChange={onCombinedRateChange}
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] === 'USD'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
value={inputs['general_setting.quota_display_type']}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
>
|
||||
<Select.Option value='USD'>USD ($)</Select.Option>
|
||||
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||
<Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field={'general_setting.custom_currency_symbol'}
|
||||
label={t('自定义货币符号')}
|
||||
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.custom_currency_symbol',
|
||||
)}
|
||||
showClear
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DisplayTokenStatEnabled'}
|
||||
@@ -195,8 +265,6 @@ export default function GeneralSettings(props) {
|
||||
onChange={handleFieldChange('DefaultCollapseSidebar')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field={'DemoSiteEnabled'}
|
||||
|
||||
@@ -18,7 +18,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Button, Col, Form, Row, Spin, DatePicker } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Spin,
|
||||
DatePicker,
|
||||
Typography,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -29,6 +38,8 @@ import {
|
||||
showWarning,
|
||||
} from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsLog(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -78,24 +89,94 @@ export default function SettingsLog(props) {
|
||||
});
|
||||
}
|
||||
async function onCleanHistoryLog() {
|
||||
try {
|
||||
setLoadingCleanHistoryLog(true);
|
||||
if (!inputs.historyTimestamp) throw new Error(t('请选择日志记录时间'));
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} ${t('条日志已清理!')}`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(t('日志清理失败:') + message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoadingCleanHistoryLog(false);
|
||||
if (!inputs.historyTimestamp) {
|
||||
showError(t('请选择日志记录时间'));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
const targetDate = dayjs(inputs.historyTimestamp);
|
||||
const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss');
|
||||
const currentTime = now.format('YYYY-MM-DD HH:mm:ss');
|
||||
const daysDiff = now.diff(targetDate, 'day');
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认清除历史日志'),
|
||||
content: (
|
||||
<div style={{ lineHeight: '1.8' }}>
|
||||
<p>
|
||||
<Text>{t('当前时间')}:</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
{currentTime}
|
||||
</Text>
|
||||
</p>
|
||||
<p>
|
||||
<Text>{t('选择时间')}:</Text>
|
||||
<Text strong type='danger'>
|
||||
{targetTime}
|
||||
</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text type='tertiary'>
|
||||
{' '}
|
||||
({t('约')} {daysDiff} {t('天前')})
|
||||
</Text>
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
marginTop: '12px',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ color: '#d46b08' }}>
|
||||
⚠️ {t('注意')}:
|
||||
</Text>
|
||||
<Text style={{ color: '#333' }}>{t('将删除')} </Text>
|
||||
<Text strong style={{ color: '#cf1322' }}>
|
||||
{targetTime}
|
||||
</Text>
|
||||
{daysDiff > 0 && (
|
||||
<Text style={{ color: '#8c8c8c' }}>
|
||||
{' '}
|
||||
({t('约')} {daysDiff} {t('天前')})
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: '#333' }}> {t('之前的所有日志')}</Text>
|
||||
</div>
|
||||
<p style={{ marginTop: '12px' }}>
|
||||
<Text type='danger'>
|
||||
{t('此操作不可恢复,请仔细确认时间后再操作!')}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('确认删除'),
|
||||
cancelText: t('取消'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
setLoadingCleanHistoryLog(true);
|
||||
const res = await API.delete(
|
||||
`/api/log/?target_timestamp=${Date.parse(inputs.historyTimestamp) / 1000}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`${data} ${t('条日志已清理!')}`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(t('日志清理失败:') + message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoadingCleanHistoryLog(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,7 +219,7 @@ export default function SettingsLog(props) {
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Spin spinning={loadingCleanHistoryLog}>
|
||||
<Form.DatePicker
|
||||
label={t('日志记录时间')}
|
||||
label={t('清除历史日志')}
|
||||
field={'historyTimestamp'}
|
||||
type='dateTime'
|
||||
inputReadOnly={true}
|
||||
@@ -149,7 +230,18 @@ export default function SettingsLog(props) {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button size='default' onClick={onCleanHistoryLog}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{ display: 'block', marginTop: 4, marginBottom: 8 }}
|
||||
>
|
||||
{t('将清除选定时间之前的所有日志')}
|
||||
</Text>
|
||||
<Button
|
||||
size='default'
|
||||
type='danger'
|
||||
onClick={onCleanHistoryLog}
|
||||
>
|
||||
{t('清除历史日志')}
|
||||
</Button>
|
||||
</Spin>
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function SettingsMonitoring(props) {
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'monitor_setting.auto_test_channel_minutes': parseInt(value),
|
||||
'monitor_setting.auto_test_channel_minutes':
|
||||
parseInt(value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -118,14 +118,20 @@ export default function SettingsPaymentGateway(props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountOptions'] !== inputs.AmountOptions &&
|
||||
inputs.AmountOptions.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountOptions)) {
|
||||
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
|
||||
if (
|
||||
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
|
||||
inputs.AmountDiscount.trim() !== ''
|
||||
) {
|
||||
if (!verifyJSON(inputs.AmountDiscount)) {
|
||||
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
|
||||
return;
|
||||
@@ -163,10 +169,16 @@ export default function SettingsPaymentGateway(props) {
|
||||
options.push({ key: 'PayMethods', value: inputs.PayMethods });
|
||||
}
|
||||
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
|
||||
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_options',
|
||||
value: inputs.AmountOptions,
|
||||
});
|
||||
}
|
||||
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
|
||||
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
|
||||
options.push({
|
||||
key: 'payment_setting.amount_discount',
|
||||
value: inputs.AmountDiscount,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
@@ -273,7 +285,7 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('为一个 JSON 文本')}
|
||||
autosize
|
||||
/>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -282,13 +294,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountOptions'
|
||||
label={t('自定义充值数量选项')}
|
||||
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
|
||||
extraText={t(
|
||||
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
@@ -297,13 +313,17 @@ export default function SettingsPaymentGateway(props) {
|
||||
<Form.TextArea
|
||||
field='AmountDiscount'
|
||||
label={t('充值金额折扣配置')}
|
||||
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
autosize
|
||||
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
|
||||
extraText={t(
|
||||
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
|
||||
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) {
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
@@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) {
|
||||
props.options.StripeMinTopUp !== undefined
|
||||
? parseFloat(props.options.StripeMinTopUp)
|
||||
: 1,
|
||||
StripePromotionCodesEnabled:
|
||||
props.options.StripePromotionCodesEnabled !== undefined
|
||||
? props.options.StripePromotionCodesEnabled
|
||||
: false,
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
@@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) {
|
||||
value: inputs.StripeMinTopUp.toString(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
originInputs['StripePromotionCodesEnabled'] !==
|
||||
inputs.StripePromotionCodesEnabled &&
|
||||
inputs.StripePromotionCodesEnabled !== undefined
|
||||
) {
|
||||
options.push({
|
||||
key: 'StripePromotionCodesEnabled',
|
||||
value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
@@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='StripePromotionCodesEnabled'
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
label={t('允许在 Stripe 支付中输入促销码')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
|
||||
</Form.Section>
|
||||
|
||||
@@ -226,8 +226,12 @@ export default function ModelRatioSettings(props) {
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
|
||||
extraText={t(
|
||||
'图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}',
|
||||
)}
|
||||
field={'ImageRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -238,9 +242,7 @@ export default function ModelRatioSettings(props) {
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ImageRatio: value })
|
||||
}
|
||||
onChange={(value) => setInputs({ ...inputs, ImageRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -249,7 +251,9 @@ export default function ModelRatioSettings(props) {
|
||||
<Form.TextArea
|
||||
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}',
|
||||
)}
|
||||
field={'AudioRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
@@ -260,9 +264,7 @@ export default function ModelRatioSettings(props) {
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AudioRatio: value })
|
||||
}
|
||||
onChange={(value) => setInputs({ ...inputs, AudioRatio: value })}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -270,8 +272,12 @@ export default function ModelRatioSettings(props) {
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
|
||||
extraText={t(
|
||||
'音频输出补全相关的倍率设置,键为模型名称,值为倍率',
|
||||
)}
|
||||
placeholder={t(
|
||||
'为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}',
|
||||
)}
|
||||
field={'AudioCompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
|
||||
@@ -108,7 +108,7 @@ const Setting = () => {
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Calculator size={18} />
|
||||
{t('倍率设置')}
|
||||
{t('分组与模型定价设置')}
|
||||
</span>
|
||||
),
|
||||
content: <RatioSetting />,
|
||||
|
||||
37
web/src/pages/UserAgreement/index.jsx
Normal file
37
web/src/pages/UserAgreement/index.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||
|
||||
const UserAgreement = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DocumentRenderer
|
||||
apiEndpoint="/api/user-agreement"
|
||||
title={t('用户协议')}
|
||||
cacheKey="user_agreement"
|
||||
emptyMessage={t('加载用户协议内容失败...')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreement;
|
||||
232
web/src/services/secureVerification.js
Normal file
232
web/src/services/secureVerification.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { API, showError } from '../helpers';
|
||||
import {
|
||||
prepareCredentialRequestOptions,
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
} from '../helpers/passkey';
|
||||
|
||||
/**
|
||||
* 通用安全验证服务
|
||||
* 验证状态完全由后端 Session 控制,前端不存储任何状态
|
||||
*/
|
||||
export class SecureVerificationService {
|
||||
/**
|
||||
* 检查用户可用的验证方式
|
||||
* @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
|
||||
*/
|
||||
static async checkAvailableVerificationMethods() {
|
||||
try {
|
||||
const [twoFAResponse, passkeyResponse, passkeySupported] =
|
||||
await Promise.all([
|
||||
API.get('/api/user/2fa/status'),
|
||||
API.get('/api/user/passkey'),
|
||||
isPasskeySupported(),
|
||||
]);
|
||||
|
||||
console.log('=== DEBUGGING VERIFICATION METHODS ===');
|
||||
console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2));
|
||||
console.log(
|
||||
'Passkey Response:',
|
||||
JSON.stringify(passkeyResponse, null, 2),
|
||||
);
|
||||
|
||||
const has2FA =
|
||||
twoFAResponse.data?.success &&
|
||||
twoFAResponse.data?.data?.enabled === true;
|
||||
const hasPasskey =
|
||||
passkeyResponse.data?.success &&
|
||||
passkeyResponse.data?.data?.enabled === true;
|
||||
|
||||
console.log('has2FA calculation:', {
|
||||
success: twoFAResponse.data?.success,
|
||||
dataExists: !!twoFAResponse.data?.data,
|
||||
enabled: twoFAResponse.data?.data?.enabled,
|
||||
result: has2FA,
|
||||
});
|
||||
|
||||
console.log('hasPasskey calculation:', {
|
||||
success: passkeyResponse.data?.success,
|
||||
dataExists: !!passkeyResponse.data?.data,
|
||||
enabled: passkeyResponse.data?.data?.enabled,
|
||||
result: hasPasskey,
|
||||
});
|
||||
|
||||
const result = {
|
||||
has2FA,
|
||||
hasPasskey,
|
||||
passkeySupported,
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to check verification methods:', error);
|
||||
return {
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行2FA验证
|
||||
* @param {string} code - 验证码
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verify2FA(code) {
|
||||
if (!code?.trim()) {
|
||||
throw new Error('请输入验证码或备用码');
|
||||
}
|
||||
|
||||
// 调用通用验证 API,验证成功后后端会设置 session
|
||||
const verifyResponse = await API.post('/api/verify', {
|
||||
method: '2fa',
|
||||
code: code.trim(),
|
||||
});
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 验证成功,session 已在后端设置
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行Passkey验证
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verifyPasskey() {
|
||||
try {
|
||||
// 开始Passkey验证
|
||||
const beginResponse = await API.post('/api/user/passkey/verify/begin');
|
||||
if (!beginResponse.data?.success) {
|
||||
throw new Error(beginResponse.data?.message || '开始验证失败');
|
||||
}
|
||||
|
||||
// 准备WebAuthn选项
|
||||
const publicKey = prepareCredentialRequestOptions(
|
||||
beginResponse.data.data.options,
|
||||
);
|
||||
|
||||
// 执行WebAuthn验证
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) {
|
||||
throw new Error('Passkey 验证被取消');
|
||||
}
|
||||
|
||||
// 构建验证结果
|
||||
const assertionResult = buildAssertionResult(credential);
|
||||
|
||||
// 完成验证
|
||||
const finishResponse = await API.post(
|
||||
'/api/user/passkey/verify/finish',
|
||||
assertionResult,
|
||||
);
|
||||
if (!finishResponse.data?.success) {
|
||||
throw new Error(finishResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 调用通用验证 API 设置 session(Passkey 验证已完成)
|
||||
const verifyResponse = await API.post('/api/verify', {
|
||||
method: 'passkey',
|
||||
});
|
||||
|
||||
if (!verifyResponse.data?.success) {
|
||||
throw new Error(verifyResponse.data?.message || '验证失败');
|
||||
}
|
||||
|
||||
// 验证成功,session 已在后端设置
|
||||
} catch (error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
throw new Error('Passkey 验证被取消或超时');
|
||||
} else if (error.name === 'InvalidStateError') {
|
||||
throw new Error('Passkey 验证状态无效');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用验证方法,根据验证类型执行相应的验证流程
|
||||
* @param {string} method - 验证方式: '2fa' | 'passkey'
|
||||
* @param {string} code - 2FA验证码(当method为'2fa'时必需)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async verify(method, code = '') {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return await this.verify2FA(code);
|
||||
case 'passkey':
|
||||
return await this.verifyPasskey();
|
||||
default:
|
||||
throw new Error(`不支持的验证方式: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设的API调用函数工厂
|
||||
*/
|
||||
export const createApiCalls = {
|
||||
/**
|
||||
* 创建查看渠道密钥的API调用
|
||||
* @param {number} channelId - 渠道ID
|
||||
*/
|
||||
viewChannelKey: (channelId) => async () => {
|
||||
// 新系统中,验证已通过中间件处理,直接调用 API 即可
|
||||
const response = await API.post(`/api/channel/${channelId}/key`, {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建自定义API调用
|
||||
* @param {string} url - API URL
|
||||
* @param {string} method - HTTP方法,默认为 'POST'
|
||||
* @param {Object} extraData - 额外的请求数据
|
||||
*/
|
||||
custom:
|
||||
(url, method = 'POST', extraData = {}) =>
|
||||
async () => {
|
||||
// 新系统中,验证已通过中间件处理
|
||||
const data = extraData;
|
||||
|
||||
let response;
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
response = await API.get(url, { params: data });
|
||||
break;
|
||||
case 'POST':
|
||||
response = await API.post(url, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await API.put(url, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await API.delete(url, { data });
|
||||
break;
|
||||
default:
|
||||
throw new Error(`不支持的HTTP方法: ${method}`);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user