Browse Source

✨ 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.
Apple\Apple 6 months ago
parent
commit
617c8e8f4f
1 changed files with 146 additions and 66 deletions
  1. 146 66
      web/src/pages/Channel/EditChannel.js

+ 146 - 66
web/src/pages/Channel/EditChannel.js

@@ -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": "[email protected]",\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": "[email protected]",\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>