Merge branch 'main' into feat_subscribe_sp1

This commit is contained in:
Little Write
2025-10-20 22:36:33 +08:00
396 changed files with 28798 additions and 5948 deletions

View File

@@ -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>

View File

@@ -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>

View 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;

View 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;

View 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;

View File

@@ -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>

View File

@@ -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
)}

View File

@@ -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') &&

View File

@@ -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([]);

View File

@@ -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'

View File

@@ -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,

View File

@@ -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>

View File

@@ -46,6 +46,7 @@ const PaymentSetting = () => {
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
});
let [loading, setLoading] = useState(false);

View File

@@ -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}
/>
{/* 右侧:其他设置 */}

View File

@@ -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('允许不安全的 OriginHTTP')}
</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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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>

View File

@@ -56,6 +56,7 @@ const PricingDisplaySettings = ({
const currencyItems = [
{ value: 'USD', label: 'USD ($)' },
{ value: 'CNY', label: 'CNY (¥)' },
{ value: 'CUSTOM', label: t('自定义货币') },
];
const handleChange = (value) => {

View File

@@ -107,6 +107,7 @@ const SearchActions = memo(
optionList={[
{ value: 'USD', label: 'USD' },
{ value: 'CNY', label: 'CNY' },
{ value: 'CUSTOM', label: t('自定义货币') },
]}
/>
)}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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 &&

View File

@@ -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>

View File

@@ -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,
}),
},

View File

@@ -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}
/>
</>
);
};

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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={

View 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;