Merge pull request #1498 from QuantumNous/multi-key-manage
feat: add multi-key management
This commit is contained in:
@@ -71,6 +71,13 @@ func parseStatusFilter(statusParam string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearChannelInfo(channel *model.Channel) {
|
||||||
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledReason = nil
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledTime = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetAllChannels(c *gin.Context) {
|
func GetAllChannels(c *gin.Context) {
|
||||||
pageInfo := common.GetPageQuery(c)
|
pageInfo := common.GetPageQuery(c)
|
||||||
channelData := make([]*model.Channel, 0)
|
channelData := make([]*model.Channel, 0)
|
||||||
@@ -145,6 +152,10 @@ func GetAllChannels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, datum := range channelData {
|
||||||
|
clearChannelInfo(datum)
|
||||||
|
}
|
||||||
|
|
||||||
countQuery := model.DB.Model(&model.Channel{})
|
countQuery := model.DB.Model(&model.Channel{})
|
||||||
if statusFilter == common.ChannelStatusEnabled {
|
if statusFilter == common.ChannelStatusEnabled {
|
||||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||||
@@ -371,6 +382,10 @@ func SearchChannels(c *gin.Context) {
|
|||||||
|
|
||||||
pagedData := channelData[startIdx:endIdx]
|
pagedData := channelData[startIdx:endIdx]
|
||||||
|
|
||||||
|
for _, datum := range pagedData {
|
||||||
|
clearChannelInfo(datum)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -394,6 +409,9 @@ func GetChannel(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if channel != nil {
|
||||||
|
clearChannelInfo(channel)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -827,6 +845,7 @@ func UpdateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
model.InitChannelCache()
|
model.InitChannelCache()
|
||||||
channel.Key = ""
|
channel.Key = ""
|
||||||
|
clearChannelInfo(&channel.Channel)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -1030,3 +1049,409 @@ func CopyChannel(c *gin.Context) {
|
|||||||
// success
|
// success
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
|
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
|
||||||
|
Page int `json:"page,omitempty"` // for get_key_status pagination
|
||||||
|
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
|
||||||
|
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiKeyStatusResponse represents the response for key status query
|
||||||
|
type MultiKeyStatusResponse struct {
|
||||||
|
Keys []KeyStatus `json:"keys"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
// Statistics
|
||||||
|
EnabledCount int `json:"enabled_count"`
|
||||||
|
ManualDisabledCount int `json:"manual_disabled_count"`
|
||||||
|
AutoDisabledCount int `json:"auto_disabled_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Default pagination parameters
|
||||||
|
page := request.Page
|
||||||
|
pageSize := request.PageSize
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50 // Default page size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics for all keys (unchanged by filtering)
|
||||||
|
var enabledCount, manualDisabledCount, autoDisabledCount int
|
||||||
|
|
||||||
|
// Build all key status data first
|
||||||
|
var allKeyStatusList []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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count for statistics (all keys)
|
||||||
|
switch status {
|
||||||
|
case 1:
|
||||||
|
enabledCount++
|
||||||
|
case 2:
|
||||||
|
manualDisabledCount++
|
||||||
|
case 3:
|
||||||
|
autoDisabledCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
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] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
allKeyStatusList = append(allKeyStatusList, KeyStatus{
|
||||||
|
Index: i,
|
||||||
|
Status: status,
|
||||||
|
DisabledTime: disabledTime,
|
||||||
|
Reason: reason,
|
||||||
|
KeyPreview: keyPreview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter if specified
|
||||||
|
var filteredKeyStatusList []KeyStatus
|
||||||
|
if request.Status != nil {
|
||||||
|
for _, keyStatus := range allKeyStatusList {
|
||||||
|
if keyStatus.Status == *request.Status {
|
||||||
|
filteredKeyStatusList = append(filteredKeyStatusList, keyStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filteredKeyStatusList = allKeyStatusList
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination based on filtered results
|
||||||
|
filteredTotal := len(filteredKeyStatusList)
|
||||||
|
totalPages := (filteredTotal + pageSize - 1) / pageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate range for current page
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
end := start + pageSize
|
||||||
|
if end > filteredTotal {
|
||||||
|
end = filteredTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the page data
|
||||||
|
var pageKeyStatusList []KeyStatus
|
||||||
|
if start < filteredTotal {
|
||||||
|
pageKeyStatusList = filteredKeyStatusList[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": MultiKeyStatusResponse{
|
||||||
|
Keys: pageKeyStatusList,
|
||||||
|
Total: filteredTotal, // Total of filtered results
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
EnabledCount: enabledCount, // Overall statistics
|
||||||
|
ManualDisabledCount: manualDisabledCount, // Overall statistics
|
||||||
|
AutoDisabledCount: autoDisabledCount, // Overall statistics
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
|
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 "enable_all_keys":
|
||||||
|
// 清空所有禁用状态,使所有密钥回到默认启用状态
|
||||||
|
var enabledCount int
|
||||||
|
if channel.ChannelInfo.MultiKeyStatusList != nil {
|
||||||
|
enabledCount = len(channel.ChannelInfo.MultiKeyStatusList)
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
|
||||||
|
|
||||||
|
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 个密钥", enabledCount),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
case "disable_all_keys":
|
||||||
|
// 禁用所有启用的密钥
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var disabledCount int
|
||||||
|
for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ {
|
||||||
|
status := 1 // default enabled
|
||||||
|
if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
|
||||||
|
status = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只禁用当前启用的密钥
|
||||||
|
if status == 1 {
|
||||||
|
channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled
|
||||||
|
disabledCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabledCount == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "没有可禁用的密钥",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 个密钥", disabledCount),
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Channel struct {
|
|||||||
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
|
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
|
||||||
AutoBan *int `json:"auto_ban" gorm:"default:1"`
|
AutoBan *int `json:"auto_ban" gorm:"default:1"`
|
||||||
OtherInfo string `json:"other_info"`
|
OtherInfo string `json:"other_info"`
|
||||||
|
Settings string `json:"settings"`
|
||||||
Tag *string `json:"tag" gorm:"index"`
|
Tag *string `json:"tag" gorm:"index"`
|
||||||
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
||||||
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
||||||
@@ -52,11 +53,13 @@ type Channel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChannelInfo struct {
|
type ChannelInfo struct {
|
||||||
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
|
IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式
|
||||||
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
|
MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量
|
||||||
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status
|
MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status
|
||||||
MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
|
MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason
|
||||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // 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
|
// Value implements driver.Valuer interface
|
||||||
@@ -70,7 +73,7 @@ func (c *ChannelInfo) Scan(value interface{}) error {
|
|||||||
return common.Unmarshal(bytesValue, c)
|
return common.Unmarshal(bytesValue, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (channel *Channel) getKeys() []string {
|
func (channel *Channel) GetKeys() []string {
|
||||||
if channel.Key == "" {
|
if channel.Key == "" {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
@@ -101,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Obtain all keys (split by \n)
|
// Obtain all keys (split by \n)
|
||||||
keys := channel.getKeys()
|
keys := channel.GetKeys()
|
||||||
if len(keys) == 0 {
|
if len(keys) == 0 {
|
||||||
// No keys available, return error, should disable the channel
|
// No keys available, return error, should disable the channel
|
||||||
return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
|
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) {
|
func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) {
|
||||||
keys := channel.getKeys()
|
keys := channel.GetKeys()
|
||||||
if len(keys) == 0 {
|
if len(keys) == 0 {
|
||||||
channel.Status = status
|
channel.Status = status
|
||||||
} else {
|
} else {
|
||||||
@@ -547,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
|
|||||||
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
|
delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
|
||||||
} else {
|
} else {
|
||||||
channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
|
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 {
|
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||||
channel.Status = common.ChannelStatusAutoDisabled
|
channel.Status = common.ChannelStatusAutoDisabled
|
||||||
@@ -569,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
|||||||
}
|
}
|
||||||
if channelCache.ChannelInfo.IsMultiKey {
|
if channelCache.ChannelInfo.IsMultiKey {
|
||||||
// 如果是多Key模式,更新缓存中的状态
|
// 如果是多Key模式,更新缓存中的状态
|
||||||
handlerMultiKeyUpdate(channelCache, usingKey, status)
|
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||||
//CacheUpdateChannel(channelCache)
|
//CacheUpdateChannel(channelCache)
|
||||||
//return true
|
//return true
|
||||||
} else {
|
} else {
|
||||||
@@ -600,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
|||||||
|
|
||||||
if channel.ChannelInfo.IsMultiKey {
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
beforeStatus := channel.Status
|
beforeStatus := channel.Status
|
||||||
handlerMultiKeyUpdate(channel, usingKey, status)
|
handlerMultiKeyUpdate(channel, usingKey, status, reason)
|
||||||
if beforeStatus != channel.Status {
|
if beforeStatus != channel.Status {
|
||||||
shouldUpdateAbilities = true
|
shouldUpdateAbilities = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func InitChannelCache() {
|
|||||||
//channelsIDM = newChannelId2channel
|
//channelsIDM = newChannelId2channel
|
||||||
for i, channel := range newChannelId2channel {
|
for i, channel := range newChannelId2channel {
|
||||||
if channel.ChannelInfo.IsMultiKey {
|
if channel.ChannelInfo.IsMultiKey {
|
||||||
channel.Keys = channel.getKeys()
|
channel.Keys = channel.GetKeys()
|
||||||
if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
|
if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
|
||||||
if oldChannel, ok := channelsIDM[i]; ok {
|
if oldChannel, ok := channelsIDM[i]; ok {
|
||||||
// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息
|
// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
|
channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
|
||||||
channelRoute.GET("/tag/models", controller.GetTagModels)
|
channelRoute.GET("/tag/models", controller.GetTagModels)
|
||||||
channelRoute.POST("/copy/:id", controller.CopyChannel)
|
channelRoute.POST("/copy/:id", controller.CopyChannel)
|
||||||
|
channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys)
|
||||||
}
|
}
|
||||||
tokenRoute := apiRouter.Group("/token")
|
tokenRoute := apiRouter.Group("/token")
|
||||||
tokenRoute.Use(middleware.UserAuth())
|
tokenRoute.Use(middleware.UserAuth())
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ export const getChannelsColumns = ({
|
|||||||
copySelectedChannel,
|
copySelectedChannel,
|
||||||
refresh,
|
refresh,
|
||||||
activePage,
|
activePage,
|
||||||
channels
|
channels,
|
||||||
|
setShowMultiKeyManageModal,
|
||||||
|
setCurrentMultiKeyChannel
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -503,47 +505,7 @@ export const getChannelsColumns = ({
|
|||||||
/>
|
/>
|
||||||
</SplitButtonGroup>
|
</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 ? (
|
record.status === 1 ? (
|
||||||
<Button
|
<Button
|
||||||
type='danger'
|
type='danger'
|
||||||
@@ -560,18 +522,55 @@ export const getChannelsColumns = ({
|
|||||||
{t('启用')}
|
{t('启用')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
}
|
||||||
|
|
||||||
<Button
|
{record.channel_info?.is_multi_key ? (
|
||||||
type='tertiary'
|
<SplitButtonGroup
|
||||||
size="small"
|
aria-label={t('多密钥渠道操作项目组')}
|
||||||
onClick={() => {
|
>
|
||||||
setEditingChannel(record);
|
<Button
|
||||||
setShowEdit(true);
|
type='tertiary'
|
||||||
}}
|
size="small"
|
||||||
>
|
onClick={() => {
|
||||||
{t('编辑')}
|
setEditingChannel(record);
|
||||||
</Button>
|
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
|
<Dropdown
|
||||||
trigger='click'
|
trigger='click'
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const ChannelsTable = (channelsData) => {
|
|||||||
setEditingTag,
|
setEditingTag,
|
||||||
copySelectedChannel,
|
copySelectedChannel,
|
||||||
refresh,
|
refresh,
|
||||||
|
// Multi-key management
|
||||||
|
setShowMultiKeyManageModal,
|
||||||
|
setCurrentMultiKeyChannel,
|
||||||
} = channelsData;
|
} = channelsData;
|
||||||
|
|
||||||
// Get all columns
|
// Get all columns
|
||||||
@@ -79,6 +82,8 @@ const ChannelsTable = (channelsData) => {
|
|||||||
refresh,
|
refresh,
|
||||||
activePage,
|
activePage,
|
||||||
channels,
|
channels,
|
||||||
|
setShowMultiKeyManageModal,
|
||||||
|
setCurrentMultiKeyChannel,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
t,
|
t,
|
||||||
@@ -98,6 +103,8 @@ const ChannelsTable = (channelsData) => {
|
|||||||
refresh,
|
refresh,
|
||||||
activePage,
|
activePage,
|
||||||
channels,
|
channels,
|
||||||
|
setShowMultiKeyManageModal,
|
||||||
|
setCurrentMultiKeyChannel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter columns based on visibility settings
|
// Filter columns based on visibility settings
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import ModelTestModal from './modals/ModelTestModal.jsx';
|
|||||||
import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
|
import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx';
|
||||||
import EditChannelModal from './modals/EditChannelModal.jsx';
|
import EditChannelModal from './modals/EditChannelModal.jsx';
|
||||||
import EditTagModal from './modals/EditTagModal.jsx';
|
import EditTagModal from './modals/EditTagModal.jsx';
|
||||||
|
import MultiKeyManageModal from './modals/MultiKeyManageModal.jsx';
|
||||||
import { createCardProPagination } from '../../../helpers/utils';
|
import { createCardProPagination } from '../../../helpers/utils';
|
||||||
|
|
||||||
const ChannelsPage = () => {
|
const ChannelsPage = () => {
|
||||||
@@ -54,6 +55,12 @@ const ChannelsPage = () => {
|
|||||||
/>
|
/>
|
||||||
<BatchTagModal {...channelsData} />
|
<BatchTagModal {...channelsData} />
|
||||||
<ModelTestModal {...channelsData} />
|
<ModelTestModal {...channelsData} />
|
||||||
|
<MultiKeyManageModal
|
||||||
|
visible={channelsData.showMultiKeyManageModal}
|
||||||
|
onCancel={() => channelsData.setShowMultiKeyManageModal(false)}
|
||||||
|
channel={channelsData.currentMultiKeyChannel}
|
||||||
|
onRefresh={channelsData.refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<CardPro
|
<CardPro
|
||||||
|
|||||||
578
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
578
web/src/components/table/channels/modals/MultiKeyManageModal.jsx
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
Select,
|
||||||
|
Pagination
|
||||||
|
} 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({});
|
||||||
|
|
||||||
|
// Pagination states
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
|
// Statistics states
|
||||||
|
const [enabledCount, setEnabledCount] = useState(0);
|
||||||
|
const [manualDisabledCount, setManualDisabledCount] = useState(0);
|
||||||
|
const [autoDisabledCount, setAutoDisabledCount] = useState(0);
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [statusFilter, setStatusFilter] = useState(null); // null=all, 1=enabled, 2=manual_disabled, 3=auto_disabled
|
||||||
|
|
||||||
|
// Load key status data
|
||||||
|
const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
|
||||||
|
if (!channel?.id) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const requestData = {
|
||||||
|
channel_id: channel.id,
|
||||||
|
action: 'get_key_status',
|
||||||
|
page: page,
|
||||||
|
page_size: size
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add status filter if specified
|
||||||
|
if (status !== null) {
|
||||||
|
requestData.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await API.post('/api/channel/multi_key/manage', requestData);
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
const data = res.data.data;
|
||||||
|
setKeyStatusList(data.keys || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
setCurrentPage(data.page || 1);
|
||||||
|
setPageSize(data.page_size || 50);
|
||||||
|
setTotalPages(data.total_pages || 0);
|
||||||
|
|
||||||
|
// Update statistics (these are always the overall statistics)
|
||||||
|
setEnabledCount(data.enabled_count || 0);
|
||||||
|
setManualDisabledCount(data.manual_disabled_count || 0);
|
||||||
|
setAutoDisabledCount(data.auto_disabled_count || 0);
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(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(currentPage, pageSize); // Reload current page
|
||||||
|
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(currentPage, pageSize); // Reload current page
|
||||||
|
onRefresh && onRefresh(); // Refresh parent component
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('启用密钥失败'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(prev => ({ ...prev, [operationId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable all disabled keys
|
||||||
|
const handleEnableAll = async () => {
|
||||||
|
setOperationLoading(prev => ({ ...prev, enable_all: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/channel/multi_key/manage', {
|
||||||
|
channel_id: channel.id,
|
||||||
|
action: 'enable_all_keys'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess(res.data.message || t('已启用所有密钥'));
|
||||||
|
// Reset to first page after bulk operation
|
||||||
|
setCurrentPage(1);
|
||||||
|
await loadKeyStatus(1, pageSize);
|
||||||
|
onRefresh && onRefresh(); // Refresh parent component
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('启用所有密钥失败'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(prev => ({ ...prev, enable_all: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable all enabled keys
|
||||||
|
const handleDisableAll = async () => {
|
||||||
|
setOperationLoading(prev => ({ ...prev, disable_all: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/channel/multi_key/manage', {
|
||||||
|
channel_id: channel.id,
|
||||||
|
action: 'disable_all_keys'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess(res.data.message || t('已禁用所有密钥'));
|
||||||
|
// Reset to first page after bulk operation
|
||||||
|
setCurrentPage(1);
|
||||||
|
await loadKeyStatus(1, pageSize);
|
||||||
|
onRefresh && onRefresh(); // Refresh parent component
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('禁用所有密钥失败'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(prev => ({ ...prev, disable_all: 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);
|
||||||
|
// Reset to first page after deletion as data structure might change
|
||||||
|
setCurrentPage(1);
|
||||||
|
await loadKeyStatus(1, pageSize);
|
||||||
|
onRefresh && onRefresh(); // Refresh parent component
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('删除禁用密钥失败'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(prev => ({ ...prev, delete_disabled: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadKeyStatus(page, pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page size change
|
||||||
|
const handlePageSizeChange = (size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1); // Reset to first page
|
||||||
|
loadKeyStatus(1, size);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle status filter change
|
||||||
|
const handleStatusFilterChange = (status) => {
|
||||||
|
setStatusFilter(status);
|
||||||
|
setCurrentPage(1); // Reset to first page when filter changes
|
||||||
|
loadKeyStatus(1, pageSize, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect to load data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && channel?.id) {
|
||||||
|
setCurrentPage(1); // Reset to first page when opening
|
||||||
|
loadKeyStatus(1, pageSize);
|
||||||
|
}
|
||||||
|
}, [visible, channel?.id]);
|
||||||
|
|
||||||
|
// Reset pagination when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setKeyStatusList([]);
|
||||||
|
setTotal(0);
|
||||||
|
setTotalPages(0);
|
||||||
|
setEnabledCount(0);
|
||||||
|
setManualDisabledCount(0);
|
||||||
|
setAutoDisabledCount(0);
|
||||||
|
setStatusFilter(null); // Reset filter
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 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 ? (
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
size='small'
|
||||||
|
loading={operationLoading[`disable_${record.index}`]}
|
||||||
|
onClick={() => handleDisableKey(record.index)}
|
||||||
|
>
|
||||||
|
{t('禁用')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
size='small'
|
||||||
|
loading={operationLoading[`enable_${record.index}`]}
|
||||||
|
onClick={() => handleEnableKey(record.index)}
|
||||||
|
>
|
||||||
|
{t('启用')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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(currentPage, pageSize)}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t('刷新')}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('确定要启用所有密钥吗?')}
|
||||||
|
onConfirm={handleEnableAll}
|
||||||
|
position={'topRight'}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
loading={operationLoading.enable_all}
|
||||||
|
>
|
||||||
|
{t('启用全部')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
{enabledCount > 0 && (
|
||||||
|
<Popconfirm
|
||||||
|
title={t('确定要禁用所有的密钥吗?')}
|
||||||
|
onConfirm={handleDisableAll}
|
||||||
|
okType={'danger'}
|
||||||
|
position={'topRight'}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
loading={operationLoading.disable_all}
|
||||||
|
>
|
||||||
|
{t('禁用全部')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
<Popconfirm
|
||||||
|
title={t('确定要删除所有已自动禁用的密钥吗?')}
|
||||||
|
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
|
||||||
|
onConfirm={handleDeleteDisabledKeys}
|
||||||
|
okType={'danger'}
|
||||||
|
position={'topRight'}
|
||||||
|
>
|
||||||
|
<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: total,
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Controls */}
|
||||||
|
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<Text style={{ fontSize: '14px', fontWeight: '500' }}>{t('状态筛选')}:</Text>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={handleStatusFilterChange}
|
||||||
|
style={{ width: '120px' }}
|
||||||
|
size='small'
|
||||||
|
placeholder={t('全部状态')}
|
||||||
|
>
|
||||||
|
<Select.Option value={null}>{t('全部状态')}</Select.Option>
|
||||||
|
<Select.Option value={1}>{t('已启用')}</Select.Option>
|
||||||
|
<Select.Option value={2}>{t('手动禁用')}</Select.Option>
|
||||||
|
<Select.Option value={3}>{t('自动禁用')}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
{statusFilter !== null && (
|
||||||
|
<Text type='quaternary' style={{ fontSize: '12px' }}>
|
||||||
|
{t('当前显示 {{count}} 条筛选结果', { count: total })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Status Table */}
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{keyStatusList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={keyStatusList}
|
||||||
|
pagination={false}
|
||||||
|
size='small'
|
||||||
|
bordered
|
||||||
|
rowKey='index'
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > 0 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type='quaternary' style={{ fontSize: '12px' }}>
|
||||||
|
{t('显示第 {{start}}-{{end}} 条,共 {{total}} 条', {
|
||||||
|
start: (currentPage - 1) * pageSize + 1,
|
||||||
|
end: Math.min(currentPage * pageSize, total),
|
||||||
|
total: total
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<Text type='quaternary' style={{ fontSize: '12px' }}>
|
||||||
|
{t('每页显示')}:
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={handlePageSizeChange}
|
||||||
|
size='small'
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
>
|
||||||
|
<Select.Option value={50}>50</Select.Option>
|
||||||
|
<Select.Option value={100}>100</Select.Option>
|
||||||
|
<Select.Option value={500}>500</Select.Option>
|
||||||
|
<Select.Option value={1000}>1000</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
total={total}
|
||||||
|
pageSize={pageSize}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showQuickJumper
|
||||||
|
size='small'
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showTotal={(total, range) =>
|
||||||
|
t('第 {{current}} / {{total}} 页', {
|
||||||
|
current: currentPage,
|
||||||
|
total: totalPages
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!loading && (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
title={t('暂无密钥数据')}
|
||||||
|
description={t('请检查渠道配置或刷新重试')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiKeyManageModal;
|
||||||
@@ -83,6 +83,10 @@ export const useChannelsData = () => {
|
|||||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||||
const [modelTablePage, setModelTablePage] = useState(1);
|
const [modelTablePage, setModelTablePage] = useState(1);
|
||||||
|
|
||||||
|
// Multi-key management states
|
||||||
|
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
|
||||||
|
const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null);
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
const requestCounter = useRef(0);
|
const requestCounter = useRef(0);
|
||||||
const allSelectingRef = useRef(false);
|
const allSelectingRef = useRef(false);
|
||||||
@@ -885,6 +889,12 @@ export const useChannelsData = () => {
|
|||||||
setModelTablePage,
|
setModelTablePage,
|
||||||
allSelectingRef,
|
allSelectingRef,
|
||||||
|
|
||||||
|
// Multi-key management states
|
||||||
|
showMultiKeyManageModal,
|
||||||
|
setShowMultiKeyManageModal,
|
||||||
|
currentMultiKeyChannel,
|
||||||
|
setCurrentMultiKeyChannel,
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
formApi,
|
formApi,
|
||||||
setFormApi,
|
setFormApi,
|
||||||
|
|||||||
Reference in New Issue
Block a user