✨ 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,
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user