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:
@@ -56,7 +56,7 @@ const OAuth2Callback = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message === 'bind') {
|
||||
if (data?.action === 'bind') {
|
||||
showSuccess(t('绑定成功!'));
|
||||
navigate('/console/personal');
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 充值')}>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Vendored
+38
-18
@@ -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 || '未知错误'));
|
||||
|
||||
Vendored
+38
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+2
@@ -52,4 +52,6 @@ i18n
|
||||
},
|
||||
});
|
||||
|
||||
window.__i18n = i18n;
|
||||
|
||||
export default i18n;
|
||||
|
||||
Vendored
+383
-336
File diff suppressed because it is too large
Load Diff
Vendored
+454
-409
File diff suppressed because it is too large
Load Diff
Vendored
+452
-407
File diff suppressed because it is too large
Load Diff
Vendored
+456
-411
File diff suppressed because it is too large
Load Diff
Vendored
+448
-403
File diff suppressed because it is too large
Load Diff
Vendored
+29
-7
@@ -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",
|
||||
"见上方动态计费详情": "见上方动态计费详情",
|
||||
"分组倍率": "分组倍率",
|
||||
"含时间条件": "含时间条件",
|
||||
"含请求条件": "含请求条件",
|
||||
"输入": "输入",
|
||||
"输出": "输出",
|
||||
"档": "档"
|
||||
"复制密钥": "复制密钥",
|
||||
"复制连接信息": "复制连接信息",
|
||||
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
|
||||
"自动填入": "自动填入",
|
||||
"忽略": "忽略",
|
||||
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
|
||||
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
|
||||
"连接信息已填入": "连接信息已填入",
|
||||
"无法读取剪贴板": "无法读取剪贴板"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+48
-3
@@ -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",
|
||||
"复制密钥": "複製金鑰",
|
||||
"复制连接信息": "複製連線資訊",
|
||||
"检测到剪贴板中的连接信息": "偵測到剪貼簿中的連線資訊",
|
||||
"自动填入": "自動填入",
|
||||
"忽略": "忽略",
|
||||
"从剪贴板粘贴配置": "從剪貼簿貼上設定",
|
||||
"剪贴板中未检测到连接信息": "剪貼簿中未偵測到連線資訊",
|
||||
"连接信息已填入": "連線資訊已填入",
|
||||
"无法读取剪贴板": "無法讀取剪貼簿"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+35
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
</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('性能监控')}>
|
||||
|
||||
Reference in New Issue
Block a user