feat(channel-ui): support multi-JSON batch creation for Vertex AI & multi-to-single mode

WHY
• Backend (0089157) now accepts structured request `{ mode, channel }`, including new `multi_to_single`.
• Need front-end to upload multiple service-account JSON files and generate correct `channel.key`.
• Improve UX: avoid red “uploadFail” state and offer drag-and-drop UI.

WHAT
1. EditChannel.js
   • Added Upload drag-area with IconBolt; `uploadTrigger="custom"`.
   • `handleJsonFileUpload` reads file, pushes content to `jsonFiles`, returns `{ shouldUpload:false, status:'success' }`.
   • New states: `batch`, `mergeToSingle`, `jsonFiles`.
   • Dynamic mode resolver: `single` | `batch` | `multi_to_single`.
   • Builds `channel.key` as JSON-object whose keys are the raw credential texts.
   • UI:
     – “Batch create” checkbox (new build only).
     – Nested “Merge to single channel (multi-key mode)” checkbox enabled when batch=true.
     – Real-time file count display.

2. Upload UX
   • Drag-and-drop, accepts `.json,application/json`.
   • Custom texts: “Click or drop files here” / “JSON credentials only”.
   • Eliminated mandatory `action` warning (`action="#"`).

3. Misc
   • Included IconBolt import.
   • Safeguard toggles reset logic to prevent stale state.

RESULT
Front-end now fully aligns with enhanced AddChannel API:
• Supports Vertex AI multi JSON batch creation.
• Supports new `multi_to_single` flow.
• Clean user feedback with successful file status.
This commit is contained in:
Apple\Apple
2025-06-16 01:47:41 +08:00
parent aa793088ed
commit 617c8e8f4f

View File

@@ -25,6 +25,7 @@ import {
ImagePreview, ImagePreview,
Card, Card,
Tag, Tag,
Upload,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { getChannelModels } from '../../helpers'; import { getChannelModels } from '../../helpers';
import { import {
@@ -34,6 +35,7 @@ import {
IconSetting, IconSetting,
IconCode, IconCode,
IconGlobe, IconGlobe,
IconBolt,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -97,8 +99,9 @@ const EditChannel = (props) => {
tag: '', tag: '',
}; };
const [batch, setBatch] = useState(false); const [batch, setBatch] = useState(false);
const [mergeToSingle, setMergeToSingle] = useState(false);
const [autoBan, setAutoBan] = useState(true); const [autoBan, setAutoBan] = useState(true);
// const [autoBan, setAutoBan] = useState(true); const [jsonFiles, setJsonFiles] = useState([]);
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const [originModelOptions, setOriginModelOptions] = useState([]); const [originModelOptions, setOriginModelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]); const [modelOptions, setModelOptions] = useState([]);
@@ -325,9 +328,20 @@ const EditChannel = (props) => {
}, [props.editingChannel.id]); }, [props.editingChannel.id]);
const submit = async () => { const submit = async () => {
if (!isEdit && (inputs.name === '' || inputs.key === '')) { if (!isEdit) {
showInfo(t('请填写渠道名称和渠道密钥!')); if (inputs.name === '') {
return; showInfo(t('请填写渠道名称!'));
return;
}
if (inputs.type === 41 && batch) {
if (jsonFiles.length === 0) {
showInfo(t('请至少选择一个 JSON 凭证文件!'));
return;
}
} else if (inputs.key === '') {
showInfo(t('请填写渠道密钥!'));
return;
}
} }
if (inputs.models.length === 0) { if (inputs.models.length === 0) {
showInfo(t('请至少选择一个模型!')); showInfo(t('请至少选择一个模型!'));
@@ -356,13 +370,32 @@ const EditChannel = (props) => {
localInputs.auto_ban = autoBan ? 1 : 0; localInputs.auto_ban = autoBan ? 1 : 0;
localInputs.models = localInputs.models.join(','); localInputs.models = localInputs.models.join(',');
localInputs.group = localInputs.groups.join(','); localInputs.group = localInputs.groups.join(',');
if (inputs.type === 41 && batch) {
const keyObj = {};
jsonFiles.forEach((content, idx) => {
keyObj[content] = idx;
});
localInputs.key = JSON.stringify(keyObj);
}
let mode = 'single';
if (batch) {
mode = mergeToSingle ? 'multi_to_single' : 'batch';
}
const payload = {
mode,
channel: localInputs,
};
if (isEdit) { if (isEdit) {
res = await API.put(`/api/channel/`, { res = await API.put(`/api/channel/`, {
...localInputs, ...localInputs,
id: parseInt(channelId), id: parseInt(channelId),
}); });
} else { } else {
res = await API.post(`/api/channel/`, localInputs); res = await API.post(`/api/channel/`, payload);
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -415,6 +448,18 @@ const EditChannel = (props) => {
} }
}; };
const handleJsonFileUpload = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
setJsonFiles((prev) => [...prev, content]);
resolve({ shouldUpload: false, status: 'success' });
};
reader.readAsText(file);
});
};
return ( return (
<> <>
<SideSheet <SideSheet
@@ -522,72 +567,107 @@ const EditChannel = (props) => {
<div> <div>
<Text strong className="block mb-2">{t('密钥')}</Text> <Text strong className="block mb-2">{t('密钥')}</Text>
{batch ? ( {batch ? (
<TextArea inputs.type === 41 ? (
name='key' <Upload
required uploadTrigger="custom"
placeholder={t('请输入密钥,一行一个')} action="#"
onChange={(value) => { accept=".json,application/json"
handleInputChange('key', value); multiple
}} beforeUpload={handleJsonFileUpload}
value={inputs.key} showUploadList
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} draggable={true}
autoComplete='new-password' dragIcon={<IconBolt />}
className="!rounded-lg" dragMainText={t('点击上传文件或拖拽文件到这里')}
/> dragSubText={t('仅支持 JSON 格式的凭证文件')}
style={{ marginTop: 10 }}
/>
) : (
<TextArea
name="key"
required
placeholder={t('请输入密钥,一行一个')}
onChange={(value) => {
handleInputChange('key', value);
}}
value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete="new-password"
className="!rounded-lg"
/>
)
) : ( ) : (
<> inputs.type === 41 ? (
{inputs.type === 41 ? ( <TextArea
<TextArea name="key"
name='key' required
required placeholder={
placeholder={ '{\n' +
'{\n' + ' "type": "service_account",\n' +
' "type": "service_account",\n' + ' "project_id": "abc-bcd-123-456",\n' +
' "project_id": "abc-bcd-123-456",\n' + ' "private_key_id": "123xxxxx456",\n' +
' "private_key_id": "123xxxxx456",\n' + ' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' +
' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' + ' "client_email": "xxx@developer.gserviceaccount.com",\n' +
' "client_email": "xxx@developer.gserviceaccount.com",\n' + ' "client_id": "111222333",\n' +
' "client_id": "111222333",\n' + ' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' +
' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' + ' "token_uri": "https://oauth2.googleapis.com/token",\n' +
' "token_uri": "https://oauth2.googleapis.com/token",\n' + ' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' +
' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' + ' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' +
' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' + ' "universe_domain": "googleapis.com"\n' +
' "universe_domain": "googleapis.com"\n' + '}'
'}' }
} onChange={(value) => {
onChange={(value) => { handleInputChange('key', value);
handleInputChange('key', value); }}
}} autosize={{ minRows: 10 }}
autosize={{ minRows: 10 }} value={inputs.key}
value={inputs.key} autoComplete="new-password"
autoComplete='new-password' className="!rounded-lg font-mono"
className="!rounded-lg font-mono" />
/> ) : (
) : ( <Input
<Input name="key"
name='key' required
required placeholder={t(type2secretPrompt(inputs.type))}
placeholder={t(type2secretPrompt(inputs.type))} onChange={(value) => {
onChange={(value) => { handleInputChange('key', value);
handleInputChange('key', value); }}
}} value={inputs.key}
value={inputs.key} autoComplete="new-password"
autoComplete='new-password' size="large"
size="large" className="!rounded-lg"
className="!rounded-lg" />
/> )
)}
</>
)} )}
</div> </div>
{!isEdit && ( {!isEdit && (
<div className="flex items-center"> <div className="flex flex-col mt-2 gap-2">
<Checkbox <div className="flex items-center">
checked={batch} <Checkbox
onChange={() => setBatch(!batch)} checked={batch}
/> onChange={() => {
<Text strong className="ml-2">{t('批量创建')}</Text> setBatch(!batch);
if (!batch === false) setMergeToSingle(false);
}}
/>
<Text strong className="ml-2">{t('批量创建')}</Text>
{inputs.type === 41 && batch && (
<Text type='tertiary' className='ml-2'>
{t('已选择 {{count}} 个文件', { count: jsonFiles.length })}
</Text>
)}
</div>
{batch && (
<div className="flex items-center pl-6">
<Checkbox
checked={mergeToSingle}
onChange={() => setMergeToSingle(!mergeToSingle)}
/>
<Text style={{ fontSize: 12 }} className="ml-2 text-gray-600">
{t('合并为单通道(多 Key 模式)')}
</Text>
</div>
)}
</div> </div>
)} )}
</div> </div>