Browse Source

feat: 渠道标签分组

CalciumIon 1 year ago
parent
commit
0ce600ed49

+ 97 - 1
controller/channel.go

@@ -57,10 +57,24 @@ func GetAllChannels(c *gin.Context) {
 		})
 		return
 	}
+	tags := make(map[string]bool)
+	channelData := make([]*model.Channel, 0, len(channels))
+	for _, channel := range channels {
+		channelTag := channel.GetTag()
+		if channelTag != "" && !tags[channelTag] {
+			tags[channelTag] = true
+			tagChannels, err := model.GetChannelsByTag(channelTag)
+			if err == nil {
+				channelData = append(channelData, tagChannels...)
+			}
+		} else {
+			channelData = append(channelData, channel)
+		}
+	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    channels,
+		"data":    channelData,
 	})
 	return
 }
@@ -279,6 +293,88 @@ func DeleteDisabledChannel(c *gin.Context) {
 	return
 }
 
+type ChannelTag struct {
+	Tag      string  `json:"tag"`
+	NewTag   *string `json:"newTag"`
+	Priority *int64  `json:"priority"`
+	Weight   *uint   `json:"weight"`
+}
+
+func DisableTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil || channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	err = model.DisableChannelByTag(channelTag.Tag)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
+func EnableTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil || channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	err = model.EnableChannelByTag(channelTag.Tag)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
+func EditTagChannels(c *gin.Context) {
+	channelTag := ChannelTag{}
+	err := c.ShouldBindJSON(&channelTag)
+	if err != nil || channelTag.Tag == "" {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.Priority, channelTag.Weight)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+	})
+	return
+}
+
 type ChannelBatch struct {
 	Ids []int `json:"ids"`
 }

+ 26 - 6
model/ability.go

@@ -10,12 +10,13 @@ import (
 )
 
 type Ability struct {
-	Group     string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
-	Model     string `json:"model" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
-	ChannelId int    `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
-	Enabled   bool   `json:"enabled"`
-	Priority  *int64 `json:"priority" gorm:"bigint;default:0;index"`
-	Weight    uint   `json:"weight" gorm:"default:0;index"`
+	Group     string  `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
+	Model     string  `json:"model" gorm:"type:varchar(64);primaryKey;autoIncrement:false"`
+	ChannelId int     `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"`
+	Enabled   bool    `json:"enabled"`
+	Priority  *int64  `json:"priority" gorm:"bigint;default:0;index"`
+	Weight    uint    `json:"weight" gorm:"default:0;index"`
+	Tag       *string `json:"tag" gorm:"index"`
 }
 
 func GetGroupModels(group string) []string {
@@ -149,6 +150,7 @@ func (channel *Channel) AddAbilities() error {
 				Enabled:   channel.Status == common.ChannelStatusEnabled,
 				Priority:  channel.Priority,
 				Weight:    uint(channel.GetWeight()),
+				Tag:       channel.Tag,
 			}
 			abilities = append(abilities, ability)
 		}
@@ -190,6 +192,24 @@ func UpdateAbilityStatus(channelId int, status bool) error {
 	return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error
 }
 
+func UpdateAbilityStatusByTag(tag string, status bool) error {
+	return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error
+}
+
+func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error {
+	ability := Ability{}
+	if newTag != nil {
+		ability.Tag = newTag
+	}
+	if priority != nil {
+		ability.Priority = priority
+	}
+	if weight != nil {
+		ability.Weight = *weight
+	}
+	return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error
+}
+
 func FixAbility() (int, error) {
 	var channelIds []int
 	count := 0

+ 54 - 0
model/channel.go

@@ -32,6 +32,7 @@ type Channel struct {
 	Priority          *int64  `json:"priority" gorm:"bigint;default:0"`
 	AutoBan           *int    `json:"auto_ban" gorm:"default:1"`
 	OtherInfo         string  `json:"other_info"`
+	Tag               *string `json:"tag" gorm:"index"`
 }
 
 func (channel *Channel) GetModels() []string {
@@ -61,6 +62,17 @@ func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) {
 	channel.OtherInfo = string(otherInfoBytes)
 }
 
+func (channel *Channel) GetTag() string {
+	if channel.Tag == nil {
+		return ""
+	}
+	return *channel.Tag
+}
+
+func (channel *Channel) SetTag(tag string) {
+	channel.Tag = &tag
+}
+
 func (channel *Channel) GetAutoBan() bool {
 	if channel.AutoBan == nil {
 		return false
@@ -87,6 +99,12 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
 	return channels, err
 }
 
+func GetChannelsByTag(tag string) ([]*Channel, error) {
+	var channels []*Channel
+	err := DB.Where("tag = ?", tag).Find(&channels).Error
+	return channels, err
+}
+
 func SearchChannels(keyword string, group string, model string) ([]*Channel, error) {
 	var channels []*Channel
 	keyCol := "`key`"
@@ -288,6 +306,42 @@ func UpdateChannelStatusById(id int, status int, reason string) {
 
 }
 
+func EnableChannelByTag(tag string) error {
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusEnabled).Error
+	if err != nil {
+		return err
+	}
+	err = UpdateAbilityStatusByTag(tag, true)
+	return err
+}
+
+func DisableChannelByTag(tag string) error {
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusManuallyDisabled).Error
+	if err != nil {
+		return err
+	}
+	err = UpdateAbilityStatusByTag(tag, false)
+	return err
+}
+
+func EditChannelByTag(tag string, newTag *string, priority *int64, weight *uint) error {
+	updateData := Channel{}
+	if newTag != nil {
+		updateData.Tag = newTag
+	}
+	if priority != nil {
+		updateData.Priority = priority
+	}
+	if weight != nil {
+		updateData.Weight = weight
+	}
+	err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
+	if err != nil {
+		return err
+	}
+	return UpdateAbilityByTag(tag, newTag, priority, weight)
+}
+
 func UpdateChannelUsedQuota(id int, quota int) {
 	if common.BatchUpdateEnabled {
 		addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota)

+ 3 - 0
router/api-router.go

@@ -91,6 +91,9 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.POST("/", controller.AddChannel)
 			channelRoute.PUT("/", controller.UpdateChannel)
 			channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel)
+			channelRoute.POST("/tag/disabled", controller.DisableTagChannels)
+			channelRoute.POST("/tag/enabled", controller.EnableTagChannels)
+			channelRoute.PUT("/tag", controller.EditTagChannels)
 			channelRoute.DELETE("/:id", controller.DeleteChannel)
 			channelRoute.POST("/batch", controller.DeleteChannelBatch)
 			channelRoute.POST("/fix", controller.FixChannelsAbilities)

+ 553 - 400
web/src/components/ChannelsTable.js

@@ -7,14 +7,14 @@ import {
   showInfo,
   showSuccess,
   showWarning,
-  timestamp2string,
+  timestamp2string
 } from '../helpers';
 
 import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
 import {
   renderGroup,
   renderNumberWithPoint,
-  renderQuota,
+  renderQuota
 } from '../helpers/render';
 import {
   Button, Divider,
@@ -28,11 +28,12 @@ import {
   Table,
   Tag,
   Tooltip,
-  Typography,
+  Typography
 } from '@douyinfe/semi-ui';
 import EditChannel from '../pages/Channel/EditChannel';
 import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
 import { loadChannelModels } from './utils.js';
+import EditTagModal from '../pages/Channel/EditTagModal.js';
 
 function renderTimestamp(timestamp) {
   return <>{timestamp2string(timestamp)}</>;
@@ -49,7 +50,7 @@ function renderType(type) {
     type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
   }
   return (
-    <Tag size='large' color={type2label[type]?.color}>
+    <Tag size="large" color={type2label[type]?.color}>
       {type2label[type]?.text}
     </Tag>
   );
@@ -64,11 +65,11 @@ const ChannelsTable = () => {
     // },
     {
       title: 'ID',
-      dataIndex: 'id',
+      dataIndex: 'id'
     },
     {
       title: '名称',
-      dataIndex: 'name',
+      dataIndex: 'name'
     },
     {
       title: '分组',
@@ -77,20 +78,20 @@ const ChannelsTable = () => {
         return (
           <div>
             <Space spacing={2}>
-              {text.split(',').map((item, index) => {
+              {text?.split(',').map((item, index) => {
                 return renderGroup(item);
               })}
             </Space>
           </div>
         );
-      },
+      }
     },
     {
       title: '类型',
       dataIndex: 'type',
       render: (text, record, index) => {
         return <div>{renderType(text)}</div>;
-      },
+      }
     },
     {
       title: '状态',
@@ -98,7 +99,7 @@ const ChannelsTable = () => {
       render: (text, record, index) => {
         if (text === 3) {
           if (record.other_info === '') {
-            record.other_info = '{}'
+            record.other_info = '{}';
           }
           let otherInfo = JSON.parse(record.other_info);
           let reason = otherInfo['status_reason'];
@@ -113,181 +114,246 @@ const ChannelsTable = () => {
         } else {
           return renderStatus(text);
         }
-      },
+      }
     },
     {
       title: '响应时间',
       dataIndex: 'response_time',
       render: (text, record, index) => {
         return <div>{renderResponseTime(text)}</div>;
-      },
+      }
     },
     {
       title: '已用/剩余',
       dataIndex: 'expired_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            <Space spacing={1}>
-              <Tooltip content={'已用额度'}>
-                <Tag color='white' type='ghost' size='large'>
-                  {renderQuota(record.used_quota)}
-                </Tag>
-              </Tooltip>
-              <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
-                <Tag
-                  color='white'
-                  type='ghost'
-                  size='large'
-                  onClick={() => {
-                    updateChannelBalance(record);
-                  }}
-                >
-                  ${renderNumberWithPoint(record.balance)}
-                </Tag>
-              </Tooltip>
-            </Space>
-          </div>
-        );
-      },
+        if (record.children === undefined) {
+          return (
+            <div>
+              <Space spacing={1}>
+                <Tooltip content={'已用额度'}>
+                  <Tag color="white" type="ghost" size="large">
+                    {renderQuota(record.used_quota)}
+                  </Tag>
+                </Tooltip>
+                <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
+                  <Tag
+                    color="white"
+                    type="ghost"
+                    size="large"
+                    onClick={() => {
+                      updateChannelBalance(record);
+                    }}
+                  >
+                    ${renderNumberWithPoint(record.balance)}
+                  </Tag>
+                </Tooltip>
+              </Space>
+            </div>
+          );
+        } else {
+          return <Tooltip content={'已用额度'}>
+            <Tag color="white" type="ghost" size="large">
+              {renderQuota(record.used_quota)}
+            </Tag>
+          </Tooltip>;
+        }
+      }
     },
     {
       title: '优先级',
       dataIndex: 'priority',
       render: (text, record, index) => {
-        return (
-          <div>
-            <InputNumber
-              style={{ width: 70 }}
-              name='priority'
-              onBlur={(e) => {
-                manageChannel(record.id, 'priority', record, e.target.value);
-              }}
-              keepFocus={true}
-              innerButtons
-              defaultValue={record.priority}
-              min={-999}
-            />
-          </div>
-        );
-      },
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name="priority"
+                onBlur={(e) => {
+                  manageChannel(record.id, 'priority', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.priority}
+                min={-999}
+              />
+            </div>
+          );
+        } else {
+          return <>
+            <Button theme="outline" type="primary">修改</Button>
+          </>;
+        }
+      }
     },
     {
       title: '权重',
       dataIndex: 'weight',
       render: (text, record, index) => {
-        return (
-          <div>
-            <InputNumber
-              style={{ width: 70 }}
-              name='weight'
-              onBlur={(e) => {
-                manageChannel(record.id, 'weight', record, e.target.value);
-              }}
-              keepFocus={true}
-              innerButtons
-              defaultValue={record.weight}
-              min={0}
-            />
-          </div>
-        );
-      },
+        if (record.children === undefined) {
+          return (
+            <div>
+              <InputNumber
+                style={{ width: 70 }}
+                name="weight"
+                onBlur={(e) => {
+                  manageChannel(record.id, 'weight', record, e.target.value);
+                }}
+                keepFocus={true}
+                innerButtons
+                defaultValue={record.weight}
+                min={0}
+              />
+            </div>
+          );
+        } else {
+          return (
+            <Button
+              theme="outline"
+              type="primary"
+            >
+              修改
+            </Button>
+          );
+        }
+      }
     },
     {
       title: '',
       dataIndex: 'operate',
-      render: (text, record, index) => (
-        <div>
-          <SplitButtonGroup
-            style={{ marginRight: 1 }}
-            aria-label='测试操作项目组'
-          >
-            <Button
-              theme='light'
-              onClick={() => {
-                testChannel(record, '');
-              }}
-            >
-              测试
-            </Button>
-            <Dropdown
-              trigger='click'
-              position='bottomRight'
-              menu={record.test_models}
-            >
+      render: (text, record, index) => {
+        if (record.children === undefined) {
+          return (
+            <div>
+              <SplitButtonGroup
+                style={{ marginRight: 1 }}
+                aria-label="测试单个渠道操作项目组"
+              >
+                <Button
+                  theme="light"
+                  onClick={() => {
+                    testChannel(record, '');
+                  }}
+                >
+                  测试
+                </Button>
+                <Dropdown
+                  trigger="click"
+                  position="bottomRight"
+                  menu={record.test_models}
+                >
+                  <Button
+                    style={{ padding: '8px 4px' }}
+                    type="primary"
+                    icon={<IconTreeTriangleDown />}
+                  ></Button>
+                </Dropdown>
+              </SplitButtonGroup>
+              {/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
+              <Popconfirm
+                title="确定是否要删除此渠道?"
+                content="此修改将不可逆"
+                okType={'danger'}
+                position={'left'}
+                onConfirm={() => {
+                  manageChannel(record.id, 'delete', record).then(() => {
+                    removeRecord(record.id);
+                  });
+                }}
+              >
+                <Button theme="light" type="danger" style={{ marginRight: 1 }}>
+                  删除
+                </Button>
+              </Popconfirm>
+              {record.status === 1 ? (
+                <Button
+                  theme="light"
+                  type="warning"
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageChannel(record.id, 'disable', record);
+                  }}
+                >
+                  禁用
+                </Button>
+              ) : (
+                <Button
+                  theme="light"
+                  type="secondary"
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageChannel(record.id, 'enable', record);
+                  }}
+                >
+                  启用
+                </Button>
+              )}
               <Button
-                style={{ padding: '8px 4px' }}
-                type='primary'
-                icon={<IconTreeTriangleDown />}
-              ></Button>
-            </Dropdown>
-          </SplitButtonGroup>
-          {/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
-          <Popconfirm
-            title='确定是否要删除此渠道?'
-            content='此修改将不可逆'
-            okType={'danger'}
-            position={'left'}
-            onConfirm={() => {
-              manageChannel(record.id, 'delete', record).then(() => {
-                removeRecord(record.id);
-              });
-            }}
-          >
-            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
-              删除
-            </Button>
-          </Popconfirm>
-          {record.status === 1 ? (
-            <Button
-              theme='light'
-              type='warning'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageChannel(record.id, 'disable', record);
-              }}
-            >
-              禁用
-            </Button>
-          ) : (
-            <Button
-              theme='light'
-              type='secondary'
-              style={{ marginRight: 1 }}
-              onClick={async () => {
-                manageChannel(record.id, 'enable', record);
-              }}
-            >
-              启用
-            </Button>
-          )}
-          <Button
-            theme='light'
-            type='tertiary'
-            style={{ marginRight: 1 }}
-            onClick={() => {
-              setEditingChannel(record);
-              setShowEdit(true);
-            }}
-          >
-            编辑
-          </Button>
-          <Popconfirm
-            title='确定是否要复制此渠道?'
-            content='复制渠道的所有信息'
-            okType={'danger'}
-            position={'left'}
-            onConfirm={async () => {
-              copySelectedChannel(record.id);
-            }}
-          >
-            <Button theme='light' type='primary' style={{ marginRight: 1 }}>
-              复制
-            </Button>
-          </Popconfirm>
-        </div>
-      ),
-    },
+                theme="light"
+                type="tertiary"
+                style={{ marginRight: 1 }}
+                onClick={() => {
+                  setEditingChannel(record);
+                  setShowEdit(true);
+                }}
+              >
+                编辑
+              </Button>
+              <Popconfirm
+                title="确定是否要复制此渠道?"
+                content="复制渠道的所有信息"
+                okType={'danger'}
+                position={'left'}
+                onConfirm={async () => {
+                  copySelectedChannel(record.id);
+                }}
+              >
+                <Button theme="light" type="primary" style={{ marginRight: 1 }}>
+                  复制
+                </Button>
+              </Popconfirm>
+            </div>
+          );
+        } else {
+          return (
+            <>
+              <Button
+                theme="light"
+                type="secondary"
+                style={{ marginRight: 1 }}
+                onClick={async () => {
+                  manageTag(record.key, 'enable');
+                }}
+              >
+                启用全部
+              </Button>
+              <Button
+                theme="light"
+                type="warning"
+                style={{ marginRight: 1 }}
+                onClick={async () => {
+                  manageTag(record.key, 'disable');
+                }}
+              >
+                禁用全部
+              </Button>
+              <Button
+                theme="light"
+                type="tertiary"
+                style={{ marginRight: 1 }}
+                onClick={() => {
+                  setShowEditTag(true);
+                  setEditingTag(record.key);
+                }}
+              >
+                编辑
+              </Button>
+            </>
+          );
+        }
+      }
+    }
   ];
 
   const [channels, setChannels] = useState([]);
@@ -301,15 +367,17 @@ const ChannelsTable = () => {
   const [updatingBalance, setUpdatingBalance] = useState(false);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
   const [showPrompt, setShowPrompt] = useState(
-    shouldShowPrompt('channel-test'),
+    shouldShowPrompt('channel-test')
   );
   const [channelCount, setChannelCount] = useState(pageSize);
   const [groupOptions, setGroupOptions] = useState([]);
   const [showEdit, setShowEdit] = useState(false);
   const [enableBatchDelete, setEnableBatchDelete] = useState(false);
   const [editingChannel, setEditingChannel] = useState({
-    id: undefined,
+    id: undefined
   });
+  const [showEditTag, setShowEditTag] = useState(false);
+  const [editingTag, setEditingTag] = useState('');
   const [selectedChannels, setSelectedChannels] = useState([]);
 
   const removeRecord = (id) => {
@@ -325,39 +393,82 @@ const ChannelsTable = () => {
   };
 
   const setChannelFormat = (channels) => {
+    let channelDates = [];
+    let channelTags = {};
     for (let i = 0; i < channels.length; i++) {
-      // if (channels[i].type === 8) {
-      //   showWarning(
-      //     '检测到您使用了“自定义渠道”类型,请更换为“OpenAI”渠道类型!',
-      //   );
-      //   showWarning('下个版本将不再支持“自定义渠道”类型!');
-      // }
       channels[i].key = '' + channels[i].id;
-      let test_models = [];
-      channels[i].models.split(',').forEach((item, index) => {
-        test_models.push({
-          node: 'item',
-          name: item,
-          onClick: () => {
-            testChannel(channels[i], item);
-          },
+
+      if (channels[i].tag === '' || channels[i].tag === null) {
+        let test_models = [];
+        channels[i].models.split(',').forEach((item, index) => {
+          test_models.push({
+            node: 'item',
+            name: item,
+            onClick: () => {
+              testChannel(channels[i], item);
+            }
+          });
         });
-      });
-      channels[i].test_models = test_models;
+        channels[i].test_models = test_models;
+        channelDates.push(channels[i]);
+      } else {
+        let tag = channels[i].tag;
+        // find from channelTags
+        let tagIndex = channelTags[tag];
+        let tagChannelDates = undefined;
+        if (tagIndex === undefined) {
+          // not found, create a new tag
+          channelTags[tag] = 1;
+          tagChannelDates = {
+            key: tag,
+            id: tag,
+            tag: tag,
+            name: '标签:' + tag,
+            group: '',
+            used_quota: 0,
+            response_time: 0
+          };
+          tagChannelDates.children = [];
+          channelDates.push(tagChannelDates);
+        } else {
+          // found, add to the tag
+          tagChannelDates = channelDates.find((item) => item.key === tag);
+        }
+
+        if (tagChannelDates.group === '') {
+          tagChannelDates.group = channels[i].group;
+        } else {
+          let channelGroupsStr = channels[i].group;
+          channelGroupsStr.split(',').forEach((item, index) => {
+            if (tagChannelDates.group.indexOf(item) === -1) {
+              tagChannelDates.group += item + ',';
+            }
+          });
+        }
+
+        tagChannelDates.children.push(channels[i]);
+        if (channels[i].status === 1) {
+          tagChannelDates.status = 1;
+        }
+        tagChannelDates.used_quota += channels[i].used_quota;
+        tagChannelDates.response_time += channels[i].response_time;
+        tagChannelDates.response_time = tagChannelDates.response_time / 2;
+      }
+
     }
     // data.key = '' + data.id
-    setChannels(channels);
-    if (channels.length >= pageSize) {
-      setChannelCount(channels.length + pageSize);
+    setChannels(channelDates);
+    if (channelDates.length >= pageSize) {
+      setChannelCount(channelDates.length + pageSize);
     } else {
-      setChannelCount(channels.length);
+      setChannelCount(channelDates.length);
     }
   };
 
   const loadChannels = async (startIdx, pageSize, idSort) => {
     setLoading(true);
     const res = await API.get(
-      `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`,
+      `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`
     );
     if (res === undefined) {
       return;
@@ -379,7 +490,7 @@ const ChannelsTable = () => {
 
   const copySelectedChannel = async (id) => {
     const channelToCopy = channels.find(
-      (channel) => String(channel.id) === String(id),
+      (channel) => String(channel.id) === String(id)
     );
     console.log(channelToCopy);
     channelToCopy.name += '_复制';
@@ -472,29 +583,63 @@ const ChannelsTable = () => {
     }
   };
 
+  const manageTag = async (tag, action) => {
+    console.log(tag, action);
+    let res;
+    switch (action) {
+      case 'enable':
+        res = await API.post('/api/channel/tag/enabled', {
+          tag: tag
+        });
+        break;
+      case 'disable':
+        res = await API.post('/api/channel/tag/disabled', {
+          tag: tag
+        });
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let newChannels = [...channels];
+      for (let i = 0; i < newChannels.length; i++) {
+        if (newChannels[i].tag === tag) {
+          let status = action === 'enable' ? 1 : 2;
+          newChannels[i]?.children?.forEach((channel) => {
+            channel.status = status;
+          });
+          newChannels[i].status = status;
+        }
+      }
+      setChannels(newChannels);
+    } else {
+      showError(message);
+    }
+  };
+
   const renderStatus = (status) => {
     switch (status) {
       case 1:
         return (
-          <Tag size='large' color='green'>
+          <Tag size="large" color="green">
             已启用
           </Tag>
         );
       case 2:
         return (
-          <Tag size='large' color='yellow'>
+          <Tag size="large" color="yellow">
             已禁用
           </Tag>
         );
       case 3:
         return (
-          <Tag size='large' color='yellow'>
+          <Tag size="large" color="yellow">
             自动禁用
           </Tag>
         );
       default:
         return (
-          <Tag size='large' color='grey'>
+          <Tag size="large" color="grey">
             未知状态
           </Tag>
         );
@@ -506,31 +651,31 @@ const ChannelsTable = () => {
     time = time.toFixed(2) + ' 秒';
     if (responseTime === 0) {
       return (
-        <Tag size='large' color='grey'>
+        <Tag size="large" color="grey">
           未测试
         </Tag>
       );
     } else if (responseTime <= 1000) {
       return (
-        <Tag size='large' color='green'>
+        <Tag size="large" color="green">
           {time}
         </Tag>
       );
     } else if (responseTime <= 3000) {
       return (
-        <Tag size='large' color='lime'>
+        <Tag size="large" color="lime">
           {time}
         </Tag>
       );
     } else if (responseTime <= 5000) {
       return (
-        <Tag size='large' color='yellow'>
+        <Tag size="large" color="yellow">
           {time}
         </Tag>
       );
     } else {
       return (
-        <Tag size='large' color='red'>
+        <Tag size="large" color="red">
           {time}
         </Tag>
       );
@@ -546,7 +691,7 @@ const ChannelsTable = () => {
     }
     setSearching(true);
     const res = await API.get(
-      `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`,
+      `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`
     );
     const { success, message, data } = res.data;
     if (success) {
@@ -649,14 +794,15 @@ const ChannelsTable = () => {
 
   let pageData = channels.slice(
     (activePage - 1) * pageSize,
-    activePage * pageSize,
+    activePage * pageSize
   );
 
   const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(channels.length / pageSize) + 1) {
       // In this case we have to load more data and then append them.
-      loadChannels(page - 1, pageSize, idSort).then((r) => {});
+      loadChannels(page - 1, pageSize, idSort).then((r) => {
+      });
     }
   };
 
@@ -682,8 +828,8 @@ const ChannelsTable = () => {
       setGroupOptions(
         res.data.data.map((group) => ({
           label: group,
-          value: group,
-        })),
+          value: group
+        }))
       );
     } catch (error) {
       showError(error.message);
@@ -698,8 +844,8 @@ const ChannelsTable = () => {
     if (record.status !== 1) {
       return {
         style: {
-          background: 'var(--semi-color-disabled-border)',
-        },
+          background: 'var(--semi-color-disabled-border)'
+        }
       };
     } else {
       return {};
@@ -707,217 +853,224 @@ const ChannelsTable = () => {
   };
 
   return (
-      <>
-        <EditChannel
-            refresh={refresh}
-            visible={showEdit}
-            handleClose={closeEdit}
-            editingChannel={editingChannel}
-        />
-        <Form
-            onSubmit={() => {
-              searchChannels(searchKeyword, searchGroup, searchModel);
-            }}
-            labelPosition='left'
+    <>
+      <EditTagModal
+        visible={showEditTag}
+        tag={editingTag}
+        handleClose={() => setShowEditTag(false)}
+        refresh={refresh}
+      />
+      <EditChannel
+        refresh={refresh}
+        visible={showEdit}
+        handleClose={closeEdit}
+        editingChannel={editingChannel}
+      />
+      <Form
+        onSubmit={() => {
+          searchChannels(searchKeyword, searchGroup, searchModel);
+        }}
+        labelPosition="left"
+      >
+        <div style={{ display: 'flex' }}>
+          <Space>
+            <Form.Input
+              field="search_keyword"
+              label="搜索渠道关键词"
+              placeholder="ID,名称和密钥 ..."
+              value={searchKeyword}
+              loading={searching}
+              onChange={(v) => {
+                setSearchKeyword(v.trim());
+              }}
+            />
+            <Form.Input
+              field="search_model"
+              label="模型"
+              placeholder="模型关键字"
+              value={searchModel}
+              loading={searching}
+              onChange={(v) => {
+                setSearchModel(v.trim());
+              }}
+            />
+            <Form.Select
+              field="group"
+              label="分组"
+              optionList={[{ label: '选择分组', value: null }, ...groupOptions]}
+              initValue={null}
+              onChange={(v) => {
+                setSearchGroup(v);
+                searchChannels(searchKeyword, v, searchModel);
+              }}
+            />
+            <Button
+              label="查询"
+              type="primary"
+              htmlType="submit"
+              className="btn-margin-right"
+              style={{ marginRight: 8 }}
+            >
+              查询
+            </Button>
+          </Space>
+        </div>
+      </Form>
+      <Divider style={{ marginBottom: 15 }} />
+      <div
+        style={{
+          display: isMobile() ? '' : 'flex',
+          marginTop: isMobile() ? 0 : -45,
+          zIndex: 999,
+          pointerEvents: 'none'
+        }}
+      >
+        <Space
+          style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}
         >
-          <div style={{display: 'flex'}}>
-            <Space>
-              <Form.Input
-                  field='search_keyword'
-                  label='搜索渠道关键词'
-                  placeholder='ID,名称和密钥 ...'
-                  value={searchKeyword}
-                  loading={searching}
-                  onChange={(v) => {
-                    setSearchKeyword(v.trim());
-                  }}
-              />
-              <Form.Input
-                  field='search_model'
-                  label='模型'
-                  placeholder='模型关键字'
-                  value={searchModel}
-                  loading={searching}
-                  onChange={(v) => {
-                    setSearchModel(v.trim());
-                  }}
-              />
-              <Form.Select
-                  field='group'
-                  label='分组'
-                  optionList={[{label: '选择分组', value: null}, ...groupOptions]}
-                  initValue={null}
-                  onChange={(v) => {
-                    setSearchGroup(v);
-                    searchChannels(searchKeyword, v, searchModel);
-                  }}
-              />
-              <Button
-                  label='查询'
-                  type='primary'
-                  htmlType='submit'
-                  className='btn-margin-right'
-                  style={{marginRight: 8}}
-              >
-                查询
-              </Button>
-            </Space>
-          </div>
-        </Form>
-        <Divider style={{marginBottom:15}}/>
-        <div
-            style={{
-              display: isMobile() ? '' : 'flex',
-              marginTop: isMobile() ? 0 : -45,
-              zIndex: 999,
-              pointerEvents: 'none',
+          <Typography.Text strong>使用ID排序</Typography.Text>
+          <Switch
+            checked={idSort}
+            label="使用ID排序"
+            uncheckedText="关"
+            aria-label="是否用ID排序"
+            onChange={(v) => {
+              localStorage.setItem('id-sort', v + '');
+              setIdSort(v);
+              loadChannels(0, pageSize, v)
+                .then()
+                .catch((reason) => {
+                  showError(reason);
+                });
+            }}
+          ></Switch>
+          <Button
+            theme="light"
+            type="primary"
+            style={{ marginRight: 8 }}
+            onClick={() => {
+              setEditingChannel({
+                id: undefined
+              });
+              setShowEdit(true);
             }}
-        >
-          <Space
-              style={{pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45}}
           >
-            <Typography.Text strong>使用ID排序</Typography.Text>
-            <Switch
-                checked={idSort}
-                label='使用ID排序'
-                uncheckedText='关'
-                aria-label='是否用ID排序'
-                onChange={(v) => {
-                  localStorage.setItem('id-sort', v + '');
-                  setIdSort(v);
-                  loadChannels(0, pageSize, v)
-                      .then()
-                      .catch((reason) => {
-                        showError(reason);
-                      });
-                }}
-            ></Switch>
-            <Button
-                theme='light'
-                type='primary'
-                style={{marginRight: 8}}
-                onClick={() => {
-                  setEditingChannel({
-                    id: undefined,
-                  });
-                  setShowEdit(true);
-                }}
-            >
-              添加渠道
+            添加渠道
+          </Button>
+          <Popconfirm
+            title="确定?"
+            okType={'warning'}
+            onConfirm={testAllChannels}
+            position={isMobile() ? 'top' : 'top'}
+          >
+            <Button theme="light" type="warning" style={{ marginRight: 8 }}>
+              测试所有通道
             </Button>
-            <Popconfirm
-                title='确定?'
-                okType={'warning'}
-                onConfirm={testAllChannels}
-                position={isMobile() ? 'top' : 'top'}
-            >
-              <Button theme='light' type='warning' style={{marginRight: 8}}>
-                测试所有通道
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定?'
-                okType={'secondary'}
-                onConfirm={updateAllChannelsBalance}
-            >
-              <Button theme='light' type='secondary' style={{marginRight: 8}}>
-                更新所有已启用通道余额
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定是否要删除禁用通道?'
-                content='此修改将不可逆'
-                okType={'danger'}
-                onConfirm={deleteAllDisabledChannels}
-            >
-              <Button theme='light' type='danger' style={{marginRight: 8}}>
-                删除禁用通道
-              </Button>
-            </Popconfirm>
+          </Popconfirm>
+          <Popconfirm
+            title="确定?"
+            okType={'secondary'}
+            onConfirm={updateAllChannelsBalance}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>
+              更新所有已启用通道余额
+            </Button>
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要删除禁用通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={deleteAllDisabledChannels}
+          >
+            <Button theme="light" type="danger" style={{ marginRight: 8 }}>
+              删除禁用通道
+            </Button>
+          </Popconfirm>
 
+          <Button
+            theme="light"
+            type="primary"
+            style={{ marginRight: 8 }}
+            onClick={refresh}
+          >
+            刷新
+          </Button>
+        </Space>
+      </div>
+      <div style={{ marginTop: 20 }}>
+        <Space>
+          <Typography.Text strong>开启批量删除</Typography.Text>
+          <Switch
+            label="开启批量删除"
+            uncheckedText="关"
+            aria-label="是否开启批量删除"
+            onChange={(v) => {
+              setEnableBatchDelete(v);
+            }}
+          ></Switch>
+          <Popconfirm
+            title="确定是否要删除所选通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={batchDeleteChannels}
+            disabled={!enableBatchDelete}
+            position={'top'}
+          >
             <Button
-                theme='light'
-                type='primary'
-                style={{marginRight: 8}}
-                onClick={refresh}
+              disabled={!enableBatchDelete}
+              theme="light"
+              type="danger"
+              style={{ marginRight: 8 }}
             >
-              刷新
+              删除所选通道
             </Button>
-          </Space>
-        </div>
-        <div style={{marginTop: 20}}>
-          <Space>
-            <Typography.Text strong>开启批量删除</Typography.Text>
-            <Switch
-                label='开启批量删除'
-                uncheckedText='关'
-                aria-label='是否开启批量删除'
-                onChange={(v) => {
-                  setEnableBatchDelete(v);
-                }}
-            ></Switch>
-            <Popconfirm
-                title='确定是否要删除所选通道?'
-                content='此修改将不可逆'
-                okType={'danger'}
-                onConfirm={batchDeleteChannels}
-                disabled={!enableBatchDelete}
-                position={'top'}
-            >
-              <Button
-                  disabled={!enableBatchDelete}
-                  theme='light'
-                  type='danger'
-                  style={{marginRight: 8}}
-              >
-                删除所选通道
-              </Button>
-            </Popconfirm>
-            <Popconfirm
-                title='确定是否要修复数据库一致性?'
-                content='进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'
-                okType={'warning'}
-                onConfirm={fixChannelsAbilities}
-                position={'top'}
-            >
-              <Button theme='light' type='secondary' style={{marginRight: 8}}>
-                修复数据库一致性
-              </Button>
-            </Popconfirm>
-          </Space>
-        </div>
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要修复数据库一致性?"
+            content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
+            okType={'warning'}
+            onConfirm={fixChannelsAbilities}
+            position={'top'}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>
+              修复数据库一致性
+            </Button>
+          </Popconfirm>
+        </Space>
+      </div>
 
-        <Table
-            className={'channel-table'}
-            style={{marginTop: 15}}
-            columns={columns}
-            dataSource={pageData}
-            pagination={{
-              currentPage: activePage,
-              pageSize: pageSize,
-              total: channelCount,
-              pageSizeOpts: [10, 20, 50, 100],
-              showSizeChanger: true,
-              formatPageText: (page) => '',
-              onPageSizeChange: (size) => {
-                handlePageSizeChange(size).then();
-              },
-              onPageChange: handlePageChange,
-            }}
-            loading={loading}
-            onRow={handleRow}
-            rowSelection={
-              enableBatchDelete
-                  ? {
-                    onChange: (selectedRowKeys, selectedRows) => {
-                      // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
-                      setSelectedChannels(selectedRows);
-                    },
-                  }
-                  : null
+
+      <Table
+        className={'channel-table'}
+        style={{ marginTop: 15 }}
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: channelCount,
+          pageSizeOpts: [10, 20, 50, 100],
+          showSizeChanger: true,
+          formatPageText: (page) => '',
+          onPageSizeChange: (size) => {
+            handlePageSizeChange(size).then();
+          },
+          onPageChange: handlePageChange
+        }}
+        loading={loading}
+        onRow={handleRow}
+        rowSelection={
+          enableBatchDelete
+            ? {
+              onChange: (selectedRowKeys, selectedRows) => {
+                // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
+                setSelectedChannels(selectedRows);
+              }
             }
-        />
-      </>
+            : null
+        }
+      />
+    </>
   );
 };
 

+ 21 - 0
web/src/components/TextInput.js

@@ -0,0 +1,21 @@
+import { Input, Typography } from '@douyinfe/semi-ui';
+import React from 'react';
+
+const TextInput = ({ label, name, value, onChange, placeholder, type = 'text' }) => {
+  return (
+    <>
+      <div style={{ marginTop: 10 }}>
+        <Typography.Text strong>{label}</Typography.Text>
+      </div>
+      <Input
+        name={name}
+        placeholder={placeholder}
+        onChange={(value) => onChange(value)}
+        value={value}
+        autoComplete="new-password"
+      />
+    </>
+  );
+}
+
+export default TextInput;

+ 2 - 0
web/src/helpers/render.js

@@ -67,6 +67,8 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
 }
 
 export function renderNumberWithPoint(num) {
+  if (num === undefined)
+    return '';
   num = num.toFixed(2);
   if (num >= 100000) {
     // Convert number to string to manipulate it

+ 217 - 184
web/src/pages/Channel/EditChannel.js

@@ -6,7 +6,7 @@ import {
   showError,
   showInfo,
   showSuccess,
-  verifyJSON,
+  verifyJSON
 } from '../../helpers';
 import { CHANNEL_OPTIONS } from '../../constants';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
@@ -21,7 +21,7 @@ import {
   Select,
   TextArea,
   Checkbox,
-  Banner,
+  Banner
 } from '@douyinfe/semi-ui';
 import { Divider } from 'semantic-ui-react';
 import { getChannelModels, loadChannelModels } from '../../components/utils.js';
@@ -30,19 +30,19 @@ import axios from 'axios';
 const MODEL_MAPPING_EXAMPLE = {
   'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
   'gpt-4-0314': 'gpt-4',
-  'gpt-4-32k-0314': 'gpt-4-32k',
+  'gpt-4-32k-0314': 'gpt-4-32k'
 };
 
 const STATUS_CODE_MAPPING_EXAMPLE = {
-  400: '500',
+  400: '500'
 };
 
 const REGION_EXAMPLE = {
-  "default": "us-central1",
-  "claude-3-5-sonnet-20240620": "europe-west1"
-}
+  'default': 'us-central1',
+  'claude-3-5-sonnet-20240620': 'europe-west1'
+};
 
-const fetchButtonTips = "1. 新建渠道时,请求通过当前浏览器发出;2. 编辑已有渠道,请求通过后端服务器发出"
+const fetchButtonTips = '1. 新建渠道时,请求通过当前浏览器发出;2. 编辑已有渠道,请求通过后端服务器发出';
 
 function type2secretPrompt(type) {
   // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
@@ -84,6 +84,9 @@ const EditChannel = (props) => {
     auto_ban: 1,
     test_model: '',
     groups: ['default'],
+    priority: 0,
+    weight: 0,
+    tag: ''
   };
   const [batch, setBatch] = useState(false);
   const [autoBan, setAutoBan] = useState(true);
@@ -108,7 +111,7 @@ const EditChannel = (props) => {
             'mj_blend',
             'mj_upscale',
             'mj_describe',
-            'mj_uploads',
+            'mj_uploads'
           ];
           break;
         case 5:
@@ -128,13 +131,13 @@ const EditChannel = (props) => {
             'mj_high_variation',
             'mj_low_variation',
             'mj_pan',
-            'mj_uploads',
+            'mj_uploads'
           ];
           break;
         case 36:
           localModels = [
             'suno_music',
-            'suno_lyrics',
+            'suno_lyrics'
           ];
           break;
         default:
@@ -171,7 +174,7 @@ const EditChannel = (props) => {
         data.model_mapping = JSON.stringify(
           JSON.parse(data.model_mapping),
           null,
-          2,
+          2
         );
       }
       setInputs(data);
@@ -190,61 +193,60 @@ const EditChannel = (props) => {
 
 
   const fetchUpstreamModelList = async (name) => {
-    if (inputs["type"] !== 1) {
-      showError("仅支持 OpenAI 接口格式")
+    if (inputs['type'] !== 1) {
+      showError('仅支持 OpenAI 接口格式');
       return;
     }
-    setLoading(true)
-    const models = inputs["models"] || []
+    setLoading(true);
+    const models = inputs['models'] || [];
     let err = false;
     if (isEdit) {
-      const res = await API.get("/api/channel/fetch_models/" + channelId)
+      const res = await API.get('/api/channel/fetch_models/' + channelId);
       if (res.data && res.data?.success) {
-        models.push(...res.data.data)
+        models.push(...res.data.data);
       } else {
-        err = true
+        err = true;
       }
     } else {
-      if (!inputs?.["key"]) {
-        showError("请填写密钥")
-        err = true
+      if (!inputs?.['key']) {
+        showError('请填写密钥');
+        err = true;
       } else {
         try {
-          const host = new URL((inputs["base_url"] || "https://api.openai.com"))
+          const host = new URL((inputs['base_url'] || 'https://api.openai.com'));
 
           const url = `https://${host.hostname}/v1/models`;
-          const key = inputs["key"];
+          const key = inputs['key'];
           const res = await axios.get(url, {
             headers: {
               'Authorization': `Bearer ${key}`
             }
-          })
+          });
           if (res.data && res.data?.success) {
-            models.push(...res.data.data.map((model) => model.id))
+            models.push(...res.data.data.map((model) => model.id));
           } else {
-            err = true
+            err = true;
           }
-        }
-        catch (error) {
-          err = true
+        } catch (error) {
+          err = true;
         }
       }
     }
     if (!err) {
       handleInputChange(name, Array.from(new Set(models)));
-      showSuccess("获取模型列表成功");
+      showSuccess('获取模型列表成功');
     } else {
       showError('获取模型列表失败');
     }
     setLoading(false);
-  }
+  };
 
   const fetchModels = async () => {
     try {
       let res = await API.get(`/api/channel/models`);
       let localModelOptions = res.data.data.map((model) => ({
         label: model.id,
-        value: model.id,
+        value: model.id
       }));
       setOriginModelOptions(localModelOptions);
       setFullModels(res.data.data.map((model) => model.id));
@@ -253,7 +255,7 @@ const EditChannel = (props) => {
           .filter((model) => {
             return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
           })
-          .map((model) => model.id),
+          .map((model) => model.id)
       );
     } catch (error) {
       showError(error.message);
@@ -269,8 +271,8 @@ const EditChannel = (props) => {
       setGroupOptions(
         res.data.data.map((group) => ({
           label: group,
-          value: group,
-        })),
+          value: group
+        }))
       );
     } catch (error) {
       showError(error.message);
@@ -283,7 +285,7 @@ const EditChannel = (props) => {
       if (!localModelOptions.find((option) => option.key === model)) {
         localModelOptions.push({
           label: model,
-          value: model,
+          value: model
         });
       }
     });
@@ -294,7 +296,8 @@ const EditChannel = (props) => {
     fetchModels().then();
     fetchGroups().then();
     if (isEdit) {
-      loadChannel().then(() => {});
+      loadChannel().then(() => {
+      });
     } else {
       setInputs(originInputs);
       let localModels = getChannelModels(inputs.type);
@@ -320,7 +323,7 @@ const EditChannel = (props) => {
     if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
       localInputs.base_url = localInputs.base_url.slice(
         0,
-        localInputs.base_url.length - 1,
+        localInputs.base_url.length - 1
       );
     }
     if (localInputs.type === 3 && localInputs.other === '') {
@@ -341,7 +344,7 @@ const EditChannel = (props) => {
     if (isEdit) {
       res = await API.put(`/api/channel/`, {
         ...localInputs,
-        id: parseInt(channelId),
+        id: parseInt(channelId)
       });
     } else {
       res = await API.post(`/api/channel/`, localInputs);
@@ -378,7 +381,7 @@ const EditChannel = (props) => {
           // 添加到下拉选项
           key: model,
           text: model,
-          value: model,
+          value: model
         });
       } else if (model) {
         showError('某些模型已存在!');
@@ -409,11 +412,11 @@ const EditChannel = (props) => {
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme='solid' size={'large'} onClick={submit}>
+              <Button theme="solid" size={'large'} onClick={submit}>
                 提交
               </Button>
               <Button
-                theme='solid'
+                theme="solid"
                 size={'large'}
                 type={'tertiary'}
                 onClick={handleCancel}
@@ -432,7 +435,7 @@ const EditChannel = (props) => {
             <Typography.Text strong>类型:</Typography.Text>
           </div>
           <Select
-            name='type'
+            name="type"
             required
             optionList={CHANNEL_OPTIONS}
             value={inputs.type}
@@ -450,8 +453,8 @@ const EditChannel = (props) => {
                       ,因为 One API 会把请求体中的 model
                       参数替换为你的部署名称(模型名称中的点会被剔除),
                       <a
-                        target='_blank'
-                        href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
+                        target="_blank"
+                        href="https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271"
                       >
                         图片演示
                       </a>
@@ -466,8 +469,8 @@ const EditChannel = (props) => {
                 </Typography.Text>
               </div>
               <Input
-                label='AZURE_OPENAI_ENDPOINT'
-                name='azure_base_url'
+                label="AZURE_OPENAI_ENDPOINT"
+                name="azure_base_url"
                 placeholder={
                   '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'
                 }
@@ -475,14 +478,14 @@ const EditChannel = (props) => {
                   handleInputChange('base_url', value);
                 }}
                 value={inputs.base_url}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
               <div style={{ marginTop: 10 }}>
                 <Typography.Text strong>默认 API 版本:</Typography.Text>
               </div>
               <Input
-                label='默认 API 版本'
-                name='azure_other'
+                label="默认 API 版本"
+                name="azure_other"
                 placeholder={
                   '请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'
                 }
@@ -490,7 +493,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -512,7 +515,7 @@ const EditChannel = (props) => {
                 </Typography.Text>
               </div>
               <Input
-                name='base_url'
+                name="base_url"
                 placeholder={
                   '请输入完整的URL,例如:https://api.openai.com/v1/chat/completions'
                 }
@@ -520,49 +523,84 @@ const EditChannel = (props) => {
                   handleInputChange('base_url', value);
                 }}
                 value={inputs.base_url}
-                autoComplete='new-password'
+                autoComplete="new-password"
+              />
+            </>
+          )}
+          {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>代理:</Typography.Text>
+              </div>
+              <Input
+                label="代理"
+                name="base_url"
+                placeholder={'此项可选,用于通过代理站来进行 API 调用'}
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
+              />
+            </>
+          )}
+          {inputs.type === 22 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>私有部署地址:</Typography.Text>
+              </div>
+              <Input
+                name="base_url"
+                placeholder={
+                  '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'
+                }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
               />
             </>
           )}
           {inputs.type === 36 && (
-              <>
-                <div style={{marginTop: 10}}>
-                  <Typography.Text strong>
-                    注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用
-                  </Typography.Text>
-                </div>
-                <Input
-                    name='base_url'
-                    placeholder={
-                      '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com '
-                    }
-                    onChange={(value) => {
-                      handleInputChange('base_url', value);
-                    }}
-                    value={inputs.base_url}
-                    autoComplete='new-password'
-                />
-              </>
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>
+                  注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用
+                </Typography.Text>
+              </div>
+              <Input
+                name="base_url"
+                placeholder={
+                  '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com '
+                }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete="new-password"
+              />
+            </>
           )}
-          <div style={{marginTop: 10}}>
+          <div style={{ marginTop: 10 }}>
             <Typography.Text strong>名称:</Typography.Text>
           </div>
           <Input
-              required
-              name='name'
+            required
+            name="name"
             placeholder={'请为渠道命名'}
             onChange={(value) => {
               handleInputChange('name', value);
             }}
             value={inputs.name}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <div style={{ marginTop: 10 }}>
             <Typography.Text strong>分组:</Typography.Text>
           </div>
           <Select
             placeholder={'请选择可以使用该渠道的分组'}
-            name='groups'
+            name="groups"
             required
             multiple
             selection
@@ -572,7 +610,7 @@ const EditChannel = (props) => {
               handleInputChange('groups', value);
             }}
             value={inputs.groups}
-            autoComplete='new-password'
+            autoComplete="new-password"
             optionList={groupOptions}
           />
           {inputs.type === 18 && (
@@ -581,7 +619,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>模型版本:</Typography.Text>
               </div>
               <Input
-                name='other'
+                name="other"
                 placeholder={
                   '请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'
                 }
@@ -589,7 +627,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -599,7 +637,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>部署地区:</Typography.Text>
               </div>
               <TextArea
-                name='other'
+                name="other"
                 placeholder={
                   '请输入部署地区,例如:us-central1\n支持使用模型映射格式\n' +
                   '{\n' +
@@ -612,18 +650,18 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
               <Typography.Text
                 style={{
                   color: 'rgba(var(--semi-blue-5), 1)',
                   userSelect: 'none',
-                  cursor: 'pointer',
+                  cursor: 'pointer'
                 }}
                 onClick={() => {
                   handleInputChange(
                     'other',
-                    JSON.stringify(REGION_EXAMPLE, null, 2),
+                    JSON.stringify(REGION_EXAMPLE, null, 2)
                   );
                 }}
               >
@@ -637,14 +675,14 @@ const EditChannel = (props) => {
                 <Typography.Text strong>知识库 ID:</Typography.Text>
               </div>
               <Input
-                label='知识库 ID'
-                name='other'
+                label="知识库 ID"
+                name="other"
                 placeholder={'请输入知识库 ID,例如:123456'}
                 onChange={(value) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -654,7 +692,7 @@ const EditChannel = (props) => {
                 <Typography.Text strong>Account ID:</Typography.Text>
               </div>
               <Input
-                name='other'
+                name="other"
                 placeholder={
                   '请输入Account ID,例如:d6b5da8hk1awo8nap34ube6gh'
                 }
@@ -662,7 +700,7 @@ const EditChannel = (props) => {
                   handleInputChange('other', value);
                 }}
                 value={inputs.other}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           )}
@@ -671,7 +709,7 @@ const EditChannel = (props) => {
           </div>
           <Select
             placeholder={'请选择该渠道所支持的模型'}
-            name='models'
+            name="models"
             required
             multiple
             selection
@@ -679,13 +717,13 @@ const EditChannel = (props) => {
               handleInputChange('models', value);
             }}
             value={inputs.models}
-            autoComplete='new-password'
+            autoComplete="new-password"
             optionList={modelOptions}
           />
           <div style={{ lineHeight: '40px', marginBottom: '12px' }}>
             <Space>
               <Button
-                type='primary'
+                type="primary"
                 onClick={() => {
                   handleInputChange('models', basicModels);
                 }}
@@ -693,7 +731,7 @@ const EditChannel = (props) => {
                 填入相关模型
               </Button>
               <Button
-                type='secondary'
+                type="secondary"
                 onClick={() => {
                   handleInputChange('models', fullModels);
                 }}
@@ -702,7 +740,7 @@ const EditChannel = (props) => {
               </Button>
               <Tooltip content={fetchButtonTips}>
                 <Button
-                  type='tertiary'
+                  type="tertiary"
                   onClick={() => {
                     fetchUpstreamModelList('models');
                   }}
@@ -711,7 +749,7 @@ const EditChannel = (props) => {
                 </Button>
               </Tooltip>
               <Button
-                type='warning'
+                type="warning"
                 onClick={() => {
                   handleInputChange('models', []);
                 }}
@@ -721,11 +759,11 @@ const EditChannel = (props) => {
             </Space>
             <Input
               addonAfter={
-                <Button type='primary' onClick={addCustomModels}>
+                <Button type="primary" onClick={addCustomModels}>
                   填入
                 </Button>
               }
-              placeholder='输入自定义模型名称'
+              placeholder="输入自定义模型名称"
               value={customModel}
               onChange={(value) => {
                 setCustomModel(value.trim());
@@ -737,24 +775,24 @@ const EditChannel = (props) => {
           </div>
           <TextArea
             placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
-            name='model_mapping'
+            name="model_mapping"
             onChange={(value) => {
               handleInputChange('model_mapping', value);
             }}
             autosize
             value={inputs.model_mapping}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <Typography.Text
             style={{
               color: 'rgba(var(--semi-blue-5), 1)',
               userSelect: 'none',
-              cursor: 'pointer',
+              cursor: 'pointer'
             }}
             onClick={() => {
               handleInputChange(
                 'model_mapping',
-                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
+                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)
               );
             }}
           >
@@ -765,8 +803,8 @@ const EditChannel = (props) => {
           </div>
           {batch ? (
             <TextArea
-              label='密钥'
-              name='key'
+              label="密钥"
+              name="key"
               required
               placeholder={'请输入密钥,一行一个'}
               onChange={(value) => {
@@ -774,14 +812,14 @@ const EditChannel = (props) => {
               }}
               value={inputs.key}
               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete='new-password'
+              autoComplete="new-password"
             />
           ) : (
             <>
               {inputs.type === 41 ? (
                 <TextArea
-                  label='鉴权json'
-                  name='key'
+                  label="鉴权json"
+                  name="key"
                   required
                   placeholder={'{\n' +
                     '  "type": "service_account",\n' +
@@ -801,23 +839,36 @@ const EditChannel = (props) => {
                   }}
                   autosize={{ minRows: 10 }}
                   value={inputs.key}
-                  autoComplete='new-password'
+                  autoComplete="new-password"
                 />
               ) : (
                 <Input
-                  label='密钥'
-                  name='key'
+                  label="密钥"
+                  name="key"
                   required
                   placeholder={type2secretPrompt(inputs.type)}
                   onChange={(value) => {
                     handleInputChange('key', value);
                   }}
                   value={inputs.key}
-                  autoComplete='new-password'
+                  autoComplete="new-password"
                 />
               )
               }
-              </>
+            </>
+          )}
+          {!isEdit && (
+            <div style={{ marginTop: 10, display: 'flex' }}>
+              <Space>
+                <Checkbox
+                  checked={batch}
+                  label="批量创建"
+                  name="batch"
+                  onChange={() => setBatch(!batch)}
+                />
+                <Typography.Text strong>批量创建</Typography.Text>
+              </Space>
+            </div>
           )}
           {inputs.type === 1 && (
             <>
@@ -825,9 +876,9 @@ const EditChannel = (props) => {
                 <Typography.Text strong>组织:</Typography.Text>
               </div>
               <Input
-                label='组织,可选,不填则为默认组织'
-                name='openai_organization'
-                placeholder='请输入组织org-xxx'
+                label="组织,可选,不填则为默认组织"
+                name="openai_organization"
+                placeholder="请输入组织org-xxx"
                 onChange={(value) => {
                   handleInputChange('openai_organization', value);
                 }}
@@ -839,8 +890,8 @@ const EditChannel = (props) => {
             <Typography.Text strong>默认测试模型:</Typography.Text>
           </div>
           <Input
-            name='test_model'
-            placeholder='不填则为模型列表第一个'
+            name="test_model"
+            placeholder="不填则为模型列表第一个"
             onChange={(value) => {
               handleInputChange('test_model', value);
             }}
@@ -849,7 +900,7 @@ const EditChannel = (props) => {
           <div style={{ marginTop: 10, display: 'flex' }}>
             <Space>
               <Checkbox
-                name='auto_ban'
+                name="auto_ban"
                 checked={autoBan}
                 onChange={() => {
                   setAutoBan(!autoBan);
@@ -861,55 +912,6 @@ const EditChannel = (props) => {
               </Typography.Text>
             </Space>
           </div>
-
-          {!isEdit && (
-            <div style={{ marginTop: 10, display: 'flex' }}>
-              <Space>
-                <Checkbox
-                  checked={batch}
-                  label='批量创建'
-                  name='batch'
-                  onChange={() => setBatch(!batch)}
-                />
-                <Typography.Text strong>批量创建</Typography.Text>
-              </Space>
-            </div>
-          )}
-          {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && inputs.type !== 36 && (
-            <>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>代理:</Typography.Text>
-              </div>
-              <Input
-                label='代理'
-                name='base_url'
-                placeholder={'此项可选,用于通过代理站来进行 API 调用'}
-                onChange={(value) => {
-                  handleInputChange('base_url', value);
-                }}
-                value={inputs.base_url}
-                autoComplete='new-password'
-              />
-            </>
-          )}
-          {inputs.type === 22 && (
-            <>
-              <div style={{ marginTop: 10 }}>
-                <Typography.Text strong>私有部署地址:</Typography.Text>
-              </div>
-              <Input
-                name='base_url'
-                placeholder={
-                  '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'
-                }
-                onChange={(value) => {
-                  handleInputChange('base_url', value);
-                }}
-                value={inputs.base_url}
-                autoComplete='new-password'
-              />
-            </>
-          )}
           <div style={{ marginTop: 10 }}>
             <Typography.Text strong>
               状态码复写(仅影响本地判断,不修改返回到上游的状态码):
@@ -917,43 +919,74 @@ const EditChannel = (props) => {
           </div>
           <TextArea
             placeholder={`此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:\n${JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)}`}
-            name='status_code_mapping'
+            name="status_code_mapping"
             onChange={(value) => {
               handleInputChange('status_code_mapping', value);
             }}
             autosize
             value={inputs.status_code_mapping}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           <Typography.Text
             style={{
               color: 'rgba(var(--semi-blue-5), 1)',
               userSelect: 'none',
-              cursor: 'pointer',
+              cursor: 'pointer'
             }}
             onClick={() => {
               handleInputChange(
                 'status_code_mapping',
-                JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2),
+                JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)
               );
             }}
           >
             填入模板
           </Typography.Text>
-          {/*<div style={{ marginTop: 10 }}>*/}
-          {/*  <Typography.Text strong>*/}
-          {/*    最大请求token(0表示不限制):*/}
-          {/*  </Typography.Text>*/}
-          {/*</div>*/}
-          {/*<Input*/}
-          {/*  label='最大请求token'*/}
-          {/*  name='max_input_tokens'*/}
-          {/*  placeholder='默认为0,表示不限制'*/}
-          {/*  onChange={(value) => {*/}
-          {/*    handleInputChange('max_input_tokens', value);*/}
-          {/*  }}*/}
-          {/*  value={inputs.max_input_tokens}*/}
-          {/*/>*/}
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道标签
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道标签"
+            name="tag"
+            placeholder={'渠道标签'}
+            onChange={(value) => {
+              handleInputChange('tag', value);
+            }}
+            value={inputs.tag}
+            autoComplete="new-password"
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道优先级
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道优先级"
+            name="priority"
+            placeholder={'渠道优先级'}
+            onChange={(value) => {
+              handleInputChange('priority', parseInt(value));
+            }}
+            value={inputs.priority}
+            autoComplete="new-password"
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>
+              渠道权重
+            </Typography.Text>
+          </div>
+          <Input
+            label="渠道权重"
+            name="weight"
+            placeholder={'渠道权重'}
+            onChange={(value) => {
+              handleInputChange('weight', parseInt(value));
+            }}
+            value={inputs.weight}
+            autoComplete="new-password"
+          />
         </Spin>
       </SideSheet>
     </>

+ 92 - 0
web/src/pages/Channel/EditTagModal.js

@@ -0,0 +1,92 @@
+import React, { useState, useEffect } from 'react';
+import { API, showError, showSuccess } from '../../helpers';
+import { SideSheet, Space, Button, Input, Typography, Spin, Modal } from '@douyinfe/semi-ui';
+import TextInput from '../../components/TextInput.js';
+
+const EditTagModal = (props) => {
+  const { visible, tag, handleClose, refresh } = props;
+  const [loading, setLoading] = useState(false);
+  const originInputs = {
+    tag: '',
+    newTag: null,
+  }
+  const [inputs, setInputs] = useState(originInputs);
+
+
+  const handleSave = async () => {
+    setLoading(true);
+    let data = {
+      tag: tag,
+    }
+    let shouldSave = true;
+    if (inputs.newTag === tag) {
+      setLoading(false);
+      return;
+    }
+    data.newTag = inputs.newTag;
+    if (data.newTag === '') {
+      Modal.confirm({
+        title: '解散标签',
+        content: '确定要解散标签吗?',
+        onCancel: () => {
+          setLoading(false);
+        },
+        onOk: async () => {
+          await submit(data);
+        }
+      });
+    } else {
+      await submit(data);
+    }
+    setLoading(false);
+  };
+
+  const submit = async (data) => {
+    try {
+      const res = await API.put('/api/channel/tag', data);
+      if (res?.data?.success) {
+        showSuccess('标签更新成功!');
+        refresh();
+        handleClose();
+      }
+    } catch (error) {
+      showError(error);
+    }
+  }
+
+  useEffect(() => {
+    setInputs({
+      ...originInputs,
+      tag: tag,
+      newTag: tag,
+    })
+  }, [visible]);
+
+  return (
+    <SideSheet
+      title="编辑标签"
+      visible={visible}
+      onCancel={handleClose}
+      footer={
+        <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+          <Space>
+            <Button onClick={handleClose}>取消</Button>
+            <Button type="primary" onClick={handleSave} loading={loading}>保存</Button>
+          </Space>
+        </div>
+      }
+    >
+      <Spin spinning={loading}>
+        <TextInput
+          label="新标签(留空则解散标签,不会删除标签下的渠道)"
+          name="newTag"
+          value={inputs.newTag}
+          onChange={(value) => setInputs({ ...inputs, newTag: value })}
+          placeholder="请输入新标签"
+        />
+      </Spin>
+    </SideSheet>
+  );
+};
+
+export default EditTagModal;