✨ 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:
@@ -25,6 +25,7 @@ import {
|
||||
ImagePreview,
|
||||
Card,
|
||||
Tag,
|
||||
Upload,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels } from '../../helpers';
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
IconSetting,
|
||||
IconCode,
|
||||
IconGlobe,
|
||||
IconBolt,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
@@ -97,8 +99,9 @@ const EditChannel = (props) => {
|
||||
tag: '',
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [mergeToSingle, setMergeToSingle] = useState(false);
|
||||
const [autoBan, setAutoBan] = useState(true);
|
||||
// const [autoBan, setAutoBan] = useState(true);
|
||||
const [jsonFiles, setJsonFiles] = useState([]);
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const [originModelOptions, setOriginModelOptions] = useState([]);
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
@@ -325,9 +328,20 @@ const EditChannel = (props) => {
|
||||
}, [props.editingChannel.id]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
|
||||
showInfo(t('请填写渠道名称和渠道密钥!'));
|
||||
return;
|
||||
if (!isEdit) {
|
||||
if (inputs.name === '') {
|
||||
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) {
|
||||
showInfo(t('请至少选择一个模型!'));
|
||||
@@ -356,13 +370,32 @@ const EditChannel = (props) => {
|
||||
localInputs.auto_ban = autoBan ? 1 : 0;
|
||||
localInputs.models = localInputs.models.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) {
|
||||
res = await API.put(`/api/channel/`, {
|
||||
...localInputs,
|
||||
id: parseInt(channelId),
|
||||
});
|
||||
} else {
|
||||
res = await API.post(`/api/channel/`, localInputs);
|
||||
res = await API.post(`/api/channel/`, payload);
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
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 (
|
||||
<>
|
||||
<SideSheet
|
||||
@@ -522,72 +567,107 @@ const EditChannel = (props) => {
|
||||
<div>
|
||||
<Text strong className="block mb-2">{t('密钥')}</Text>
|
||||
{batch ? (
|
||||
<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 ? (
|
||||
<Upload
|
||||
uploadTrigger="custom"
|
||||
action="#"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
beforeUpload={handleJsonFileUpload}
|
||||
showUploadList
|
||||
draggable={true}
|
||||
dragIcon={<IconBolt />}
|
||||
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 ? (
|
||||
<TextArea
|
||||
name='key'
|
||||
required
|
||||
placeholder={
|
||||
'{\n' +
|
||||
' "type": "service_account",\n' +
|
||||
' "project_id": "abc-bcd-123-456",\n' +
|
||||
' "private_key_id": "123xxxxx456",\n' +
|
||||
' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' +
|
||||
' "client_email": "xxx@developer.gserviceaccount.com",\n' +
|
||||
' "client_id": "111222333",\n' +
|
||||
' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' +
|
||||
' "token_uri": "https://oauth2.googleapis.com/token",\n' +
|
||||
' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' +
|
||||
' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' +
|
||||
' "universe_domain": "googleapis.com"\n' +
|
||||
'}'
|
||||
}
|
||||
onChange={(value) => {
|
||||
handleInputChange('key', value);
|
||||
}}
|
||||
autosize={{ minRows: 10 }}
|
||||
value={inputs.key}
|
||||
autoComplete='new-password'
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
name='key'
|
||||
required
|
||||
placeholder={t(type2secretPrompt(inputs.type))}
|
||||
onChange={(value) => {
|
||||
handleInputChange('key', value);
|
||||
}}
|
||||
value={inputs.key}
|
||||
autoComplete='new-password'
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
inputs.type === 41 ? (
|
||||
<TextArea
|
||||
name="key"
|
||||
required
|
||||
placeholder={
|
||||
'{\n' +
|
||||
' "type": "service_account",\n' +
|
||||
' "project_id": "abc-bcd-123-456",\n' +
|
||||
' "private_key_id": "123xxxxx456",\n' +
|
||||
' "private_key": "-----BEGIN PRIVATE KEY-----xxxx\n' +
|
||||
' "client_email": "xxx@developer.gserviceaccount.com",\n' +
|
||||
' "client_id": "111222333",\n' +
|
||||
' "auth_uri": "https://accounts.google.com/o/oauth2/auth",\n' +
|
||||
' "token_uri": "https://oauth2.googleapis.com/token",\n' +
|
||||
' "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",\n' +
|
||||
' "client_x509_cert_url": "https://xxxxx.gserviceaccount.com",\n' +
|
||||
' "universe_domain": "googleapis.com"\n' +
|
||||
'}'
|
||||
}
|
||||
onChange={(value) => {
|
||||
handleInputChange('key', value);
|
||||
}}
|
||||
autosize={{ minRows: 10 }}
|
||||
value={inputs.key}
|
||||
autoComplete="new-password"
|
||||
className="!rounded-lg font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
name="key"
|
||||
required
|
||||
placeholder={t(type2secretPrompt(inputs.type))}
|
||||
onChange={(value) => {
|
||||
handleInputChange('key', value);
|
||||
}}
|
||||
value={inputs.key}
|
||||
autoComplete="new-password"
|
||||
size="large"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={batch}
|
||||
onChange={() => setBatch(!batch)}
|
||||
/>
|
||||
<Text strong className="ml-2">{t('批量创建')}</Text>
|
||||
<div className="flex flex-col mt-2 gap-2">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={batch}
|
||||
onChange={() => {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user