feat: add multi-key management

This commit is contained in:
CaIon
2025-08-04 16:52:31 +08:00
parent 10b04416c1
commit ecdd9d1ccb
9 changed files with 730 additions and 65 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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且轮询保留轮询索引信息

View File

@@ -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())

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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 support@quantumnous.com
*/
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;

View File

@@ -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,