Merge branch 'origin/main' into nightly

Resolve 4 conflicts:
- relay/compatible_handler.go: accept main's refactor (postConsumeQuota -> service.PostTextConsumeQuota)
- service/quota.go: accept main's PostClaudeConsumeQuota deletion, keep nightly's tiered billing in PostWssConsumeQuota and PostAudioConsumeQuota
- web/src/i18n/locales/{en,zh-CN}.json: merge both sets of translation keys

Post-merge integration:
- Add tiered billing (TryTieredSettle, InjectTieredBillingInfo) to PostTextConsumeQuota
- Update tool pricing calls to use nightly's generic GetToolPriceForModel/GetToolPrice API
This commit is contained in:
CaIon
2026-04-02 00:39:13 +08:00
122 changed files with 8307 additions and 3923 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ const OAuth2Callback = (props) => {
return;
}
if (message === 'bind') {
if (data?.action === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/personal');
} else {
+20 -16
View File
@@ -221,23 +221,27 @@ const FooterBar = () => {
return (
<div className='w-full'>
{footer ? (
<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>
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
<div
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='text-sm flex-shrink-0'>
<span className='!text-semi-color-text-1'>
{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>
</div>
</footer>
) : (
customFooter
)}
@@ -23,6 +23,7 @@ import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralP
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 { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -66,7 +67,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析TopupGroupRatio出错:', error);
newInputs[item.key] = item.value;
}
break;
@@ -78,7 +78,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析AmountOptions出错:', error);
newInputs['AmountOptions'] = item.value;
}
break;
@@ -90,7 +89,6 @@ const PaymentSetting = () => {
2,
);
} catch (error) {
console.error('解析AmountDiscount出错:', error);
newInputs['AmountDiscount'] = item.value;
}
break;
@@ -146,6 +144,9 @@ const PaymentSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
@@ -306,9 +306,9 @@ const PersonalSetting = () => {
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
);
const res = await API.post('/api/oauth/wechat/bind', {
code: inputs.wechat_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('微信账户绑定成功!'));
@@ -378,9 +378,10 @@ const PersonalSetting = () => {
return;
}
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
);
const res = await API.post('/api/oauth/email/bind', {
email: inputs.email,
code: inputs.email_verification_code,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('邮箱账户绑定成功!'));
@@ -108,7 +108,7 @@ const SystemSetting = () => {
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
'fetch_setting.apply_ip_filter_for_domain': false,
'fetch_setting.apply_ip_filter_for_domain': true,
});
const [originInputs, setOriginInputs] = useState({});
@@ -847,7 +847,7 @@ const SystemSetting = () => {
}
style={{ marginBottom: 8 }}
>
{t('对域名启用 IP 过滤(实验性')}
{t('对域名启用 IP 过滤(推荐开启')}
</Form.Checkbox>
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
@@ -538,19 +538,24 @@ export const getChannelsColumns = ({
</Tooltip>
<Tooltip
content={
t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
record.type === 57
? t('查看 Codex 帐号信息与用量')
: t('剩余额度') +
': ' +
renderQuotaWithAmount(record.balance) +
t(',点击更新')
}
>
<Tag
color='white'
type='ghost'
color={record.type === 57 ? 'light-blue' : 'white'}
type={record.type === 57 ? 'light' : 'ghost'}
shape='circle'
className={record.type === 57 ? 'cursor-pointer' : ''}
onClick={() => updateChannelBalance(record)}
>
{renderQuotaWithAmount(record.balance)}
{record.type === 57
? t('帐号信息')
: renderQuotaWithAmount(record.balance)}
</Tag>
</Tooltip>
</Space>
@@ -22,9 +22,11 @@ import {
Modal,
Button,
Progress,
Tag,
Typography,
Spin,
Tag,
Descriptions,
Collapse,
} from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
@@ -43,6 +45,68 @@ const pickStrokeColor = (percent) => {
return '#3b82f6';
};
const normalizePlanType = (value) => {
if (value == null) return '';
return String(value).trim().toLowerCase();
};
const getWindowDurationSeconds = (windowData) => {
const value = Number(windowData?.limit_window_seconds);
if (!Number.isFinite(value) || value <= 0) return null;
return value;
};
const classifyWindowByDuration = (windowData) => {
const seconds = getWindowDurationSeconds(windowData);
if (seconds == null) return null;
return seconds >= 24 * 60 * 60 ? 'weekly' : 'fiveHour';
};
const resolveRateLimitWindows = (data) => {
const rateLimit = data?.rate_limit ?? {};
const primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;
const windows = [primary, secondary].filter(Boolean);
const planType = normalizePlanType(data?.plan_type ?? rateLimit?.plan_type);
let fiveHourWindow = null;
let weeklyWindow = null;
for (const windowData of windows) {
const bucket = classifyWindowByDuration(windowData);
if (bucket === 'fiveHour' && !fiveHourWindow) {
fiveHourWindow = windowData;
continue;
}
if (bucket === 'weekly' && !weeklyWindow) {
weeklyWindow = windowData;
}
}
if (planType === 'free') {
if (!weeklyWindow) {
weeklyWindow = primary ?? secondary ?? null;
}
return { fiveHourWindow: null, weeklyWindow };
}
if (!fiveHourWindow && !weeklyWindow) {
return {
fiveHourWindow: primary ?? null,
weeklyWindow: secondary ?? null,
};
}
if (!fiveHourWindow) {
fiveHourWindow = windows.find((windowData) => windowData !== weeklyWindow) ?? null;
}
if (!weeklyWindow) {
weeklyWindow = windows.find((windowData) => windowData !== fiveHourWindow) ?? null;
}
return { fiveHourWindow, weeklyWindow };
};
const formatDurationSeconds = (seconds, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const s = Number(seconds);
@@ -66,8 +130,93 @@ const formatUnixSeconds = (unixSeconds) => {
}
};
const getDisplayText = (value) => {
if (value == null) return '';
return String(value).trim();
};
const formatAccountTypeLabel = (value, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const normalized = normalizePlanType(value);
switch (normalized) {
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
case 'team':
return 'Team';
case 'enterprise':
return 'Enterprise';
default:
return getDisplayText(value) || tt('未识别');
}
};
const getAccountTypeTagColor = (value) => {
const normalized = normalizePlanType(value);
switch (normalized) {
case 'enterprise':
return 'green';
case 'team':
return 'cyan';
case 'pro':
return 'blue';
case 'plus':
return 'violet';
case 'free':
return 'amber';
default:
return 'grey';
}
};
const resolveUsageStatusTag = (t, rateLimit) => {
const tt = typeof t === 'function' ? t : (v) => v;
if (!rateLimit || Object.keys(rateLimit).length === 0) {
return <Tag color='grey'>{tt('待确认')}</Tag>;
}
if (rateLimit?.allowed && !rateLimit?.limit_reached) {
return <Tag color='green'>{tt('可用')}</Tag>;
}
return <Tag color='red'>{tt('受限')}</Tag>;
};
const AccountInfoValue = ({ t, value, onCopy, monospace = false }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const text = getDisplayText(value);
const hasValue = text !== '';
return (
<div className='flex min-w-0 items-start justify-between gap-2'>
<div
className={`min-w-0 flex-1 break-all text-xs leading-5 text-semi-color-text-1 ${
monospace ? 'font-mono' : ''
}`}
>
{hasValue ? text : '-'}
</div>
<Button
size='small'
type='tertiary'
theme='borderless'
className='shrink-0 px-1 text-xs'
disabled={!hasValue}
onClick={() => onCopy?.(text)}
>
{tt('复制')}
</Button>
</div>
);
};
const RateLimitWindowCard = ({ t, title, windowData }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const hasWindowData =
!!windowData &&
typeof windowData === 'object' &&
Object.keys(windowData).length > 0;
const percent = clampPercent(windowData?.used_percent ?? 0);
const resetAt = windowData?.reset_at;
const resetAfterSeconds = windowData?.reset_after_seconds;
@@ -83,26 +232,30 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
</Text>
</div>
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
{hasWindowData ? (
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
) : (
<div className='mt-3 text-sm text-semi-color-text-2'>-</div>
)}
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
<div>
{tt('已使用:')}
{percent}%
{hasWindowData ? `${percent}%` : '-'}
</div>
<div>
{tt('距离重置:')}
{formatDurationSeconds(resetAfterSeconds, tt)}
{hasWindowData ? formatDurationSeconds(resetAfterSeconds, tt) : '-'}
</div>
<div>
{tt('窗口:')}
{formatDurationSeconds(limitWindowSeconds, tt)}
{hasWindowData ? formatDurationSeconds(limitWindowSeconds, tt) : '-'}
</div>
</div>
</div>
@@ -111,84 +264,139 @@ const RateLimitWindowCard = ({ t, title, windowData }) => {
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 primary = rateLimit?.primary_window ?? null;
const secondary = rateLimit?.secondary_window ?? null;
const allowed = !!rateLimit?.allowed;
const limitReached = !!rateLimit?.limit_reached;
const { fiveHourWindow, weeklyWindow } = resolveRateLimitWindows(data);
const upstreamStatus = payload?.upstream_status;
const statusTag =
allowed && !limitReached ? (
<Tag color='green'>{tt('可用')}</Tag>
) : (
<Tag color='red'>{tt('受限')}</Tag>
);
const accountType = data?.plan_type ?? rateLimit?.plan_type;
const accountTypeLabel = formatAccountTypeLabel(accountType, tt);
const accountTypeTagColor = getAccountTypeTagColor(accountType);
const statusTag = resolveUsageStatusTag(tt, rateLimit);
const userId = data?.user_id;
const email = data?.email;
const accountId = data?.account_id;
const errorMessage =
payload?.success === false ? getDisplayText(payload?.message) || tt('获取用量失败') : '';
const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
return (
<div className='flex flex-col gap-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'>
{tt('渠道:')}
{record?.name || '-'} ({tt('编号:')}
{record?.id || '-'})
</Text>
<div className='flex items-center gap-2'>
{statusTag}
<Button
size='small'
type='tertiary'
theme='borderless'
onClick={onRefresh}
>
<div className='flex flex-col gap-4'>
{errorMessage && (
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{errorMessage}
</div>
)}
<div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='text-xs font-medium text-semi-color-text-2'>
{tt('Codex 帐号')}
</div>
<div className='mt-2 flex flex-wrap items-center gap-2'>
<Tag
color={accountTypeTagColor}
type='light'
shape='circle'
size='large'
className='font-semibold'
>
{accountTypeLabel}
</Tag>
{statusTag}
<Tag color='grey' type='light' shape='circle'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Tag>
</div>
</div>
<Button size='small' type='tertiary' theme='outline' onClick={onRefresh}>
{tt('刷新')}
</Button>
</div>
<div className='mt-2 rounded-lg bg-semi-color-fill-0 px-3 py-2'>
<Descriptions>
<Descriptions.Item itemKey='User ID'>
<AccountInfoValue
t={tt}
value={userId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
<Descriptions.Item itemKey={tt('邮箱')}>
<AccountInfoValue t={tt} value={email} onCopy={onCopy} />
</Descriptions.Item>
<Descriptions.Item itemKey='Account ID'>
<AccountInfoValue
t={tt}
value={accountId}
onCopy={onCopy}
monospace={true}
/>
</Descriptions.Item>
</Descriptions>
</div>
<div className='mt-2 text-xs text-semi-color-text-2'>
{tt('渠道:')}
{record?.name || '-'} ({tt('编号:')}
{record?.id || '-'})
</div>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Text type='tertiary' size='small'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Text>
<div>
<div className='mb-2'>
<div className='text-sm font-semibold text-semi-color-text-0'>
{tt('额度窗口')}
</div>
<Text type='tertiary' size='small'>
{tt('用于观察当前帐号在 Codex 上游的限额使用情况')}
</Text>
</div>
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<RateLimitWindowCard
t={tt}
title={tt('5小时窗口')}
windowData={primary}
windowData={fiveHourWindow}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={secondary}
windowData={weeklyWindow}
/>
</div>
<div>
<div className='mb-1 flex items-center justify-between gap-2'>
<div className='text-sm font-medium'>{tt('原始 JSON')}</div>
<Button
size='small'
type='primary'
theme='outline'
onClick={() => onCopy?.(rawText)}
disabled={!rawText}
>
{tt('复制')}
</Button>
</div>
<pre className='max-h-[50vh] overflow-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
{rawText}
</pre>
</div>
<Collapse
activeKey={showRawJson ? ['raw-json'] : []}
onChange={(activeKey) => {
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
setShowRawJson(keys.includes('raw-json'));
}}
>
<Collapse.Panel header={tt('原始 JSON')} itemKey='raw-json'>
<div className='mb-2 flex justify-end'>
<Button
size='small'
type='primary'
theme='outline'
onClick={() => onCopy?.(rawText)}
disabled={!rawText}
>
{tt('复制')}
</Button>
</div>
<pre className='max-h-[50vh] overflow-y-auto rounded-lg bg-semi-color-fill-0 p-3 text-xs text-semi-color-text-0'>
{rawText}
</pre>
</Collapse.Panel>
</Collapse>
</div>
);
};
@@ -283,7 +491,7 @@ export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
Modal.info({
title: tt('Codex 用量'),
title: tt('Codex 帐号与用量'),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
File diff suppressed because it is too large Load Diff
@@ -116,6 +116,8 @@ const renderTokenKey = (
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
) => {
const revealed = !!showKeys[record.id];
const loading = !!loadingTokenKeys[record.id];
@@ -145,18 +147,35 @@ const renderTokenKey = (
await toggleTokenVisibility(record);
}}
/>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
await copyTokenKey(record);
}}
/>
<Dropdown
trigger='click'
position='bottomRight'
clickToHide
menu={[
{
node: 'item',
name: t('复制密钥'),
onClick: () => copyTokenKey(record),
},
{
node: 'item',
name: t('复制连接信息'),
onClick: () => copyTokenConnectionString(record),
},
]}
>
<Button
theme='borderless'
size='small'
type='tertiary'
icon={<IconCopy />}
loading={loading}
aria-label='copy token key'
onClick={async (e) => {
e.stopPropagation();
}}
/>
</Dropdown>
</div>
}
/>
@@ -444,6 +463,7 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -484,6 +504,8 @@ export const getTokensColumns = ({
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
t,
),
},
{
@@ -43,6 +43,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -60,6 +61,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -73,6 +75,7 @@ const TokensTable = (tokensData) => {
loadingTokenKeys,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
manageToken,
onOpenLink,
setEditingToken,
@@ -0,0 +1,54 @@
/*
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 { Typography } from '@douyinfe/semi-ui';
const { Text } = Typography;
const ParamOverrideEntry = ({ count, onOpen, t }) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'wrap',
}}
>
<Text
type='tertiary'
size='small'
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{t('{{count}} 项操作', { count })}
</Text>
<Text
link
size='small'
style={{ fontWeight: 600 }}
onClick={onOpen}
>
{t('查看详情')}
</Text>
</div>
);
};
export default React.memo(ParamOverrideEntry);
@@ -25,6 +25,7 @@ import LogsFilters from './UsageLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import UserInfoModal from './modals/UserInfoModal';
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
import ParamOverrideModal from './modals/ParamOverrideModal';
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -39,6 +40,7 @@ const LogsPage = () => {
<ColumnSelectorModal {...logsData} />
<UserInfoModal {...logsData} />
<ChannelAffinityUsageCacheModal {...logsData} />
<ParamOverrideModal {...logsData} />
{/* Main Content */}
<CardPro
@@ -0,0 +1,272 @@
/*
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, { useMemo } from 'react';
import {
Modal,
Button,
Empty,
Divider,
Typography,
} from '@douyinfe/semi-ui';
import { IconCopy } from '@douyinfe/semi-icons';
import { copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const parseAuditLine = (line) => {
if (typeof line !== 'string') {
return null;
}
const firstSpaceIndex = line.indexOf(' ');
if (firstSpaceIndex <= 0) {
return { action: line, content: line };
}
return {
action: line.slice(0, firstSpaceIndex),
content: line.slice(firstSpaceIndex + 1),
};
};
const getActionLabel = (action, t) => {
switch ((action || '').toLowerCase()) {
case 'set':
return t('设置');
case 'delete':
return t('删除');
case 'copy':
return t('复制');
case 'move':
return t('移动');
case 'append':
return t('追加');
case 'prepend':
return t('前置');
case 'trim_prefix':
return t('去前缀');
case 'trim_suffix':
return t('去后缀');
case 'ensure_prefix':
return t('保前缀');
case 'ensure_suffix':
return t('保后缀');
case 'trim_space':
return t('去空格');
case 'to_lower':
return t('转小写');
case 'to_upper':
return t('转大写');
case 'replace':
return t('替换');
case 'regex_replace':
return t('正则替换');
case 'set_header':
return t('设请求头');
case 'delete_header':
return t('删请求头');
case 'copy_header':
return t('复制请求头');
case 'move_header':
return t('移动请求头');
case 'pass_headers':
return t('透传请求头');
case 'sync_fields':
return t('同步字段');
case 'return_error':
return t('返回错误');
default:
return action;
}
};
const ParamOverrideModal = ({
showParamOverrideModal,
setShowParamOverrideModal,
paramOverrideTarget,
t,
}) => {
const lines = Array.isArray(paramOverrideTarget?.lines)
? paramOverrideTarget.lines
: [];
const parsedLines = useMemo(() => {
return lines.map(parseAuditLine);
}, [lines]);
const copyAll = async () => {
const content = lines.join('\n');
if (!content) {
return;
}
if (await copy(content)) {
showSuccess(t('参数覆盖已复制'));
return;
}
showError(t('无法复制到剪贴板,请手动复制'));
};
return (
<Modal
title={t('参数覆盖详情')}
visible={showParamOverrideModal}
onCancel={() => setShowParamOverrideModal(false)}
footer={null}
centered
closable
maskClosable
width={640}
>
<div style={{ padding: '8px 20px 20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
marginBottom: 10,
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ marginBottom: 4 }}>
<Text style={{ fontWeight: 600 }}>
{t('{{count}} 项操作', { count: lines.length })}
</Text>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
fontSize: 12,
color: 'var(--semi-color-text-2)',
}}
>
{paramOverrideTarget?.modelName ? (
<Text type='tertiary' size='small'>
{paramOverrideTarget.modelName}
</Text>
) : null}
{paramOverrideTarget?.requestId ? (
<Text type='tertiary' size='small'>
{t('Request ID')}: {paramOverrideTarget.requestId}
</Text>
) : null}
{paramOverrideTarget?.requestPath ? (
<Text type='tertiary' size='small'>
{t('请求路径')}: {paramOverrideTarget.requestPath}
</Text>
) : null}
</div>
</div>
<Button
icon={<IconCopy />}
theme='borderless'
type='tertiary'
size='small'
onClick={copyAll}
disabled={lines.length === 0}
>
{t('复制')}
</Button>
</div>
<Divider margin='12px' />
{lines.length === 0 ? (
<Empty
description={t('暂无参数覆盖记录')}
style={{ padding: '24px 0 8px' }}
/>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
maxHeight: '56vh',
overflowY: 'auto',
paddingRight: 2,
}}
>
{parsedLines.map((item, index) => {
if (!item) {
return null;
}
return (
<div
key={`${item.action}-${index}`}
style={{
padding: '10px 12px',
borderRadius: 10,
border: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-fill-0)',
display: 'flex',
gap: 12,
alignItems: 'flex-start',
}}
>
<div
style={{
flex: '0 0 auto',
minWidth: 74,
}}
>
<Text
style={{
display: 'inline-block',
fontSize: 11,
fontWeight: 700,
lineHeight: '20px',
padding: '0 8px',
borderRadius: 999,
background: 'rgba(var(--semi-blue-5), 0.12)',
color: 'var(--semi-color-primary)',
}}
>
{getActionLabel(item.action, t)}
</Text>
</div>
<Text
style={{
flex: 1,
minWidth: 0,
fontFamily:
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--semi-color-text-0)',
}}
>
{item.content}
</Text>
</div>
);
})}
</div>
)}
</div>
</Modal>
);
};
export default ParamOverrideModal;
+50 -11
View File
@@ -87,6 +87,9 @@ const RechargeCard = ({
statusLoading,
topupInfo,
onOpenHistory,
enableWaffoTopUp,
waffoTopUp,
waffoPayMethods,
subscriptionLoading = false,
subscriptionPlans = [],
billingPreference,
@@ -224,19 +227,19 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp || enableWaffoTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp}
disabled={!enableOnlineTopUp && !enableStripeTopUp && !enableWaffoTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
@@ -288,11 +291,11 @@ 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('选择支付方式')}>
{payMethods && payMethods.length > 0 ? (
<Space wrap>
{payMethods.map((payMethod) => {
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
@@ -352,17 +355,13 @@ const RechargeCard = ({
);
})}
</Space>
) : (
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
{t('暂无可用的支付方式,请联系管理员配置')}
</div>
)}
</Form.Slot>
</Col>
)}
</Row>
)}
{(enableOnlineTopUp || enableStripeTopUp) && (
{(enableOnlineTopUp || enableStripeTopUp || enableWaffoTopUp) && (
<Form.Slot
label={
<div className='flex items-center gap-2'>
@@ -483,6 +482,46 @@ 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 充值')}>
+61 -10
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useContext, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
API,
showError,
@@ -41,6 +42,7 @@ import TopupHistoryModal from './modals/TopupHistoryModal';
const TopUp = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
@@ -69,6 +71,11 @@ const TopUp = () => {
const [creemOpen, setCreemOpen] = useState(false);
const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
// Waffo 相关状态
const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [payWay, setPayWay] = useState('');
@@ -256,7 +263,6 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setOpen(false);
@@ -302,7 +308,6 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setCreemOpen(false);
@@ -310,6 +315,37 @@ 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('支付请求失败'));
}
} else {
showError(res);
}
} catch (e) {
showError(t('支付请求失败'));
} finally {
setPaymentLoading(false);
}
};
const processCreemCallback = (data) => {
// 与 Stripe 保持一致的实现方式
window.open(data.checkout_url, '_blank');
@@ -449,17 +485,21 @@ const TopUp = () => {
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
: data.enable_waffo_topup
? data.waffo_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);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
// 设置 Creem 产品
try {
console.log(' data is ?', data);
console.log(' creem products is ?', data.creem_products);
const products = JSON.parse(data.creem_products || '[]');
setCreemProducts(products);
} catch (e) {
@@ -474,7 +514,6 @@ const TopUp = () => {
// 初始化显示实付金额
getAmount(minTopUpValue);
} catch (e) {
console.log('解析支付方式失败:', e);
setPayMethods([]);
}
@@ -487,10 +526,10 @@ const TopUp = () => {
setPresetAmounts(customPresets);
}
} else {
console.error('获取充值配置失败:', data);
showError(data || t('获取充值配置失败'));
}
} catch (error) {
console.error('获取充值配置异常:', error);
showError(t('获取充值配置异常'));
}
};
@@ -531,6 +570,15 @@ const TopUp = () => {
showSuccess(t('邀请链接已复制到剪切板'));
};
// URL 参数自动打开账单弹窗(支付回跳时触发)
useEffect(() => {
if (searchParams.get('show_history') === 'true') {
setOpenHistory(true);
searchParams.delete('show_history');
setSearchParams(searchParams, { replace: true });
}
}, []);
useEffect(() => {
// 始终获取最新用户数据,确保余额等统计信息准确
getUserQuota().then();
@@ -587,7 +635,7 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
// amount fetch failed silently
}
setAmountLoading(false);
};
@@ -613,7 +661,7 @@ const TopUp = () => {
showError(res);
}
} catch (err) {
console.log(err);
// amount fetch failed silently
} finally {
setAmountLoading(false);
}
@@ -740,6 +788,9 @@ const TopUp = () => {
enableCreemTopUp={enableCreemTopUp}
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
enableWaffoTopUp={enableWaffoTopUp}
waffoTopUp={waffoTopUp}
waffoPayMethods={waffoPayMethods}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
@@ -37,13 +37,13 @@ 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: '待支付' },
failed: { type: 'danger', key: '失败' },
expired: { type: 'danger', key: '已过期' },
};
@@ -51,6 +51,7 @@ const STATUS_CONFIG = {
const PAYMENT_METHOD_MAP = {
stripe: 'Stripe',
creem: 'Creem',
waffo: 'Waffo',
alipay: '支付宝',
wxpay: '微信',
};
@@ -62,7 +63,6 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const isMobile = useIsMobile();
const loadTopups = async (currentPage, currentPageSize) => {
@@ -82,7 +82,6 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
Toast.error({ content: message || t('加载失败') });
}
} catch (error) {
console.error('Load topups error:', error);
Toast.error({ content: t('加载账单失败') });
} finally {
setLoading(false);
@@ -214,17 +213,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
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>
);
const actions = [];
if (record.status === 'pending') {
actions.push(
<Button
key="complete"
size='small'
type='primary'
theme='outline'
onClick={() => confirmAdminComplete(record.trade_no)}
>
{t('补单')}
</Button>
);
}
return actions.length > 0 ? <>{actions}</> : null;
},
});
}
+2 -2
View File
@@ -68,7 +68,7 @@ export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
param_override_template: CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
value_regex: '',
ttl_seconds: 0,
skip_retry_on_failure: false,
skip_retry_on_failure: true,
include_using_group: true,
include_rule_name: true,
},
@@ -80,7 +80,7 @@ export const CHANNEL_AFFINITY_RULE_TEMPLATES = {
param_override_template: CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
value_regex: '',
ttl_seconds: 0,
skip_retry_on_failure: false,
skip_retry_on_failure: true,
include_using_group: true,
include_rule_name: true,
},
+38 -18
View File
@@ -36,6 +36,20 @@ export let API = axios.create({
},
});
function redirectToOAuthUrl(url, options = {}) {
const { openInNewTab = false } = options;
const targetUrl = typeof url === 'string' ? url : url.toString();
if (openInNewTab) {
window.open(targetUrl, '_blank');
return;
}
window.location.assign(targetUrl);
}
function patchAPIInstance(instance) {
const originalGet = instance.get.bind(instance);
const inFlightGetRequests = new Map();
@@ -249,7 +263,7 @@ export async function onDiscordOAuthClicked(client_id, options = {}) {
const redirect_uri = `${window.location.origin}/oauth/discord`;
const response_type = 'code';
const scope = 'identify+openid';
window.open(
redirectToOAuthUrl(
`https://discord.com/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`,
);
}
@@ -268,17 +282,13 @@ export async function onOIDCClicked(
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
if (openInNewTab) {
window.open(url.toString(), '_blank');
} else {
window.location.href = url.toString();
}
redirectToOAuthUrl(url, { openInNewTab });
}
export async function onGitHubOAuthClicked(github_client_id, options = {}) {
const state = await prepareOAuthState(options);
if (!state) return;
window.open(
redirectToOAuthUrl(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
@@ -289,7 +299,7 @@ export async function onLinuxDOOAuthClicked(
) {
const state = await prepareOAuthState(options);
if (!state) return;
window.open(
redirectToOAuthUrl(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
@@ -307,29 +317,39 @@ export async function onLinuxDOOAuthClicked(
export async function onCustomOAuthClicked(provider, options = {}) {
const state = await prepareOAuthState(options);
if (!state) return;
try {
const redirect_uri = `${window.location.origin}/oauth/${provider.slug}`;
// Check if authorization_endpoint is a full URL or relative path
let authUrl;
if (provider.authorization_endpoint.startsWith('http://') ||
provider.authorization_endpoint.startsWith('https://')) {
if (
provider.authorization_endpoint.startsWith('http://') ||
provider.authorization_endpoint.startsWith('https://')
) {
authUrl = new URL(provider.authorization_endpoint);
} else {
// Relative path - this is a configuration error, show error message
console.error('Custom OAuth authorization_endpoint must be a full URL:', provider.authorization_endpoint);
showError('OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)');
console.error(
'Custom OAuth authorization_endpoint must be a full URL:',
provider.authorization_endpoint,
);
showError(
'OAuth 配置错误:授权端点必须是完整的 URL(以 http:// 或 https:// 开头)',
);
return;
}
authUrl.searchParams.set('client_id', provider.client_id);
authUrl.searchParams.set('redirect_uri', redirect_uri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', provider.scopes || 'openid profile email');
authUrl.searchParams.set(
'scope',
provider.scopes || 'openid profile email',
);
authUrl.searchParams.set('state', state);
window.open(authUrl.toString());
redirectToOAuthUrl(authUrl);
} catch (error) {
console.error('Failed to initiate custom OAuth:', error);
showError('OAuth 登录失败:' + (error.message || '未知错误'));
+38
View File
@@ -80,3 +80,41 @@ export function getServerAddress() {
return serverAddress;
}
export const CHANNEL_CONN_CLIPBOARD_TYPE = 'newapi_channel_conn';
/**
* @param {string} key - 完整的 API key(含 sk- 前缀)
* @param {string} url - 服务器地址
* @returns {string} JSON 格式的连接字符串
*/
export function encodeChannelConnectionString(key, url) {
return JSON.stringify({
_type: CHANNEL_CONN_CLIPBOARD_TYPE,
key,
url,
});
}
/**
* @param {string} text - 剪贴板文本
* @returns {{ key: string, url: string } | null}
*/
export function parseChannelConnectionString(text) {
if (!text || typeof text !== 'string') return null;
try {
const parsed = JSON.parse(text.trim());
if (
parsed &&
typeof parsed === 'object' &&
parsed._type === CHANNEL_CONN_CLIPBOARD_TYPE &&
typeof parsed.key === 'string' &&
typeof parsed.url === 'string'
) {
return { key: parsed.key, url: parsed.url };
}
} catch {
// not valid JSON
}
return null;
}
+13 -1
View File
@@ -29,7 +29,11 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
import {
fetchTokenKey as fetchTokenKeyById,
getServerAddress,
encodeChannelConnectionString,
} from '../../helpers/token';
export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
const { t } = useTranslation();
@@ -198,6 +202,13 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
await copyText(`sk-${fullKey}`);
};
const copyTokenConnectionString = async (record) => {
const fullKey = await fetchTokenKey(record);
const serverUrl = getServerAddress();
const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
await copyText(connStr);
};
// Open link function for chat integrations
const onOpenLink = async (type, url, record) => {
const fullKey = await fetchTokenKey(record);
@@ -465,6 +476,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
fetchTokenKey,
toggleTokenVisibility,
copyTokenKey,
copyTokenConnectionString,
onOpenLink,
manageToken,
searchTokens,
+62
View File
@@ -40,6 +40,7 @@ import {
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../../constants';
import { useTableCompactMode } from '../common/useTableCompactMode';
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
export const useLogsData = () => {
const { t } = useTranslation();
@@ -182,6 +183,8 @@ export const useLogsData = () => {
] = useState(false);
const [channelAffinityUsageCacheTarget, setChannelAffinityUsageCacheTarget] =
useState(null);
const [showParamOverrideModal, setShowParamOverrideModal] = useState(false);
const [paramOverrideTarget, setParamOverrideTarget] = useState(null);
// Initialize default column visibility
const initDefaultColumns = () => {
@@ -346,6 +349,20 @@ export const useLogsData = () => {
setShowChannelAffinityUsageCacheModal(true);
};
const openParamOverrideModal = (log, other) => {
const lines = Array.isArray(other?.po) ? other.po.filter(Boolean) : [];
if (lines.length === 0) {
return;
}
setParamOverrideTarget({
lines,
modelName: log?.model_name || '',
requestId: log?.request_id || '',
requestPath: other?.request_path || '',
});
setShowParamOverrideModal(true);
};
// Format logs data
const setLogsFormat = (logs) => {
const requestConversionDisplayValue = (conversionChain) => {
@@ -511,6 +528,47 @@ export const useLogsData = () => {
value: other.request_path,
});
}
if (isAdminUser && other?.stream_status) {
const ss = other.stream_status;
const isOk = ss.status === 'ok';
const statusLabel = isOk ? '✓ ' + t('正常') : '✗ ' + t('异常');
let streamValue = statusLabel + ' (' + (ss.end_reason || 'unknown') + ')';
if (ss.error_count > 0) {
streamValue += ` [${t('软错误')}: ${ss.error_count}]`;
}
if (ss.end_error) {
streamValue += ` - ${ss.end_error}`;
}
expandDataLocal.push({
key: t('流状态'),
value: streamValue,
});
if (Array.isArray(ss.errors) && ss.errors.length > 0) {
expandDataLocal.push({
key: t('流错误详情'),
value: (
<div style={{ maxWidth: 600, whiteSpace: 'pre-line', wordBreak: 'break-word', lineHeight: 1.6 }}>
{ss.errors.join('\n')}
</div>
),
});
}
}
if (Array.isArray(other?.po) && other.po.length > 0) {
expandDataLocal.push({
key: t('参数覆盖'),
value: (
<ParamOverrideEntry
count={other.po.length}
t={t}
onOpen={(event) => {
event.stopPropagation();
openParamOverrideModal(logs[i], other);
}}
/>
),
});
}
if (other?.billing_source === 'subscription') {
const planId = other?.subscription_plan_id;
const planTitle = other?.subscription_plan_title || '';
@@ -738,6 +796,9 @@ export const useLogsData = () => {
setShowChannelAffinityUsageCacheModal,
channelAffinityUsageCacheTarget,
openChannelAffinityUsageCacheModal,
showParamOverrideModal,
setShowParamOverrideModal,
paramOverrideTarget,
// Functions
loadLogs,
@@ -749,6 +810,7 @@ export const useLogsData = () => {
setLogsFormat,
hasExpandableRows,
setLogType,
openParamOverrideModal,
// Translation
t,
+2
View File
@@ -52,4 +52,6 @@ i18n
},
});
window.__i18n = i18n;
export default i18n;
+383 -336
View File
File diff suppressed because it is too large Load Diff
+454 -409
View File
File diff suppressed because it is too large Load Diff
+452 -407
View File
File diff suppressed because it is too large Load Diff
+456 -411
View File
File diff suppressed because it is too large Load Diff
+448 -403
View File
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -388,6 +388,10 @@
"保存通用设置": "保存通用设置",
"保存邮箱域名白名单设置": "保存邮箱域名白名单设置",
"保存额度设置": "保存额度设置",
"保留天数": "保留天数",
"保留文件数": "保留文件数",
"保留最近N个文件": "保留最近N个文件",
"保留最近N天": "保留最近N天",
"修复数据库一致性": "修复数据库一致性",
"修改为": "修改为",
"修改子渠道优先级": "修改子渠道优先级",
@@ -731,7 +735,7 @@
"在此输入系统名称": "在此输入系统名称",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
"域名IP过滤详细说明": "推荐开启:开启后会对域名进行 DNS 解析并检查解析后的 IP 是否为私有地址,可有效防止 DNS 重绑定攻击绕过 SSRF 防护。注意:域名可能解析到多个 IPv4/IPv6 地址,若配置了 IP 过滤列表,请确保覆盖这些地址,否则可能导致访问失败。",
"域名白名单": "域名白名单",
"域名黑名单": "域名黑名单",
"基本信息": "基本信息",
@@ -866,7 +870,7 @@
"密钥预览": "密钥预览",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写",
"对免费模型启用预消耗": "对免费模型启用预消耗",
"对域名启用 IP 过滤(实验性": "对域名启用 IP 过滤(实验性",
"对域名启用 IP 过滤(推荐开启": "对域名启用 IP 过滤(推荐开启",
"对外运营模式": "对外运营模式",
"导入": "导入",
"导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?",
@@ -880,7 +884,9 @@
"将为选中的 ": "将为选中的 ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "将仅保留第一个密钥文件,其余文件将被移除,是否继续?",
"将删除": "将删除",
"将删除 {{value}} 天前的日志文件。": "将删除 {{value}} 天前的日志文件。",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "将只保留最近 {{value}} 个日志文件,其余将被删除。",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?",
"将清除选定时间之前的所有日志": "将清除选定时间之前的所有日志",
"小时": "小时",
@@ -944,6 +950,7 @@
"已注销": "已注销",
"已添加": "已添加",
"已添加到白名单": "已添加到白名单",
"已清理 {{count}} 个日志文件,释放 {{size}}": "已清理 {{count}} 个日志文件,释放 {{size}}",
"已清空测试结果": "已清空测试结果",
"已用": "已用",
"已用/剩余": "已用/剩余",
@@ -1240,8 +1247,12 @@
"日志已下载": "日志已下载",
"日志已加载": "日志已加载",
"日志已复制到剪贴板": "日志已复制到剪贴板",
"日志时间范围": "日志时间范围",
"日志总大小": "日志总大小",
"日志文件数": "日志文件数",
"日志流": "日志流",
"日志清理失败:": "日志清理失败:",
"日志目录": "日志目录",
"日志类型": "日志类型",
"日志设置": "日志设置",
"日志详情": "日志详情",
@@ -1340,6 +1351,8 @@
"服务可用性": "服务可用性",
"服务商": "服务商",
"服务器地址": "服务器地址",
"服务器日志功能未启用(未配置日志目录)": "服务器日志功能未启用(未配置日志目录)",
"服务器日志管理": "服务器日志管理",
"服务显示名称": "服务显示名称",
"未发现新增模型": "未发现新增模型",
"未发现重复密钥": "未发现重复密钥",
@@ -1591,6 +1604,8 @@
"添加键值对": "添加键值对",
"添加问答": "添加问答",
"添加额度": "添加额度",
"清理方式": "清理方式",
"清理日志文件": "清理日志文件",
"清空": "清空",
"清空重定向": "清空重定向",
"清除历史日志": "清除历史日志",
@@ -1756,6 +1771,7 @@
"确认延长容器时长": "确认延长容器时长",
"确认操作": "确认操作",
"确认新密码": "确认新密码",
"确认清理日志文件?": "确认清理日志文件?",
"确认清除历史日志": "确认清除历史日志",
"确认禁用": "确认禁用",
"确认补单": "确认补单",
@@ -1817,6 +1833,7 @@
"管理员账号": "管理员账号",
"管理员账号已经初始化过,请继续设置其他参数": "管理员账号已经初始化过,请继续设置其他参数",
"管理模型、标签、端点等预填组": "管理模型、标签、端点等预填组",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。",
"类型": "类型",
"粘贴图片失败": "粘贴图片失败",
"精确": "精确",
@@ -2211,6 +2228,7 @@
"请输入新的部署名称": "请输入新的部署名称",
"请输入显示名称": "请输入显示名称",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。",
"请输入有效的数值": "请输入有效的数值",
"请输入有效的数字": "请输入有效的数字",
"请输入有效的镜像地址": "请输入有效的镜像地址",
"请输入标签名称": "请输入标签名称",
@@ -3042,7 +3060,6 @@
"止": "止",
"值": "值",
"添加条件组": "添加条件组",
"添加条件": "添加条件",
"添加时间条件": "添加时间条件",
"同时满足": "同时满足",
"新年促销": "新年促销",
@@ -3060,11 +3077,16 @@
"缓存创建": "缓存创建",
"缓存创建-1h": "缓存创建-1h",
"见上方动态计费详情": "见上方动态计费详情",
"分组倍率": "分组倍率",
"含时间条件": "含时间条件",
"含请求条件": "含请求条件",
"输入": "输入",
"输出": "输出",
"档": "档"
"复制密钥": "复制密钥",
"复制连接信息": "复制连接信息",
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
"自动填入": "自动填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
}
}
+48 -3
View File
@@ -389,6 +389,10 @@
"保存通用设置": "儲存通用設定",
"保存邮箱域名白名单设置": "儲存信箱域名白名單設定",
"保存额度设置": "儲存額度設定",
"保留天数": "保留天數",
"保留文件数": "保留檔案數",
"保留最近N个文件": "保留最近N個檔案",
"保留最近N天": "保留最近N天",
"修复数据库一致性": "修復資料庫一致性",
"修改为": "修改為",
"修改子渠道优先级": "修改子管道優先級",
@@ -733,7 +737,7 @@
"在此输入系统名称": "在此輸入系統名稱",
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此輸入隱私政策內容,支援 Markdown & HTML 程式碼",
"在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页": "在此輸入首頁內容,支援 Markdown & HTML 程式碼,設定後首頁的狀態訊息將不再顯示。如果輸入的是一個連結,則會使用該連結作為 iframe 的 src 屬性,這允許你設定任意網頁作為首頁",
"域名IP过滤详细说明": "⚠️此功能為實驗性選項,域名可能解析到多個 IPv4/IPv6 位址,若開啟,請確保 IP 過濾列表覆蓋這些位址,否則可能導致訪問失敗。",
"域名IP过滤详细说明": "建議開啟:開啟後會對域名進行 DNS 解析並檢查解析後的 IP 是否為私有位址,可有效防止 DNS 重綁定攻擊繞過 SSRF 防護。注意:域名可能解析到多個 IPv4/IPv6 位址,若配置了 IP 過濾列表,請確保覆蓋這些位址,否則可能導致訪問失敗。",
"域名白名单": "域名白名單",
"域名黑名单": "域名黑名單",
"基本信息": "基本資訊",
@@ -869,7 +873,7 @@
"密钥预览": "密鑰預覽",
"对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "對於官方管道,new-api已經內置位址,除非是第三方代理站點或者Azure的特殊接入位址,否則不需要填寫",
"对免费模型启用预消耗": "對免費模型啟用預消耗",
"对域名启用 IP 过滤(实验性": "對域名啟用 IP 過濾(實驗性",
"对域名启用 IP 过滤(推荐开启": "對域名啟用 IP 過濾(建議開啟",
"对外运营模式": "對外運營模式",
"导入": "導入",
"导入的配置将覆盖当前设置,是否继续?": "導入的設定將覆蓋當前設定,是否繼續?",
@@ -882,7 +886,9 @@
"将 reasoning_content 转换为 <think> 标签拼接到内容中": "將 reasoning_content 轉換為 <think> 標籤拼接到內容中",
"将为选中的 ": "將為選中的 ",
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "將僅保留第一個密鑰檔案,其餘檔案將被移除,是否繼續?",
"将只保留最近 {{value}} 个日志文件,其余将被删除。": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。",
"将删除": "將刪除",
"将删除 {{value}} 天前的日志文件。": "將刪除 {{value}} 天前的日誌檔案。",
"将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "將刪除已使用、已禁用及過期的兌換碼,此操作不可撤銷。",
"将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "將清除所有儲存的設定並恢復預設設定,此操作不可撤銷。是否繼續?",
"将清除选定时间之前的所有日志": "將清除選定時間之前的所有日誌",
@@ -947,6 +953,7 @@
"已注销": "已註銷",
"已添加": "已添加",
"已添加到白名单": "已添加到白名單",
"已清理 {{count}} 个日志文件,释放 {{size}}": "已清理 {{count}} 個日誌檔案,釋放 {{size}}",
"已清空测试结果": "已清空測試結果",
"已用": "已用",
"已用/剩余": "已用/剩餘",
@@ -1183,6 +1190,8 @@
"收益统计": "收益統計",
"收起": "收起",
"收起侧边栏": "收起側邊欄",
"向左展开": "向左展開",
"向右展开": "向右展開",
"收起内容": "收起內容",
"放大": "放大",
"放大编辑": "放大編輯",
@@ -1243,8 +1252,12 @@
"日志已下载": "日誌已下載",
"日志已加载": "日誌已載入",
"日志已复制到剪贴板": "日誌已複製到剪貼板",
"日志时间范围": "日誌時間範圍",
"日志总大小": "日誌總大小",
"日志文件数": "日誌檔案數",
"日志流": "日誌流",
"日志清理失败:": "日誌清理失敗:",
"日志目录": "日誌目錄",
"日志类型": "日誌類型",
"日志设置": "日誌設定",
"日志详情": "日誌詳情",
@@ -1344,6 +1357,8 @@
"服务可用性": "服務可用性",
"服务商": "服務商",
"服务器地址": "伺服器位址",
"服务器日志功能未启用(未配置日志目录)": "伺服器日誌功能未啟用(未配置日誌目錄)",
"服务器日志管理": "伺服器日誌管理",
"服务显示名称": "服務顯示名稱",
"未发现新增模型": "未發現新增模型",
"未发现重复密钥": "未發現重複密鑰",
@@ -1597,6 +1612,8 @@
"添加键值对": "添加鍵值對",
"添加问答": "添加問答",
"添加额度": "添加額度",
"清理方式": "清理方式",
"清理日志文件": "清理日誌檔案",
"清空": "清空",
"清空重定向": "清空重定向",
"清除历史日志": "清除歷史日誌",
@@ -1762,6 +1779,7 @@
"确认延长容器时长": "確認延長容器時長",
"确认操作": "確認操作",
"确认新密码": "確認新密碼",
"确认清理日志文件?": "確認清理日誌檔案?",
"确认清除历史日志": "確認清除歷史日誌",
"确认禁用": "確認禁用",
"确认补单": "確認補單",
@@ -1823,6 +1841,7 @@
"管理员设置了外部链接,点击下方按钮访问": "管理員設定了外部連結,點擊下方按鈕訪問",
"管理员账号": "管理員帳號",
"管理员账号已经初始化过,请继续设置其他参数": "管理員帳號已經初始化過,請繼續設定其他參數",
"管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。": "管理伺服器運行日誌檔案。日誌檔案會隨運行時間不斷累積,建議定期清理以釋放磁碟空間。",
"管理模型、标签、端点等预填组": "管理模型、標籤、端點等預填組",
"类型": "類型",
"粘贴图片失败": "貼上圖片失敗",
@@ -2219,6 +2238,7 @@
"请输入新的部署名称": "請輸入新的部署名稱",
"请输入显示名称": "請輸入顯示名稱",
"请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。": "請輸入有效的JSON格式的請求體。您可以參考預覽面板中的預設請求體格式。",
"请输入有效的数值": "請輸入有效的數值",
"请输入有效的数字": "請輸入有效的數位",
"请输入有效的镜像地址": "請輸入有效的鏡像位址",
"请输入标签名称": "請輸入標籤名稱",
@@ -2635,6 +2655,12 @@
"验证配置错误": "驗證設定錯誤",
"高级设置": "進階設定",
"高级配置": "進階設定",
"核心配置": "核心設定",
"创建渠道所需的基本信息": "建立頻道所需的基本資訊",
"请求配置": "請求設定",
"渠道行为": "頻道行為",
"额外设置": "額外設定",
"上游模型管理": "上游模型管理",
"黑名单": "黑名單",
"默认": "預設",
"默认 API 版本": "預設 API 版本",
@@ -2900,6 +2926,16 @@
"1h缓存创建:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h缓存创建倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}": "1h快取建立:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 1h快取建立倍率 {{cacheCreationRatio1h}} * {{ratioType}} {{ratio}} = {{amount}}",
"输出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 输出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}": "輸出:{{tokens}} / 1M * 模型倍率 {{modelRatio}} * 輸出倍率 {{completionRatio}} * {{ratioType}} {{ratio}} = {{amount}}",
"空": "空",
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端點映射僅用於模型廣場展示,不會影響模型真實呼叫。如需配置真實呼叫,請前往「管道管理」。",
"购买订阅获得模型额度/次数": "購買訂閱取得模型額度/次數",
"生产环境 RSA 私钥 Base64 (PKCS#8 DER)": "正式環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)": "沙盒環境 RSA 私鑰 Base64 (PKCS#8 DER)",
"生产环境 Waffo 公钥 Base64 (X.509 DER)": "正式環境 Waffo 公鑰 Base64 (X.509 DER)",
"沙盒环境 Waffo 公钥 Base64 (X.509 DER)": "沙盒環境 Waffo 公鑰 Base64 (X.509 DER)",
"支付方式类型": "付款方式類型",
"支付方式名称": "付款方式名稱",
"获取充值配置失败": "取得儲值設定失敗",
"获取充值配置异常": "儲值設定異常",
"{{ratioType}} {{ratio}}x": "{{ratioType}} {{ratio}}x",
"模型价格:{{symbol}}{{price}}": "模型價格:{{symbol}}{{price}}",
"模型价格 {{price}}": "模型價格 {{price}}",
@@ -2937,6 +2973,15 @@
"输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens",
"输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens",
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens"
"输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens",
"复制密钥": "複製金鑰",
"复制连接信息": "複製連線資訊",
"检测到剪贴板中的连接信息": "偵測到剪貼簿中的連線資訊",
"自动填入": "自動填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "從剪貼簿貼上設定",
"剪贴板中未检测到连接信息": "剪貼簿中未偵測到連線資訊",
"连接信息已填入": "連線資訊已填入",
"无法读取剪贴板": "無法讀取剪貼簿"
}
}
+35
View File
@@ -31,6 +31,13 @@ body {
background-color: var(--semi-color-bg-0);
}
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
@media (min-width: 768px) {
body {
overflow-y: hidden;
}
}
.app-layout {
height: 100vh;
height: 100dvh;
@@ -469,6 +476,9 @@ html.dark .sbg-variant-green {
.custom-footer {
font-size: 1.1em;
}
.custom-footer.na-cb6feafeb3990c78 {
position: relative;
}
/* 卡片内容容器通用样式 */
.card-content-container {
@@ -989,3 +999,28 @@ html.dark .with-pastel-balls::before {
.semi-datepicker-range-input {
border-radius: 10px !important;
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Channel advanced settings side panel animations */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.ec-dbcd0a3c01b55203 { forced-color-adjust: auto; }
@@ -31,8 +31,7 @@ import { useTranslation } from 'react-i18next';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const GEMINI_SETTING_EXAMPLE = {
default: 'OFF',
HARM_CATEGORY_CIVIC_INTEGRITY: 'BLOCK_NONE',
default: 'OFF'
};
const GEMINI_VERSION_EXAMPLE = {
@@ -539,6 +539,15 @@ export default function SettingsChannelAffinity(props) {
dataIndex: 'ttl_seconds',
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
},
{
title: t('失败后不重试'),
dataIndex: 'skip_retry_on_failure',
render: (value) => (
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
{value ? t('是') : t('否')}
</Tag>
),
},
{
title: t('覆盖模板'),
render: (_, record) => {
@@ -1096,6 +1105,18 @@ export default function SettingsChannelAffinity(props) {
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
<Collapse
keepDOM
activeKey={modalAdvancedActiveKey}
@@ -1251,18 +1272,6 @@ export default function SettingsChannelAffinity(props) {
</Text>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Switch
field='skip_retry_on_failure'
label={t('失败后不重试')}
/>
<Text type='tertiary' size='small'>
{t('开启后,若该规则命中且请求失败,将不会切换渠道重试。')}
</Text>
</Col>
</Row>
</Collapse.Panel>
</Collapse>
@@ -0,0 +1,608 @@
/*
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, useRef } from 'react';
import {
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
Table,
Modal,
Input,
Space,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function SettingsPaymentGatewayWaffo(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
WaffoEnabled: false,
WaffoApiKey: '',
WaffoPrivateKey: '',
WaffoPublicCert: '',
WaffoSandboxPublicCert: '',
WaffoSandboxApiKey: '',
WaffoSandboxPrivateKey: '',
WaffoSandbox: false,
WaffoMerchantId: '',
WaffoCurrency: 'USD',
WaffoUnitPrice: 1.0,
WaffoMinTopUp: 1,
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
const iconFileInputRef = useRef(null);
const handleIconFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const MAX_ICON_SIZE = 100 * 1024; // 100 KB
if (file.size > MAX_ICON_SIZE) {
showError(t('图标文件不能超过 100KB,请压缩后重新上传'));
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (event) => {
setPayMethodForm((prev) => ({ ...prev, icon: event.target.result }));
};
reader.readAsDataURL(file);
e.target.value = '';
};
// 支付方式列表
const [waffoPayMethods, setWaffoPayMethods] = useState([]);
// 支付方式弹窗
const [payMethodModalVisible, setPayMethodModalVisible] = useState(false);
// 当前编辑的索引,-1 表示新增
const [editingPayMethodIndex, setEditingPayMethodIndex] = useState(-1);
// 弹窗内表单字段的临时状态
const [payMethodForm, setPayMethodForm] = useState({
name: '',
icon: '',
payMethodType: '',
payMethodName: '',
});
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
WaffoEnabled: props.options.WaffoEnabled === 'true' || props.options.WaffoEnabled === true,
WaffoApiKey: props.options.WaffoApiKey || '',
WaffoPrivateKey: props.options.WaffoPrivateKey || '',
WaffoPublicCert: props.options.WaffoPublicCert || '',
WaffoSandboxPublicCert: props.options.WaffoSandboxPublicCert || '',
WaffoSandboxApiKey: props.options.WaffoSandboxApiKey || '',
WaffoSandboxPrivateKey: props.options.WaffoSandboxPrivateKey || '',
WaffoSandbox: props.options.WaffoSandbox === 'true',
WaffoMerchantId: props.options.WaffoMerchantId || '',
WaffoCurrency: props.options.WaffoCurrency || 'USD',
WaffoUnitPrice: parseFloat(props.options.WaffoUnitPrice) || 1.0,
WaffoMinTopUp: parseInt(props.options.WaffoMinTopUp) || 1,
WaffoNotifyUrl: props.options.WaffoNotifyUrl || '',
WaffoReturnUrl: props.options.WaffoReturnUrl || '',
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
// 解析支付方式列表
try {
const rawPayMethods = props.options.WaffoPayMethods;
if (rawPayMethods) {
const parsed = JSON.parse(rawPayMethods);
if (Array.isArray(parsed)) {
setWaffoPayMethods(parsed);
}
}
} catch {
setWaffoPayMethods([]);
}
}
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitWaffoSetting = async () => {
setLoading(true);
try {
const options = [];
options.push({
key: 'WaffoEnabled',
value: inputs.WaffoEnabled ? 'true' : 'false',
});
if (inputs.WaffoApiKey && inputs.WaffoApiKey !== '') {
options.push({ key: 'WaffoApiKey', value: inputs.WaffoApiKey });
}
if (inputs.WaffoPrivateKey && inputs.WaffoPrivateKey !== '') {
options.push({ key: 'WaffoPrivateKey', value: inputs.WaffoPrivateKey });
}
options.push({ key: 'WaffoPublicCert', value: inputs.WaffoPublicCert || '' });
options.push({ key: 'WaffoSandboxPublicCert', value: inputs.WaffoSandboxPublicCert || '' });
if (inputs.WaffoSandboxApiKey && inputs.WaffoSandboxApiKey !== '') {
options.push({ key: 'WaffoSandboxApiKey', value: inputs.WaffoSandboxApiKey });
}
if (inputs.WaffoSandboxPrivateKey && inputs.WaffoSandboxPrivateKey !== '') {
options.push({ key: 'WaffoSandboxPrivateKey', value: inputs.WaffoSandboxPrivateKey });
}
options.push({
key: 'WaffoSandbox',
value: inputs.WaffoSandbox ? 'true' : 'false',
});
options.push({ key: 'WaffoMerchantId', value: inputs.WaffoMerchantId || '' });
options.push({ key: 'WaffoCurrency', value: inputs.WaffoCurrency || '' });
options.push({
key: 'WaffoUnitPrice',
value: String(inputs.WaffoUnitPrice || 1.0),
});
options.push({
key: 'WaffoMinTopUp',
value: String(inputs.WaffoMinTopUp || 1),
});
options.push({ key: 'WaffoNotifyUrl', value: inputs.WaffoNotifyUrl || '' });
options.push({ key: 'WaffoReturnUrl', value: inputs.WaffoReturnUrl || '' });
// 保存支付方式列表
options.push({
key: 'WaffoPayMethods',
value: JSON.stringify(waffoPayMethods),
});
// 发送请求
const requestQueue = options.map((opt) =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
}),
);
const results = await Promise.all(requestQueue);
// 检查所有请求是否成功
const errorResults = results.filter((res) => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach((res) => {
showError(res.data.message);
});
} else {
showSuccess(t('更新成功'));
// 更新本地存储的原始值
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
showError(t('更新失败'));
}
setLoading(false);
};
// 打开新增弹窗
const openAddPayMethodModal = () => {
setEditingPayMethodIndex(-1);
setPayMethodForm({ name: '', icon: '', payMethodType: '', payMethodName: '' });
setPayMethodModalVisible(true);
};
// 打开编辑弹窗
const openEditPayMethodModal = (record, index) => {
setEditingPayMethodIndex(index);
setPayMethodForm({
name: record.name || '',
icon: record.icon || '',
payMethodType: record.payMethodType || '',
payMethodName: record.payMethodName || '',
});
setPayMethodModalVisible(true);
};
// 确认保存弹窗(新增或编辑)
const handlePayMethodModalOk = () => {
if (!payMethodForm.name || payMethodForm.name.trim() === '') {
showError(t('支付方式名称不能为空'));
return;
}
const newMethod = {
name: payMethodForm.name.trim(),
icon: payMethodForm.icon.trim(),
payMethodType: payMethodForm.payMethodType.trim(),
payMethodName: payMethodForm.payMethodName.trim(),
};
if (editingPayMethodIndex === -1) {
// 新增
setWaffoPayMethods([...waffoPayMethods, newMethod]);
} else {
// 编辑
const updated = [...waffoPayMethods];
updated[editingPayMethodIndex] = newMethod;
setWaffoPayMethods(updated);
}
setPayMethodModalVisible(false);
};
// 删除支付方式
const handleDeletePayMethod = (index) => {
const updated = waffoPayMethods.filter((_, i) => i !== index);
setWaffoPayMethods(updated);
};
// 支付方式表格列定义
const payMethodColumns = [
{
title: t('显示名称'),
dataIndex: 'name',
},
{
title: t('图标'),
dataIndex: 'icon',
render: (text) =>
text ? (
<img
src={text}
alt='icon'
style={{ width: 24, height: 24, objectFit: 'contain' }}
/>
) : (
<Text type='tertiary'></Text>
),
},
{
title: t('支付方式类型'),
dataIndex: 'payMethodType',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('支付方式名称'),
dataIndex: 'payMethodName',
render: (text) => text || <Text type='tertiary'></Text>,
},
{
title: t('操作'),
key: 'action',
render: (_, record, index) => (
<Space>
<Button
size='small'
onClick={() => openEditPayMethodModal(record, index)}
>
{t('编辑')}
</Button>
<Button
size='small'
type='danger'
onClick={() => handleDeletePayMethod(index)}
>
{t('删除')}
</Button>
</Space>
),
},
];
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Waffo 设置')}>
<Text>
{t('Waffo 是一个支付聚合平台,支持多种支付方式。')}
<a href='https://waffo.com' target='_blank' rel='noreferrer'>
Waffo Official Site
</a>
<br />
</Text>
<Banner
type='info'
description={t(
'请在 Waffo 后台获取 API 密钥、商户 ID 以及 RSA 密钥对,并配置回调地址。',
)}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoEnabled'
label={t('启用 Waffo')}
size='default'
checkedText=''
uncheckedText=''
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='WaffoSandbox'
label={t('沙盒模式')}
size='default'
checkedText=''
uncheckedText=''
extraText={t('启用后将使用 Waffo 沙盒环境')}
/>
</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='WaffoApiKey'
label={t('API 密钥 (生产)')}
placeholder={t('生产环境 Waffo API 密钥')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoSandboxApiKey'
label={t('API 密钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo API 密钥')}
type='password'
/>
</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='WaffoMerchantId'
label={t('商户 ID')}
placeholder={t('Waffo 商户 ID')}
/>
</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.TextArea
field='WaffoPrivateKey'
label={t('RSA 私钥 (生产)')}
placeholder={t('生产环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPrivateKey'
label={t('RSA 私钥 (沙盒)')}
placeholder={t('沙盒环境 RSA 私钥 Base64 (PKCS#8 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</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.TextArea
field='WaffoPublicCert'
label={t('Waffo 公钥 (生产)')}
placeholder={t('生产环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.TextArea
field='WaffoSandboxPublicCert'
label={t('Waffo 公钥 (沙盒)')}
placeholder={t('沙盒环境 Waffo 公钥 Base64 (X.509 DER)')}
type='password'
autosize={{ minRows: 3, maxRows: 6 }}
/>
</Col>
</Row>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='WaffoCurrency'
label={t('货币')}
disabled
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoUnitPrice'
label={t('单价 (USD)')}
placeholder='1.0'
min={0}
step={0.1}
extraText={t('每个充值单位对应的 USD 金额,默认 1.0')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='WaffoMinTopUp'
label={t('最低充值数量')}
placeholder='1'
min={1}
step={1}
extraText={t('Waffo 充值的最低数量,默认 1')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoNotifyUrl'
label={t('回调通知地址')}
placeholder={t('例如 https://example.com/api/waffo/webhook')}
extraText={t('留空则自动使用 服务器地址 + /api/waffo/webhook')}
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='WaffoReturnUrl'
label={t('支付返回地址')}
placeholder={t('例如 https://example.com/console/topup')}
extraText={t('支付完成后用户跳转的页面,留空则自动使用 服务器地址 + /console/topup')}
/>
</Col>
</Row>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</Form.Section>
</Form>
{/* 支付方式配置区块(独立于 Form,使用独立状态管理) */}
<div style={{ marginTop: 24 }}>
<Typography.Title heading={6} style={{ marginBottom: 8 }}>{t('支付方式')}</Typography.Title>
<Text type='secondary'>
{t('配置 Waffo 充值时可用的支付方式,保存后在充值页面展示给用户。')}
</Text>
<div style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={openAddPayMethodModal}>
{t('新增支付方式')}
</Button>
</div>
<Table
columns={payMethodColumns}
dataSource={waffoPayMethods}
rowKey={(record, index) => index}
pagination={false}
size='small'
empty={<Text type='tertiary'>{t('暂无支付方式,点击上方按钮新增')}</Text>}
/>
<Button onClick={submitWaffoSetting} style={{ marginTop: 16 }}>
{t('更新 Waffo 设置')}
</Button>
</div>
{/* 新增/编辑支付方式弹窗 */}
<Modal
title={editingPayMethodIndex === -1 ? t('新增支付方式') : t('编辑支付方式')}
visible={payMethodModalVisible}
onOk={handlePayMethodModalOk}
onCancel={() => setPayMethodModalVisible(false)}
okText={t('确定')}
cancelText={t('取消')}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('显示名称')}</Text>
<span style={{ color: 'var(--semi-color-danger)', marginLeft: 4 }}>*</span>
</div>
<Input
value={payMethodForm.name}
onChange={(val) => setPayMethodForm({ ...payMethodForm, name: val })}
placeholder={t('例如:Credit Card')}
/>
<Text type='tertiary' size='small'>{t('用户在充值页面看到的支付方式名称,例如:Credit Card')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('图标')}</Text>
</div>
<Space align='center'>
{payMethodForm.icon && (
<img
src={payMethodForm.icon}
alt='preview'
style={{
width: 32,
height: 32,
objectFit: 'contain',
border: '1px solid var(--semi-color-border)',
borderRadius: 4,
}}
/>
)}
<input
type='file'
accept='image/*'
ref={iconFileInputRef}
style={{ display: 'none' }}
onChange={handleIconFileChange}
/>
<Button
size='small'
onClick={() => iconFileInputRef.current?.click()}
>
{payMethodForm.icon ? t('重新上传') : t('上传图片')}
</Button>
{payMethodForm.icon && (
<Button
size='small'
type='danger'
onClick={() =>
setPayMethodForm((prev) => ({ ...prev, icon: '' }))
}
>
{t('清除')}
</Button>
)}
</Space>
<div>
<Text type='tertiary' size='small'>{t('上传 PNG/JPG/SVG 图片,建议尺寸 ≤ 128×128px')}</Text>
</div>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Type')}</Text>
</div>
<Input
value={payMethodForm.payMethodType}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodType: val })}
placeholder='CREDITCARD,DEBITCARD'
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空,例如:CREDITCARD,DEBITCARD(最多64位)')}</Text>
</div>
<div>
<div style={{ marginBottom: 4 }}>
<Text strong>{t('Pay Method Name')}</Text>
</div>
<Input
value={payMethodForm.payMethodName}
onChange={(val) => setPayMethodForm({ ...payMethodForm, payMethodName: val })}
placeholder={t('可空')}
maxLength={64}
/>
<Text type='tertiary' size='small'>{t('Waffo API 参数,可空(最多64位)')}</Text>
</div>
</div>
</Modal>
</Spin>
);
}
@@ -23,12 +23,15 @@ import {
Button,
Col,
Form,
InputNumber,
Row,
Spin,
Progress,
Descriptions,
Tag,
Popconfirm,
RadioGroup,
Radio,
Typography,
} from '@douyinfe/semi-ui';
import {
@@ -68,10 +71,14 @@ export default function SettingsPerformance(props) {
'performance_setting.monitor_enabled': false,
'performance_setting.monitor_cpu_threshold': 90,
'performance_setting.monitor_memory_threshold': 90,
'performance_setting.monitor_disk_threshold': 90,
'performance_setting.monitor_disk_threshold': 95,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [logInfo, setLogInfo] = useState(null);
const [logCleanupMode, setLogCleanupMode] = useState('by_count');
const [logCleanupValue, setLogCleanupValue] = useState(10);
const [logCleanupLoading, setLogCleanupLoading] = useState(false);
function handleFieldChange(fieldName) {
return (value) => {
@@ -167,6 +174,46 @@ export default function SettingsPerformance(props) {
}
}
async function fetchLogInfo() {
try {
const res = await API.get('/api/performance/logs');
if (res.data.success) {
setLogInfo(res.data.data);
}
} catch (error) {
console.error('Failed to fetch log info:', error);
}
}
async function cleanupLogFiles() {
if (logCleanupValue == null || isNaN(logCleanupValue) || logCleanupValue < 1) {
showError(t('请输入有效的数值'));
return;
}
setLogCleanupLoading(true);
try {
const res = await API.delete(
`/api/performance/logs?mode=${logCleanupMode}&value=${logCleanupValue}`,
);
if (res.data.success) {
const { deleted_count, freed_bytes } = res.data.data;
showSuccess(
t('已清理 {{count}} 个日志文件,释放 {{size}}', {
count: deleted_count,
size: formatBytes(freed_bytes),
}),
);
} else {
showError(res.data.message || t('清理失败'));
}
fetchLogInfo();
} catch (error) {
showError(t('清理失败'));
} finally {
setLogCleanupLoading(false);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
@@ -187,6 +234,7 @@ export default function SettingsPerformance(props) {
refForm.current.setValues({ ...inputs, ...currentInputs });
}
fetchStats();
fetchLogInfo();
}, [props.options]);
const diskCacheUsagePercent =
@@ -351,6 +399,112 @@ export default function SettingsPerformance(props) {
</Form>
</Spin>
{/* 服务器日志管理 */}
<Form.Section text={t('服务器日志管理')}>
<Banner
type='info'
description={t(
'管理服务器运行日志文件。日志文件会随运行时间不断累积,建议定期清理以释放磁盘空间。',
)}
style={{ marginBottom: 16 }}
/>
{logInfo === null ? null : logInfo.enabled ? (
<>
<Descriptions
data={[
{ key: t('日志目录'), value: logInfo.log_dir },
{
key: t('日志文件数'),
value: logInfo.file_count,
},
{
key: t('日志总大小'),
value: formatBytes(logInfo.total_size),
},
...(logInfo.oldest_time && logInfo.newest_time
? [
{
key: t('日志时间范围'),
value: `${new Date(logInfo.oldest_time).toLocaleDateString()} ~ ${new Date(logInfo.newest_time).toLocaleDateString()}`,
},
]
: []),
]}
style={{ marginBottom: 16 }}
/>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
{t('清理方式')}
</Text>
<RadioGroup
value={logCleanupMode}
onChange={(e) => setLogCleanupMode(e.target.value)}
>
<Radio value='by_count'>{t('保留最近N个文件')}</Radio>
<Radio value='by_days'>{t('保留最近N天')}</Radio>
</RadioGroup>
</div>
</Col>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
{logCleanupMode === 'by_count'
? t('保留文件数')
: t('保留天数')}
</Text>
<InputNumber
value={logCleanupValue}
min={1}
max={logCleanupMode === 'by_count' ? 1000 : 3650}
onChange={(value) => setLogCleanupValue(value)}
style={{ width: '100%' }}
/>
</div>
</Col>
<Col xs={24} sm={12} md={8}>
<div style={{ marginBottom: 12 }}>
<Text
strong
style={{
display: 'block',
marginBottom: 8,
visibility: 'hidden',
}}
>
&nbsp;
</Text>
<Popconfirm
title={t('确认清理日志文件?')}
content={
logCleanupMode === 'by_count'
? t(
'将只保留最近 {{value}} 个日志文件,其余将被删除。',
{ value: logCleanupValue },
)
: t('将删除 {{value}} 天前的日志文件。', {
value: logCleanupValue,
})
}
onConfirm={cleanupLogFiles}
>
<Button type='danger' loading={logCleanupLoading}>
{t('清理日志文件')}
</Button>
</Popconfirm>
</div>
</Col>
</Row>
</>
) : (
<Banner
type='warning'
description={t('服务器日志功能未启用(未配置日志目录)')}
/>
)}
</Form.Section>
{/* 性能统计 */}
<Spin spinning={statsLoading}>
<Form.Section text={t('性能监控')}>