Merge origin/main into nightly

Resolve conflicts:
- .gitignore: keep nightly additions (.test, skills-lock.json)
- relay/helper/price.go: keep both billingexpr and model imports
- en.json / zh-CN.json: keep nightly's superset of i18n entries
- service/billing_session.go: add missing 3rd arg to DecreaseUserQuota
- en.json / zh-CN.json: deduplicate 129+320 duplicate i18n keys
This commit is contained in:
CaIon
2026-04-23 21:37:03 +08:00
117 changed files with 9031 additions and 2910 deletions
@@ -21,8 +21,9 @@ import React, { useRef, useEffect } from 'react';
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import { Loader2, Check, X } from 'lucide-react';
import { Loader2, Check, X, Settings, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isAdmin } from '../../helpers/utils';
const MessageContent = ({
message,
@@ -64,6 +65,44 @@ const MessageContent = ({
errorText = t('请求发生错误');
}
if (message.errorCode === 'model_price_error') {
return (
<div className={`${className}`}>
<div
className='rounded-lg p-3 space-y-2'
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center gap-2'>
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
{t('模型价格未配置')}
</Typography.Text>
</div>
<Typography.Paragraph
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
style={{ wordBreak: 'break-word' }}
>
{errorText}
</Typography.Paragraph>
{isAdmin() && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={14} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
>
{t('前往设置')}
</Button>
)}
</div>
</div>
);
}
return (
<div className={`${className}`}>
<Typography.Text className='text-white'>{errorText}</Typography.Text>
+75 -15
View File
@@ -18,12 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -48,6 +49,17 @@ const PaymentSetting = () => {
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1.0,
WaffoPancakeMinTopUp: 1,
});
let [loading, setLoading] = useState(false);
@@ -96,8 +108,21 @@ const PaymentSetting = () => {
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
case 'WaffoPancakeUnitPrice':
case 'WaffoPancakeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
case 'WaffoPancakeMerchantID':
case 'WaffoPancakePrivateKey':
case 'WaffoPancakeStoreID':
case 'WaffoPancakeProductID':
case 'WaffoPancakeReturnURL':
case 'WaffoPancakeCurrency':
newInputs[item.key] = item.value;
break;
case 'WaffoPancakeSandbox':
newInputs[item.key] = toBoolean(item.value);
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
@@ -108,7 +133,7 @@ const PaymentSetting = () => {
}
});
setInputs(newInputs);
setInputs((prev) => ({ ...prev, ...newInputs }));
} else {
showError(t(message));
}
@@ -133,19 +158,54 @@ const PaymentSetting = () => {
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} />
<Tabs
type='card'
defaultActiveKey='general'
contentStyle={{ paddingTop: 24 }}
>
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
<SettingsGeneralPayment
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
<SettingsPaymentGateway
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
<SettingsPaymentGatewayStripe
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
<SettingsPaymentGatewayCreem
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
<SettingsPaymentGatewayWaffo
options={inputs}
refresh={onRefresh}
hideSectionTitle
/>
</Tabs.TabPane>
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
{/* <SettingsPaymentGatewayWaffoPancake*/}
{/* options={inputs}*/}
{/* refresh={onRefresh}*/}
{/* hideSectionTitle*/}
{/* />*/}
{/*</Tabs.TabPane>*/}
</Tabs>
</Card>
</Spin>
</>
+125 -20
View File
@@ -45,6 +45,8 @@ import EmailBindModal from './personal/modals/EmailBindModal';
import WeChatBindModal from './personal/modals/WeChatBindModal';
import AccountDeleteModal from './personal/modals/AccountDeleteModal';
import ChangePasswordModal from './personal/modals/ChangePasswordModal';
import SecureVerificationModal from '../common/modals/SecureVerificationModal';
import { useSecureVerification } from '../../hooks/common/useSecureVerification';
const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext);
@@ -76,6 +78,10 @@ const PersonalSetting = () => {
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [
passkeyRequiredVerificationMethod,
setPasskeyRequiredVerificationMethod,
] = useState(null);
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
warningThreshold: 100000,
@@ -91,6 +97,34 @@ const PersonalSetting = () => {
recordIpLog: false,
});
const {
isModalVisible: isPasskeyVerificationModalVisible,
verificationMethods: passkeyVerificationMethods,
verificationState: passkeyVerificationState,
startVerification: startPasskeyVerification,
executeVerification: executePasskeyVerification,
cancelVerification: cancelPasskeyVerification,
setVerificationCode: setPasskeyVerificationCode,
switchVerificationMethod: switchPasskeyVerificationMethod,
checkVerificationMethods: checkPasskeyVerificationMethods,
} = useSecureVerification({
onSuccess: () => {
setPasskeyRequiredVerificationMethod(null);
},
});
const visiblePasskeyVerificationMethods = passkeyRequiredVerificationMethod
? {
...passkeyVerificationMethods,
has2FA:
passkeyRequiredVerificationMethod === '2fa' &&
passkeyVerificationMethods.has2FA,
hasPasskey:
passkeyRequiredVerificationMethod === 'passkey' &&
passkeyVerificationMethods.hasPasskey,
}
: passkeyVerificationMethods;
useEffect(() => {
let saved = localStorage.getItem('status');
if (saved) {
@@ -203,18 +237,57 @@ const PersonalSetting = () => {
}
};
const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
const startPasskeyManagementVerification = async (apiCall, options = {}) => {
const methods = await checkPasskeyVerificationMethods();
const requiredMethod = methods.has2FA
? '2fa'
: methods.hasPasskey
? 'passkey'
: null;
if (!requiredMethod) {
showError(t('您需要先启用两步验证或 Passkey 才能执行此操作'));
return;
}
if (requiredMethod === 'passkey' && !methods.passkeySupported) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
setPasskeyRequiredVerificationMethod(requiredMethod);
await startPasskeyVerification(apiCall, {
preferredMethod: requiredMethod,
title: t('安全验证'),
...options,
});
};
const startPasskeyRegistration = async () => {
const methods = await checkPasskeyVerificationMethods();
if (!methods.has2FA) {
try {
await registerPasskey();
} catch (error) {
showError(error.message || t('Passkey 注册失败,请重试'));
}
return;
}
setPasskeyRequiredVerificationMethod('2fa');
await startPasskeyVerification(registerPasskey, {
preferredMethod: '2fa',
title: t('安全验证'),
});
};
const registerPasskey = async () => {
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;
throw new Error(message || t('无法发起 Passkey 注册'));
}
const publicKey = prepareCredentialCreationOptions(
@@ -223,49 +296,69 @@ const PersonalSetting = () => {
const credential = await navigator.credentials.create({ publicKey });
const payload = buildRegistrationResult(credential);
if (!payload) {
showError(t('Passkey 注册失败,请重试'));
return;
throw new Error(t('Passkey 注册失败,请重试'));
}
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 注册失败,请重试'));
if (!finishRes.data.success) {
throw new Error(
finishRes.data.message || t('Passkey 注册失败,请重试'),
);
}
showSuccess(t('Passkey 注册成功'));
await loadPasskeyStatus();
return finishRes.data;
} catch (error) {
if (error?.name === 'AbortError') {
showInfo(t('已取消 Passkey 注册'));
} else {
showError(t('Passkey 注册失败,请重试'));
return { cancelled: true };
}
throw new Error(error?.message || t('Passkey 注册失败,请重试'));
} finally {
setPasskeyRegisterLoading(false);
}
};
const handleRemovePasskey = async () => {
const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
await startPasskeyRegistration();
};
const removePasskey = 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('操作失败,请重试'));
if (!success) {
throw new Error(message || t('操作失败,请重试'));
}
showSuccess(t('Passkey 已解绑'));
await loadPasskeyStatus();
return res.data;
} catch (error) {
showError(t('操作失败,请重试'));
throw new Error(error?.message || t('操作失败,请重试'));
} finally {
setPasskeyDeleteLoading(false);
}
};
const handleRemovePasskey = async () => {
await startPasskeyManagementVerification(removePasskey);
};
const handlePasskeyVerificationCancel = () => {
setPasskeyRequiredVerificationMethod(null);
cancelPasskeyVerification();
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -556,6 +649,18 @@ const PersonalSetting = () => {
turnstileSiteKey={turnstileSiteKey}
setTurnstileToken={setTurnstileToken}
/>
<SecureVerificationModal
visible={isPasskeyVerificationModalVisible}
verificationMethods={visiblePasskeyVerificationMethods}
verificationState={passkeyVerificationState}
onVerify={executePasskeyVerification}
onCancel={handlePasskeyVerificationCancel}
onCodeChange={setPasskeyVerificationCode}
onMethodSwitch={switchPasskeyVerificationMethod}
title={passkeyVerificationState.title}
description={passkeyVerificationState.description}
/>
</div>
);
};
@@ -29,6 +29,7 @@ import {
Collapse,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
import { MOBILE_BREAKPOINT } from '../../../../hooks/common/useIsMobile';
const { Text } = Typography;
@@ -98,10 +99,12 @@ const resolveRateLimitWindows = (data) => {
}
if (!fiveHourWindow) {
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
fiveHourWindow =
windows.find((windowData) => windowData !== weeklyWindow) ?? null;
}
if (!weeklyWindow) {
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
weeklyWindow =
windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
}
return { fiveHourWindow, weeklyWindow };
@@ -135,6 +138,40 @@ const getDisplayText = (value) => {
return String(value).trim();
};
const isMobileViewport = () =>
typeof window !== 'undefined' && window.innerWidth < MOBILE_BREAKPOINT;
const getCodexUsageModalLayout = () => {
if (isMobileViewport()) {
return {
width: 'calc(100vw - 16px)',
style: {
top: 8,
maxWidth: 'calc(100vw - 16px)',
margin: '0 auto',
},
bodyStyle: {
maxHeight: 'calc(100vh - 148px)',
overflowY: 'auto',
padding: '16px 16px 12px',
},
};
}
return {
width: 900,
style: {
top: 24,
maxWidth: 'min(900px, 92vw)',
},
bodyStyle: {
maxHeight: 'calc(100vh - 172px)',
overflowY: 'auto',
padding: '20px 24px 16px',
},
};
};
const formatAccountTypeLabel = (value, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const normalized = normalizePlanType(value);
@@ -224,7 +261,7 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
return (
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='flex flex-wrap items-start justify-between gap-x-3 gap-y-1'>
<div className='font-medium'>{title}</div>
<Text type='tertiary' size='small'>
{tt('重置时间:')}
@@ -262,12 +299,86 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
);
};
const RateLimitWindowGrid = ({ t, fiveHourWindow, weeklyWindow }) => {
const tt = typeof t === 'function' ? t : (v) => v;
return (
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<RateLimitWindowCard
t={tt}
title={tt('5小时窗口')}
windowData={fiveHourWindow}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={weeklyWindow}
/>
</div>
);
};
const RateLimitGroupSection = ({
t,
title,
description,
rateLimitSource,
statusTag,
meteredFeature,
}) => {
const tt = typeof t === 'function' ? t : (v) => v;
const { fiveHourWindow, weeklyWindow } =
resolveRateLimitWindows(rateLimitSource);
const featureText = getDisplayText(meteredFeature);
return (
<section className='space-y-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-sm font-semibold text-semi-color-text-0'>
{title}
</div>
{statusTag}
</div>
{(description || featureText) && (
<div className='flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
{description ? <span>{description}</span> : null}
{featureText ? (
<div className='inline-flex max-w-full items-center gap-2 rounded-full bg-semi-color-fill-0 px-2 py-1'>
<span className='text-[11px] text-semi-color-text-2'>
metered_feature
</span>
<span className='min-w-0 break-all font-mono text-xs text-semi-color-text-0'>
{featureText}
</span>
</div>
) : null}
</div>
)}
</div>
</div>
<RateLimitWindowGrid
t={tt}
fiveHourWindow={fiveHourWindow}
weeklyWindow={weeklyWindow}
/>
</section>
);
};
const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const [showRawJson, setShowRawJson] = useState(false);
const data = payload?.data ?? null;
const rateLimit = data?.rate_limit ?? {};
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
const additionalRateLimits = Array.isArray(data?.additional_rate_limits)
? data.additional_rate_limits.filter(
(item) =>
item && typeof item === 'object' && Object.keys(item).length > 0,
)
: [];
const upstreamStatus = payload?.upstream_status;
const accountType = data?.plan_type ?? rateLimit?.plan_type;
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
@@ -277,7 +388,9 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
const email = data?.email;
const accountId = data?.account_id;
const errorMessage =
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
payload?.success === false
? getDisplayText(payload?.message) || tt('获取用量失败')
: '';
const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
@@ -313,7 +426,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
</Tag>
</div>
</div>
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
<Button
size='small'
type='tertiary'
theme='outline'
onClick={onRefresh}
>
{tt('刷新')}
</Button>
</div>
@@ -355,22 +473,61 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
{tt('额度窗口')}
</div>
<Text type='tertiary' size='small'>
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
{tt(
'用于观察当前帐号在 Codex 上游的基础限额与附加计费能力使用情况',
)}
</Text>
</div>
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<RateLimitWindowCard
<div className='space-y-5'>
<RateLimitGroupSection
t={tt}
title={tt('5小时窗口')}
windowData={fiveHourWindow}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={weeklyWindow}
title={tt('基础额度')}
description={tt('当前帐号的基础额度窗口')}
rateLimitSource={data}
statusTag={statusTag}
/>
{additionalRateLimits.length > 0 ? (
<div className='space-y-4 border-t border-semi-color-border pt-4'>
<div>
<div className='text-sm font-semibold text-semi-color-text-0'>
{tt('附加额度')}
</div>
<Text type='tertiary' size='small'>
{tt('按模型或能力拆分的附加计费能力窗口')}
</Text>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
getDisplayText(item?.limit_name) ||
getDisplayText(item?.metered_feature) ||
`${tt('附加额度')} ${index + 1}`;
return (
<div
key={`${limitName}-${getDisplayText(item?.metered_feature)}-${index}`}
className={
index > 0 ? 'border-t border-semi-color-border pt-4' : ''
}
>
<RateLimitGroupSection
t={tt}
title={limitName}
description={tt('附加计费能力')}
rateLimitSource={item}
statusTag={resolveUsageStatusTag(tt, item?.rate_limit)}
meteredFeature={item?.metered_feature}
/>
</div>
);
})}
</div>
</div>
) : null}
</div>
<Collapse
@@ -489,12 +646,14 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const layout = getCodexUsageModalLayout();
Modal.info({
title: tt('Codex 帐号与用量'),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
centered: false,
width: layout.width,
style: layout.style,
bodyStyle: layout.bodyStyle,
content: (
<CodexUsageLoader
t={tt}
@@ -208,6 +208,7 @@ const EditChannelModal = (props) => {
allow_safety_identifier: false,
allow_include_obfuscation: false,
allow_inference_geo: false,
allow_speed: false,
claude_beta_query: false,
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
@@ -890,6 +891,7 @@ const EditChannelModal = (props) => {
parsedSettings.allow_include_obfuscation || false;
data.allow_inference_geo =
parsedSettings.allow_inference_geo || false;
data.allow_speed = parsedSettings.allow_speed || false;
data.claude_beta_query = parsedSettings.claude_beta_query || false;
data.upstream_model_update_check_enabled =
parsedSettings.upstream_model_update_check_enabled === true;
@@ -919,6 +921,7 @@ const EditChannelModal = (props) => {
data.allow_safety_identifier = false;
data.allow_include_obfuscation = false;
data.allow_inference_geo = false;
data.allow_speed = false;
data.claude_beta_query = false;
data.upstream_model_update_check_enabled = false;
data.upstream_model_update_auto_sync_enabled = false;
@@ -936,6 +939,7 @@ const EditChannelModal = (props) => {
data.allow_safety_identifier = false;
data.allow_include_obfuscation = false;
data.allow_inference_geo = false;
data.allow_speed = false;
data.claude_beta_query = false;
data.upstream_model_update_check_enabled = false;
data.upstream_model_update_auto_sync_enabled = false;
@@ -1776,6 +1780,7 @@ const EditChannelModal = (props) => {
}
if (localInputs.type === 14) {
settings.allow_inference_geo = localInputs.allow_inference_geo === true;
settings.allow_speed = localInputs.allow_speed === true;
settings.claude_beta_query = localInputs.claude_beta_query === true;
}
}
@@ -1823,6 +1828,7 @@ const EditChannelModal = (props) => {
delete localInputs.allow_safety_identifier;
delete localInputs.allow_include_obfuscation;
delete localInputs.allow_inference_geo;
delete localInputs.allow_speed;
delete localInputs.claude_beta_query;
delete localInputs.upstream_model_update_check_enabled;
delete localInputs.upstream_model_update_auto_sync_enabled;
@@ -2480,6 +2486,7 @@ const EditChannelModal = (props) => {
</div>
<Form.Switch field='allow_service_tier' label={t('允许 service_tier 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} />
<Form.Switch field='allow_inference_geo' label={t('允许 inference_geo 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_inference_geo', value)} extraText={t('inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} />
<Form.Switch field='allow_speed' label={t('允许 speed 透传')} checkedText={t('开')} uncheckedText={t('关')} onChange={(value) => handleChannelOtherSettingsChange('allow_speed', value)} extraText={t('speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式')} />
</>
)}
</div>
@@ -30,6 +30,7 @@ import {
Banner,
} from '@douyinfe/semi-ui';
import { IconSearch, IconInfoCircle } from '@douyinfe/semi-icons';
import { Settings } from 'lucide-react';
import { copy, showError, showInfo, showSuccess } from '../../../../helpers';
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
@@ -168,17 +169,43 @@ const ModelTestModal = ({
}
return (
<div className='flex items-center gap-2'>
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type='tertiary'>
{t('请求时长: ${time}s').replace(
'${time}',
testResult.time.toFixed(2),
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
<Tag color={testResult.success ? 'green' : 'red'} shape='circle'>
{testResult.success ? t('成功') : t('失败')}
</Tag>
{testResult.success && (
<Typography.Text type='tertiary'>
{t('请求时长: ${time}s').replace(
'${time}',
testResult.time.toFixed(2),
)}
</Typography.Text>
)}
</div>
{!testResult.success && testResult.message && (
<div className='flex flex-col gap-1'>
<Typography.Text
type='danger'
size='small'
className='break-all'
style={{ maxWidth: '400px', fontSize: '12px' }}
>
{testResult.message}
</Typography.Text>
{testResult.errorCode === 'model_price_error' && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={12} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
style={{ width: 'fit-content' }}
>
{t('前往设置')}
</Button>
)}
</Typography.Text>
</div>
)}
</div>
);
@@ -360,7 +360,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
{
title: t('索引'),
dataIndex: 'index',
render: (text) => `#${text}`,
render: (text) => `#${Number(text) + 1}`,
},
// {
// title: t('密钥预览'),
@@ -25,8 +25,12 @@ import {
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
@@ -41,6 +45,7 @@ import {
Avatar,
Row,
Col,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
@@ -57,10 +62,12 @@ const EditRedemptionModal = (props) => {
const [loading, setLoading] = useState(isEdit);
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const getInitValues = () => ({
name: '',
quota: 100000,
amount: Number(quotaToDisplayAmount(100000).toFixed(6)),
count: 1,
expired_time: null,
});
@@ -79,6 +86,7 @@ const EditRedemptionModal = (props) => {
} else {
data.expired_time = new Date(data.expired_time * 1000);
}
data.amount = Number(quotaToDisplayAmount(data.quota || 0).toFixed(6));
formApiRef.current?.setValues({ ...getInitValues(), ...data });
} else {
showError(message);
@@ -104,7 +112,12 @@ const EditRedemptionModal = (props) => {
setLoading(true);
let localInputs = { ...values };
localInputs.count = parseInt(localInputs.count) || 0;
localInputs.quota = parseInt(localInputs.quota) || 0;
localInputs.quota = displayAmountToQuota(localInputs.amount);
if (localInputs.quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
localInputs.name = name;
if (!localInputs.expired_time) {
localInputs.expired_time = 0;
@@ -285,37 +298,63 @@ const EditRedemptionModal = (props) => {
</div>
<Row gutter={12}>
<Col span={12}>
<Form.AutoComplete
field='quota'
label={t('额')}
placeholder={t('请输入额度')}
<Col span={24}>
<Form.InputNumber
field='amount'
label={t('额')}
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
min={0}
step={0.000001}
style={{ width: '100%' }}
type='number'
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
extraText={renderQuotaWithPrompt(
Number(values.quota) || 0,
)}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
onChange={(val) => {
const amount = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('amount', amount);
formApiRef.current?.setValue(
'quota',
displayAmountToQuota(amount),
);
}}
showClear
/>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('输入额度')}
rules={[
{ required: true, message: t('请输入额度') },
{
validator: (rule, v) => {
const num = parseInt(v, 10);
return num > 0
? Promise.resolve()
: Promise.reject(t('额度必须大于0'));
},
},
]}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('quota', quota);
formApiRef.current?.setValue(
'amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col>
{!isEdit && (
<Col span={12}>
@@ -536,6 +536,13 @@ export const getTokensColumns = ({
return <div>{renderTimestamp(text)}</div>;
},
},
{
title: t('最后使用时间'),
dataIndex: 'accessed_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
title: t('过期时间'),
dataIndex: 'expired_time',
@@ -24,10 +24,14 @@ import {
showSuccess,
timestamp2string,
renderGroupOption,
renderQuotaWithPrompt,
getCurrencyConfig,
getModelCategories,
selectFilter,
} from '../../../../helpers';
import {
quotaToDisplayAmount,
displayAmountToQuota,
} from '../../../../helpers/quota';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import {
Button,
@@ -41,6 +45,7 @@ import {
Form,
Col,
Row,
InputNumber,
} from '@douyinfe/semi-ui';
import {
IconCreditCard,
@@ -62,11 +67,13 @@ const EditTokenModal = (props) => {
const formApiRef = useRef(null);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const isEdit = props.editingToken.id !== undefined;
const getInitValues = () => ({
name: '',
remain_quota: 0,
remain_amount: 0,
expired_time: -1,
unlimited_quota: true,
model_limits_enabled: false,
@@ -162,6 +169,9 @@ const EditTokenModal = (props) => {
} else {
data.model_limits = [];
}
data.remain_amount = Number(
quotaToDisplayAmount(data.remain_quota || 0).toFixed(6),
);
if (formApiRef.current) {
formApiRef.current.setValues({ ...getInitValues(), ...data });
}
@@ -209,7 +219,14 @@ const EditTokenModal = (props) => {
setLoading(true);
if (isEdit) {
let { tokenCount: _tc, ...localInputs } = values;
localInputs.remain_quota = parseInt(localInputs.remain_quota);
localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
return;
}
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) {
@@ -245,7 +262,14 @@ const EditTokenModal = (props) => {
} else {
localInputs.name = baseName;
}
localInputs.remain_quota = parseInt(localInputs.remain_quota);
localInputs.remain_quota = localInputs.unlimited_quota
? 0
: displayAmountToQuota(localInputs.remain_amount);
if (!localInputs.unlimited_quota && localInputs.remain_quota <= 0) {
showError(t('请输入金额'));
setLoading(false);
break;
}
if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time);
@@ -497,28 +521,63 @@ const EditTokenModal = (props) => {
</div>
<Row gutter={12}>
<Col span={24}>
<Form.AutoComplete
field='remain_quota'
label={t('额')}
placeholder={t('请输入额度')}
type='number'
<Form.InputNumber
field='remain_amount'
label={t('额')}
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
precision={6}
disabled={values.unlimited_quota}
extraText={renderQuotaWithPrompt(values.remain_quota)}
rules={
values.unlimited_quota
? []
: [{ required: true, message: t('请输入额度') }]
}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' },
]}
min={0}
step={0.000001}
onChange={(val) => {
const amount = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('remain_amount', amount);
formApiRef.current?.setValue(
'remain_quota',
displayAmountToQuota(amount),
);
}}
style={{ width: '100%' }}
showClear
/>
</Col>
<Col span={24}>
<div
className='text-xs cursor-pointer mt-1'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='remain_quota'
label={t('额度')}
placeholder={t('输入额度')}
disabled={values.unlimited_quota}
min={0}
step={500000}
rules={
values.unlimited_quota
? []
: [{ required: true, message: t('请输入额度') }]
}
onChange={(val) => {
const quota = val === '' || val == null ? 0 : val;
formApiRef.current?.setValue('remain_quota', quota);
formApiRef.current?.setValue(
'remain_amount',
Number(quotaToDisplayAmount(quota).toFixed(6)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
</Col>
<Col span={24}>
<Form.Switch
field='unlimited_quota'
@@ -845,7 +845,12 @@ export const getLogsColumns = ({
),
dataIndex: 'ip',
render: (text, record, index) => {
return (record.type === 2 || record.type === 5) && text ? (
const showIp =
(record.type === 2 ||
record.type === 5 ||
(isAdminUser && record.type === 1)) &&
text;
return showIp ? (
<Tooltip content={text}>
<span>
<Tag
@@ -24,7 +24,6 @@ import {
showError,
showSuccess,
renderQuota,
renderQuotaWithPrompt,
getCurrencyConfig,
} from '../../../../helpers';
import {
@@ -46,6 +45,8 @@ import {
Row,
Col,
InputNumber,
RadioGroup,
Radio,
} from '@douyinfe/semi-ui';
import {
IconUser,
@@ -53,7 +54,7 @@ import {
IconClose,
IconLink,
IconUserGroup,
IconPlus,
IconEdit,
} from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal';
@@ -63,13 +64,18 @@ const EditUserModal = (props) => {
const { t } = useTranslation();
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [addQuotaModalOpen, setIsModalOpen] = useState(false);
const [addQuotaLocal, setAddQuotaLocal] = useState('');
const [addAmountLocal, setAddAmountLocal] = useState('');
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
const [adjustQuotaLocal, setAdjustQuotaLocal] = useState('');
const [adjustAmountLocal, setAdjustAmountLocal] = useState('');
const [adjustMode, setAdjustMode] = useState('add');
const [adjustLoading, setAdjustLoading] = useState(false);
const isMobile = useIsMobile();
const [groupOptions, setGroupOptions] = useState([]);
const [bindingModalVisible, setBindingModalVisible] = useState(false);
const formApiRef = useRef(null);
const [showAdjustQuotaRaw, setShowAdjustQuotaRaw] = useState(false);
const [showQuotaInput, setShowQuotaInput] = useState(false);
const [inputs, setInputs] = useState(null);
const isEdit = Boolean(userId);
@@ -85,6 +91,7 @@ const EditUserModal = (props) => {
linux_do_id: '',
email: '',
quota: 0,
quota_amount: 0,
group: 'default',
remark: '',
});
@@ -107,13 +114,22 @@ const EditUserModal = (props) => {
const { success, message, data } = res.data;
if (success) {
data.password = '';
formApiRef.current?.setValues({ ...getInitValues(), ...data });
data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (inputs && formApiRef.current) {
formApiRef.current.setValues(inputs);
}
}, [inputs]);
useEffect(() => {
loadUser();
if (userId) fetchGroups();
@@ -132,8 +148,8 @@ const EditUserModal = (props) => {
const submit = async (values) => {
setLoading(true);
let payload = { ...values };
if (typeof payload.quota === 'string')
payload.quota = parseInt(payload.quota) || 0;
delete payload.quota;
delete payload.quota_amount;
if (userId) {
payload.id = parseInt(userId);
}
@@ -150,11 +166,60 @@ const EditUserModal = (props) => {
setLoading(false);
};
/* --------------------- quota helper -------------------- */
const addLocalQuota = () => {
const current = parseInt(formApiRef.current?.getValue('quota') || 0);
const delta = parseInt(addQuotaLocal) || 0;
formApiRef.current?.setValue('quota', current + delta);
/* --------------------- atomic quota adjust -------------------- */
const adjustQuota = async () => {
const quotaVal = parseInt(adjustQuotaLocal) || 0;
if (quotaVal <= 0 && adjustMode !== 'override') return;
if (adjustMode === 'override' && (adjustQuotaLocal === '' || adjustQuotaLocal == null)) return;
setAdjustLoading(true);
try {
const res = await API.post('/api/user/manage', {
id: parseInt(userId),
action: 'add_quota',
mode: adjustMode,
value: adjustMode === 'override' ? quotaVal : Math.abs(quotaVal),
});
const { success, message } = res.data;
if (success) {
showSuccess(t('调整额度成功'));
setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
const userRes = await API.get(`/api/user/${userId}`);
if (userRes.data.success) {
const data = userRes.data.data;
data.password = '';
data.quota_amount = Number(
quotaToDisplayAmount(data.quota || 0).toFixed(6),
);
setInputs({ ...getInitValues(), ...data });
}
props.refresh();
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
setAdjustLoading(false);
};
const getPreviewText = () => {
const current = formApiRef.current?.getValue('quota') || 0;
const val = parseInt(adjustQuotaLocal) || 0;
let result;
switch (adjustMode) {
case 'add':
result = current + Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}+${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'subtract':
result = current - Math.abs(val);
return `${t('当前额度')}${renderQuota(current)}-${renderQuota(Math.abs(val))} = ${renderQuota(result)}`;
case 'override':
return `${t('当前额度')}${renderQuota(current)}${renderQuota(val)}`;
default:
return '';
}
};
/* --------------------------- UI --------------------------- */
@@ -305,24 +370,47 @@ const EditUserModal = (props) => {
<Col span={10}>
<Form.InputNumber
field='quota'
label={t('剩余额度')}
placeholder={t('请输入新的剩余额度')}
step={500000}
extraText={renderQuotaWithPrompt(values.quota || 0)}
rules={[{ required: true, message: t('请输入额度') }]}
field='quota_amount'
label={t('金额')}
prefix={getCurrencyConfig().symbol}
precision={6}
step={0.000001}
style={{ width: '100%' }}
readonly
/>
</Col>
<Col span={14}>
<Form.Slot label={t('添加额度')}>
<Form.Slot label={t('调整额度')}>
<Button
icon={<IconPlus />}
onClick={() => setIsModalOpen(true)}
/>
icon={<IconEdit />}
onClick={() => setAdjustModalOpen(true)}
>
{t('调整额度')}
</Button>
</Form.Slot>
</Col>
<Col span={24}>
<div
className='text-xs cursor-pointer'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowQuotaInput((v) => !v)}
>
{showQuotaInput
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showQuotaInput ? 'block' : 'none' }} className='mt-2'>
<Form.InputNumber
field='quota'
label={t('额度')}
placeholder={t('请输入额度')}
style={{ width: '100%' }}
readonly
/>
</div>
</Col>
</Row>
</Card>
)}
@@ -372,81 +460,102 @@ const EditUserModal = (props) => {
formApiRef={formApiRef}
/>
{/* 添加额度模态框 */}
{/* 调整额度模态框 */}
<Modal
centered
visible={addQuotaModalOpen}
onOk={() => {
addLocalQuota();
setIsModalOpen(false);
setAddQuotaLocal('');
setAddAmountLocal('');
}}
visible={adjustModalOpen}
onOk={adjustQuota}
onCancel={() => {
setIsModalOpen(false);
setAdjustModalOpen(false);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
setAdjustMode('add');
}}
confirmLoading={adjustLoading}
closable={null}
title={
<div className='flex items-center'>
<IconPlus className='mr-2' />
{t('添加额度')}
<IconEdit className='mr-2' />
{t('调整额度')}
</div>
}
>
<div className='mb-4'>
{(() => {
const current = formApiRef.current?.getValue('quota') || 0;
return (
<Text type='secondary' className='block mb-2'>
{`${t('新额度:')}${renderQuota(current)} + ${renderQuota(addQuotaLocal)} = ${renderQuota(current + parseInt(addQuotaLocal || 0))}`}
</Text>
);
})()}
<Text type='secondary' className='block mb-2'>
{getPreviewText()}
</Text>
</div>
{getCurrencyConfig().type !== 'TOKENS' && (
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('金额')}</Text>
<Text size='small' type='tertiary'>
{' '}
({t('仅用于换算,实际保存的是额度')})
</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={addAmountLocal}
precision={2}
onChange={(val) => {
setAddAmountLocal(val);
setAddQuotaLocal(
val != null && val !== ''
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
: '',
);
}}
style={{ width: '100%' }}
showClear
/>
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('操作')}</Text>
</div>
)}
<div>
<RadioGroup
type='button'
value={adjustMode}
onChange={(e) => {
setAdjustMode(e.target.value);
setAdjustQuotaLocal('');
setAdjustAmountLocal('');
}}
style={{ width: '100%' }}
>
<Radio value='add'>{t('添加')}</Radio>
<Radio value='subtract'>{t('减少')}</Radio>
<Radio value='override'>{t('覆盖')}</Radio>
</RadioGroup>
</div>
<div className='mb-3'>
<div className='mb-1'>
<Text size='small'>{t('金额')}</Text>
</div>
<InputNumber
prefix={getCurrencyConfig().symbol}
placeholder={t('输入金额')}
value={adjustAmountLocal}
precision={6}
min={adjustMode === 'override' ? undefined : 0}
step={0.000001}
onChange={(val) => {
const amount = val === '' || val == null ? '' : val;
setAdjustAmountLocal(amount);
setAdjustQuotaLocal(
amount === ''
? ''
: adjustMode === 'override'
? displayAmountToQuota(amount)
: displayAmountToQuota(Math.abs(amount)),
);
}}
style={{ width: '100%' }}
showClear
/>
</div>
<div
className='text-xs cursor-pointer mt-2'
style={{ color: 'var(--semi-color-text-2)' }}
onClick={() => setShowAdjustQuotaRaw((v) => !v)}
>
{showAdjustQuotaRaw
? `${t('收起原生额度输入')}`
: `${t('使用原生额度输入')}`}
</div>
<div style={{ display: showAdjustQuotaRaw ? 'block' : 'none' }} className='mt-2'>
<div className='mb-1'>
<Text size='small'>{t('额度')}</Text>
</div>
<InputNumber
placeholder={t('输入额度')}
value={addQuotaLocal}
value={adjustQuotaLocal}
min={adjustMode === 'override' ? undefined : 0}
onChange={(val) => {
setAddQuotaLocal(val);
setAddAmountLocal(
val != null && val !== ''
? Number(
(
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
).toFixed(2),
)
: '',
const quota = val === '' || val == null ? '' : val;
setAdjustQuotaLocal(quota);
setAdjustAmountLocal(
quota === ''
? ''
: adjustMode === 'override'
? Number(quotaToDisplayAmount(quota).toFixed(6))
: Number(quotaToDisplayAmount(Math.abs(quota)).toFixed(6)),
);
}}
style={{ width: '100%' }}
+60 -57
View File
@@ -21,7 +21,6 @@ import React, { useEffect, useRef, useState } from 'react';
import {
Avatar,
Typography,
Tag,
Card,
Button,
Banner,
@@ -32,6 +31,7 @@ import {
Col,
Spin,
Tooltip,
Tag,
Tabs,
TabPane,
} from '@douyinfe/semi-ui';
@@ -88,8 +88,7 @@ const RechargeCard = ({
topupInfo,
onOpenHistory,
enableWaffoTopUp,
waffoTopUp,
waffoPayMethods,
enableWaffoPancakeTopUp,
subscriptionLoading = false,
subscriptionPlans = [],
billingPreference,
@@ -105,6 +104,7 @@ const RechargeCard = ({
const [activeTab, setActiveTab] = useState('topup');
const shouldShowSubscription =
!subscriptionLoading && subscriptionPlans.length > 0;
const regularPayMethods = payMethods || [];
useEffect(() => {
if (initialTabSetRef.current) return;
@@ -227,19 +227,31 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? (
) : enableOnlineTopUp ||
enableStripeTopUp ||
enableCreemTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
{(enableOnlineTopUp ||
enableStripeTopUp ||
enableWaffoTopUp ||
enableWaffoPancakeTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWaffoTopUp}
disabled={
!enableOnlineTopUp &&
!enableStripeTopUp &&
!enableWaffoTopUp &&
!enableWaffoPancakeTopUp
}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
@@ -291,16 +303,27 @@ const RechargeCard = ({
style={{ width: '100%' }}
/>
</Col>
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
{regularPayMethods.length > 0 && (
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
<Space wrap>
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
{regularPayMethods.map((payMethod) => {
const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const isWaffo =
typeof payMethod.type === 'string' &&
payMethod.type.startsWith('waffo:');
const isWaffoPancake =
payMethod.type === 'waffo_pancake';
const disabled =
(!enableOnlineTopUp && !isStripe) ||
(!enableOnlineTopUp &&
!isStripe &&
!isWaffo &&
!isWaffoPancake) ||
(!enableStripeTopUp && isStripe) ||
(!enableWaffoTopUp && isWaffo) ||
(!enableWaffoPancakeTopUp && isWaffoPancake) ||
minTopupVal > Number(topUpCount || 0);
const buttonEl = (
@@ -320,6 +343,21 @@ const RechargeCard = ({
<SiWechat size={18} color='#07C160' />
) : payMethod.type === 'stripe' ? (
<SiStripe size={18} color='#635BFF' />
) : payMethod.icon ? (
<img
src={payMethod.icon}
alt={payMethod.name}
style={{
width: 18,
height: 18,
objectFit: 'contain',
}}
/>
) : payMethod.type === 'waffo_pancake' ? (
<CreditCard
size={18}
color='var(--semi-color-primary)'
/>
) : (
<CreditCard
size={18}
@@ -355,8 +393,8 @@ const RechargeCard = ({
);
})}
</Space>
</Form.Slot>
</Col>
</Form.Slot>
</Col>
)}
</Row>
)}
@@ -388,7 +426,9 @@ const RechargeCard = ({
<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;
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0;
@@ -404,7 +444,7 @@ const RechargeCard = ({
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) { }
} catch (e) {}
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
@@ -455,7 +495,10 @@ const RechargeCard = ({
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
? ((1 - parseFloat(discount)) * 100).toFixed(1)
? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
@@ -482,46 +525,6 @@ const RechargeCard = ({
</Form.Slot>
)}
{/* Waffo 充值区域 */}
{enableWaffoTopUp &&
waffoPayMethods &&
waffoPayMethods.length > 0 && (
<Form.Slot label={t('Waffo 充值')}>
<Space wrap>
{waffoPayMethods.map((method, index) => (
<Button
key={index}
theme='outline'
type='tertiary'
onClick={() => waffoTopUp(index)}
loading={paymentLoading}
icon={
method.icon ? (
<img
src={method.icon}
alt={method.name}
style={{
width: 36,
height: 36,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
size={18}
color='var(--semi-color-text-2)'
/>
)
}
className='!rounded-lg !px-4 !py-2'
>
{method.name}
</Button>
))}
</Space>
</Form.Slot>
)}
{/* Creem 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}>
@@ -442,6 +442,14 @@ const SubscriptionPlansCard = ({
(subscription?.end_time || 0) * 1000,
).toLocaleString()}
</div>
{isActive && subscription?.next_reset_time > 0 && (
<div className='text-xs text-gray-500 mb-2'>
{t('下一次重置')}:{' '}
{new Date(
subscription.next_reset_time * 1000,
).toLocaleString()}
</div>
)}
<div className='text-xs text-gray-500 mb-2'>
{t('总额度')}:{' '}
{totalAmount > 0 ? (
+195 -35
View File
@@ -75,6 +75,8 @@ const TopUp = () => {
const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
const [enableWaffoPancakeTopUp, setEnableWaffoPancakeTopUp] = useState(false);
const [waffoPancakeMinTopUp, setWaffoPancakeMinTopUp] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
@@ -112,6 +114,39 @@ const TopUp = () => {
discount: {},
});
const confirmPayMethods = [
...payMethods,
...waffoPayMethods.map((method, index) => ({
...method,
type: `waffo:${index}`,
min_topup: waffoMinTopUp,
color: method.color || 'rgba(var(--semi-primary-5), 1)',
})),
];
const getPayMethodConfig = (payment) =>
confirmPayMethods.find((method) => method.type === payment);
const getPaymentMinTopUp = (payment) => {
const configuredMinTopUp = Number(getPayMethodConfig(payment)?.min_topup);
return Number.isFinite(configuredMinTopUp) && configuredMinTopUp > 0
? configuredMinTopUp
: minTopUp;
};
const requestAmountByPayment = async (payment, value) => {
if (payment === 'stripe') {
return getStripeAmount(value);
}
if (payment === 'waffo_pancake') {
return getWaffoPancakeAmount(value);
}
if (typeof payment === 'string' && payment.startsWith('waffo:')) {
return getWaffoAmount(value);
}
return getAmount(value);
};
const topUp = async () => {
if (redemptionCode === '') {
showInfo(t('请输入兑换码!'));
@@ -162,6 +197,16 @@ const TopUp = () => {
showError(t('管理员未开启Stripe充值!'));
return;
}
} else if (payment === 'waffo_pancake') {
if (!enableWaffoPancakeTopUp) {
showError(t('管理员未开启 Waffo Pancake 充值!'));
return;
}
} else if (payment.startsWith('waffo:')) {
if (!enableWaffoTopUp) {
showError(t('管理员未开启 Waffo 充值!'));
return;
}
} else {
if (!enableOnlineTopUp) {
showError(t('管理员未开启在线充值!'));
@@ -172,14 +217,11 @@ const TopUp = () => {
setPayWay(payment);
setPaymentLoading(true);
try {
if (payment === 'stripe') {
await getStripeAmount();
} else {
await getAmount();
}
const selectedMinTopUp = getPaymentMinTopUp(payment);
await requestAmountByPayment(payment);
if (topUpCount < minTopUp) {
showError(t('充值数量不能小于') + minTopUp);
if (topUpCount < selectedMinTopUp) {
showError(t('充值数量不能小于') + selectedMinTopUp);
return;
}
setOpen(true);
@@ -191,6 +233,29 @@ const TopUp = () => {
};
const onlineTopUp = async () => {
if (payWay === 'waffo_pancake') {
setConfirmLoading(true);
try {
await waffoPancakeTopUp();
} finally {
setOpen(false);
setConfirmLoading(false);
}
return;
}
if (payWay.startsWith('waffo:')) {
const payMethodIndex = Number(payWay.split(':')[1]);
setConfirmLoading(true);
try {
await waffoTopUp(Number.isFinite(payMethodIndex) ? payMethodIndex : 0);
} finally {
setOpen(false);
setConfirmLoading(false);
}
return;
}
if (payWay === 'stripe') {
// Stripe 支付处理
if (amount === 0) {
@@ -317,32 +382,122 @@ const TopUp = () => {
const waffoTopUp = async (payMethodIndex) => {
try {
if (topUpCount < waffoMinTopUp) {
showError(t('充值数量不能小于') + waffoMinTopUp);
return;
}
setPaymentLoading(true);
const requestBody = {
amount: parseInt(topUpCount),
};
if (payMethodIndex != null) {
requestBody.pay_method_index = payMethodIndex;
}
const res = await API.post('/api/user/waffo/pay', requestBody);
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success' && data?.payment_url) {
window.open(data.payment_url, '_blank');
} else {
showError(data || t('支付请求失败'));
}
if (topUpCount < waffoMinTopUp) {
showError(t('充值数量不能小于') + waffoMinTopUp);
return;
}
setPaymentLoading(true);
const requestBody = {
amount: parseInt(topUpCount),
};
if (payMethodIndex != null) {
requestBody.pay_method_index = payMethodIndex;
}
const res = await API.post('/api/user/waffo/pay', requestBody);
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success' && data?.payment_url) {
window.open(data.payment_url, '_blank');
} else {
showError(res);
showError(data || t('支付请求失败'));
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
setPaymentLoading(false);
}
};
const getWaffoAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/waffo/amount', {
amount: parseInt(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
};
const waffoPancakeTopUp = async () => {
const minTopUpValue = Number(waffoPancakeMinTopUp || 1);
if (topUpCount < minTopUpValue) {
showError(t('充值数量不能小于') + minTopUpValue);
return;
}
setPaymentLoading(true);
try {
const res = await API.post('/api/user/waffo-pancake/pay', {
amount: parseInt(topUpCount),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
const checkoutUrl = data?.checkout_url || '';
if (checkoutUrl) {
window.open(checkoutUrl, '_blank');
} else {
showError(t('支付请求失败'));
}
} else {
const errorMsg =
typeof data === 'string' ? data : message || t('支付请求失败');
showError(errorMsg);
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
}
};
const getWaffoPancakeAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/waffo-pancake/amount', {
amount: parseInt(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
};
@@ -481,20 +636,26 @@ const TopUp = () => {
const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false;
const enableCreemTopUp = data.enable_creem_topup || false;
const enableWaffoTopUp = data.enable_waffo_topup || false;
const enableWaffoPancakeTopUp =
data.enable_waffo_pancake_topup || false;
const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: data.enable_waffo_topup
: enableWaffoTopUp
? data.waffo_min_topup
: enableWaffoPancakeTopUp
? data.waffo_pancake_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp);
const enableWaffoTopUp = data.enable_waffo_topup || false;
setEnableWaffoTopUp(enableWaffoTopUp);
setWaffoPayMethods(data.waffo_pay_methods || []);
setWaffoMinTopUp(data.waffo_min_topup || 1);
setEnableWaffoPancakeTopUp(enableWaffoPancakeTopUp);
setWaffoPancakeMinTopUp(data.waffo_pancake_min_topup || 1);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
@@ -739,7 +900,7 @@ const TopUp = () => {
amountLoading={amountLoading}
renderAmount={renderAmount}
payWay={payWay}
payMethods={payMethods}
payMethods={confirmPayMethods}
amountNumber={amount}
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/>
@@ -789,8 +950,7 @@ const TopUp = () => {
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
enableWaffoTopUp={enableWaffoTopUp}
waffoTopUp={waffoTopUp}
waffoPayMethods={waffoPayMethods}
enableWaffoPancakeTopUp={enableWaffoPancakeTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
@@ -804,7 +964,7 @@ const TopUp = () => {
setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount}
amountLoading={amountLoading}
payMethods={payMethods}
payMethods={confirmPayMethods}
preTopUp={preTopUp}
paymentLoading={paymentLoading}
payWay={payWay}
@@ -140,6 +140,17 @@ const PaymentConfirmModal = ({
size={16}
color='#635BFF'
/>
) : payMethod.icon ? (
<img
src={payMethod.icon}
alt={payMethod.name}
className='mr-2'
style={{
width: 16,
height: 16,
objectFit: 'contain',
}}
/>
) : (
<CreditCard
className='mr-2'
@@ -161,6 +161,16 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
const columns = useMemo(() => {
const baseColumns = [
...(userIsAdmin
? [
{
title: t('用户ID'),
dataIndex: 'user_id',
key: 'user_id',
render: (userId) => <Text>{userId ?? '-'}</Text>,
},
]
: []),
{
title: t('订单号'),
dataIndex: 'trade_no',