Merge branch 'main' into pr/Bliod-Cook/2610

This commit is contained in:
Bliod-Cook
2026-01-26 05:23:51 +00:00
92 changed files with 4819 additions and 307 deletions

View File

@@ -17,9 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import {
API,
getLogo,
@@ -73,6 +74,7 @@ const LoginForm = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
@@ -108,20 +110,26 @@ const LoginForm = () => {
localStorage.setItem('aff', affCode);
}
const [status] = useState(() => {
const status = useMemo(() => {
if (statusState?.status) return statusState.status;
const savedStatus = localStorage.getItem('status');
return savedStatus ? JSON.parse(savedStatus) : {};
});
if (!savedStatus) return {};
try {
return JSON.parse(savedStatus) || {};
} catch (err) {
return {};
}
}, [statusState?.status]);
useEffect(() => {
if (status.turnstile_check) {
if (status?.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
// 从 status 获取用户协议和隐私政策的启用状态
setHasUserAgreement(status.user_agreement_enabled || false);
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
setHasUserAgreement(status?.user_agreement_enabled || false);
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
}, [status]);
useEffect(() => {

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useContext, useEffect, useRef, useState } from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
@@ -31,7 +31,15 @@ import {
onDiscordOAuthClicked,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
import {
Button,
Card,
Checkbox,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import {
@@ -51,6 +59,7 @@ import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import WeChatIcon from '../common/logo/WeChatIcon';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useTranslation } from 'react-i18next';
import { SiDiscord } from 'react-icons/si';
@@ -72,6 +81,7 @@ const RegisterForm = () => {
});
const { username, password, password2 } = inputs;
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
@@ -106,25 +116,29 @@ const RegisterForm = () => {
localStorage.setItem('aff', affCode);
}
const [status] = useState(() => {
const status = useMemo(() => {
if (statusState?.status) return statusState.status;
const savedStatus = localStorage.getItem('status');
return savedStatus ? JSON.parse(savedStatus) : {};
});
if (!savedStatus) return {};
try {
return JSON.parse(savedStatus) || {};
} catch (err) {
return {};
}
}, [statusState?.status]);
const [showEmailVerification, setShowEmailVerification] = useState(() => {
return status.email_verification ?? false;
});
const [showEmailVerification, setShowEmailVerification] = useState(false);
useEffect(() => {
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setShowEmailVerification(!!status?.email_verification);
if (status?.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
// 从 status 获取用户协议和隐私政策的启用状态
setHasUserAgreement(status.user_agreement_enabled || false);
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
setHasUserAgreement(status?.user_agreement_enabled || false);
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
}, [status]);
useEffect(() => {
@@ -235,7 +249,7 @@ const RegisterForm = () => {
setVerificationCodeLoading(true);
try {
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
`/api/verification?email=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
@@ -405,7 +419,15 @@ const RegisterForm = () => {
theme='outline'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
type='tertiary'
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
icon={
<SiDiscord
style={{
color: '#5865F2',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleDiscordClick}
loading={discordLoading}
>
@@ -619,7 +641,9 @@ const RegisterForm = () => {
htmlType='submit'
onClick={handleSubmit}
loading={registerLoading}
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
disabled={
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
}
>
{t('注册')}
</Button>

View File

@@ -0,0 +1,71 @@
/*
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 { Form, Tag, Typography } from '@douyinfe/semi-ui';
export default function HttpStatusCodeRulesInput(props) {
const { Text } = Typography;
const {
label,
field,
placeholder,
extraText,
onChange,
parsed,
invalidText,
} = props;
return (
<>
<Form.Input
label={label}
placeholder={placeholder}
extraText={extraText}
field={field}
onChange={onChange}
/>
{parsed?.ok && parsed.tokens?.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
}}
>
{parsed.tokens.map((token) => (
<Tag key={token} size='small'>
{token}
</Tag>
))}
</div>
)}
{!parsed?.ok && (
<Text type='danger' style={{ display: 'block', marginTop: 8 }}>
{invalidText}
{parsed?.invalidTokens && parsed.invalidTokens.length > 0
? `: ${parsed.invalidTokens.join(', ')}`
: ''}
</Text>
)}
</>
);
}

View File

@@ -39,6 +39,7 @@ const ModelSetting = () => {
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
'global.pass_through_request_enabled': false,
'global.thinking_model_blacklist': '[]',
'global.chat_completions_to_responses_policy': '{}',
'general_setting.ping_interval_enabled': false,
'general_setting.ping_interval_seconds': 60,
'gemini.thinking_adapter_enabled': false,
@@ -59,10 +60,16 @@ const ModelSetting = () => {
item.key === 'claude.model_headers_settings' ||
item.key === 'claude.default_max_tokens' ||
item.key === 'gemini.supported_imagine_models' ||
item.key === 'global.thinking_model_blacklist'
item.key === 'global.thinking_model_blacklist' ||
item.key === 'global.chat_completions_to_responses_policy'
) {
if (item.value !== '') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (e) {
// Keep raw value so user can fix it, and avoid crashing the page.
console.error(`Invalid JSON for option ${item.key}:`, e);
}
}
}
// Keep boolean config keys ending with enabled/Enabled so UI parses correctly.

View File

@@ -70,6 +70,8 @@ const OperationSetting = () => {
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
'checkin_setting.enabled': false,

View File

@@ -440,7 +440,7 @@ const NotificationSettings = ({
data={[
{ value: 100000, label: '0.2$' },
{ value: 500000, label: '1$' },
{ value: 1000000, label: '5$' },
{ value: 1000000, label: '2$' },
{ value: 5000000, label: '10$' },
]}
onChange={(val) => handleFormChange('warningThreshold', val)}

View File

@@ -0,0 +1,151 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui';
import { API, copy, showError, showSuccess } from '../../../../helpers';
const { Text } = Typography;
const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [authorizeUrl, setAuthorizeUrl] = useState('');
const [input, setInput] = useState('');
const startOAuth = async () => {
setLoading(true);
try {
const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true });
if (!res?.data?.success) {
console.error('Codex OAuth start failed:', res?.data?.message);
throw new Error(t('启动授权失败'));
}
const url = res?.data?.data?.authorize_url || '';
if (!url) {
console.error('Codex OAuth start response missing authorize_url:', res?.data);
throw new Error(t('响应缺少授权链接'));
}
setAuthorizeUrl(url);
window.open(url, '_blank', 'noopener,noreferrer');
showSuccess(t('已打开授权页面'));
} catch (error) {
showError(error?.message || t('启动授权失败'));
} finally {
setLoading(false);
}
};
const completeOAuth = async () => {
if (!input || !input.trim()) {
showError(t('请先粘贴回调 URL'));
return;
}
setLoading(true);
try {
const res = await API.post(
'/api/channel/codex/oauth/complete',
{ input },
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
console.error('Codex OAuth complete failed:', res?.data?.message);
throw new Error(t('授权失败'));
}
const key = res?.data?.data?.key || '';
if (!key) {
console.error('Codex OAuth complete response missing key:', res?.data);
throw new Error(t('响应缺少凭据'));
}
onSuccess && onSuccess(key);
showSuccess(t('已生成授权凭据'));
onCancel && onCancel();
} catch (error) {
showError(error?.message || t('授权失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!visible) return;
setAuthorizeUrl('');
setInput('');
}, [visible]);
return (
<Modal
title={t('Codex 授权')}
visible={visible}
onCancel={onCancel}
maskClosable={false}
closeOnEsc
width={720}
footer={
<Space>
<Button theme='borderless' onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
{t('生成并填入')}
</Button>
</Space>
}
>
<Space vertical spacing='tight' style={{ width: '100%' }}>
<Banner
type='info'
description={t(
'1) 点击「打开授权页面」完成登录2) 浏览器会跳转到 localhost页面打不开也没关系3) 复制地址栏完整 URL 粘贴到下方4) 点击「生成并填入」。',
)}
/>
<Space wrap>
<Button type='primary' onClick={startOAuth} loading={loading}>
{t('打开授权页面')}
</Button>
<Button
theme='outline'
disabled={!authorizeUrl || loading}
onClick={() => copy(authorizeUrl)}
>
{t('复制授权链接')}
</Button>
</Space>
<Input
value={input}
onChange={(value) => setInput(value)}
placeholder={t('请粘贴完整回调 URL包含 code 与 state')}
showClear
/>
<Text type='tertiary' size='small'>
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON包含 access_token / refresh_token / account_id。')}
</Text>
</Space>
</Modal>
);
};
export default CodexOAuthModal;

View File

@@ -0,0 +1,190 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Button, Progress, Tag, Typography } from '@douyinfe/semi-ui';
const { Text } = Typography;
const clampPercent = (value) => {
const v = Number(value);
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(100, v));
};
const pickStrokeColor = (percent) => {
const p = clampPercent(percent);
if (p >= 95) return '#ef4444';
if (p >= 80) return '#f59e0b';
return '#3b82f6';
};
const formatDurationSeconds = (seconds, t) => {
const tt = typeof t === 'function' ? t : (v) => v;
const s = Number(seconds);
if (!Number.isFinite(s) || s <= 0) return '-';
const total = Math.floor(s);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0) return `${hours}${tt('小时')} ${minutes}${tt('分钟')}`;
if (minutes > 0) return `${minutes}${tt('分钟')} ${secs}${tt('秒')}`;
return `${secs}${tt('秒')}`;
};
const formatUnixSeconds = (unixSeconds) => {
const v = Number(unixSeconds);
if (!Number.isFinite(v) || v <= 0) return '-';
try {
return new Date(v * 1000).toLocaleString();
} catch (error) {
return String(unixSeconds);
}
};
const RateLimitWindowCard = ({ t, title, windowData }) => {
const tt = typeof t === 'function' ? t : (v) => v;
const percent = clampPercent(windowData?.used_percent ?? 0);
const resetAt = windowData?.reset_at;
const resetAfterSeconds = windowData?.reset_after_seconds;
const limitWindowSeconds = windowData?.limit_window_seconds;
return (
<div className='rounded-lg border border-semi-color-border bg-semi-color-bg-0 p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='font-medium'>{title}</div>
<Text type='tertiary' size='small'>
{tt('重置时间:')}
{formatUnixSeconds(resetAt)}
</Text>
</div>
<div className='mt-2'>
<Progress
percent={percent}
stroke={pickStrokeColor(percent)}
showInfo={true}
/>
</div>
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-semi-color-text-2'>
<div>
{tt('已使用:')}
{percent}%
</div>
<div>
{tt('距离重置:')}
{formatDurationSeconds(resetAfterSeconds, tt)}
</div>
<div>
{tt('窗口:')}
{formatDurationSeconds(limitWindowSeconds, tt)}
</div>
</div>
</div>
);
};
export const openCodexUsageModal = ({ t, record, payload, onCopy }) => {
const tt = typeof t === 'function' ? t : (v) => v;
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 upstreamStatus = payload?.upstream_status;
const statusTag =
allowed && !limitReached ? (
<Tag color='green'>{tt('可用')}</Tag>
) : (
<Tag color='red'>{tt('受限')}</Tag>
);
const rawText =
typeof data === 'string' ? data : JSON.stringify(data ?? payload, null, 2);
Modal.info({
title: (
<div className='flex items-center gap-2'>
<span>{tt('Codex 用量')}</span>
{statusTag}
</div>
),
centered: true,
width: 900,
style: { maxWidth: '95vw' },
content: (
<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>
<Text type='tertiary' size='small'>
{tt('上游状态码:')}
{upstreamStatus ?? '-'}
</Text>
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<RateLimitWindowCard
t={tt}
title={tt('5小时窗口')}
windowData={primary}
/>
<RateLimitWindowCard
t={tt}
title={tt('每周窗口')}
windowData={secondary}
/>
</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>
</div>
),
footer: (
<div className='flex justify-end gap-2'>
<Button type='primary' theme='solid' onClick={() => Modal.destroyAll()}>
{tt('关闭')}
</Button>
</div>
),
});
};

View File

@@ -58,6 +58,7 @@ import {
import ModelSelectModal from './ModelSelectModal';
import SingleModelSelectModal from './SingleModelSelectModal';
import OllamaModelModal from './OllamaModelModal';
import CodexOAuthModal from './CodexOAuthModal';
import JSONEditor from '../../../common/ui/JSONEditor';
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -95,7 +96,7 @@ const REGION_EXAMPLE = {
// 支持并且已适配通过接口获取模型列表的渠道类型
const MODEL_FETCHABLE_TYPES = new Set([
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 35, 40, 42, 48, 43,
1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43,
]);
function type2secretPrompt(type) {
@@ -117,6 +118,8 @@ function type2secretPrompt(type) {
return '按照如下格式输入: AccessKey|SecretKey, 如果上游是New API则直接输ApiKey';
case 51:
return '按照如下格式输入: AccessKey|SecretAccessKey';
case 57:
return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id';
default:
return '请输入渠道对应的鉴权密钥';
}
@@ -222,6 +225,9 @@ const EditChannelModal = (props) => {
}, [inputs.model_mapping]);
const [isIonetChannel, setIsIonetChannel] = useState(false);
const [ionetMetadata, setIonetMetadata] = useState(null);
const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false);
const [codexCredentialRefreshing, setCodexCredentialRefreshing] =
useState(false);
// 密钥显示状态
const [keyDisplayState, setKeyDisplayState] = useState({
@@ -513,10 +519,34 @@ const EditChannelModal = (props) => {
// 重置手动输入模式状态
setUseManualInput(false);
if (value === 57) {
setBatch(false);
setMultiToSingle(false);
setMultiKeyMode('random');
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue('vertex_files', []);
}
setInputs((prev) => ({ ...prev, vertex_files: [] }));
}
}
//setAutoBan
};
const formatJsonField = (fieldName) => {
const rawValue = (inputs?.[fieldName] ?? '').trim();
if (!rawValue) return;
try {
const parsed = JSON.parse(rawValue);
handleInputChange(fieldName, JSON.stringify(parsed, null, 2));
} catch (error) {
showError(`${t('JSON格式错误')}: ${error.message}`);
}
};
const loadChannel = async () => {
setLoading(true);
let res = await API.get(`/api/channel/${channelId}`);
@@ -863,6 +893,32 @@ const EditChannelModal = (props) => {
}
};
const handleCodexOAuthGenerated = (key) => {
handleInputChange('key', key);
formatJsonField('key');
};
const handleRefreshCodexCredential = async () => {
if (!isEdit) return;
setCodexCredentialRefreshing(true);
try {
const res = await API.post(
`/api/channel/${channelId}/codex/refresh`,
{},
{ skipErrorHandler: true },
);
if (!res?.data?.success) {
throw new Error(res?.data?.message || 'Failed to refresh credential');
}
showSuccess(t('凭证已刷新'));
} catch (error) {
showError(error.message || t('刷新失败'));
} finally {
setCodexCredentialRefreshing(false);
}
};
useEffect(() => {
if (inputs.type !== 45) {
doubaoApiClickCountRef.current = 0;
@@ -1111,6 +1167,47 @@ const EditChannelModal = (props) => {
const formValues = formApiRef.current ? formApiRef.current.getValues() : {};
let localInputs = { ...formValues };
if (localInputs.type === 57) {
if (batch) {
showInfo(t('Codex 渠道不支持批量创建'));
return;
}
const rawKey = (localInputs.key || '').trim();
if (!isEdit && rawKey === '') {
showInfo(t('请输入密钥!'));
return;
}
if (rawKey !== '') {
if (!verifyJSON(rawKey)) {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
}
try {
const parsed = JSON.parse(rawKey);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
showInfo(t('密钥必须是 JSON 对象'));
return;
}
const accessToken = String(parsed.access_token || '').trim();
const accountId = String(parsed.account_id || '').trim();
if (!accessToken) {
showInfo(t('密钥 JSON 必须包含 access_token'));
return;
}
if (!accountId) {
showInfo(t('密钥 JSON 必须包含 account_id'));
return;
}
localInputs.key = JSON.stringify(parsed);
} catch (error) {
showInfo(t('密钥必须是合法的 JSON 格式!'));
return;
}
}
}
if (localInputs.type === 41) {
const keyType = localInputs.vertex_key_type || 'json';
if (keyType === 'api_key') {
@@ -1442,7 +1539,7 @@ const EditChannelModal = (props) => {
}
};
const batchAllowed = !isEdit || isMultiKeyChannel;
const batchAllowed = (!isEdit || isMultiKeyChannel) && inputs.type !== 57;
const batchExtra = batchAllowed ? (
<Space>
{!isEdit && (
@@ -1903,87 +2000,171 @@ const EditChannelModal = (props) => {
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
extraText={
<div className='flex items-center gap-2 flex-wrap'>
{isEdit &&
isMultiKeyChannel &&
keyMode === 'append' && (
<Text type='warning' size='small'>
{t(
'追加模式:新密钥将添加到现有密钥列表的末尾',
)}
</Text>
<Space>
)}
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</div>
}
showClear
/>
)
) : (
<>
{inputs.type === 57 ? (
<>
<Form.TextArea
field='key'
label={
isEdit
? t('密钥(编辑模式下,保存的密钥不会显示)')
: t('密钥')
}
placeholder={t(
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
)}
rules={
isEdit
? []
: [{ required: true, message: t('请输入密钥') }]
}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
disabled={isIonetLocked}
extraText={
<div className='flex flex-col gap-2'>
<Text type='tertiary' size='small'>
{t(
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
)}
</Text>
<Space wrap spacing='tight'>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
type='primary'
theme='outline'
onClick={() =>
setCodexOAuthModalVisible(true)
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
disabled={isIonetLocked}
>
{t('文件上传')}
{t('Codex 授权')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleRefreshCodexCredential}
loading={codexCredentialRefreshing}
disabled={isIonetLocked}
>
{t('刷新凭证')}
</Button>
)}
<Button
size='small'
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
type='primary'
theme='outline'
onClick={() => formatJsonField('key')}
disabled={isIonetLocked}
>
{t('手动输入')}
{t('格式化')}
</Button>
{isEdit && (
<Button
size='small'
type='primary'
theme='outline'
onClick={handleShow2FAModal}
disabled={isIonetLocked}
>
{t('查看密钥')}
</Button>
)}
{batchExtra}
</Space>
</div>
)}
}
autosize
showClear
/>
<CodexOAuthModal
visible={codexOAuthModalVisible}
onCancel={() => setCodexOAuthModalVisible(false)}
onSuccess={handleCodexOAuthGenerated}
/>
</>
) : inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>
<Text className='text-sm font-medium'>
{t('密钥输入方式')}
</Text>
<Space>
<Button
size='small'
type={
!useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(false);
// 切换到文件上传模式时清空手动输入的密钥
if (formApiRef.current) {
formApiRef.current.setValue('key', '');
}
handleInputChange('key', '');
}}
>
{t('文件上传')}
</Button>
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
setVertexKeys([]);
setVertexFileList([]);
if (formApiRef.current) {
formApiRef.current.setValue(
'vertex_files',
[],
);
}
setInputs((prev) => ({
...prev,
vertex_files: [],
}));
}}
>
{t('手动输入')}
</Button>
</Space>
</div>
)}
{batch && (
<Banner
@@ -2896,6 +3077,12 @@ const EditChannelModal = (props) => {
>
{t('新格式模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => formatJsonField('param_override')}
>
{t('格式化')}
</Text>
</div>
}
showClear
@@ -2936,6 +3123,12 @@ const EditChannelModal = (props) => {
>
{t('填入模板')}
</Text>
<Text
className='!text-semi-color-primary cursor-pointer'
onClick={() => formatJsonField('header_override')}
>
{t('格式化')}
</Text>
</div>
<div>
<Text type='tertiary' size='small'>