Просмотр исходного кода

feat: add multi-key management

CaIon 4 месяцев назад
Родитель
Сommit
ecdd9d1ccb

+ 258 - 0
controller/channel.go

@@ -1030,3 +1030,261 @@ func CopyChannel(c *gin.Context) {
 	// success
 	c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
 }
+
+// MultiKeyManageRequest represents the request for multi-key management operations
+type MultiKeyManageRequest struct {
+	ChannelId int    `json:"channel_id"`
+	Action    string `json:"action"`              // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
+	KeyIndex  *int   `json:"key_index,omitempty"` // for disable_key and enable_key actions
+}
+
+// MultiKeyStatusResponse represents the response for key status query
+type MultiKeyStatusResponse struct {
+	Keys []KeyStatus `json:"keys"`
+}
+
+type KeyStatus struct {
+	Index        int    `json:"index"`
+	Status       int    `json:"status"` // 1: enabled, 2: disabled
+	DisabledTime int64  `json:"disabled_time,omitempty"`
+	Reason       string `json:"reason,omitempty"`
+	KeyPreview   string `json:"key_preview"` // first 10 chars of key for identification
+}
+
+// ManageMultiKeys handles multi-key management operations
+func ManageMultiKeys(c *gin.Context) {
+	request := MultiKeyManageRequest{}
+	err := c.ShouldBindJSON(&request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	channel, err := model.GetChannelById(request.ChannelId, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "渠道不存在",
+		})
+		return
+	}
+
+	if !channel.ChannelInfo.IsMultiKey {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该渠道不是多密钥模式",
+		})
+		return
+	}
+
+	switch request.Action {
+	case "get_key_status":
+		keys := channel.GetKeys()
+		var keyStatusList []KeyStatus
+
+		for i, key := range keys {
+			status := 1 // default enabled
+			var disabledTime int64
+			var reason string
+
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
+					status = s
+				}
+			}
+
+			if status != 1 {
+				if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+					disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i]
+				}
+				if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+					reason = channel.ChannelInfo.MultiKeyDisabledReason[i]
+				}
+			}
+
+			// Create key preview (first 10 chars)
+			keyPreview := key
+			if len(key) > 10 {
+				keyPreview = key[:10] + "..."
+			}
+
+			keyStatusList = append(keyStatusList, KeyStatus{
+				Index:        i,
+				Status:       status,
+				DisabledTime: disabledTime,
+				Reason:       reason,
+				KeyPreview:   keyPreview,
+			})
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+			"data":    MultiKeyStatusResponse{Keys: keyStatusList},
+		})
+		return
+
+	case "disable_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要禁用的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		if channel.ChannelInfo.MultiKeyStatusList == nil {
+			channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledTime == nil {
+			channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledReason == nil {
+			channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+		}
+
+		channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled
+		channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
+		channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = "手动禁用"
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已禁用",
+		})
+		return
+
+	case "enable_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要启用的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		// 从状态列表中删除该密钥的记录,使其回到默认启用状态
+		if channel.ChannelInfo.MultiKeyStatusList != nil {
+			delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+			delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+			delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex)
+		}
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已启用",
+		})
+		return
+
+	case "delete_disabled_keys":
+		keys := channel.GetKeys()
+		var remainingKeys []string
+		var deletedCount int
+		var newStatusList = make(map[int]int)
+		var newDisabledTime = make(map[int]int64)
+		var newDisabledReason = make(map[int]string)
+
+		newIndex := 0
+		for i, key := range keys {
+			status := 1 // default enabled
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
+					status = s
+				}
+			}
+
+			// 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥
+			if status == 3 {
+				deletedCount++
+			} else {
+				remainingKeys = append(remainingKeys, key)
+				// 保留非自动禁用密钥的状态信息,重新索引
+				if status != 1 {
+					newStatusList[newIndex] = status
+					if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+						if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
+							newDisabledTime[newIndex] = t
+						}
+					}
+					if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+						if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
+							newDisabledReason[newIndex] = r
+						}
+					}
+				}
+				newIndex++
+			}
+		}
+
+		if deletedCount == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "没有需要删除的自动禁用密钥",
+			})
+			return
+		}
+
+		// Update channel with remaining keys
+		channel.Key = strings.Join(remainingKeys, "\n")
+		channel.ChannelInfo.MultiKeySize = len(remainingKeys)
+		channel.ChannelInfo.MultiKeyStatusList = newStatusList
+		channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
+		channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
+			"data":    deletedCount,
+		})
+		return
+
+	default:
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "不支持的操作",
+		})
+		return
+	}
+}

+ 22 - 11
model/channel.go

@@ -41,6 +41,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"`
+	Settings          string  `json:"settings"`
 	Tag               *string `json:"tag" gorm:"index"`
 	Setting           *string `json:"setting" gorm:"type:text"` // 渠道额外设置
 	ParamOverride     *string `json:"param_override" gorm:"type:text"`
@@ -52,11 +53,13 @@ type Channel struct {
 }
 
 type ChannelInfo struct {
-	IsMultiKey           bool                  `json:"is_multi_key"`            // 是否多Key模式
-	MultiKeySize         int                   `json:"multi_key_size"`          // 多Key模式下的Key数量
-	MultiKeyStatusList   map[int]int           `json:"multi_key_status_list"`   // key状态列表,key index -> status
-	MultiKeyPollingIndex int                   `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
-	MultiKeyMode         constant.MultiKeyMode `json:"multi_key_mode"`
+	IsMultiKey             bool                  `json:"is_multi_key"`              // 是否多Key模式
+	MultiKeySize           int                   `json:"multi_key_size"`            // 多Key模式下的Key数量
+	MultiKeyStatusList     map[int]int           `json:"multi_key_status_list"`     // key状态列表,key index -> status
+	MultiKeyDisabledReason map[int]string        `json:"multi_key_disabled_reason"` // key禁用原因列表,key index -> reason
+	MultiKeyDisabledTime   map[int]int64         `json:"multi_key_disabled_time"`   // key禁用时间列表,key index -> time
+	MultiKeyPollingIndex   int                   `json:"multi_key_polling_index"`   // 多Key模式下轮询的key索引
+	MultiKeyMode           constant.MultiKeyMode `json:"multi_key_mode"`
 }
 
 // Value implements driver.Valuer interface
@@ -70,7 +73,7 @@ func (c *ChannelInfo) Scan(value interface{}) error {
 	return common.Unmarshal(bytesValue, c)
 }
 
-func (channel *Channel) getKeys() []string {
+func (channel *Channel) GetKeys() []string {
 	if channel.Key == "" {
 		return []string{}
 	}
@@ -101,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
 	}
 
 	// Obtain all keys (split by \n)
-	keys := channel.getKeys()
+	keys := channel.GetKeys()
 	if len(keys) == 0 {
 		// No keys available, return error, should disable the channel
 		return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
@@ -528,8 +531,8 @@ func CleanupChannelPollingLocks() {
 	})
 }
 
-func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
-	keys := channel.getKeys()
+func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) {
+	keys := channel.GetKeys()
 	if len(keys) == 0 {
 		channel.Status = status
 	} else {
@@ -547,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
 			delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
 		} else {
 			channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
+			if channel.ChannelInfo.MultiKeyDisabledReason == nil {
+				channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+			}
+			if channel.ChannelInfo.MultiKeyDisabledTime == nil {
+				channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+			}
+			channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
+			channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
 		}
 		if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
 			channel.Status = common.ChannelStatusAutoDisabled
@@ -569,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 		}
 		if channelCache.ChannelInfo.IsMultiKey {
 			// 如果是多Key模式,更新缓存中的状态
-			handlerMultiKeyUpdate(channelCache, usingKey, status)
+			handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
 			//CacheUpdateChannel(channelCache)
 			//return true
 		} else {
@@ -600,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 
 		if channel.ChannelInfo.IsMultiKey {
 			beforeStatus := channel.Status
-			handlerMultiKeyUpdate(channel, usingKey, status)
+			handlerMultiKeyUpdate(channel, usingKey, status, reason)
 			if beforeStatus != channel.Status {
 				shouldUpdateAbilities = true
 			}

+ 1 - 1
model/channel_cache.go

@@ -70,7 +70,7 @@ func InitChannelCache() {
 	//channelsIDM = newChannelId2channel
 	for i, channel := range newChannelId2channel {
 		if channel.ChannelInfo.IsMultiKey {
-			channel.Keys = channel.getKeys()
+			channel.Keys = channel.GetKeys()
 			if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
 				if oldChannel, ok := channelsIDM[i]; ok {
 					// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息

+ 1 - 0
router/api-router.go

@@ -120,6 +120,7 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
 			channelRoute.GET("/tag/models", controller.GetTagModels)
 			channelRoute.POST("/copy/:id", controller.CopyChannel)
+			channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
 		}
 		tokenRoute := apiRouter.Group("/token")
 		tokenRoute.Use(middleware.UserAuth())

+ 47 - 48
web/src/components/table/channels/ChannelsColumnDefs.js

@@ -210,7 +210,9 @@ export const getChannelsColumns = ({
   copySelectedChannel,
   refresh,
   activePage,
-  channels
+  channels,
+  setShowMultiKeyManageModal,
+  setCurrentMultiKeyChannel
 }) => {
   return [
     {
@@ -503,36 +505,50 @@ export const getChannelsColumns = ({
                 />
               </SplitButtonGroup>
 
+              {
+                record.status === 1 ? (
+                  <Button
+                    type='danger'
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'disable', record)}
+                  >
+                    {t('禁用')}
+                  </Button>
+                ) : (
+                  <Button
+                    size="small"
+                    onClick={() => manageChannel(record.id, 'enable', record)}
+                  >
+                    {t('启用')}
+                  </Button>
+                )
+              }
+
               {record.channel_info?.is_multi_key ? (
                 <SplitButtonGroup
                   aria-label={t('多密钥渠道操作项目组')}
                 >
-                  {
-                    record.status === 1 ? (
-                      <Button
-                        type='danger'
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'disable', record)}
-                      >
-                        {t('禁用')}
-                      </Button>
-                    ) : (
-                      <Button
-                        size="small"
-                        onClick={() => manageChannel(record.id, 'enable', record)}
-                      >
-                        {t('启用')}
-                      </Button>
-                    )
-                  }
+                  <Button
+                    type='tertiary'
+                    size="small"
+                    onClick={() => {
+                      setEditingChannel(record);
+                      setShowEdit(true);
+                    }}
+                  >
+                    {t('编辑')}
+                  </Button>
                   <Dropdown
                     trigger='click'
                     position='bottomRight'
                     menu={[
                       {
                         node: 'item',
-                        name: t('启用全部密钥'),
-                        onClick: () => manageChannel(record.id, 'enable_all', record),
+                        name: t('多key管理'),
+                        onClick: () => {
+                          setCurrentMultiKeyChannel(record);
+                          setShowMultiKeyManageModal(true);
+                        },
                       }
                     ]}
                   >
@@ -544,35 +560,18 @@ export const getChannelsColumns = ({
                   </Dropdown>
                 </SplitButtonGroup>
               ) : (
-                record.status === 1 ? (
-                  <Button
-                    type='danger'
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'disable', record)}
-                  >
-                    {t('禁用')}
-                  </Button>
-                ) : (
-                  <Button
-                    size="small"
-                    onClick={() => manageChannel(record.id, 'enable', record)}
-                  >
-                    {t('启用')}
-                  </Button>
-                )
+                <Button
+                  type='tertiary'
+                  size="small"
+                  onClick={() => {
+                    setEditingChannel(record);
+                    setShowEdit(true);
+                  }}
+                >
+                  {t('编辑')}
+                </Button>
               )}
 
-              <Button
-                type='tertiary'
-                size="small"
-                onClick={() => {
-                  setEditingChannel(record);
-                  setShowEdit(true);
-                }}
-              >
-                {t('编辑')}
-              </Button>
-
               <Dropdown
                 trigger='click'
                 position='bottomRight'

+ 7 - 0
web/src/components/table/channels/ChannelsTable.jsx

@@ -57,6 +57,9 @@ const ChannelsTable = (channelsData) => {
     setEditingTag,
     copySelectedChannel,
     refresh,
+    // Multi-key management
+    setShowMultiKeyManageModal,
+    setCurrentMultiKeyChannel,
   } = channelsData;
 
   // Get all columns
@@ -79,6 +82,8 @@ const ChannelsTable = (channelsData) => {
       refresh,
       activePage,
       channels,
+      setShowMultiKeyManageModal,
+      setCurrentMultiKeyChannel,
     });
   }, [
     t,
@@ -98,6 +103,8 @@ const ChannelsTable = (channelsData) => {
     refresh,
     activePage,
     channels,
+    setShowMultiKeyManageModal,
+    setCurrentMultiKeyChannel,
   ]);
 
   // Filter columns based on visibility settings

+ 7 - 0
web/src/components/table/channels/index.jsx

@@ -30,6 +30,7 @@ import ModelTestModal from './modals/ModelTestModal.jsx';
 import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
 import EditChannelModal from './modals/EditChannelModal.jsx';
 import EditTagModal from './modals/EditTagModal.jsx';
+import MultiKeyManageModal from './modals/MultiKeyManageModal.jsx';
 import { createCardProPagination } from '../../../helpers/utils';
 
 const ChannelsPage = () => {
@@ -54,6 +55,12 @@ const ChannelsPage = () => {
       />
       <BatchTagModal {...channelsData} />
       <ModelTestModal {...channelsData} />
+      <MultiKeyManageModal
+        visible={channelsData.showMultiKeyManageModal}
+        onCancel={() => channelsData.setShowMultiKeyManageModal(false)}
+        channel={channelsData.currentMultiKeyChannel}
+        onRefresh={channelsData.refresh}
+      />
 
       {/* Main Content */}
       <CardPro

+ 372 - 0
web/src/components/table/channels/modals/MultiKeyManageModal.jsx

@@ -0,0 +1,372 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal,
+  Button,
+  Table,
+  Tag,
+  Typography,
+  Space,
+  Tooltip,
+  Popconfirm,
+  Empty,
+  Spin,
+  Banner
+} from '@douyinfe/semi-ui';
+import { 
+  IconRefresh,
+  IconDelete,
+  IconClose,
+  IconSave,
+  IconSetting
+} from '@douyinfe/semi-icons';
+import { API, showError, showSuccess, timestamp2string } from '../../../../helpers/index.js';
+
+const { Text, Title } = Typography;
+
+const MultiKeyManageModal = ({
+  visible,
+  onCancel,
+  channel,
+  onRefresh
+}) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [keyStatusList, setKeyStatusList] = useState([]);
+  const [operationLoading, setOperationLoading] = useState({});
+
+  // Load key status data
+  const loadKeyStatus = async () => {
+    if (!channel?.id) return;
+    
+    setLoading(true);
+    try {
+      const res = await API.post('/api/channel/multi_key/manage', {
+        channel_id: channel.id,
+        action: 'get_key_status'
+      });
+      
+      if (res.data.success) {
+        setKeyStatusList(res.data.data.keys || []);
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('获取密钥状态失败'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // Disable a specific key
+  const handleDisableKey = async (keyIndex) => {
+    const operationId = `disable_${keyIndex}`;
+    setOperationLoading(prev => ({ ...prev, [operationId]: true }));
+    
+    try {
+      const res = await API.post('/api/channel/multi_key/manage', {
+        channel_id: channel.id,
+        action: 'disable_key',
+        key_index: keyIndex
+      });
+      
+      if (res.data.success) {
+        showSuccess(t('密钥已禁用'));
+        await loadKeyStatus(); // Reload data
+        onRefresh && onRefresh(); // Refresh parent component
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('禁用密钥失败'));
+    } finally {
+      setOperationLoading(prev => ({ ...prev, [operationId]: false }));
+    }
+  };
+
+  // Enable a specific key
+  const handleEnableKey = async (keyIndex) => {
+    const operationId = `enable_${keyIndex}`;
+    setOperationLoading(prev => ({ ...prev, [operationId]: true }));
+    
+    try {
+      const res = await API.post('/api/channel/multi_key/manage', {
+        channel_id: channel.id,
+        action: 'enable_key',
+        key_index: keyIndex
+      });
+      
+      if (res.data.success) {
+        showSuccess(t('密钥已启用'));
+        await loadKeyStatus(); // Reload data
+        onRefresh && onRefresh(); // Refresh parent component
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('启用密钥失败'));
+    } finally {
+      setOperationLoading(prev => ({ ...prev, [operationId]: false }));
+    }
+  };
+
+  // Delete all disabled keys
+  const handleDeleteDisabledKeys = async () => {
+    setOperationLoading(prev => ({ ...prev, delete_disabled: true }));
+    
+    try {
+      const res = await API.post('/api/channel/multi_key/manage', {
+        channel_id: channel.id,
+        action: 'delete_disabled_keys'
+      });
+      
+      if (res.data.success) {
+        showSuccess(res.data.message);
+        await loadKeyStatus(); // Reload data
+        onRefresh && onRefresh(); // Refresh parent component
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      showError(t('删除禁用密钥失败'));
+    } finally {
+      setOperationLoading(prev => ({ ...prev, delete_disabled: false }));
+    }
+  };
+
+  // Effect to load data when modal opens
+  useEffect(() => {
+    if (visible && channel?.id) {
+      loadKeyStatus();
+    }
+  }, [visible, channel?.id]);
+
+  // Get status tag component
+  const renderStatusTag = (status) => {
+    switch (status) {
+      case 1:
+        return <Tag color='green' shape='circle'>{t('已启用')}</Tag>;
+      case 2:
+        return <Tag color='red' shape='circle'>{t('已禁用')}</Tag>;
+      case 3:
+        return <Tag color='orange' shape='circle'>{t('自动禁用')}</Tag>;
+      default:
+        return <Tag color='grey' shape='circle'>{t('未知状态')}</Tag>;
+    }
+  };
+
+  // Table columns definition
+  const columns = [
+    {
+      title: t('索引'),
+      dataIndex: 'index',
+      render: (text) => `#${text}`,
+    },
+    {
+      title: t('密钥预览'),
+      dataIndex: 'key_preview',
+      render: (text) => (
+        <Text code style={{ fontSize: '12px' }}>
+          {text}
+        </Text>
+      ),
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      width: 100,
+      render: (status) => renderStatusTag(status),
+    },
+    {
+      title: t('禁用原因'),
+      dataIndex: 'reason',
+      width: 220,
+      render: (reason, record) => {
+        if (record.status === 1 || !reason) {
+          return <Text type='quaternary'>-</Text>;
+        }
+        return (
+          <Tooltip content={reason}>
+            <Text style={{ maxWidth: '200px', display: 'block' }} ellipsis>
+              {reason}
+            </Text>
+          </Tooltip>
+        );
+      },
+    },
+    {
+      title: t('禁用时间'),
+      dataIndex: 'disabled_time',
+      width: 150,
+      render: (time, record) => {
+        if (record.status === 1 || !time) {
+          return <Text type='quaternary'>-</Text>;
+        }
+        return (
+          <Tooltip content={timestamp2string(time)}>
+            <Text style={{ fontSize: '12px' }}>
+              {timestamp2string(time)}
+            </Text>
+          </Tooltip>
+        );
+      },
+    },
+    {
+      title: t('操作'),
+      key: 'action',
+      width: 120,
+      render: (_, record) => (
+        <Space>
+          {record.status === 1 ? (
+            <Popconfirm
+              title={t('确定要禁用此密钥吗?')}
+              content={t('禁用后该密钥将不再被使用')}
+              onConfirm={() => handleDisableKey(record.index)}
+            >
+              <Button
+                type='danger'
+                size='small'
+                loading={operationLoading[`disable_${record.index}`]}
+              >
+                {t('禁用')}
+              </Button>
+            </Popconfirm>
+          ) : (
+            <Popconfirm
+              title={t('确定要启用此密钥吗?')}
+              content={t('启用后该密钥将重新被使用')}
+              onConfirm={() => handleEnableKey(record.index)}
+            >
+              <Button
+                type='primary'
+                size='small'
+                loading={operationLoading[`enable_${record.index}`]}
+              >
+                {t('启用')}
+              </Button>
+            </Popconfirm>
+          )}
+        </Space>
+      ),
+    },
+  ];
+
+  // Calculate statistics
+  const enabledCount = keyStatusList.filter(key => key.status === 1).length;
+  const manualDisabledCount = keyStatusList.filter(key => key.status === 2).length;
+  const autoDisabledCount = keyStatusList.filter(key => key.status === 3).length;
+  const totalCount = keyStatusList.length;
+
+  return (
+    <Modal
+      title={
+        <Space>
+          <IconSetting />
+          <span>{t('多密钥管理')} - {channel?.name}</span>
+        </Space>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      width={800}
+      height={600}
+      footer={
+        <Space>
+          <Button onClick={onCancel}>{t('关闭')}</Button>
+          <Button
+            icon={<IconRefresh />}
+            onClick={loadKeyStatus}
+            loading={loading}
+          >
+            {t('刷新')}
+          </Button>
+          {autoDisabledCount > 0 && (
+            <Popconfirm
+              title={t('确定要删除所有已自动禁用的密钥吗?')}
+              content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
+              onConfirm={handleDeleteDisabledKeys}
+            >
+              <Button
+                type='danger'
+                icon={<IconDelete />}
+                loading={operationLoading.delete_disabled}
+              >
+                {t('删除自动禁用密钥')}
+              </Button>
+            </Popconfirm>
+          )}
+        </Space>
+      }
+    >
+      <div style={{ padding: '16px 0' }}>
+        {/* Statistics Banner */}
+        <Banner
+          type='info'
+          style={{ marginBottom: '16px' }}
+          description={
+            <div>
+              <Text>
+                {t('总共 {{total}} 个密钥,{{enabled}} 个已启用,{{manual}} 个手动禁用,{{auto}} 个自动禁用', {
+                  total: totalCount,
+                  enabled: enabledCount,
+                  manual: manualDisabledCount,
+                  auto: autoDisabledCount
+                })}
+              </Text>
+              {channel?.channel_info?.multi_key_mode && (
+                <div style={{ marginTop: '4px' }}>
+                  <Text type='quaternary' style={{ fontSize: '12px' }}>
+                    {t('多密钥模式')}: {channel.channel_info.multi_key_mode === 'random' ? t('随机') : t('轮询')}
+                  </Text>
+                </div>
+              )}
+            </div>
+          }
+        />
+
+        {/* Key Status Table */}
+        <Spin spinning={loading}>
+          {keyStatusList.length > 0 ? (
+            <Table
+              columns={columns}
+              dataSource={keyStatusList}
+              pagination={false}
+              size='small'
+              bordered
+              rowKey='index'
+              style={{ maxHeight: '400px', overflow: 'auto' }}
+            />
+          ) : (
+            !loading && (
+              <Empty
+                image={Empty.PRESENTED_IMAGE_SIMPLE}
+                title={t('暂无密钥数据')}
+                description={t('请检查渠道配置或刷新重试')}
+              />
+            )
+          )}
+        </Spin>
+      </div>
+    </Modal>
+  );
+};
+
+export default MultiKeyManageModal; 

+ 10 - 0
web/src/hooks/channels/useChannelsData.js

@@ -83,6 +83,10 @@ export const useChannelsData = () => {
   const [isProcessingQueue, setIsProcessingQueue] = useState(false);
   const [modelTablePage, setModelTablePage] = useState(1);
 
+  // Multi-key management states
+  const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
+  const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null);
+
   // Refs
   const requestCounter = useRef(0);
   const allSelectingRef = useRef(false);
@@ -885,6 +889,12 @@ export const useChannelsData = () => {
     setModelTablePage,
     allSelectingRef,
 
+    // Multi-key management states
+    showMultiKeyManageModal,
+    setShowMultiKeyManageModal,
+    currentMultiKeyChannel,
+    setCurrentMultiKeyChannel,
+
     // Form
     formApi,
     setFormApi,