Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8907e5cf6d | |||
| 2cabe4f6ac | |||
| ca85d224f1 | |||
| b6dd701ce8 | |||
| 7130ec8e32 | |||
| c79b6cea32 | |||
| 54f470bc46 | |||
| 1ea54eb2ed | |||
| 750dea9de7 | |||
| ac9a1ae876 | |||
| c59253ea6e | |||
| 571301adfd | |||
| abee65c1db | |||
| 734ef7ddac | |||
| 58af8ed688 | |||
| 8a3107448b | |||
| a0f48f5a30 | |||
| 2177620e68 | |||
| 991a09f47a | |||
| 4abe75faed | |||
| 5a3f011ac2 | |||
| 4bbdc8e2b2 | |||
| 4c0c9ffd0a | |||
| 20575e798a | |||
| f5a52bc5b5 | |||
| a5b4c2f7b5 | |||
| 1dcb9a01ce | |||
| 8e52b42c0a | |||
| f2dbdbc445 | |||
| 8c4f0e6992 | |||
| 2fb3e265f6 | |||
| 2da040f74c | |||
| 2757d555c7 | |||
| 1dd1ef862f | |||
| 4886f86cc8 | |||
| a08e027bde | |||
| 92a979751e | |||
| e1796b92cd | |||
| 74057324b2 | |||
| 8127ad9929 | |||
| a1b796b7ea | |||
| 3ed146f239 |
@@ -83,6 +83,7 @@ var GitHubClientId = ""
|
||||
var GitHubClientSecret = ""
|
||||
var LinuxDOClientId = ""
|
||||
var LinuxDOClientSecret = ""
|
||||
var LinuxDOMinimumTrustLevel = 0
|
||||
|
||||
var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
|
||||
+10
-10
@@ -11,22 +11,22 @@ import "one-api/constant"
|
||||
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||
|
||||
type EndpointInfo struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
||||
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||
}
|
||||
|
||||
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
ContextKeyChannelBaseUrl ContextKey = "base_url"
|
||||
ContextKeyChannelType ContextKey = "channel_type"
|
||||
ContextKeyChannelSetting ContextKey = "channel_setting"
|
||||
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
|
||||
ContextKeyChannelParamOverride ContextKey = "param_override"
|
||||
ContextKeyChannelOrganization ContextKey = "channel_organization"
|
||||
ContextKeyChannelAutoBan ContextKey = "auto_ban"
|
||||
|
||||
+19
-11
@@ -220,21 +220,29 @@ func LinuxdoOAuth(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
if common.RegisterEnabled {
|
||||
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
user.DisplayName = linuxdoUser.Name
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
|
||||
user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
|
||||
user.DisplayName = linuxdoUser.Name
|
||||
user.Role = common.RoleCommonUser
|
||||
user.Status = common.UserStatusEnabled
|
||||
|
||||
affCode := session.Get("aff")
|
||||
inviterId := 0
|
||||
if affCode != nil {
|
||||
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||
}
|
||||
affCode := session.Get("aff")
|
||||
inviterId := 0
|
||||
if affCode != nil {
|
||||
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
|
||||
}
|
||||
|
||||
if err := user.Insert(inviterId); err != nil {
|
||||
if err := user.Insert(inviterId); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
+41
-40
@@ -41,46 +41,47 @@ func GetStatus(c *gin.Context) {
|
||||
cs := console_setting.GetConsoleSetting()
|
||||
|
||||
data := gin.H{
|
||||
"version": common.Version,
|
||||
"start_time": common.StartTime,
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
||||
"linuxdo_client_id": common.LinuxDOClientId,
|
||||
"telegram_oauth": common.TelegramOAuthEnabled,
|
||||
"telegram_bot_name": common.TelegramBotName,
|
||||
"system_name": common.SystemName,
|
||||
"logo": common.Logo,
|
||||
"footer_html": common.Footer,
|
||||
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
||||
"wechat_login": common.WeChatAuthEnabled,
|
||||
"server_address": setting.ServerAddress,
|
||||
"price": setting.Price,
|
||||
"stripe_unit_price": setting.StripeUnitPrice,
|
||||
"min_topup": setting.MinTopUp,
|
||||
"stripe_min_topup": setting.StripeMinTopUp,
|
||||
"turnstile_check": common.TurnstileCheckEnabled,
|
||||
"turnstile_site_key": common.TurnstileSiteKey,
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
|
||||
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
"pay_methods": setting.PayMethods,
|
||||
"usd_exchange_rate": setting.USDExchangeRate,
|
||||
"version": common.Version,
|
||||
"start_time": common.StartTime,
|
||||
"email_verification": common.EmailVerificationEnabled,
|
||||
"github_oauth": common.GitHubOAuthEnabled,
|
||||
"github_client_id": common.GitHubClientId,
|
||||
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
|
||||
"linuxdo_client_id": common.LinuxDOClientId,
|
||||
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
|
||||
"telegram_oauth": common.TelegramOAuthEnabled,
|
||||
"telegram_bot_name": common.TelegramBotName,
|
||||
"system_name": common.SystemName,
|
||||
"logo": common.Logo,
|
||||
"footer_html": common.Footer,
|
||||
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
|
||||
"wechat_login": common.WeChatAuthEnabled,
|
||||
"server_address": setting.ServerAddress,
|
||||
"price": setting.Price,
|
||||
"stripe_unit_price": setting.StripeUnitPrice,
|
||||
"min_topup": setting.MinTopUp,
|
||||
"stripe_min_topup": setting.StripeMinTopUp,
|
||||
"turnstile_check": common.TurnstileCheckEnabled,
|
||||
"turnstile_site_key": common.TurnstileSiteKey,
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"display_in_currency": common.DisplayInCurrencyEnabled,
|
||||
"enable_batch_update": common.BatchUpdateEnabled,
|
||||
"enable_drawing": common.DrawingEnabled,
|
||||
"enable_task": common.TaskEnabled,
|
||||
"enable_data_export": common.DataExportEnabled,
|
||||
"data_export_default_time": common.DataExportDefaultTime,
|
||||
"default_collapse_sidebar": common.DefaultCollapseSidebar,
|
||||
"enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
|
||||
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||
"mj_notify_enabled": setting.MjNotifyEnabled,
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
"pay_methods": setting.PayMethods,
|
||||
"usd_exchange_rate": setting.USDExchangeRate,
|
||||
|
||||
// 面板启用开关
|
||||
"api_info_enabled": cs.ApiInfoEnabled,
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetMissingModels returns the list of model names that are referenced by channels
|
||||
// but do not have corresponding records in the models meta table.
|
||||
// This helps administrators quickly discover models that need configuration.
|
||||
func GetMissingModels(c *gin.Context) {
|
||||
missing, err := model.GetMissingModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
missing, err := model.GetMissingModels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": missing,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": missing,
|
||||
})
|
||||
}
|
||||
|
||||
+135
-135
@@ -1,178 +1,178 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllModelsMeta 获取模型列表(分页)
|
||||
func GetAllModelsMeta(c *gin.Context) {
|
||||
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// 填充附加字段
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Model{}).Count(&total)
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// 填充附加字段
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Model{}).Count(&total)
|
||||
|
||||
// 统计供应商计数(全部数据,不受分页影响)
|
||||
vendorCounts, _ := model.GetVendorModelCounts()
|
||||
// 统计供应商计数(全部数据,不受分页影响)
|
||||
vendorCounts, _ := model.GetVendorModelCounts()
|
||||
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": modelsMeta,
|
||||
"total": total,
|
||||
"page": pageInfo.GetPage(),
|
||||
"page_size": pageInfo.GetPageSize(),
|
||||
"vendor_counts": vendorCounts,
|
||||
})
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"items": modelsMeta,
|
||||
"total": total,
|
||||
"page": pageInfo.GetPage(),
|
||||
"page_size": pageInfo.GetPageSize(),
|
||||
"vendor_counts": vendorCounts,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchModelsMeta 搜索模型列表
|
||||
func SearchModelsMeta(c *gin.Context) {
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
vendor := c.Query("vendor")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
keyword := c.Query("keyword")
|
||||
vendor := c.Query("vendor")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
for _, m := range modelsMeta {
|
||||
fillModelExtra(m)
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(modelsMeta)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetModelMeta 根据 ID 获取单条模型信息
|
||||
func GetModelMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var m model.Model
|
||||
if err := model.DB.First(&m, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
fillModelExtra(&m)
|
||||
common.ApiSuccess(c, &m)
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var m model.Model
|
||||
if err := model.DB.First(&m, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
fillModelExtra(&m)
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// CreateModelMeta 新建模型
|
||||
func CreateModelMeta(c *gin.Context) {
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.ModelName == "" {
|
||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.ModelName == "" {
|
||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
if err := m.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// UpdateModelMeta 更新模型
|
||||
func UpdateModelMeta(c *gin.Context) {
|
||||
statusOnly := c.Query("status_only") == "true"
|
||||
statusOnly := c.Query("status_only") == "true"
|
||||
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少模型 ID")
|
||||
return
|
||||
}
|
||||
var m model.Model
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if m.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少模型 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if statusOnly {
|
||||
// 只更新状态,防止误清空其他字段
|
||||
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
if statusOnly {
|
||||
// 只更新状态,防止误清空其他字段
|
||||
if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "模型名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
if err := m.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
// DeleteModelMeta 删除模型
|
||||
func DeleteModelMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, nil)
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
|
||||
func fillModelExtra(m *model.Model) {
|
||||
if m.Endpoints == "" {
|
||||
eps := model.GetModelSupportEndpointTypes(m.ModelName)
|
||||
if b, err := json.Marshal(eps); err == nil {
|
||||
m.Endpoints = string(b)
|
||||
}
|
||||
}
|
||||
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
|
||||
m.BoundChannels = channels
|
||||
}
|
||||
// 填充启用分组
|
||||
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
|
||||
// 填充计费类型
|
||||
m.QuotaType = model.GetModelQuotaType(m.ModelName)
|
||||
if m.Endpoints == "" {
|
||||
eps := model.GetModelSupportEndpointTypes(m.ModelName)
|
||||
if b, err := json.Marshal(eps); err == nil {
|
||||
m.Endpoints = string(b)
|
||||
}
|
||||
}
|
||||
if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
|
||||
m.BoundChannels = channels
|
||||
}
|
||||
// 填充启用分组
|
||||
m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
|
||||
// 填充计费类型
|
||||
m.QuotaType = model.GetModelQuotaType(m.ModelName)
|
||||
}
|
||||
|
||||
+66
-66
@@ -1,90 +1,90 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
|
||||
func GetPrefillGroups(c *gin.Context) {
|
||||
groupType := c.Query("type")
|
||||
groups, err := model.GetAllPrefillGroups(groupType)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, groups)
|
||||
groupType := c.Query("type")
|
||||
groups, err := model.GetAllPrefillGroups(groupType)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, groups)
|
||||
}
|
||||
|
||||
// CreatePrefillGroup 创建新的预填组
|
||||
func CreatePrefillGroup(c *gin.Context) {
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Name == "" || g.Type == "" {
|
||||
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前检查名称
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Name == "" || g.Type == "" {
|
||||
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前检查名称
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := g.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
if err := g.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
}
|
||||
|
||||
// UpdatePrefillGroup 更新预填组
|
||||
func UpdatePrefillGroup(c *gin.Context) {
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少组 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
var g model.PrefillGroup
|
||||
if err := c.ShouldBindJSON(&g); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if g.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少组 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "组名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := g.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
if err := g.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &g)
|
||||
}
|
||||
|
||||
// DeletePrefillGroup 删除预填组
|
||||
func DeletePrefillGroup(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeletePrefillGroupByID(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeletePrefillGroupByID(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -39,14 +39,14 @@ func GetPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": pricing,
|
||||
"vendors": model.GetVendors(),
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||
"auto_groups": setting.AutoGroups,
|
||||
})
|
||||
"success": true,
|
||||
"data": pricing,
|
||||
"vendors": model.GetVendors(),
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||
"auto_groups": setting.AutoGroups,
|
||||
})
|
||||
}
|
||||
|
||||
func ResetModelRatio(c *gin.Context) {
|
||||
|
||||
+5
-1
@@ -42,7 +42,11 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
|
||||
case relayconstant.RelayModeResponses:
|
||||
err = relay.ResponsesHelper(c)
|
||||
case relayconstant.RelayModeGemini:
|
||||
err = relay.GeminiHelper(c)
|
||||
if strings.Contains(c.Request.URL.Path, "embed") {
|
||||
err = relay.GeminiEmbeddingHandler(c)
|
||||
} else {
|
||||
err = relay.GeminiHelper(c)
|
||||
}
|
||||
default:
|
||||
err = relay.TextHelper(c)
|
||||
}
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ func Login(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 检查是否启用2FA
|
||||
if model.IsTwoFAEnabled(user.Id) {
|
||||
// 设置pending session,等待2FA验证
|
||||
@@ -77,7 +77,7 @@ func Login(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "请输入两步验证码",
|
||||
"success": true,
|
||||
@@ -87,7 +87,7 @@ func Login(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
setupLogin(&user, c)
|
||||
}
|
||||
|
||||
|
||||
+93
-93
@@ -1,124 +1,124 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllVendors 获取供应商列表(分页)
|
||||
func GetAllVendors(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Vendor{}).Count(&total)
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
model.DB.Model(&model.Vendor{}).Count(&total)
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// SearchVendors 搜索供应商
|
||||
func SearchVendors(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
keyword := c.Query("keyword")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(vendors)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetVendorMeta 根据 ID 获取供应商
|
||||
func GetVendorMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
v, err := model.GetVendorByID(id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, v)
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
v, err := model.GetVendorByID(id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, v)
|
||||
}
|
||||
|
||||
// CreateVendorMeta 新建供应商
|
||||
func CreateVendorMeta(c *gin.Context) {
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
common.ApiErrorMsg(c, "供应商名称不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前先检查名称
|
||||
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Name == "" {
|
||||
common.ApiErrorMsg(c, "供应商名称不能为空")
|
||||
return
|
||||
}
|
||||
// 创建前先检查名称
|
||||
if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := v.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
if err := v.Insert(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
}
|
||||
|
||||
// UpdateVendorMeta 更新供应商
|
||||
func UpdateVendorMeta(c *gin.Context) {
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
var v model.Vendor
|
||||
if err := c.ShouldBindJSON(&v); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if v.Id == 0 {
|
||||
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||
return
|
||||
}
|
||||
// 名称冲突检查
|
||||
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
} else if dup {
|
||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := v.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
if err := v.Update(); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &v)
|
||||
}
|
||||
|
||||
// DeleteVendorMeta 删除供应商
|
||||
func DeleteVendorMeta(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -8,3 +8,7 @@ type ChannelSettings struct {
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelOtherSettings struct {
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
}
|
||||
|
||||
+8
-3
@@ -210,6 +210,7 @@ type GeminiImagePrediction struct {
|
||||
|
||||
// Embedding related structs
|
||||
type GeminiEmbeddingRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Content GeminiChatContent `json:"content"`
|
||||
TaskType string `json:"taskType,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
@@ -220,10 +221,14 @@ type GeminiBatchEmbeddingRequest struct {
|
||||
Requests []*GeminiEmbeddingRequest `json:"requests"`
|
||||
}
|
||||
|
||||
type GeminiEmbedding struct {
|
||||
Values []float64 `json:"values"`
|
||||
type GeminiEmbeddingResponse struct {
|
||||
Embedding ContentEmbedding `json:"embedding"`
|
||||
}
|
||||
|
||||
type GeminiBatchEmbeddingResponse struct {
|
||||
Embeddings []*GeminiEmbedding `json:"embeddings"`
|
||||
Embeddings []*ContentEmbedding `json:"embeddings"`
|
||||
}
|
||||
|
||||
type ContentEmbedding struct {
|
||||
Values []float64 `json:"values"`
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type GeneralOpenAIRequest struct {
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Verbosity json.RawMessage `json:"verbosity,omitempty"` // gpt-5
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
|
||||
@@ -244,6 +244,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
|
||||
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
|
||||
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
|
||||
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
|
||||
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
|
||||
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
|
||||
|
||||
+23
-1
@@ -42,7 +42,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"`
|
||||
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
|
||||
Tag *string `json:"tag" gorm:"index"`
|
||||
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
|
||||
ParamOverride *string `json:"param_override" gorm:"type:text"`
|
||||
@@ -838,6 +838,28 @@ func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
|
||||
channel.Setting = common.GetPointer[string](string(settingBytes))
|
||||
}
|
||||
|
||||
func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings {
|
||||
setting := dto.ChannelOtherSettings{}
|
||||
if channel.OtherSettings != "" {
|
||||
err := common.UnmarshalJsonStr(channel.OtherSettings, &setting)
|
||||
if err != nil {
|
||||
common.SysError("failed to unmarshal setting: " + err.Error())
|
||||
channel.OtherSettings = "{}" // 清空设置以避免后续错误
|
||||
_ = channel.Save() // 保存修改
|
||||
}
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) {
|
||||
settingBytes, err := common.Marshal(setting)
|
||||
if err != nil {
|
||||
common.SysError("failed to marshal setting: " + err.Error())
|
||||
return
|
||||
}
|
||||
channel.OtherSettings = string(settingBytes)
|
||||
}
|
||||
|
||||
func (channel *Channel) GetParamOverride() map[string]interface{} {
|
||||
paramOverride := make(map[string]interface{})
|
||||
if channel.ParamOverride != nil && *channel.ParamOverride != "" {
|
||||
|
||||
@@ -64,6 +64,22 @@ var DB *gorm.DB
|
||||
|
||||
var LOG_DB *gorm.DB
|
||||
|
||||
// dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors
|
||||
func dropIndexIfExists(tableName string, indexName string) {
|
||||
if !common.UsingMySQL {
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
// Check index existence via information_schema
|
||||
err := DB.Raw(
|
||||
"SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
|
||||
tableName, indexName,
|
||||
).Scan(&count).Error
|
||||
if err == nil && count > 0 {
|
||||
_ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error
|
||||
}
|
||||
}
|
||||
|
||||
func createRootAccountIfNeed() error {
|
||||
var user User
|
||||
//if user.Status != common.UserStatusEnabled {
|
||||
@@ -235,6 +251,9 @@ func InitLogDB() (err error) {
|
||||
}
|
||||
|
||||
func migrateDB() error {
|
||||
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||
dropIndexIfExists("models", "uk_model_name")
|
||||
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||
if !common.UsingPostgreSQL {
|
||||
return migrateDBFast()
|
||||
}
|
||||
@@ -264,6 +283,10 @@ func migrateDB() error {
|
||||
}
|
||||
|
||||
func migrateDBFast() error {
|
||||
// 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录
|
||||
dropIndexIfExists("models", "uk_model_name")
|
||||
dropIndexIfExists("vendors", "uk_vendor_name")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
migrations := []struct {
|
||||
|
||||
+22
-22
@@ -2,29 +2,29 @@ package model
|
||||
|
||||
// GetMissingModels returns model names that are referenced in the system
|
||||
func GetMissingModels() ([]string, error) {
|
||||
// 1. 获取所有已启用模型(去重)
|
||||
models := GetEnabledModels()
|
||||
if len(models) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
// 1. 获取所有已启用模型(去重)
|
||||
models := GetEnabledModels()
|
||||
if len(models) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// 2. 查询已有的元数据模型名
|
||||
var existing []string
|
||||
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 2. 查询已有的元数据模型名
|
||||
var existing []string
|
||||
if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, e := range existing {
|
||||
existingSet[e] = struct{}{}
|
||||
}
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, e := range existing {
|
||||
existingSet[e] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 收集缺失模型
|
||||
var missing []string
|
||||
for _, name := range models {
|
||||
if _, ok := existingSet[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
// 3. 收集缺失模型
|
||||
var missing []string
|
||||
for _, name := range models {
|
||||
if _, ok := existingSet[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
+20
-20
@@ -3,32 +3,32 @@ package model
|
||||
// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
|
||||
// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。
|
||||
func GetModelEnableGroups(modelName string) []string {
|
||||
// 确保缓存最新
|
||||
GetPricing()
|
||||
// 确保缓存最新
|
||||
GetPricing()
|
||||
|
||||
if modelName == "" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
if modelName == "" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
modelEnableGroupsLock.RLock()
|
||||
groups, ok := modelEnableGroups[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return groups
|
||||
modelEnableGroupsLock.RLock()
|
||||
groups, ok := modelEnableGroups[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetModelQuotaType 返回指定模型的计费类型(quota_type)。
|
||||
// 同样使用缓存映射,避免每次遍历定价切片。
|
||||
func GetModelQuotaType(modelName string) int {
|
||||
GetPricing()
|
||||
GetPricing()
|
||||
|
||||
modelEnableGroupsLock.RLock()
|
||||
quota, ok := modelQuotaTypeMap[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return quota
|
||||
modelEnableGroupsLock.RLock()
|
||||
quota, ok := modelQuotaTypeMap[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return quota
|
||||
}
|
||||
|
||||
+139
-135
@@ -1,11 +1,11 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"strconv"
|
||||
"strings"
|
||||
"one-api/common"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Model 用于存储模型的元数据,例如描述、标签等
|
||||
@@ -23,182 +23,186 @@ import (
|
||||
|
||||
// 模型名称匹配规则
|
||||
const (
|
||||
NameRuleExact = iota // 0 精确匹配
|
||||
NameRulePrefix // 1 前缀匹配
|
||||
NameRuleContains // 2 包含匹配
|
||||
NameRuleSuffix // 3 后缀匹配
|
||||
NameRuleExact = iota // 0 精确匹配
|
||||
NameRulePrefix // 1 前缀匹配
|
||||
NameRuleContains // 2 包含匹配
|
||||
NameRuleSuffix // 3 后缀匹配
|
||||
)
|
||||
|
||||
type BoundChannel struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Id int `json:"id"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Id int `json:"id"`
|
||||
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,priority:1"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"`
|
||||
VendorID int `json:"vendor_id,omitempty" gorm:"index"`
|
||||
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name,priority:2"`
|
||||
|
||||
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
||||
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
||||
QuotaType int `json:"quota_type" gorm:"-"`
|
||||
NameRule int `json:"name_rule" gorm:"default:0"`
|
||||
BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
|
||||
EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"`
|
||||
QuotaType int `json:"quota_type" gorm:"-"`
|
||||
NameRule int `json:"name_rule" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Insert 创建新的模型元数据记录
|
||||
func (mi *Model) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
mi.CreatedTime = now
|
||||
mi.UpdatedTime = now
|
||||
return DB.Create(mi).Error
|
||||
now := common.GetTimestamp()
|
||||
mi.CreatedTime = now
|
||||
mi.UpdatedTime = now
|
||||
return DB.Create(mi).Error
|
||||
}
|
||||
|
||||
// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID)
|
||||
func IsModelNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新现有模型记录
|
||||
func (mi *Model) Update() error {
|
||||
// 仅更新需要变更的字段,避免覆盖 CreatedTime
|
||||
mi.UpdatedTime = common.GetTimestamp()
|
||||
|
||||
// 排除 created_time,其余字段自动更新,避免新增字段时需要维护列表
|
||||
return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error
|
||||
mi.UpdatedTime = common.GetTimestamp()
|
||||
// 使用 Session 配置并选择所有字段,允许零值(如空字符串)也能被更新
|
||||
return DB.Session(&gorm.Session{AllowGlobalUpdate: false, FullSaveAssociations: false}).
|
||||
Model(&Model{}).
|
||||
Where("id = ?", mi.Id).
|
||||
Omit("created_time").
|
||||
Select("*").
|
||||
Updates(mi).Error
|
||||
}
|
||||
|
||||
// Delete 软删除模型记录
|
||||
func (mi *Model) Delete() error {
|
||||
return DB.Delete(mi).Error
|
||||
return DB.Delete(mi).Error
|
||||
}
|
||||
|
||||
// GetModelByName 根据模型名称查询元数据
|
||||
func GetModelByName(name string) (*Model, error) {
|
||||
var mi Model
|
||||
err := DB.Where("model_name = ?", name).First(&mi).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mi, nil
|
||||
var mi Model
|
||||
err := DB.Where("model_name = ?", name).First(&mi).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mi, nil
|
||||
}
|
||||
|
||||
// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响)
|
||||
func GetVendorModelCounts() (map[int64]int64, error) {
|
||||
var stats []struct {
|
||||
VendorID int64
|
||||
Count int64
|
||||
}
|
||||
if err := DB.Model(&Model{}).
|
||||
Select("vendor_id as vendor_id, count(*) as count").
|
||||
Group("vendor_id").
|
||||
Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[int64]int64, len(stats))
|
||||
for _, s := range stats {
|
||||
m[s.VendorID] = s.Count
|
||||
}
|
||||
return m, nil
|
||||
var stats []struct {
|
||||
VendorID int64
|
||||
Count int64
|
||||
}
|
||||
if err := DB.Model(&Model{}).
|
||||
Select("vendor_id as vendor_id, count(*) as count").
|
||||
Group("vendor_id").
|
||||
Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[int64]int64, len(stats))
|
||||
for _, s := range stats {
|
||||
m[s.VendorID] = s.Count
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetAllModels 分页获取所有模型元数据
|
||||
func GetAllModels(offset int, limit int) ([]*Model, error) {
|
||||
var models []*Model
|
||||
err := DB.Offset(offset).Limit(limit).Find(&models).Error
|
||||
return models, err
|
||||
var models []*Model
|
||||
err := DB.Offset(offset).Limit(limit).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// GetBoundChannels 查询支持该模型的渠道(名称+类型)
|
||||
func GetBoundChannels(modelName string) ([]BoundChannel, error) {
|
||||
var channels []BoundChannel
|
||||
err := DB.Table("channels").
|
||||
Select("channels.name, channels.type").
|
||||
Joins("join abilities on abilities.channel_id = channels.id").
|
||||
Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
|
||||
Group("channels.id").
|
||||
Scan(&channels).Error
|
||||
return channels, err
|
||||
var channels []BoundChannel
|
||||
err := DB.Table("channels").
|
||||
Select("channels.name, channels.type").
|
||||
Joins("join abilities on abilities.channel_id = channels.id").
|
||||
Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
|
||||
Group("channels.id").
|
||||
Scan(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含
|
||||
func FindModelByNameWithRule(name string) (*Model, error) {
|
||||
// 1. 精确匹配
|
||||
if m, err := GetModelByName(name); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
// 2. 规则匹配
|
||||
var models []*Model
|
||||
if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prefixMatch, suffixMatch, containsMatch *Model
|
||||
for _, m := range models {
|
||||
switch m.NameRule {
|
||||
case NameRulePrefix:
|
||||
if strings.HasPrefix(name, m.ModelName) {
|
||||
if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
|
||||
prefixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleSuffix:
|
||||
if strings.HasSuffix(name, m.ModelName) {
|
||||
if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
|
||||
suffixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleContains:
|
||||
if strings.Contains(name, m.ModelName) {
|
||||
if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
|
||||
containsMatch = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if prefixMatch != nil {
|
||||
return prefixMatch, nil
|
||||
}
|
||||
if suffixMatch != nil {
|
||||
return suffixMatch, nil
|
||||
}
|
||||
if containsMatch != nil {
|
||||
return containsMatch, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
// 1. 精确匹配
|
||||
if m, err := GetModelByName(name); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
// 2. 规则匹配
|
||||
var models []*Model
|
||||
if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prefixMatch, suffixMatch, containsMatch *Model
|
||||
for _, m := range models {
|
||||
switch m.NameRule {
|
||||
case NameRulePrefix:
|
||||
if strings.HasPrefix(name, m.ModelName) {
|
||||
if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
|
||||
prefixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleSuffix:
|
||||
if strings.HasSuffix(name, m.ModelName) {
|
||||
if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
|
||||
suffixMatch = m
|
||||
}
|
||||
}
|
||||
case NameRuleContains:
|
||||
if strings.Contains(name, m.ModelName) {
|
||||
if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
|
||||
containsMatch = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if prefixMatch != nil {
|
||||
return prefixMatch, nil
|
||||
}
|
||||
if suffixMatch != nil {
|
||||
return suffixMatch, nil
|
||||
}
|
||||
if containsMatch != nil {
|
||||
return containsMatch, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// SearchModels 根据关键词和供应商搜索模型,支持分页
|
||||
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
|
||||
var models []*Model
|
||||
db := DB.Model(&Model{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
|
||||
}
|
||||
if vendor != "" {
|
||||
// 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
|
||||
if vid, err := strconv.Atoi(vendor); err == nil {
|
||||
db = db.Where("models.vendor_id = ?", vid)
|
||||
} else {
|
||||
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
|
||||
return models, total, err
|
||||
var models []*Model
|
||||
db := DB.Model(&Model{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
|
||||
}
|
||||
if vendor != "" {
|
||||
// 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
|
||||
if vid, err := strconv.Atoi(vendor); err == nil {
|
||||
db = db.Where("models.vendor_id = ?", vid)
|
||||
} else {
|
||||
db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
|
||||
return models, total, err
|
||||
}
|
||||
|
||||
@@ -336,6 +336,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.LinuxDOClientId = value
|
||||
case "LinuxDOClientSecret":
|
||||
common.LinuxDOClientSecret = value
|
||||
case "LinuxDOMinimumTrustLevel":
|
||||
common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value)
|
||||
case "Footer":
|
||||
common.Footer = value
|
||||
case "SystemName":
|
||||
|
||||
+72
-72
@@ -1,11 +1,11 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql/driver"
|
||||
"one-api/common"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
|
||||
@@ -20,107 +20,107 @@ type JSONValue json.RawMessage
|
||||
|
||||
// Value 实现 driver.Valuer 接口,用于数据库写入
|
||||
func (j JSONValue) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(j), nil
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(j), nil
|
||||
}
|
||||
|
||||
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
|
||||
func (j *JSONValue) Scan(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*j = nil
|
||||
return nil
|
||||
case []byte:
|
||||
// 拷贝底层字节,避免保留底层缓冲区
|
||||
b := make([]byte, len(v))
|
||||
copy(b, v)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
case string:
|
||||
*j = JSONValue([]byte(v))
|
||||
return nil
|
||||
default:
|
||||
// 其他类型尝试序列化为 JSON
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*j = nil
|
||||
return nil
|
||||
case []byte:
|
||||
// 拷贝底层字节,避免保留底层缓冲区
|
||||
b := make([]byte, len(v))
|
||||
copy(b, v)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
case string:
|
||||
*j = JSONValue([]byte(v))
|
||||
return nil
|
||||
default:
|
||||
// 其他类型尝试序列化为 JSON
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
|
||||
func (j JSONValue) MarshalJSON() ([]byte, error) {
|
||||
if j == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
if j == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
|
||||
func (j *JSONValue) UnmarshalJSON(data []byte) error {
|
||||
if data == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
b := make([]byte, len(data))
|
||||
copy(b, data)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
if data == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
b := make([]byte, len(data))
|
||||
copy(b, data)
|
||||
*j = JSONValue(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
type PrefillGroup struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
|
||||
Type string `json:"type" gorm:"size:32;index;not null"`
|
||||
Items JSONValue `json:"items" gorm:"type:json"`
|
||||
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
|
||||
Type string `json:"type" gorm:"size:32;index;not null"`
|
||||
Items JSONValue `json:"items" gorm:"type:json"`
|
||||
Description string `json:"description,omitempty" gorm:"type:varchar(255)"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
// Insert 新建组
|
||||
func (g *PrefillGroup) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
g.CreatedTime = now
|
||||
g.UpdatedTime = now
|
||||
return DB.Create(g).Error
|
||||
now := common.GetTimestamp()
|
||||
g.CreatedTime = now
|
||||
g.UpdatedTime = now
|
||||
return DB.Create(g).Error
|
||||
}
|
||||
|
||||
// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID)
|
||||
func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新组
|
||||
func (g *PrefillGroup) Update() error {
|
||||
g.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(g).Error
|
||||
g.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(g).Error
|
||||
}
|
||||
|
||||
// DeleteByID 根据 ID 删除组
|
||||
func DeletePrefillGroupByID(id int) error {
|
||||
return DB.Delete(&PrefillGroup{}, id).Error
|
||||
return DB.Delete(&PrefillGroup{}, id).Error
|
||||
}
|
||||
|
||||
// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
|
||||
func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
|
||||
var groups []*PrefillGroup
|
||||
query := DB.Model(&PrefillGroup{})
|
||||
if groupType != "" {
|
||||
query = query.Where("type = ?", groupType)
|
||||
}
|
||||
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
var groups []*PrefillGroup
|
||||
query := DB.Model(&PrefillGroup{})
|
||||
if groupType != "" {
|
||||
query = query.Where("type = ?", groupType)
|
||||
}
|
||||
if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
+141
-139
@@ -1,30 +1,31 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Pricing struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
QuotaType int `json:"quota_type"`
|
||||
ModelRatio float64 `json:"model_ratio"`
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
ModelName string `json:"model_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
QuotaType int `json:"quota_type"`
|
||||
ModelRatio float64 `json:"model_ratio"`
|
||||
ModelPrice float64 `json:"model_price"`
|
||||
OwnerBy string `json:"owner_by"`
|
||||
CompletionRatio float64 `json:"completion_ratio"`
|
||||
EnableGroup []string `json:"enable_groups"`
|
||||
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
|
||||
}
|
||||
|
||||
type PricingVendor struct {
|
||||
@@ -35,11 +36,11 @@ type PricingVendor struct {
|
||||
}
|
||||
|
||||
var (
|
||||
pricingMap []Pricing
|
||||
vendorsList []PricingVendor
|
||||
supportedEndpointMap map[string]common.EndpointInfo
|
||||
lastGetPricingTime time.Time
|
||||
updatePricingLock sync.Mutex
|
||||
pricingMap []Pricing
|
||||
vendorsList []PricingVendor
|
||||
supportedEndpointMap map[string]common.EndpointInfo
|
||||
lastGetPricingTime time.Time
|
||||
updatePricingLock sync.Mutex
|
||||
|
||||
// 缓存映射:模型名 -> 启用分组 / 计费类型
|
||||
modelEnableGroups = make(map[string][]string)
|
||||
@@ -121,19 +122,19 @@ func updatePricing() {
|
||||
for _, m := range prefixList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range suffixList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range containsList {
|
||||
@@ -179,34 +180,34 @@ func updatePricing() {
|
||||
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
||||
modelSupportEndpointsStr := make(map[string][]string)
|
||||
|
||||
// 先根据已有能力填充原生端点
|
||||
for _, ability := range enableAbilities {
|
||||
endpoints := modelSupportEndpointsStr[ability.Model]
|
||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||
for _, channelType := range channelTypes {
|
||||
if !common.StringsContains(endpoints, string(channelType)) {
|
||||
endpoints = append(endpoints, string(channelType))
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
// 先根据已有能力填充原生端点
|
||||
for _, ability := range enableAbilities {
|
||||
endpoints := modelSupportEndpointsStr[ability.Model]
|
||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||
for _, channelType := range channelTypes {
|
||||
if !common.StringsContains(endpoints, string(channelType)) {
|
||||
endpoints = append(endpoints, string(channelType))
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
|
||||
// 再补充模型自定义端点
|
||||
for modelName, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
endpoints := modelSupportEndpointsStr[modelName]
|
||||
for k := range raw {
|
||||
if !common.StringsContains(endpoints, k) {
|
||||
endpoints = append(endpoints, k)
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[modelName] = endpoints
|
||||
}
|
||||
}
|
||||
// 再补充模型自定义端点
|
||||
for modelName, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
endpoints := modelSupportEndpointsStr[modelName]
|
||||
for k := range raw {
|
||||
if !common.StringsContains(endpoints, k) {
|
||||
endpoints = append(endpoints, k)
|
||||
}
|
||||
}
|
||||
modelSupportEndpointsStr[modelName] = endpoints
|
||||
}
|
||||
}
|
||||
|
||||
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
|
||||
for model, endpoints := range modelSupportEndpointsStr {
|
||||
@@ -216,92 +217,93 @@ func updatePricing() {
|
||||
supportedEndpoints = append(supportedEndpoints, endpointType)
|
||||
}
|
||||
modelSupportEndpointTypes[model] = supportedEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
|
||||
supportedEndpointMap = make(map[string]common.EndpointInfo)
|
||||
// 1. 默认端点
|
||||
for _, endpoints := range modelSupportEndpointTypes {
|
||||
for _, et := range endpoints {
|
||||
if info, ok := common.GetDefaultEndpointInfo(et); ok {
|
||||
if _, exists := supportedEndpointMap[string(et)]; !exists {
|
||||
supportedEndpointMap[string(et)] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. 自定义端点(models 表)覆盖默认
|
||||
for _, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
for k, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
|
||||
case map[string]interface{}:
|
||||
ep := common.EndpointInfo{Method: "POST"}
|
||||
if p, ok := val["path"].(string); ok {
|
||||
ep.Path = p
|
||||
}
|
||||
if m, ok := val["method"].(string); ok {
|
||||
ep.Method = strings.ToUpper(m)
|
||||
}
|
||||
supportedEndpointMap[k] = ep
|
||||
default:
|
||||
// ignore unsupported types
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
|
||||
supportedEndpointMap = make(map[string]common.EndpointInfo)
|
||||
// 1. 默认端点
|
||||
for _, endpoints := range modelSupportEndpointTypes {
|
||||
for _, et := range endpoints {
|
||||
if info, ok := common.GetDefaultEndpointInfo(et); ok {
|
||||
if _, exists := supportedEndpointMap[string(et)]; !exists {
|
||||
supportedEndpointMap[string(et)] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. 自定义端点(models 表)覆盖默认
|
||||
for _, meta := range metaMap {
|
||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||
continue
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||
for k, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
|
||||
case map[string]interface{}:
|
||||
ep := common.EndpointInfo{Method: "POST"}
|
||||
if p, ok := val["path"].(string); ok {
|
||||
ep.Path = p
|
||||
}
|
||||
if m, ok := val["method"].(string); ok {
|
||||
ep.Method = strings.ToUpper(m)
|
||||
}
|
||||
supportedEndpointMap[k] = ep
|
||||
default:
|
||||
// ignore unsupported types
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pricingMap = make([]Pricing, 0)
|
||||
for model, groups := range modelGroupsMap {
|
||||
pricing := Pricing{
|
||||
ModelName: model,
|
||||
EnableGroup: groups.Items(),
|
||||
SupportedEndpointTypes: modelSupportEndpointTypes[model],
|
||||
}
|
||||
pricingMap = make([]Pricing, 0)
|
||||
for model, groups := range modelGroupsMap {
|
||||
pricing := Pricing{
|
||||
ModelName: model,
|
||||
EnableGroup: groups.Items(),
|
||||
SupportedEndpointTypes: modelSupportEndpointTypes[model],
|
||||
}
|
||||
|
||||
// 补充模型元数据(描述、标签、供应商、状态)
|
||||
if meta, ok := metaMap[model]; ok {
|
||||
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
|
||||
if meta.Status != 1 {
|
||||
continue
|
||||
}
|
||||
pricing.Description = meta.Description
|
||||
pricing.Tags = meta.Tags
|
||||
pricing.VendorID = meta.VendorID
|
||||
}
|
||||
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
|
||||
if findPrice {
|
||||
pricing.ModelPrice = modelPrice
|
||||
pricing.QuotaType = 1
|
||||
} else {
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
|
||||
pricing.ModelRatio = modelRatio
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
// 补充模型元数据(描述、标签、供应商、状态)
|
||||
if meta, ok := metaMap[model]; ok {
|
||||
// 若模型被禁用(status!=1),则直接跳过,不返回给前端
|
||||
if meta.Status != 1 {
|
||||
continue
|
||||
}
|
||||
pricing.Description = meta.Description
|
||||
pricing.Icon = meta.Icon
|
||||
pricing.Tags = meta.Tags
|
||||
pricing.VendorID = meta.VendorID
|
||||
}
|
||||
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
|
||||
if findPrice {
|
||||
pricing.ModelPrice = modelPrice
|
||||
pricing.QuotaType = 1
|
||||
} else {
|
||||
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
|
||||
pricing.ModelRatio = modelRatio
|
||||
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
|
||||
pricing.QuotaType = 0
|
||||
}
|
||||
pricingMap = append(pricingMap, pricing)
|
||||
}
|
||||
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
modelEnableGroupsLock.Lock()
|
||||
modelEnableGroups = make(map[string][]string)
|
||||
modelQuotaTypeMap = make(map[string]int)
|
||||
for _, p := range pricingMap {
|
||||
modelEnableGroups[p.ModelName] = p.EnableGroup
|
||||
modelQuotaTypeMap[p.ModelName] = p.QuotaType
|
||||
}
|
||||
modelEnableGroupsLock.Unlock()
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
modelEnableGroupsLock.Lock()
|
||||
modelEnableGroups = make(map[string][]string)
|
||||
modelQuotaTypeMap = make(map[string]int)
|
||||
for _, p := range pricingMap {
|
||||
modelEnableGroups[p.ModelName] = p.EnableGroup
|
||||
modelQuotaTypeMap[p.ModelName] = p.QuotaType
|
||||
}
|
||||
modelEnableGroupsLock.Unlock()
|
||||
|
||||
lastGetPricingTime = time.Now()
|
||||
lastGetPricingTime = time.Now()
|
||||
}
|
||||
|
||||
// GetSupportedEndpointMap 返回全局端点到路径的映射
|
||||
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
|
||||
return supportedEndpointMap
|
||||
return supportedEndpointMap
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ package model
|
||||
// 该方法用于需要最新数据的内部管理 API,
|
||||
// 因此会绕过默认的 1 分钟延迟刷新。
|
||||
func RefreshPricing() {
|
||||
updatePricingLock.Lock()
|
||||
defer updatePricingLock.Unlock()
|
||||
updatePricingLock.Lock()
|
||||
defer updatePricingLock.Unlock()
|
||||
|
||||
modelSupportEndpointsLock.Lock()
|
||||
defer modelSupportEndpointsLock.Unlock()
|
||||
modelSupportEndpointsLock.Lock()
|
||||
defer modelSupportEndpointsLock.Unlock()
|
||||
|
||||
updatePricing()
|
||||
updatePricing()
|
||||
}
|
||||
|
||||
+47
-47
@@ -1,9 +1,9 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Vendor 用于存储供应商信息,供模型引用
|
||||
@@ -13,76 +13,76 @@ import (
|
||||
// 本表同样遵循 3NF 设计范式
|
||||
|
||||
type Vendor struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,priority:1"`
|
||||
Description string `json:"description,omitempty" gorm:"type:text"`
|
||||
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name,priority:2"`
|
||||
}
|
||||
|
||||
// Insert 创建新的供应商记录
|
||||
func (v *Vendor) Insert() error {
|
||||
now := common.GetTimestamp()
|
||||
v.CreatedTime = now
|
||||
v.UpdatedTime = now
|
||||
return DB.Create(v).Error
|
||||
now := common.GetTimestamp()
|
||||
v.CreatedTime = now
|
||||
v.UpdatedTime = now
|
||||
return DB.Create(v).Error
|
||||
}
|
||||
|
||||
// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID)
|
||||
func IsVendorNameDuplicated(id int, name string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
if name == "" {
|
||||
return false, nil
|
||||
}
|
||||
var cnt int64
|
||||
err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
|
||||
return cnt > 0, err
|
||||
}
|
||||
|
||||
// Update 更新供应商记录
|
||||
func (v *Vendor) Update() error {
|
||||
v.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(v).Error
|
||||
v.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(v).Error
|
||||
}
|
||||
|
||||
// Delete 软删除供应商
|
||||
func (v *Vendor) Delete() error {
|
||||
return DB.Delete(v).Error
|
||||
return DB.Delete(v).Error
|
||||
}
|
||||
|
||||
// GetVendorByID 根据 ID 获取供应商
|
||||
func GetVendorByID(id int) (*Vendor, error) {
|
||||
var v Vendor
|
||||
err := DB.First(&v, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
var v Vendor
|
||||
err := DB.First(&v, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GetAllVendors 获取全部供应商(分页)
|
||||
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
|
||||
var vendors []*Vendor
|
||||
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
|
||||
return vendors, err
|
||||
var vendors []*Vendor
|
||||
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
|
||||
return vendors, err
|
||||
}
|
||||
|
||||
// SearchVendors 按关键字搜索供应商
|
||||
func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) {
|
||||
db := DB.Model(&Vendor{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var vendors []*Vendor
|
||||
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return vendors, total, nil
|
||||
}
|
||||
db := DB.Model(&Vendor{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
db = db.Where("name LIKE ? OR description LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var vendors []*Vendor
|
||||
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return vendors, total, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package aws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -113,14 +112,14 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*
|
||||
}
|
||||
claudeReq := claudeReq_.(*dto.ClaudeRequest)
|
||||
awsClaudeReq := copyRequest(claudeReq)
|
||||
awsReq.Body, err = json.Marshal(awsClaudeReq)
|
||||
awsReq.Body, err = common.Marshal(awsClaudeReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
|
||||
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
|
||||
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
claudeInfo := &claude.ClaudeResponseInfo{
|
||||
@@ -165,14 +164,14 @@ func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
claudeReq := claudeReq_.(*dto.ClaudeRequest)
|
||||
|
||||
awsClaudeReq := copyRequest(claudeReq)
|
||||
awsReq.Body, err = json.Marshal(awsClaudeReq)
|
||||
awsReq.Body, err = common.Marshal(awsClaudeReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
|
||||
}
|
||||
|
||||
awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)
|
||||
if err != nil {
|
||||
return types.NewError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeChannelAwsClientError), nil
|
||||
return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
|
||||
}
|
||||
stream := awsResp.GetStream()
|
||||
defer stream.Close()
|
||||
|
||||
@@ -114,7 +114,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if strings.HasPrefix(info.UpstreamModelName, "text-embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "embedding") ||
|
||||
strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") {
|
||||
return fmt.Sprintf("%s/%s/models/%s:batchEmbedContents", info.BaseUrl, version, info.UpstreamModelName), nil
|
||||
action := "embedContent"
|
||||
if info.IsGeminiBatchEmbedding {
|
||||
action = "batchEmbedContents"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil
|
||||
}
|
||||
|
||||
action := "generateContent"
|
||||
@@ -159,6 +163,9 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
if len(inputs) == 0 {
|
||||
return nil, errors.New("input is empty")
|
||||
}
|
||||
// We always build a batch-style payload with `requests`, so ensure we call the
|
||||
// batch endpoint upstream to avoid payload/endpoint mismatches.
|
||||
info.IsGeminiBatchEmbedding = true
|
||||
// process all inputs
|
||||
geminiRequests := make([]map[string]interface{}, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
@@ -176,7 +183,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
// set specific parameters for different models
|
||||
// https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent
|
||||
switch info.UpstreamModelName {
|
||||
case "text-embedding-004","gemini-embedding-exp-03-07","gemini-embedding-001":
|
||||
case "text-embedding-004", "gemini-embedding-exp-03-07", "gemini-embedding-001":
|
||||
// Only newer models introduced after 2024 support OutputDimensionality
|
||||
if request.Dimensions > 0 {
|
||||
geminiRequest["outputDimensionality"] = request.Dimensions
|
||||
@@ -201,6 +208,10 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
|
||||
strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
|
||||
return NativeGeminiEmbeddingHandler(c, resp, info)
|
||||
}
|
||||
if info.IsStream {
|
||||
return GeminiTextGenerationStreamHandler(c, info, resp)
|
||||
} else {
|
||||
@@ -225,18 +236,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
return GeminiChatHandler(c, info, resp)
|
||||
}
|
||||
|
||||
//if usage.(*dto.Usage).CompletionTokenDetails.ReasoningTokens > 100 {
|
||||
// // 没有请求-thinking的情况下,产生思考token,则按照思考模型计费
|
||||
// if !strings.HasSuffix(info.OriginModelName, "-thinking") &&
|
||||
// !strings.HasSuffix(info.OriginModelName, "-nothinking") {
|
||||
// thinkingModelName := info.OriginModelName + "-thinking"
|
||||
// if operation_setting.SelfUseModeEnabled || helper.ContainPriceOrRatio(thinkingModelName) {
|
||||
// info.OriginModelName = thinkingModelName
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
return nil, types.NewError(errors.New("not implemented"), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -12,6 +11,8 @@ import (
|
||||
"one-api/types"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -63,6 +64,42 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
|
||||
defer common.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: info.PromptTokens,
|
||||
TotalTokens: info.PromptTokens,
|
||||
}
|
||||
|
||||
if info.IsGeminiBatchEmbedding {
|
||||
var geminiResponse dto.GeminiBatchEmbeddingResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
var geminiResponse dto.GeminiEmbeddingResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
common.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
|
||||
@@ -128,7 +128,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
|
||||
// 特殊处理 responses API
|
||||
if info.RelayMode == relayconstant.RelayModeResponses {
|
||||
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview")
|
||||
responsesApiVersion := "preview"
|
||||
if info.ChannelOtherSettings.AzureResponsesVersion != "" {
|
||||
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
|
||||
}
|
||||
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=%s", responsesApiVersion)
|
||||
return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -74,13 +74,14 @@ type RelayInfo struct {
|
||||
FirstResponseTime time.Time
|
||||
isFirstResponse bool
|
||||
//SendLastReasoningResponse bool
|
||||
ApiType int
|
||||
IsStream bool
|
||||
IsPlayground bool
|
||||
UsePrice bool
|
||||
RelayMode int
|
||||
UpstreamModelName string
|
||||
OriginModelName string
|
||||
ApiType int
|
||||
IsStream bool
|
||||
IsGeminiBatchEmbedding bool
|
||||
IsPlayground bool
|
||||
UsePrice bool
|
||||
RelayMode int
|
||||
UpstreamModelName string
|
||||
OriginModelName string
|
||||
//RecodeModelName string
|
||||
RequestURLPath string
|
||||
ApiVersion string
|
||||
@@ -101,6 +102,7 @@ type RelayInfo struct {
|
||||
AudioUsage bool
|
||||
ReasoningEffort string
|
||||
ChannelSetting dto.ChannelSettings
|
||||
ChannelOtherSettings dto.ChannelOtherSettings
|
||||
ParamOverride map[string]interface{}
|
||||
UserSetting dto.UserSetting
|
||||
UserEmail string
|
||||
@@ -291,6 +293,12 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
|
||||
if ok {
|
||||
info.ChannelSetting = channelSetting
|
||||
}
|
||||
|
||||
channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting)
|
||||
if ok {
|
||||
info.ChannelOtherSettings = channelOtherSettings
|
||||
}
|
||||
|
||||
userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting)
|
||||
if ok {
|
||||
info.UserSetting = userSetting
|
||||
|
||||
@@ -264,3 +264,118 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
||||
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func GeminiEmbeddingHandler(c *gin.Context) (newAPIError *types.NewAPIError) {
|
||||
relayInfo := relaycommon.GenRelayInfoGemini(c)
|
||||
|
||||
isBatch := strings.HasSuffix(c.Request.URL.Path, "batchEmbedContents")
|
||||
relayInfo.IsGeminiBatchEmbedding = isBatch
|
||||
|
||||
var promptTokens int
|
||||
var req any
|
||||
var err error
|
||||
var inputTexts []string
|
||||
|
||||
if isBatch {
|
||||
batchRequest := &dto.GeminiBatchEmbeddingRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, batchRequest)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
req = batchRequest
|
||||
for _, r := range batchRequest.Requests {
|
||||
for _, part := range r.Content.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
singleRequest := &dto.GeminiEmbeddingRequest{}
|
||||
err = common.UnmarshalBodyReusable(c, singleRequest)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
req = singleRequest
|
||||
for _, part := range singleRequest.Content.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
promptTokens = service.CountTokenInput(strings.Join(inputTexts, "\n"), relayInfo.UpstreamModelName)
|
||||
relayInfo.SetPromptTokens(promptTokens)
|
||||
c.Set("prompt_tokens", promptTokens)
|
||||
|
||||
err = helper.ModelMappedHelper(c, relayInfo, req)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, 0)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
|
||||
if newAPIError != nil {
|
||||
return newAPIError
|
||||
}
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
|
||||
}
|
||||
}()
|
||||
|
||||
adaptor := GetAdaptor(relayInfo.ApiType)
|
||||
if adaptor == nil {
|
||||
return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
adaptor.Init(relayInfo)
|
||||
|
||||
var requestBody io.Reader
|
||||
jsonData, err := common.Marshal(req)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
// apply param override
|
||||
if len(relayInfo.ParamOverride) > 0 {
|
||||
reqMap := make(map[string]interface{})
|
||||
_ = common.Unmarshal(jsonData, &reqMap)
|
||||
for key, value := range relayInfo.ParamOverride {
|
||||
reqMap[key] = value
|
||||
}
|
||||
jsonData, err = common.Marshal(reqMap)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
}
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
|
||||
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
|
||||
if err != nil {
|
||||
common.LogError(c, "Do gemini request failed: "+err.Error())
|
||||
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
var httpResp *http.Response
|
||||
if resp != nil {
|
||||
httpResp = resp.(*http.Response)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
newAPIError = service.RelayErrorHandler(httpResp, false)
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
return newAPIError
|
||||
}
|
||||
}
|
||||
|
||||
usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo)
|
||||
if openaiErr != nil {
|
||||
service.ResetStatusCode(openaiErr, statusCodeMappingStr)
|
||||
return openaiErr
|
||||
}
|
||||
|
||||
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
+4
-6
@@ -140,10 +140,10 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
||||
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
|
||||
}
|
||||
}()
|
||||
includeUsage := false
|
||||
includeUsage := true
|
||||
// 判断用户是否需要返回使用情况
|
||||
if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage {
|
||||
includeUsage = true
|
||||
if textRequest.StreamOptions != nil {
|
||||
includeUsage = textRequest.StreamOptions.IncludeUsage
|
||||
}
|
||||
|
||||
// 如果不支持StreamOptions,将StreamOptions设置为nil
|
||||
@@ -158,9 +158,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
|
||||
}
|
||||
}
|
||||
|
||||
if includeUsage {
|
||||
relayInfo.ShouldIncludeUsage = true
|
||||
}
|
||||
relayInfo.ShouldIncludeUsage = includeUsage
|
||||
|
||||
adaptor := GetAdaptor(relayInfo.ApiType)
|
||||
if adaptor == nil {
|
||||
|
||||
@@ -12,6 +12,9 @@ var Chats = []map[string]string{
|
||||
{
|
||||
"Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}",
|
||||
},
|
||||
{
|
||||
"流畅阅读": "fluentread",
|
||||
},
|
||||
{
|
||||
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ const (
|
||||
ErrorCodeBadResponse ErrorCode = "bad_response"
|
||||
ErrorCodeBadResponseBody ErrorCode = "bad_response_body"
|
||||
ErrorCodeEmptyResponse ErrorCode = "empty_response"
|
||||
ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error"
|
||||
|
||||
// sql error
|
||||
ErrorCodeQueryDataError ErrorCode = "query_data_error"
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
/*
|
||||
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, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -15,16 +34,22 @@ import {
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCode,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
IconAlertTriangle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
|
||||
const generateUniqueId = (() => {
|
||||
let counter = 0;
|
||||
return () => `kv_${counter++}`;
|
||||
})();
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
@@ -43,24 +68,51 @@ const JSONEditor = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 初始化JSON数据
|
||||
const [jsonData, setJsonData] = useState(() => {
|
||||
// 初始化时解析JSON数据
|
||||
// 将对象转换为键值对数组(包含唯一ID)
|
||||
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
|
||||
const entries = Object.entries(obj);
|
||||
return entries.map(([key, value], index) => {
|
||||
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
|
||||
const prev = prevPairs[index];
|
||||
const shouldReuseId = prev && prev.key === key;
|
||||
return {
|
||||
id: shouldReuseId ? prev.id : generateUniqueId(),
|
||||
key,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
||||
const keyValueArrayToObject = useCallback((arr) => {
|
||||
const result = {};
|
||||
arr.forEach(item => {
|
||||
if (item.key) {
|
||||
result[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// 初始化键值对数组
|
||||
const [keyValuePairs, setKeyValuePairs] = useState(() => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed;
|
||||
return objectToKeyValueArray(parsed);
|
||||
} catch (error) {
|
||||
return {};
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
return objectToKeyValueArray(value);
|
||||
}
|
||||
return {};
|
||||
return [];
|
||||
});
|
||||
|
||||
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
|
||||
// 手动模式下的本地文本缓冲
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
@@ -69,22 +121,38 @@ const JSONEditor = ({
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
const [editMode, setEditMode] = useState(() => {
|
||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const keyCount = Object.keys(parsed).length;
|
||||
return keyCount > 10 ? 'manual' : 'visual';
|
||||
} catch (error) {
|
||||
// JSON无效时默认显示手动编辑模式
|
||||
return 'manual';
|
||||
}
|
||||
}
|
||||
return 'visual';
|
||||
});
|
||||
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
|
||||
// 计算重复的键
|
||||
const duplicateKeys = useMemo(() => {
|
||||
const keyCount = {};
|
||||
const duplicates = new Set();
|
||||
|
||||
keyValuePairs.forEach(pair => {
|
||||
if (pair.key) {
|
||||
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
||||
if (keyCount[pair.key] > 1) {
|
||||
duplicates.add(pair.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
}, [keyValuePairs]);
|
||||
|
||||
// 数据同步 - 当value变化时更新键值对数组
|
||||
useEffect(() => {
|
||||
try {
|
||||
let parsed = {};
|
||||
@@ -93,16 +161,20 @@ const JSONEditor = ({
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
|
||||
// 只在外部值真正改变时更新,避免循环更新
|
||||
const currentObj = keyValueArrayToObject(keyValuePairs);
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
}
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不更新jsonData
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
|
||||
// 外部 value 变化时,若不在手动模式,则同步手动文本
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
@@ -112,45 +184,47 @@ const JSONEditor = ({
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newData) => {
|
||||
setJsonData(newData);
|
||||
setJsonError('');
|
||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||
const handleVisualChange = useCallback((newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
setJsonError('');
|
||||
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field]);
|
||||
}, [onChange, formApi, field, keyValueArrayToObject]);
|
||||
|
||||
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
JSON.parse(newValue);
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// 无效 JSON 时不回传,避免外部值把输入重置
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
}, [onChange]);
|
||||
}, [onChange, objectToKeyValueArray, keyValuePairs]);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
// 从可视化模式切换到手动模式
|
||||
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
|
||||
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
||||
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
// 从手动模式切换到可视化模式,需要验证JSON
|
||||
try {
|
||||
let parsed = {};
|
||||
if (manualText && manualText.trim()) {
|
||||
@@ -160,98 +234,166 @@ const JSONEditor = ({
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不切换模式
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value, manualText, jsonData]);
|
||||
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newData = { ...jsonData };
|
||||
const keys = Object.keys(newData);
|
||||
const newPairs = [...keyValuePairs];
|
||||
const existingKeys = newPairs.map(p => p.key);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (newData.hasOwnProperty(newKey)) {
|
||||
while (existingKeys.includes(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newData[newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
newPairs.push({
|
||||
id: generateUniqueId(),
|
||||
key: newKey,
|
||||
value: ''
|
||||
});
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((keyToRemove) => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[keyToRemove];
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
const removeKeyValue = useCallback((id) => {
|
||||
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((oldKey, newKey) => {
|
||||
if (oldKey === newKey || !newKey) return;
|
||||
const newData = {};
|
||||
Object.entries(jsonData).forEach(([k, v]) => {
|
||||
if (k === oldKey) {
|
||||
newData[newKey] = v;
|
||||
} else {
|
||||
newData[k] = v;
|
||||
}
|
||||
});
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
const updateKey = useCallback((id, newKey) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((key, newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = newValue;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
const updateValue = useCallback((id, newValue) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
if (template) {
|
||||
const templateString = JSON.stringify(template, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, templateString);
|
||||
}
|
||||
|
||||
// 同步内部与外部值,避免出现杂字符
|
||||
setManualText(templateString);
|
||||
setJsonData(template);
|
||||
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
|
||||
onChange?.(templateString);
|
||||
|
||||
// 清除错误状态
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, editMode, formApi, field]);
|
||||
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||||
// 渲染值输入控件(支持嵌套)
|
||||
const renderValueInput = (pairId, value) => {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconCode size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('无效的JSON数据,请检查格式')}
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
/>
|
||||
<Text type="tertiary" className="ml-2">
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value !== null) {
|
||||
// 简化嵌套对象的处理,使用TextArea
|
||||
return (
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
onChange={(txt) => {
|
||||
try {
|
||||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||
updateValue(pairId, obj);
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}}
|
||||
placeholder={t('输入JSON对象')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串或其他原始类型
|
||||
return (
|
||||
<Input
|
||||
placeholder={t('参数值')}
|
||||
value={String(value)}
|
||||
onChange={(newValue) => {
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '') {
|
||||
const num = Number(newValue);
|
||||
// 检查是否为整数
|
||||
if (Number.isInteger(num)) {
|
||||
convertedValue = num;
|
||||
}
|
||||
}
|
||||
updateValue(pairId, convertedValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{keyValuePairs.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
@@ -259,29 +401,55 @@ const JSONEditor = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Row key={index} gutter={8} align="middle">
|
||||
<Col span={6}>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
{renderValueInput(key, value)}
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
{keyValuePairs.map((pair, index) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
const isLastDuplicate = isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
|
||||
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle">
|
||||
<Col span={6}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={pair.key}
|
||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||
status={isDuplicate ? 'warning' : undefined}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<Tooltip
|
||||
content={
|
||||
isLastDuplicate
|
||||
? t('这是重复键中的最后一个,其值将被使用')
|
||||
: t('重复的键名,此值将被后面的同名键覆盖')
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
style={{
|
||||
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
{renderValueInput(pair.id, pair.value)}
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
@@ -297,249 +465,96 @@ const JSONEditor = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 添加嵌套对象
|
||||
const flattenObject = useCallback((parentKey) => {
|
||||
const newData = { ...jsonData };
|
||||
let primitive = '';
|
||||
const obj = newData[parentKey];
|
||||
if (obj && typeof obj === 'object') {
|
||||
const firstKey = Object.keys(obj)[0];
|
||||
if (firstKey !== undefined) {
|
||||
const firstVal = obj[firstKey];
|
||||
if (typeof firstVal !== 'object') primitive = firstVal;
|
||||
}
|
||||
}
|
||||
newData[parentKey] = primitive;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
const addNestedObject = useCallback((parentKey) => {
|
||||
const newData = { ...jsonData };
|
||||
if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
|
||||
newData[parentKey] = {};
|
||||
}
|
||||
const existingKeys = Object.keys(newData[parentKey]);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (newData[parentKey].hasOwnProperty(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newData[parentKey][newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 渲染参数值输入控件(支持嵌套)
|
||||
const renderValueInput = (key, value) => {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
/>
|
||||
<Text type="tertiary" className="ml-2">
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
style={{ width: '100%' }}
|
||||
step={key === 'temperature' ? 0.1 : 1}
|
||||
precision={key === 'temperature' ? 2 : 0}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value !== null) {
|
||||
// 渲染嵌套对象
|
||||
const entries = Object.entries(value);
|
||||
return (
|
||||
<Card className="!rounded-2xl">
|
||||
{entries.length === 0 && (
|
||||
<Text type="tertiary" className="text-gray-500 text-xs">
|
||||
{t('空对象,点击下方加号添加字段')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entries.map(([nestedKey, nestedValue], index) => (
|
||||
<Row key={index} gutter={4} align="middle" className="mb-1">
|
||||
<Col span={8}>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={t('键名')}
|
||||
value={nestedKey}
|
||||
onChange={(newKey) => {
|
||||
const newData = { ...jsonData };
|
||||
const oldValue = newData[key][nestedKey];
|
||||
delete newData[key][nestedKey];
|
||||
newData[key][newKey] = oldValue;
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={14}>
|
||||
{typeof nestedValue === 'object' && nestedValue !== null ? (
|
||||
<TextArea
|
||||
size="small"
|
||||
rows={2}
|
||||
value={JSON.stringify(nestedValue, null, 2)}
|
||||
onChange={(txt) => {
|
||||
try {
|
||||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||
const newData = { ...jsonData };
|
||||
newData[key][nestedKey] = obj;
|
||||
handleVisualChange(newData);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={t('值')}
|
||||
value={String(nestedValue)}
|
||||
onChange={(newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
newData[key][nestedKey] = convertedValue;
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[key][nestedKey];
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center mt-1 gap-2">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
type="tertiary"
|
||||
onClick={() => addNestedObject(key)}
|
||||
>
|
||||
{t('添加字段')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconRefresh />}
|
||||
type="tertiary"
|
||||
onClick={() => flattenObject(key)}
|
||||
>
|
||||
{t('转换为值')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串或其他原始类型
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
placeholder={t('参数值')}
|
||||
value={String(value)}
|
||||
onChange={(newValue) => {
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
updateValue(key, convertedValue);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="tertiary"
|
||||
onClick={() => {
|
||||
// 将当前值转换为对象
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = { '1': value };
|
||||
handleVisualChange(newData);
|
||||
}}
|
||||
title={t('转换为对象')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染区域编辑器(特殊格式)
|
||||
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
||||
const renderRegionEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||||
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 默认区域 */}
|
||||
<Form.Slot label={t('默认区域')}>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultEntry ? defaultEntry[1] : ''}
|
||||
onChange={(value) => updateValue('default', value)}
|
||||
value={defaultPair ? defaultPair.value : ''}
|
||||
onChange={(value) => {
|
||||
if (defaultPair) {
|
||||
updateValue(defaultPair.id, value);
|
||||
} else {
|
||||
const newPairs = [...keyValuePairs, {
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value
|
||||
}];
|
||||
handleVisualChange(newPairs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Slot>
|
||||
|
||||
{/* 模型专用区域 */}
|
||||
<Form.Slot label={t('模型专用区域')}>
|
||||
<div>
|
||||
{modelEntries.map(([modelName, region], index) => (
|
||||
<Row key={index} gutter={8} align="middle" className="mb-2">
|
||||
<Col span={10}>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={modelName}
|
||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={region}
|
||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(modelName)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
{modelPairs.map((pair) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
|
||||
<Col span={10}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={pair.key}
|
||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||
status={isDuplicate ? 'warning' : undefined}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<Tooltip content={t('重复的键名')}>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
style={{ color: '#faad14', fontSize: '14px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={pair.value}
|
||||
onChange={(newValue) => updateValue(pair.id, newValue)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
@@ -666,4 +681,4 @@ const JSONEditor = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
export default JSONEditor;
|
||||
@@ -201,12 +201,20 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
if (Array.isArray(chats)) {
|
||||
let chatItems = [];
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let shouldSkip = false;
|
||||
let chat = {};
|
||||
for (let key in chats[i]) {
|
||||
let link = chats[i][key];
|
||||
if (typeof link !== 'string') continue; // 确保链接是字符串
|
||||
if (link.startsWith('fluent')) {
|
||||
shouldSkip = true;
|
||||
break; // 跳过 Fluent Read
|
||||
}
|
||||
chat.text = key;
|
||||
chat.itemKey = 'chat' + i;
|
||||
chat.to = '/console/chat/' + i;
|
||||
}
|
||||
if (shouldSkip || !chat.text) continue; // 避免推入空项
|
||||
chatItems.push(chat);
|
||||
}
|
||||
setChatItems(chatItems);
|
||||
|
||||
@@ -85,6 +85,7 @@ const SystemSetting = () => {
|
||||
LinuxDOOAuthEnabled: '',
|
||||
LinuxDOClientId: '',
|
||||
LinuxDOClientSecret: '',
|
||||
LinuxDOMinimumTrustLevel: '',
|
||||
ServerAddress: '',
|
||||
});
|
||||
|
||||
@@ -472,6 +473,12 @@ const SystemSetting = () => {
|
||||
value: inputs.LinuxDOClientSecret,
|
||||
});
|
||||
}
|
||||
if (originInputs['LinuxDOMinimumTrustLevel'] !== inputs.LinuxDOMinimumTrustLevel) {
|
||||
options.push({
|
||||
key: 'LinuxDOMinimumTrustLevel',
|
||||
value: inputs.LinuxDOMinimumTrustLevel,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
@@ -916,14 +923,14 @@ const SystemSetting = () => {
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
|
||||
<Form.Input
|
||||
field='LinuxDOClientId'
|
||||
label={t('Linux DO Client ID')}
|
||||
placeholder={t('输入你注册的 LinuxDO OAuth APP 的 ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
|
||||
<Form.Input
|
||||
field='LinuxDOClientSecret'
|
||||
label={t('Linux DO Client Secret')}
|
||||
@@ -931,6 +938,13 @@ const SystemSetting = () => {
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={4} lg={4} xl={4}>
|
||||
<Form.Input
|
||||
field='LinuxDOMinimumTrustLevel'
|
||||
label='LinuxDO Minimum Trust Level'
|
||||
placeholder='允许注册的最低信任等级'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitLinuxDOOAuth}>
|
||||
{t('保存 Linux DO OAuth 设置')}
|
||||
|
||||
@@ -544,7 +544,7 @@ export const getChannelsColumns = ({
|
||||
menu={[
|
||||
{
|
||||
node: 'item',
|
||||
name: t('多key管理'),
|
||||
name: t('多密钥管理'),
|
||||
onClick: () => {
|
||||
setCurrentMultiKeyChannel(record);
|
||||
setShowMultiKeyManageModal(true);
|
||||
|
||||
@@ -132,6 +132,7 @@ const EditChannelModal = (props) => {
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
system_prompt_override: false,
|
||||
settings: '',
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
@@ -187,38 +188,31 @@ const EditChannelModal = (props) => {
|
||||
handleInputChange('setting', settingsJson);
|
||||
};
|
||||
|
||||
// 解析渠道设置JSON为单独的状态
|
||||
const parseChannelSettings = (settingJson) => {
|
||||
try {
|
||||
if (settingJson && settingJson.trim()) {
|
||||
const parsed = JSON.parse(settingJson);
|
||||
setChannelSettings({
|
||||
force_format: parsed.force_format || false,
|
||||
thinking_to_content: parsed.thinking_to_content || false,
|
||||
proxy: parsed.proxy || '',
|
||||
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
|
||||
system_prompt: parsed.system_prompt || '',
|
||||
});
|
||||
} else {
|
||||
setChannelSettings({
|
||||
force_format: false,
|
||||
thinking_to_content: false,
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析渠道设置失败:', error);
|
||||
setChannelSettings({
|
||||
force_format: false,
|
||||
thinking_to_content: false,
|
||||
proxy: '',
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
});
|
||||
const handleChannelOtherSettingsChange = (key, value) => {
|
||||
// 更新内部状态
|
||||
setChannelSettings(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// 同步更新到表单字段
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步更新inputs状态
|
||||
setInputs(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// 需要更新settings,是一个json,例如{"azure_responses_version": "preview"}
|
||||
let settings = {};
|
||||
if (inputs.settings) {
|
||||
try {
|
||||
settings = JSON.parse(inputs.settings);
|
||||
} catch (error) {
|
||||
console.error('解析设置失败:', error);
|
||||
}
|
||||
}
|
||||
settings[key] = value;
|
||||
const settingsJson = JSON.stringify(settings);
|
||||
handleInputChange('settings', settingsJson);
|
||||
}
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
if (formApiRef.current) {
|
||||
@@ -360,6 +354,17 @@ const EditChannelModal = (props) => {
|
||||
data.system_prompt_override = false;
|
||||
}
|
||||
|
||||
if (data.settings) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(data.settings);
|
||||
data.azure_responses_version = parsedSettings.azure_responses_version || '';
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
data.azure_responses_version = '';
|
||||
data.region = '';
|
||||
}
|
||||
}
|
||||
|
||||
setInputs(data);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(data);
|
||||
@@ -587,6 +592,8 @@ const EditChannelModal = (props) => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key_mode', undefined);
|
||||
}
|
||||
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
|
||||
setInputs(getInitValues());
|
||||
}
|
||||
}, [props.visible, channelId]);
|
||||
|
||||
@@ -1257,6 +1264,7 @@ const EditChannelModal = (props) => {
|
||||
|
||||
{inputs.type === 41 && (
|
||||
<JSONEditor
|
||||
key={`region-${isEdit ? channelId : 'new'}`}
|
||||
field='other'
|
||||
label={t('部署地区')}
|
||||
placeholder={t(
|
||||
@@ -1374,6 +1382,15 @@ const EditChannelModal = (props) => {
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='azure_responses_version'
|
||||
label={t('默认 Responses API 版本,为空则使用上方版本')}
|
||||
placeholder={t('例如:preview')}
|
||||
onChange={(value) => handleChannelOtherSettingsChange('azure_responses_version', value)}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1552,6 +1569,7 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
|
||||
<JSONEditor
|
||||
key={`model_mapping-${isEdit ? channelId : 'new'}`}
|
||||
field='model_mapping'
|
||||
label={t('模型重定向')}
|
||||
placeholder={
|
||||
@@ -1655,6 +1673,7 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
|
||||
<JSONEditor
|
||||
key={`status_code_mapping-${isEdit ? channelId : 'new'}`}
|
||||
field='status_code_mapping'
|
||||
label={t('状态码复写')}
|
||||
placeholder={
|
||||
|
||||
@@ -30,20 +30,17 @@ import {
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Spin,
|
||||
Banner,
|
||||
Select,
|
||||
Pagination
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Progress,
|
||||
Card
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconRefresh,
|
||||
IconDelete,
|
||||
IconClose,
|
||||
IconSave,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess, timestamp2string } from '../../../../helpers/index.js';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { Text } = Typography;
|
||||
|
||||
const MultiKeyManageModal = ({
|
||||
visible,
|
||||
@@ -55,13 +52,13 @@ const MultiKeyManageModal = ({
|
||||
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 [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
|
||||
// Statistics states
|
||||
const [enabledCount, setEnabledCount] = useState(0);
|
||||
const [manualDisabledCount, setManualDisabledCount] = useState(0);
|
||||
@@ -73,7 +70,7 @@ const MultiKeyManageModal = ({
|
||||
// Load key status data
|
||||
const loadKeyStatus = async (page = currentPage, size = pageSize, status = statusFilter) => {
|
||||
if (!channel?.id) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const requestData = {
|
||||
@@ -82,22 +79,22 @@ const MultiKeyManageModal = ({
|
||||
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);
|
||||
setPageSize(data.page_size || 10);
|
||||
setTotalPages(data.total_pages || 0);
|
||||
|
||||
|
||||
// Update statistics (these are always the overall statistics)
|
||||
setEnabledCount(data.enabled_count || 0);
|
||||
setManualDisabledCount(data.manual_disabled_count || 0);
|
||||
@@ -117,14 +114,14 @@ const MultiKeyManageModal = ({
|
||||
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
|
||||
@@ -143,14 +140,14 @@ const MultiKeyManageModal = ({
|
||||
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
|
||||
@@ -168,13 +165,13 @@ const MultiKeyManageModal = ({
|
||||
// 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
|
||||
@@ -194,13 +191,13 @@ const MultiKeyManageModal = ({
|
||||
// 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
|
||||
@@ -220,13 +217,13 @@ const MultiKeyManageModal = ({
|
||||
// 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
|
||||
@@ -285,17 +282,24 @@ const MultiKeyManageModal = ({
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Percentages for progress display
|
||||
const enabledPercent = total > 0 ? Math.round((enabledCount / total) * 100) : 0;
|
||||
const manualDisabledPercent = total > 0 ? Math.round((manualDisabledCount / total) * 100) : 0;
|
||||
const autoDisabledPercent = total > 0 ? Math.round((autoDisabledCount / total) * 100) : 0;
|
||||
|
||||
// 取消饼图:不再需要图表数据与配置
|
||||
|
||||
// Get status tag component
|
||||
const renderStatusTag = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Tag color='green' shape='circle'>{t('已启用')}</Tag>;
|
||||
return <Tag color='green' shape='circle' size='small'>{t('已启用')}</Tag>;
|
||||
case 2:
|
||||
return <Tag color='red' shape='circle'>{t('已禁用')}</Tag>;
|
||||
return <Tag color='red' shape='circle' size='small'>{t('已禁用')}</Tag>;
|
||||
case 3:
|
||||
return <Tag color='orange' shape='circle'>{t('自动禁用')}</Tag>;
|
||||
return <Tag color='orange' shape='circle' size='small'>{t('自动禁用')}</Tag>;
|
||||
default:
|
||||
return <Tag color='grey' shape='circle'>{t('未知状态')}</Tag>;
|
||||
return <Tag color='grey' shape='circle' size='small'>{t('未知状态')}</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,13 +322,11 @@ const MultiKeyManageModal = ({
|
||||
{
|
||||
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>;
|
||||
@@ -341,7 +343,6 @@ const MultiKeyManageModal = ({
|
||||
{
|
||||
title: t('禁用时间'),
|
||||
dataIndex: 'disabled_time',
|
||||
width: 150,
|
||||
render: (time, record) => {
|
||||
if (record.status === 1 || !time) {
|
||||
return <Text type='quaternary'>-</Text>;
|
||||
@@ -358,7 +359,8 @@ const MultiKeyManageModal = ({
|
||||
{
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
@@ -389,196 +391,194 @@ const MultiKeyManageModal = ({
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<IconSetting />
|
||||
<span>{t('多密钥管理')} - {channel?.name}</span>
|
||||
<Text>{t('多密钥管理')}</Text>
|
||||
{channel?.name && (
|
||||
<Tag size='small' shape='circle' color='white'>{channel.name}</Tag>
|
||||
)}
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{t('总密钥数')}: {total}
|
||||
</Tag>
|
||||
{channel?.channel_info?.multi_key_mode && (
|
||||
<Tag size='small' shape='circle' color='white'>
|
||||
{channel.channel_info.multi_key_mode === 'random' ? t('随机模式') : t('轮询模式')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={900}
|
||||
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>
|
||||
}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Statistics Banner */}
|
||||
<Banner
|
||||
type='info'
|
||||
style={{ marginBottom: '16px', flexShrink: 0 }}
|
||||
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 className="flex flex-col mb-5">
|
||||
{/* Stats & Mode */}
|
||||
<div
|
||||
className="rounded-xl p-4 mb-3"
|
||||
style={{
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
border: '1px solid var(--semi-color-border)'
|
||||
}}
|
||||
>
|
||||
<Row gutter={16} align="middle">
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge dot type='success' />
|
||||
<Text type='tertiary'>{t('已启用')}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div style={{ marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0 }}>
|
||||
<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 className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#22c55e' }}>{enabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
</div>
|
||||
<Progress percent={enabledPercent} showInfo={false} size="small" stroke="#22c55e" style={{ height: 6, borderRadius: 999 }} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge dot type='danger' />
|
||||
<Text type='tertiary'>{t('手动禁用')}</Text>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#ef4444' }}>{manualDisabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
</div>
|
||||
<Progress percent={manualDisabledPercent} showInfo={false} size="small" stroke="#ef4444" style={{ height: 6, borderRadius: 999 }} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ background: 'var(--semi-color-bg-0)', border: '1px solid var(--semi-color-border)', borderRadius: 12, padding: 12 }}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge dot type='warning' />
|
||||
<Text type='tertiary'>{t('自动禁用')}</Text>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<Text style={{ fontSize: 18, fontWeight: 700, color: '#f59e0b' }}>{autoDisabledCount}</Text>
|
||||
<Text style={{ fontSize: 18, color: 'var(--semi-color-text-2)' }}>/ {total}</Text>
|
||||
</div>
|
||||
<Progress percent={autoDisabledPercent} showInfo={false} size="small" stroke="#f59e0b" style={{ height: 6, borderRadius: 999 }} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Key Status Table */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{/* Table */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Spin spinning={loading}>
|
||||
{keyStatusList.length > 0 ? (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto', marginBottom: '16px' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={keyStatusList}
|
||||
pagination={false}
|
||||
size='small'
|
||||
bordered
|
||||
rowKey='index'
|
||||
scroll={{ y: 'calc(100vh - 400px)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
padding: '12px 0',
|
||||
borderTop: '1px solid var(--semi-color-border)',
|
||||
backgroundColor: 'var(--semi-color-bg-1)'
|
||||
}}>
|
||||
<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>
|
||||
<Card className='!rounded-xl'>
|
||||
<Table
|
||||
title={() => (
|
||||
<Row gutter={12} style={{ width: '100%' }}>
|
||||
<Col span={14}>
|
||||
<Row gutter={12} style={{ alignItems: 'center' }}>
|
||||
<Col>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilterChange}
|
||||
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>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={10} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => loadKeyStatus(currentPage, pageSize)}
|
||||
loading={loading}
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
{(manualDisabledCount + autoDisabledCount) > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要启用所有密钥吗?')}
|
||||
onConfirm={handleEnableAll}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
loading={operationLoading.enable_all}
|
||||
>
|
||||
{t('启用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{enabledCount > 0 && (
|
||||
<Popconfirm
|
||||
title={t('确定要禁用所有的密钥吗?')}
|
||||
onConfirm={handleDisableAll}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
loading={operationLoading.disable_all}
|
||||
>
|
||||
{t('禁用全部')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除所有已自动禁用的密钥吗?')}
|
||||
content={t('此操作不可撤销,将永久删除已自动禁用的密钥')}
|
||||
onConfirm={handleDeleteDisabledKeys}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
loading={operationLoading.delete_disabled}
|
||||
>
|
||||
{t('删除自动禁用密钥')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
title={t('暂无密钥数据')}
|
||||
description={t('请检查渠道配置或刷新重试')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
columns={columns}
|
||||
dataSource={keyStatusList}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
loadKeyStatus(page, size);
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
setCurrentPage(1);
|
||||
handlePageSizeChange(size);
|
||||
}
|
||||
}}
|
||||
size='small'
|
||||
bordered={false}
|
||||
rowKey='index'
|
||||
scroll={{ x: 'max-content' }}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 140, height: 140 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 140, height: 140 }} />}
|
||||
title={t('暂无密钥数据')}
|
||||
description={t('请检查渠道配置或刷新重试')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
|
||||
/**
|
||||
* 模型标签筛选组件
|
||||
* @param {string|'all'} filterTag 当前选中的标签
|
||||
* @param {Function} setFilterTag setter
|
||||
* @param {Array} models 当前过滤后模型列表(用于计数)
|
||||
* @param {Array} allModels 所有模型列表(用于获取所有标签)
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingTags = ({ filterTag, setFilterTag, models = [], allModels = [], loading = false, t }) => {
|
||||
// 提取系统所有标签
|
||||
const getAllTags = React.useMemo(() => {
|
||||
const tagSet = new Set();
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.tags) {
|
||||
model.tags
|
||||
.split(/[,;|\s]+/) // 逗号、分号、竖线或空白字符
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(tag => tagSet.add(tag.toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算标签对应的模型数量
|
||||
const getTagCount = React.useCallback((tag) => {
|
||||
if (tag === 'all') return models.length;
|
||||
|
||||
const tagLower = tag.toLowerCase();
|
||||
return models.filter(model => {
|
||||
if (!model.tags) return false;
|
||||
return model.tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|\s]+/)
|
||||
.map(tg => tg.trim())
|
||||
.includes(tagLower);
|
||||
}).length;
|
||||
}, [models]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const result = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部标签'),
|
||||
tagCount: getTagCount('all'),
|
||||
disabled: models.length === 0,
|
||||
}
|
||||
];
|
||||
|
||||
getAllTags.forEach(tag => {
|
||||
const count = getTagCount(tag);
|
||||
result.push({
|
||||
value: tag,
|
||||
label: tag,
|
||||
tagCount: count,
|
||||
disabled: count === 0,
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [getAllTags, getTagCount, t, models.length]);
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('标签')}
|
||||
items={items}
|
||||
activeValue={filterTag}
|
||||
onChange={setFilterTag}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingTags;
|
||||
@@ -23,6 +23,7 @@ import PricingGroups from '../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../filter/PricingVendors';
|
||||
import PricingTags from '../filter/PricingTags';
|
||||
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
@@ -47,6 +48,8 @@ const PricingSidebar = ({
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
tokenUnit,
|
||||
@@ -60,6 +63,7 @@ const PricingSidebar = ({
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
tagModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
@@ -67,6 +71,7 @@ const PricingSidebar = ({
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue: categoryProps.searchValue,
|
||||
});
|
||||
|
||||
@@ -81,6 +86,7 @@ const PricingSidebar = ({
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setFilterTag,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
});
|
||||
@@ -125,6 +131,15 @@ const PricingSidebar = ({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={handleGroupClick}
|
||||
|
||||
@@ -50,6 +50,7 @@ const PricingTopSection = ({
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -81,14 +82,16 @@ const PricingTopSection = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 供应商介绍区域(含骨架屏) */}
|
||||
<PricingVendorIntroWithSkeleton
|
||||
loading={loading}
|
||||
filterVendor={filterVendor}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
t={t}
|
||||
/>
|
||||
{/* 供应商介绍区域(桌面端显示) */}
|
||||
{!isMobile && (
|
||||
<PricingVendorIntroWithSkeleton
|
||||
loading={loading}
|
||||
filterVendor={filterVendor}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 搜索和操作区域 */}
|
||||
{SearchAndActions}
|
||||
|
||||
@@ -166,7 +166,7 @@ const PricingVendorIntro = ({
|
||||
if (filterVendor === 'all') {
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 全部供应商的头像组合 */}
|
||||
<div className="flex-shrink-0">
|
||||
@@ -210,7 +210,7 @@ const PricingVendorIntro = ({
|
||||
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 供应商图标 */}
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
@@ -25,7 +25,7 @@ const PricingVendorIntroSkeleton = ({
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<Card className="!rounded-2xl with-pastel-balls" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 供应商图标骨架 */}
|
||||
<div className="flex-shrink-0 min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
|
||||
@@ -40,6 +40,7 @@ const PricingFilterModal = ({
|
||||
setFilterQuotaType: sidebarProps.setFilterQuotaType,
|
||||
setFilterEndpointType: sidebarProps.setFilterEndpointType,
|
||||
setFilterVendor: sidebarProps.setFilterVendor,
|
||||
setFilterTag: sidebarProps.setFilterTag,
|
||||
setCurrentPage: sidebarProps.setCurrentPage,
|
||||
setTokenUnit: sidebarProps.setTokenUnit,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import PricingGroups from '../../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../../filter/PricingVendors';
|
||||
import PricingTags from '../../filter/PricingTags';
|
||||
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
@@ -45,6 +46,8 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
@@ -55,6 +58,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
tagModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
@@ -62,6 +66,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue: sidebarProps.searchValue,
|
||||
});
|
||||
|
||||
@@ -91,6 +96,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingTags
|
||||
filterTag={filterTag}
|
||||
setFilterTag={setFilterTag}
|
||||
models={tagModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
|
||||
@@ -29,9 +29,19 @@ const CARD_STYLES = {
|
||||
};
|
||||
|
||||
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型图标(使用供应商图标)
|
||||
// 获取模型图标(优先模型图标,其次供应商图标)
|
||||
const getModelIcon = () => {
|
||||
// 优先使用供应商图标
|
||||
// 1) 优先使用模型自定义图标
|
||||
if (modelData?.icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(modelData.icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2) 退化为供应商图标
|
||||
if (modelData?.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
|
||||
@@ -81,7 +81,17 @@ const PricingCardView = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 优先使用供应商图标
|
||||
// 1) 优先使用模型自定义图标
|
||||
if (model.icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(model.icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2) 退化为供应商图标
|
||||
if (model.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
@@ -126,7 +136,7 @@ const PricingCardView = ({
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
currency,
|
||||
});
|
||||
return formatPriceInfo(priceData, t);
|
||||
};
|
||||
@@ -161,7 +171,7 @@ const PricingCardView = ({
|
||||
{billingTag}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{renderLimitedItems({
|
||||
{customTags.length > 0 && renderLimitedItems({
|
||||
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
|
||||
renderItem: (item, idx) => item.element,
|
||||
maxDisplay: 3
|
||||
@@ -292,7 +302,7 @@ const PricingCardView = ({
|
||||
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('分组')}: {groupRatio[selectedGroup]}
|
||||
{t('分组')}: {priceData.usedGroupRatio}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,17 @@ function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render model icon column: prefer model.icon, then fallback to vendor icon
|
||||
const renderModelIconCol = (record, vendorMap) => {
|
||||
const iconKey = record?.icon || vendorMap[record?.vendor_id]?.icon;
|
||||
if (!iconKey) return '-';
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
{getLobeHubIcon(iconKey, 20)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render vendor column with icon
|
||||
const renderVendorTag = (vendorId, vendorMap, t) => {
|
||||
if (!vendorId || !vendorMap[vendorId]) return '-';
|
||||
@@ -222,6 +233,13 @@ export const getModelsColumns = ({
|
||||
vendorMap,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('图标'),
|
||||
dataIndex: 'icon',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (text, record) => renderModelIconCol(record, vendorMap),
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, FileText } from 'lucide-react';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
@@ -112,6 +113,7 @@ const EditModelModal = (props) => {
|
||||
const getInitValues = () => ({
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
description: '',
|
||||
icon: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
@@ -314,6 +316,27 @@ const EditModelModal = (props) => {
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('模型图标')}
|
||||
placeholder={t('请输入图标名称')}
|
||||
extraText={
|
||||
<span>
|
||||
{t('图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'},查询所有可用图标请 ')}
|
||||
<Typography.Text
|
||||
link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
|
||||
icon={<IconLink />}
|
||||
underline
|
||||
>
|
||||
{t('请点击我')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
|
||||
@@ -158,10 +158,10 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
|
||||
<Form.Input
|
||||
field="icon"
|
||||
label={t('供应商图标')}
|
||||
placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')}
|
||||
placeholder={t("请输入图标名称")}
|
||||
extraText={
|
||||
<span>
|
||||
{t('图标使用@lobehub/icons库,查询所有可用图标 ')}
|
||||
{t('图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={\'platform\'}、OpenRouter.Avatar.shape={\'square\'},查询所有可用图标请 ')}
|
||||
<Typography.Text
|
||||
link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
|
||||
icon={<IconLink />}
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Switch,
|
||||
Popover,
|
||||
Typography,
|
||||
Input,
|
||||
Modal
|
||||
} from '@douyinfe/semi-ui';
|
||||
@@ -46,21 +47,22 @@ import {
|
||||
IconEyeClosed,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
// progress color helper
|
||||
const getProgressColor = (pct) => {
|
||||
if (pct === 100) return 'var(--semi-color-success)';
|
||||
if (pct <= 10) return 'var(--semi-color-danger)';
|
||||
if (pct <= 30) return 'var(--semi-color-warning)';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Render functions
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render status column with switch and progress bar
|
||||
const renderStatus = (text, record, manageToken, t) => {
|
||||
// Render status column only (no usage)
|
||||
const renderStatus = (text, record, t) => {
|
||||
const enabled = text === 1;
|
||||
const handleToggle = (checked) => {
|
||||
if (checked) {
|
||||
manageToken(record.id, 'enable', record);
|
||||
} else {
|
||||
manageToken(record.id, 'disable', record);
|
||||
}
|
||||
};
|
||||
|
||||
let tagColor = 'black';
|
||||
let tagText = t('未知状态');
|
||||
@@ -78,69 +80,11 @@ const renderStatus = (text, record, manageToken, t) => {
|
||||
tagText = t('已耗尽');
|
||||
}
|
||||
|
||||
const used = parseInt(record.used_quota) || 0;
|
||||
const remain = parseInt(record.remain_quota) || 0;
|
||||
const total = used + remain;
|
||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||
|
||||
const getProgressColor = (pct) => {
|
||||
if (pct === 100) return 'var(--semi-color-success)';
|
||||
if (pct <= 10) return 'var(--semi-color-danger)';
|
||||
if (pct <= 30) return 'var(--semi-color-warning)';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const quotaSuffix = record.unlimited_quota ? (
|
||||
<div className='text-xs'>{t('无限额度')}</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-end'>
|
||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||
<Progress
|
||||
percent={percent}
|
||||
stroke={getProgressColor(percent)}
|
||||
aria-label='quota usage'
|
||||
format={() => `${percent.toFixed(0)}%`}
|
||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Tag
|
||||
color={tagColor}
|
||||
shape='circle'
|
||||
size='large'
|
||||
prefixIcon={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
aria-label='token status switch'
|
||||
/>
|
||||
}
|
||||
suffixIcon={quotaSuffix}
|
||||
>
|
||||
return (
|
||||
<Tag color={tagColor} shape='circle' size='small'>
|
||||
{tagText}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
const tooltipContent = record.unlimited_quota ? (
|
||||
<div className='text-xs'>
|
||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-xs'>
|
||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
||||
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
|
||||
<div>{t('总额度')}: {renderQuota(total)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// Render group column
|
||||
@@ -292,35 +236,81 @@ const renderAllowIps = (text, t) => {
|
||||
return <Space wrap>{ipTags}</Space>;
|
||||
};
|
||||
|
||||
// Render separate quota usage column
|
||||
const renderQuotaUsage = (text, record, t) => {
|
||||
const { Paragraph } = Typography;
|
||||
const used = parseInt(record.used_quota) || 0;
|
||||
const remain = parseInt(record.remain_quota) || 0;
|
||||
const total = used + remain;
|
||||
if (record.unlimited_quota) {
|
||||
const popoverContent = (
|
||||
<div className='text-xs p-2'>
|
||||
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||
{t('已用额度')}: {renderQuota(used)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={popoverContent} position='top'>
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('无限额度')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||
const popoverContent = (
|
||||
<div className='text-xs p-2'>
|
||||
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||
{t('已用额度')}: {renderQuota(used)}
|
||||
</Paragraph>
|
||||
<Paragraph copyable={{ content: renderQuota(remain) }}>
|
||||
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
|
||||
</Paragraph>
|
||||
<Paragraph copyable={{ content: renderQuota(total) }}>
|
||||
{t('总额度')}: {renderQuota(total)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={popoverContent} position='top'>
|
||||
<Tag color='white' shape='circle'>
|
||||
<div className='flex flex-col items-end'>
|
||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||
<Progress
|
||||
percent={percent}
|
||||
stroke={getProgressColor(percent)}
|
||||
aria-label='quota usage'
|
||||
format={() => `${percent.toFixed(0)}%`}
|
||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
// Render operations column
|
||||
const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit, manageToken, refresh, t) => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
let chatsArray = [];
|
||||
let shouldUseCustom = true;
|
||||
|
||||
if (shouldUseCustom) {
|
||||
try {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats)) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let chat = {};
|
||||
chat.node = 'item';
|
||||
for (let key in chats[i]) {
|
||||
if (chats[i].hasOwnProperty(key)) {
|
||||
chat.key = i;
|
||||
chat.name = key;
|
||||
chat.onClick = () => {
|
||||
onOpenLink(key, chats[i][key], record);
|
||||
};
|
||||
}
|
||||
}
|
||||
chatsArray.push(chat);
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem('chats');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
const name = Object.keys(item)[0];
|
||||
if (!name) continue;
|
||||
chatsArray.push({
|
||||
node: 'item',
|
||||
key: i,
|
||||
name,
|
||||
onClick: () => onOpenLink(name, item[name], record),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showError(t('聊天链接配置错误,请联系管理员'));
|
||||
}
|
||||
} catch (_) {
|
||||
showError(t('聊天链接配置错误,请联系管理员'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -338,7 +328,7 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
|
||||
} else {
|
||||
onOpenLink(
|
||||
'default',
|
||||
chats[0][Object.keys(chats[0])[0]],
|
||||
chatsArray[0].name ? (parsed => parsed)(localStorage.getItem('chats')) : '',
|
||||
record,
|
||||
);
|
||||
}
|
||||
@@ -359,6 +349,29 @@ const renderOperations = (text, record, onOpenLink, setEditingToken, setShowEdit
|
||||
</Dropdown>
|
||||
</SplitButtonGroup>
|
||||
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await manageToken(record.id, 'disable', record);
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await manageToken(record.id, 'enable', record);
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
@@ -412,7 +425,12 @@ export const getTokensColumns = ({
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (text, record) => renderStatus(text, record, manageToken, t),
|
||||
render: (text, record) => renderStatus(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('剩余额度/总额度'),
|
||||
key: 'quota_usage',
|
||||
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
|
||||
@@ -17,7 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Notification, Button, Space, Toast, Typography, Select } from '@douyinfe/semi-ui';
|
||||
import { API, showError, getModelCategories, selectFilter } from '../../../helpers';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import TokensTable from './TokensTable.jsx';
|
||||
import TokensActions from './TokensActions.jsx';
|
||||
@@ -28,9 +30,245 @@ import { useTokensData } from '../../../hooks/tokens/useTokensData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const TokensPage = () => {
|
||||
const tokensData = useTokensData();
|
||||
function TokensPage() {
|
||||
// Define the function first, then pass it into the hook to avoid TDZ errors
|
||||
const openFluentNotificationRef = useRef(null);
|
||||
const tokensData = useTokensData((key) => openFluentNotificationRef.current?.(key));
|
||||
const isMobile = useIsMobile();
|
||||
const latestRef = useRef({ tokens: [], selectedKeys: [], t: (k) => k, selectedModel: '', prefillKey: '' });
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [fluentNoticeOpen, setFluentNoticeOpen] = useState(false);
|
||||
const [prefillKey, setPrefillKey] = useState('');
|
||||
|
||||
// Keep latest data for handlers inside notifications
|
||||
useEffect(() => {
|
||||
latestRef.current = {
|
||||
tokens: tokensData.tokens,
|
||||
selectedKeys: tokensData.selectedKeys,
|
||||
t: tokensData.t,
|
||||
selectedModel,
|
||||
prefillKey,
|
||||
};
|
||||
}, [tokensData.tokens, tokensData.selectedKeys, tokensData.t, selectedModel, prefillKey]);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/models');
|
||||
const { success, message, data } = res.data || {};
|
||||
if (success) {
|
||||
const categories = getModelCategories(tokensData.t);
|
||||
const options = (data || []).map((model) => {
|
||||
let icon = null;
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (key !== 'all' && category.filter({ model_name: model })) {
|
||||
icon = category.icon;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
{model}
|
||||
</span>
|
||||
),
|
||||
value: model,
|
||||
};
|
||||
});
|
||||
setModelOptions(options);
|
||||
} else {
|
||||
showError(tokensData.t(message));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e.message || 'Failed to load models');
|
||||
}
|
||||
};
|
||||
|
||||
function openFluentNotification(key) {
|
||||
const { t } = latestRef.current;
|
||||
const SUPPRESS_KEY = 'fluent_notify_suppressed';
|
||||
if (modelOptions.length === 0) {
|
||||
// fire-and-forget; a later effect will refresh the notice content
|
||||
loadModels()
|
||||
}
|
||||
if (!key && localStorage.getItem(SUPPRESS_KEY) === '1') return;
|
||||
const container = document.getElementById('fluent-new-api-container');
|
||||
if (!container) {
|
||||
Toast.warning(t('未检测到 FluentRead(流畅阅读),请确认扩展已启用'));
|
||||
return;
|
||||
}
|
||||
setPrefillKey(key || '');
|
||||
setFluentNoticeOpen(true);
|
||||
Notification.info({
|
||||
id: 'fluent-detected',
|
||||
title: t('检测到 FluentRead(流畅阅读)'),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{key
|
||||
? t('请选择模型。')
|
||||
: t('选择模型后可一键填充当前选中令牌(或本页第一个令牌)。')}
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder={t('请选择模型')}
|
||||
optionList={modelOptions}
|
||||
onChange={setSelectedModel}
|
||||
filter={selectFilter}
|
||||
style={{ width: 320 }}
|
||||
showClear
|
||||
searchable
|
||||
emptyContent={t('暂无数据')}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Button theme="solid" type="primary" onClick={handlePrefillToFluent}>
|
||||
{t('一键填充到 FluentRead')}
|
||||
</Button>
|
||||
{!key && (
|
||||
<Button type="warning" onClick={() => {
|
||||
localStorage.setItem(SUPPRESS_KEY, '1');
|
||||
Notification.close('fluent-detected');
|
||||
Toast.info(t('已关闭后续提醒'));
|
||||
}}>
|
||||
{t('不再提醒')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="tertiary" onClick={() => Notification.close('fluent-detected')}>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
// assign after definition so hook callback can call it safely
|
||||
openFluentNotificationRef.current = openFluentNotification;
|
||||
|
||||
// Prefill to Fluent handler
|
||||
const handlePrefillToFluent = () => {
|
||||
const { tokens, selectedKeys, t, selectedModel: chosenModel, prefillKey: overrideKey } = latestRef.current;
|
||||
const container = document.getElementById('fluent-new-api-container');
|
||||
if (!container) {
|
||||
Toast.error(t('未检测到 Fluent 容器'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chosenModel) {
|
||||
Toast.warning(t('请选择模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
try {
|
||||
status = JSON.parse(status);
|
||||
serverAddress = status.server_address || '';
|
||||
} catch (_) { }
|
||||
}
|
||||
if (!serverAddress) serverAddress = window.location.origin;
|
||||
|
||||
let apiKeyToUse = '';
|
||||
if (overrideKey) {
|
||||
apiKeyToUse = 'sk-' + overrideKey;
|
||||
} else {
|
||||
const token = (selectedKeys && selectedKeys.length === 1)
|
||||
? selectedKeys[0]
|
||||
: (tokens && tokens.length > 0 ? tokens[0] : null);
|
||||
if (!token) {
|
||||
Toast.warning(t('没有可用令牌用于填充'));
|
||||
return;
|
||||
}
|
||||
apiKeyToUse = 'sk-' + token.key;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: 'new-api',
|
||||
baseUrl: serverAddress,
|
||||
apiKey: apiKeyToUse,
|
||||
model: chosenModel,
|
||||
};
|
||||
|
||||
container.dispatchEvent(new CustomEvent('fluent:prefill', { detail: payload }));
|
||||
Toast.success(t('已发送到 Fluent'));
|
||||
Notification.close('fluent-detected');
|
||||
};
|
||||
|
||||
// Show notification when Fluent container is available
|
||||
useEffect(() => {
|
||||
const onAppeared = () => {
|
||||
openFluentNotification();
|
||||
};
|
||||
const onRemoved = () => {
|
||||
setFluentNoticeOpen(false);
|
||||
Notification.close('fluent-detected');
|
||||
};
|
||||
|
||||
window.addEventListener('fluent-container:appeared', onAppeared);
|
||||
window.addEventListener('fluent-container:removed', onRemoved);
|
||||
return () => {
|
||||
window.removeEventListener('fluent-container:appeared', onAppeared);
|
||||
window.removeEventListener('fluent-container:removed', onRemoved);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// When modelOptions or language changes while the notice is open, refresh the content
|
||||
useEffect(() => {
|
||||
if (fluentNoticeOpen) {
|
||||
openFluentNotification();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modelOptions, selectedModel, tokensData.t, fluentNoticeOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const selector = '#fluent-new-api-container';
|
||||
const root = document.body || document.documentElement;
|
||||
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) {
|
||||
console.log('Fluent container detected (initial):', existing);
|
||||
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: existing }));
|
||||
}
|
||||
|
||||
const isOrContainsTarget = (node) => {
|
||||
if (!(node && node.nodeType === 1)) return false;
|
||||
if (node.id === 'fluent-new-api-container') return true;
|
||||
return typeof node.querySelector === 'function' && !!node.querySelector(selector);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
// appeared
|
||||
for (const added of m.addedNodes) {
|
||||
if (isOrContainsTarget(added)) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
console.log('Fluent container appeared:', el);
|
||||
window.dispatchEvent(new CustomEvent('fluent-container:appeared', { detail: el }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// removed
|
||||
for (const removed of m.removedNodes) {
|
||||
if (isOrContainsTarget(removed)) {
|
||||
const elNow = document.querySelector(selector);
|
||||
if (!elNow) {
|
||||
console.log('Fluent container removed');
|
||||
window.dispatchEvent(new CustomEvent('fluent-container:removed'));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
@@ -119,6 +357,6 @@ const TokensPage = () => {
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default TokensPage;
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Tag,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Switch,
|
||||
Popover,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
|
||||
|
||||
@@ -89,7 +90,6 @@ const renderUsername = (text, record) => {
|
||||
* Render user statistics
|
||||
*/
|
||||
const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
||||
const enabled = record.status === 1;
|
||||
const isDeleted = record.DeletedAt !== null;
|
||||
|
||||
// Determine tag text & color like original status column
|
||||
@@ -100,60 +100,17 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
||||
tagText = t('已注销');
|
||||
} else if (record.status === 1) {
|
||||
tagColor = 'green';
|
||||
tagText = t('已激活');
|
||||
tagText = t('已启用');
|
||||
} else if (record.status === 2) {
|
||||
tagColor = 'red';
|
||||
tagText = t('已封禁');
|
||||
tagText = t('已禁用');
|
||||
}
|
||||
|
||||
const handleToggle = (checked) => {
|
||||
if (checked) {
|
||||
showEnableDisableModal(record, 'enable');
|
||||
} else {
|
||||
showEnableDisableModal(record, 'disable');
|
||||
}
|
||||
};
|
||||
|
||||
const used = parseInt(record.used_quota) || 0;
|
||||
const remain = parseInt(record.quota) || 0;
|
||||
const total = used + remain;
|
||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||
|
||||
const getProgressColor = (pct) => {
|
||||
if (pct === 100) return 'var(--semi-color-success)';
|
||||
if (pct <= 10) return 'var(--semi-color-danger)';
|
||||
if (pct <= 30) return 'var(--semi-color-warning)';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const quotaSuffix = (
|
||||
<div className='flex flex-col items-end'>
|
||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||
<Progress
|
||||
percent={percent}
|
||||
stroke={getProgressColor(percent)}
|
||||
aria-label='quota usage'
|
||||
format={() => `${percent.toFixed(0)}%`}
|
||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Tag
|
||||
color={tagColor}
|
||||
shape='circle'
|
||||
size='large'
|
||||
prefixIcon={
|
||||
<Switch
|
||||
size='small'
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={isDeleted}
|
||||
aria-label='user status switch'
|
||||
/>
|
||||
}
|
||||
suffixIcon={quotaSuffix}
|
||||
size='small'
|
||||
>
|
||||
{tagText}
|
||||
</Tag>
|
||||
@@ -161,9 +118,6 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
||||
|
||||
const tooltipContent = (
|
||||
<div className='text-xs'>
|
||||
<div>{t('已用额度')}: {renderQuota(used)}</div>
|
||||
<div>{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)</div>
|
||||
<div>{t('总额度')}: {renderQuota(total)}</div>
|
||||
<div>{t('调用次数')}: {renderNumber(record.request_count)}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -175,6 +129,43 @@ const renderStatistics = (text, record, showEnableDisableModal, t) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Render separate quota usage column
|
||||
const renderQuotaUsage = (text, record, t) => {
|
||||
const { Paragraph } = Typography;
|
||||
const used = parseInt(record.used_quota) || 0;
|
||||
const remain = parseInt(record.quota) || 0;
|
||||
const total = used + remain;
|
||||
const percent = total > 0 ? (remain / total) * 100 : 0;
|
||||
const popoverContent = (
|
||||
<div className='text-xs p-2'>
|
||||
<Paragraph copyable={{ content: renderQuota(used) }}>
|
||||
{t('已用额度')}: {renderQuota(used)}
|
||||
</Paragraph>
|
||||
<Paragraph copyable={{ content: renderQuota(remain) }}>
|
||||
{t('剩余额度')}: {renderQuota(remain)} ({percent.toFixed(0)}%)
|
||||
</Paragraph>
|
||||
<Paragraph copyable={{ content: renderQuota(total) }}>
|
||||
{t('总额度')}: {renderQuota(total)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={popoverContent} position='top'>
|
||||
<Tag color='white' shape='circle'>
|
||||
<div className='flex flex-col items-end'>
|
||||
<span className='text-xs leading-none'>{`${renderQuota(remain)} / ${renderQuota(total)}`}</span>
|
||||
<Progress
|
||||
percent={percent}
|
||||
aria-label='quota usage'
|
||||
format={() => `${percent.toFixed(0)}%`}
|
||||
style={{ width: '100%', marginTop: '1px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</Tag>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render invite information
|
||||
*/
|
||||
@@ -204,6 +195,7 @@ const renderOperations = (text, record, {
|
||||
setShowEditUser,
|
||||
showPromoteModal,
|
||||
showDemoteModal,
|
||||
showEnableDisableModal,
|
||||
showDeleteModal,
|
||||
t
|
||||
}) => {
|
||||
@@ -213,6 +205,22 @@ const renderOperations = (text, record, {
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => showEnableDisableModal(record, 'disable')}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => showEnableDisableModal(record, 'enable')}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
@@ -270,6 +278,16 @@ export const getUsersColumns = ({
|
||||
dataIndex: 'username',
|
||||
render: (text, record) => renderUsername(text, record),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'info',
|
||||
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
|
||||
},
|
||||
{
|
||||
title: t('剩余额度/总额度'),
|
||||
key: 'quota_usage',
|
||||
render: (text, record) => renderQuotaUsage(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
@@ -284,11 +302,6 @@ export const getUsersColumns = ({
|
||||
return <div>{renderRole(text, t)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'info',
|
||||
render: (text, record, index) => renderStatistics(text, record, showEnableDisableModal, t),
|
||||
},
|
||||
{
|
||||
title: t('邀请信息'),
|
||||
dataIndex: 'invite',
|
||||
|
||||
@@ -81,7 +81,7 @@ export const CHANNEL_OPTIONS = [
|
||||
{
|
||||
value: 16,
|
||||
color: 'violet',
|
||||
label: '智谱 ChatGLM',
|
||||
label: '智谱 ChatGLM(已经弃用,请使用智谱 GLM-4V)',
|
||||
},
|
||||
{
|
||||
value: 26,
|
||||
|
||||
+61
-11
@@ -419,7 +419,11 @@ export function getChannelIcon(channelType) {
|
||||
|
||||
/**
|
||||
* 根据图标名称动态获取 LobeHub 图标组件
|
||||
* @param {string} iconName - 图标名称
|
||||
* 支持:
|
||||
* - 基础:"OpenAI"、"OpenAI.Color" 等
|
||||
* - 额外属性(点号链式):"OpenAI.Avatar.type={'platform'}"、"OpenRouter.Avatar.shape={'square'}"
|
||||
* - 继续兼容第二参数 size;若字符串里有 size=,以字符串为准
|
||||
* @param {string} iconName - 图标名称/描述
|
||||
* @param {number} size - 图标大小,默认为 14
|
||||
* @returns {JSX.Element} - 对应的图标组件或 Avatar
|
||||
*/
|
||||
@@ -430,22 +434,68 @@ export function getLobeHubIcon(iconName, size = 14) {
|
||||
return <Avatar size="extra-extra-small">?</Avatar>;
|
||||
}
|
||||
|
||||
let IconComponent;
|
||||
// 解析组件路径与点号链式属性
|
||||
const segments = String(iconName).split('.');
|
||||
const baseKey = segments[0];
|
||||
const BaseIcon = LobeIcons[baseKey];
|
||||
|
||||
if (iconName.includes('.')) {
|
||||
const [base, variant] = iconName.split('.');
|
||||
const BaseIcon = LobeIcons[base];
|
||||
IconComponent = BaseIcon ? BaseIcon[variant] : undefined;
|
||||
let IconComponent = undefined;
|
||||
let propStartIndex = 1;
|
||||
|
||||
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
|
||||
IconComponent = BaseIcon[segments[1]];
|
||||
propStartIndex = 2;
|
||||
} else {
|
||||
IconComponent = LobeIcons[iconName];
|
||||
IconComponent = LobeIcons[baseKey];
|
||||
propStartIndex = 1;
|
||||
}
|
||||
|
||||
if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) {
|
||||
return <IconComponent size={size} />;
|
||||
// 失败兜底
|
||||
if (!IconComponent || (typeof IconComponent !== 'function' && typeof IconComponent !== 'object')) {
|
||||
const firstLetter = String(iconName).charAt(0).toUpperCase();
|
||||
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
|
||||
}
|
||||
|
||||
const firstLetter = iconName.charAt(0).toUpperCase();
|
||||
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
|
||||
// 解析点号链式属性,形如:key={...}、key='...'、key="..."、key=123、key、key=true/false
|
||||
const props = {};
|
||||
|
||||
const parseValue = (raw) => {
|
||||
if (raw == null) return true;
|
||||
let v = String(raw).trim();
|
||||
// 去除一层花括号包裹
|
||||
if (v.startsWith('{') && v.endsWith('}')) {
|
||||
v = v.slice(1, -1).trim();
|
||||
}
|
||||
// 去除引号
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
return v.slice(1, -1);
|
||||
}
|
||||
// 布尔
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
// 数字
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v);
|
||||
// 其他原样返回字符串
|
||||
return v;
|
||||
};
|
||||
|
||||
for (let i = propStartIndex; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (!seg) continue;
|
||||
const eqIdx = seg.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
props[seg.trim()] = true;
|
||||
continue;
|
||||
}
|
||||
const key = seg.slice(0, eqIdx).trim();
|
||||
const valRaw = seg.slice(eqIdx + 1).trim();
|
||||
props[key] = parseValue(valRaw);
|
||||
}
|
||||
|
||||
// 兼容第二参数 size,若字符串中未显式指定 size,则使用函数入参
|
||||
if (props.size == null && size != null) props.size = size;
|
||||
|
||||
return <IconComponent {...props} />;
|
||||
}
|
||||
|
||||
// 颜色列表
|
||||
|
||||
+59
-18
@@ -581,13 +581,37 @@ export const calculateModelPrice = ({
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
precision = 4
|
||||
precision = 4,
|
||||
}) => {
|
||||
// 1. 选择实际使用的分组
|
||||
let usedGroup = selectedGroup;
|
||||
let usedGroupRatio = groupRatio[selectedGroup];
|
||||
|
||||
if (selectedGroup === 'all' || usedGroupRatio === undefined) {
|
||||
// 在模型可用分组中选择倍率最小的分组,若无则使用 1
|
||||
let minRatio = Number.POSITIVE_INFINITY;
|
||||
if (Array.isArray(record.enable_groups) && record.enable_groups.length > 0) {
|
||||
record.enable_groups.forEach((g) => {
|
||||
const r = groupRatio[g];
|
||||
if (r !== undefined && r < minRatio) {
|
||||
minRatio = r;
|
||||
usedGroup = g;
|
||||
usedGroupRatio = r;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果找不到合适分组倍率,回退为 1
|
||||
if (usedGroupRatio === undefined) {
|
||||
usedGroupRatio = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 根据计费类型计算价格
|
||||
if (record.quota_type === 0) {
|
||||
// 按量计费
|
||||
const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
|
||||
const completionRatioPriceUSD =
|
||||
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
|
||||
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
|
||||
const completionRatioPriceUSD = record.model_ratio * record.completion_ratio * 2 * usedGroupRatio;
|
||||
|
||||
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
||||
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
||||
@@ -602,22 +626,32 @@ export const calculateModelPrice = ({
|
||||
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
|
||||
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
|
||||
unitLabel,
|
||||
isPerToken: true
|
||||
};
|
||||
} else {
|
||||
// 按次计费
|
||||
const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup];
|
||||
const displayVal = displayPrice(priceUSD);
|
||||
|
||||
return {
|
||||
price: displayVal,
|
||||
isPerToken: false
|
||||
isPerToken: true,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
}
|
||||
|
||||
// 按次计费
|
||||
const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
|
||||
const displayVal = displayPrice(priceUSD);
|
||||
|
||||
return {
|
||||
price: displayVal,
|
||||
isPerToken: false,
|
||||
usedGroup,
|
||||
usedGroupRatio,
|
||||
};
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t) => {
|
||||
const groupTag = priceData.usedGroup ? (
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }} className="ml-1 text-xs">
|
||||
{t('分组')} {priceData.usedGroup}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<>
|
||||
@@ -627,15 +661,19 @@ export const formatPriceInfo = (priceData, t) => {
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('补全')} {priceData.completionPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
{groupTag}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('模型价格')} {priceData.price}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
{groupTag}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
@@ -699,6 +737,7 @@ const DEFAULT_PRICING_FILTERS = {
|
||||
filterQuotaType: 'all',
|
||||
filterEndpointType: 'all',
|
||||
filterVendor: 'all',
|
||||
filterTag: 'all',
|
||||
currentPage: 1,
|
||||
};
|
||||
|
||||
@@ -713,6 +752,7 @@ export const resetPricingFilters = ({
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setFilterTag,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
}) => {
|
||||
@@ -726,5 +766,6 @@ export const resetPricingFilters = ({
|
||||
setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
|
||||
setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
|
||||
setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
|
||||
setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
|
||||
setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
|
||||
};
|
||||
|
||||
@@ -31,13 +31,14 @@ export const useModelPricingData = () => {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState('default');
|
||||
const [selectedGroup, setSelectedGroup] = useState('all');
|
||||
const [showModelDetail, setShowModelDetail] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState(null);
|
||||
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
|
||||
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
|
||||
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
|
||||
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
|
||||
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
@@ -88,6 +89,20 @@ export const useModelPricingData = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 标签筛选
|
||||
if (filterTag !== 'all') {
|
||||
const tagLower = filterTag.toLowerCase();
|
||||
result = result.filter(model => {
|
||||
if (!model.tags) return false;
|
||||
const tagsArr = model.tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|\s]+/)
|
||||
.map(tag => tag.trim())
|
||||
.filter(Boolean);
|
||||
return tagsArr.includes(tagLower);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchValue.length > 0) {
|
||||
const searchTerm = searchValue.toLowerCase();
|
||||
@@ -100,7 +115,7 @@ export const useModelPricingData = () => {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
|
||||
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag]);
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
@@ -165,7 +180,7 @@ export const useModelPricingData = () => {
|
||||
if (success) {
|
||||
setGroupRatio(group_ratio);
|
||||
setUsableGroup(usable_group);
|
||||
setSelectedGroup(userState.user ? userState.user.group : 'default');
|
||||
setSelectedGroup('all');
|
||||
// 构建供应商 Map 方便查找
|
||||
const vendorMap = {};
|
||||
if (Array.isArray(vendors)) {
|
||||
@@ -218,12 +233,17 @@ export const useModelPricingData = () => {
|
||||
setSelectedGroup(group);
|
||||
// 同时将分组过滤设置为该分组
|
||||
setFilterGroup(group);
|
||||
showInfo(
|
||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||||
group: group,
|
||||
ratio: groupRatio[group],
|
||||
}),
|
||||
);
|
||||
|
||||
if (group === 'all') {
|
||||
showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
|
||||
} else {
|
||||
showInfo(
|
||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||||
group: group,
|
||||
ratio: groupRatio[group] ?? 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openModelDetail = (model) => {
|
||||
@@ -233,7 +253,9 @@ export const useModelPricingData = () => {
|
||||
|
||||
const closeModelDetail = () => {
|
||||
setShowModelDetail(false);
|
||||
setSelectedModel(null);
|
||||
setTimeout(() => {
|
||||
setSelectedModel(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -243,7 +265,7 @@ export const useModelPricingData = () => {
|
||||
// 当筛选条件变化时重置到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
|
||||
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag, searchValue]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
@@ -269,6 +291,8 @@ export const useModelPricingData = () => {
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
filterTag,
|
||||
setFilterTag,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
|
||||
@@ -17,115 +17,128 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
/*
|
||||
统一计算模型筛选后的各种集合与动态计数,供多个组件复用
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// 工具函数:将 tags 字符串转为小写去重数组
|
||||
const normalizeTags = (tags = '') =>
|
||||
tags
|
||||
.toLowerCase()
|
||||
.split(/[,;|\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* 统一计算模型筛选后的各种集合与动态计数,供多个组件复用
|
||||
*/
|
||||
export const usePricingFilterCounts = ({
|
||||
models = [],
|
||||
filterGroup = 'all',
|
||||
filterQuotaType = 'all',
|
||||
filterEndpointType = 'all',
|
||||
filterVendor = 'all',
|
||||
filterTag = 'all',
|
||||
searchValue = '',
|
||||
}) => {
|
||||
// 所有模型(不再需要分类过滤)
|
||||
// 均使用同一份模型列表,避免创建新引用
|
||||
const allModels = models;
|
||||
|
||||
// 针对计费类型按钮计数
|
||||
const quotaTypeModels = useMemo(() => {
|
||||
let result = allModels;
|
||||
if (filterGroup !== 'all') {
|
||||
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
|
||||
/**
|
||||
* 通用过滤函数
|
||||
* @param {Object} model
|
||||
* @param {Array<string>} ignore 需要忽略的过滤条件 key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const matchesFilters = (model, ignore = []) => {
|
||||
// 分组
|
||||
if (!ignore.includes('group') && filterGroup !== 'all') {
|
||||
if (!model.enable_groups || !model.enable_groups.includes(filterGroup)) return false;
|
||||
}
|
||||
if (filterEndpointType !== 'all') {
|
||||
result = result.filter(m =>
|
||||
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
|
||||
);
|
||||
|
||||
// 计费类型
|
||||
if (!ignore.includes('quota') && filterQuotaType !== 'all') {
|
||||
if (model.quota_type !== filterQuotaType) return false;
|
||||
}
|
||||
if (filterVendor !== 'all') {
|
||||
|
||||
// 端点类型
|
||||
if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
|
||||
if (
|
||||
!model.supported_endpoint_types ||
|
||||
!model.supported_endpoint_types.includes(filterEndpointType)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
// 供应商
|
||||
if (!ignore.includes('vendor') && filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
result = result.filter(m => !m.vendor_name);
|
||||
} else {
|
||||
result = result.filter(m => m.vendor_name === filterVendor);
|
||||
if (model.vendor_name) return false;
|
||||
} else if (model.vendor_name !== filterVendor) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allModels, filterGroup, filterEndpointType, filterVendor]);
|
||||
|
||||
// 针对端点类型按钮计数
|
||||
const endpointTypeModels = useMemo(() => {
|
||||
let result = allModels;
|
||||
if (filterGroup !== 'all') {
|
||||
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
|
||||
// 标签
|
||||
if (!ignore.includes('tag') && filterTag !== 'all') {
|
||||
const tagsArr = normalizeTags(model.tags);
|
||||
if (!tagsArr.includes(filterTag.toLowerCase())) return false;
|
||||
}
|
||||
if (filterQuotaType !== 'all') {
|
||||
result = result.filter(m => m.quota_type === filterQuotaType);
|
||||
}
|
||||
if (filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
result = result.filter(m => !m.vendor_name);
|
||||
} else {
|
||||
result = result.filter(m => m.vendor_name === filterVendor);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allModels, filterGroup, filterQuotaType, filterVendor]);
|
||||
|
||||
// === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
|
||||
const groupCountModels = useMemo(() => {
|
||||
let result = allModels;
|
||||
|
||||
// 不应用 filterGroup 本身
|
||||
if (filterQuotaType !== 'all') {
|
||||
result = result.filter(m => m.quota_type === filterQuotaType);
|
||||
}
|
||||
if (filterEndpointType !== 'all') {
|
||||
result = result.filter(m =>
|
||||
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
|
||||
);
|
||||
}
|
||||
if (filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
result = result.filter(m => !m.vendor_name);
|
||||
} else {
|
||||
result = result.filter(m => m.vendor_name === filterVendor);
|
||||
}
|
||||
}
|
||||
if (searchValue && searchValue.length > 0) {
|
||||
// 搜索
|
||||
if (!ignore.includes('search') && searchValue) {
|
||||
const term = searchValue.toLowerCase();
|
||||
result = result.filter(m =>
|
||||
m.model_name.toLowerCase().includes(term) ||
|
||||
(m.description && m.description.toLowerCase().includes(term)) ||
|
||||
(m.tags && m.tags.toLowerCase().includes(term)) ||
|
||||
(m.vendor_name && m.vendor_name.toLowerCase().includes(term))
|
||||
);
|
||||
const tags = model.tags ? model.tags.toLowerCase() : '';
|
||||
if (
|
||||
!(
|
||||
model.model_name.toLowerCase().includes(term) ||
|
||||
(model.description && model.description.toLowerCase().includes(term)) ||
|
||||
tags.includes(term) ||
|
||||
(model.vendor_name && model.vendor_name.toLowerCase().includes(term))
|
||||
)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return result;
|
||||
}, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
|
||||
|
||||
// 针对供应商按钮计数
|
||||
const vendorModels = useMemo(() => {
|
||||
let result = allModels;
|
||||
if (filterGroup !== 'all') {
|
||||
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
|
||||
}
|
||||
if (filterQuotaType !== 'all') {
|
||||
result = result.filter(m => m.quota_type === filterQuotaType);
|
||||
}
|
||||
if (filterEndpointType !== 'all') {
|
||||
result = result.filter(m =>
|
||||
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [allModels, filterGroup, filterQuotaType, filterEndpointType]);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 生成不同视图所需的模型集合
|
||||
const quotaTypeModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['quota'])),
|
||||
[allModels, filterGroup, filterEndpointType, filterVendor, filterTag]
|
||||
);
|
||||
|
||||
const endpointTypeModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
|
||||
[allModels, filterGroup, filterQuotaType, filterVendor, filterTag]
|
||||
);
|
||||
|
||||
const vendorModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['vendor'])),
|
||||
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterTag]
|
||||
);
|
||||
|
||||
const tagModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['tag'])),
|
||||
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterVendor]
|
||||
);
|
||||
|
||||
const groupCountModels = useMemo(
|
||||
() => allModels.filter((m) => matchesFilters(m, ['group'])),
|
||||
[
|
||||
allModels,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
filterTag,
|
||||
searchValue,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
tagModels,
|
||||
};
|
||||
};
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useTokensData = () => {
|
||||
export const useTokensData = (openFluentNotification) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Basic state
|
||||
@@ -121,6 +121,10 @@ export const useTokensData = () => {
|
||||
|
||||
// Open link function for chat integrations
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
if (url && url.startsWith('fluent')) {
|
||||
openFluentNotification(record.key);
|
||||
return;
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
"已用额度": "Quota used",
|
||||
"剩余额度": "Remaining quota",
|
||||
"总额度": "Total quota",
|
||||
"剩余额度/总额度": "Remaining/Total",
|
||||
"智能熔断": "Smart fallback",
|
||||
"当前分组为 auto,会自动选择最优分组,当一个组不可用时自动降级到下一个组(熔断机制)": "The current group is auto, it will automatically select the optimal group, and automatically downgrade to the next group when a group is unavailable (breakage mechanism)",
|
||||
"过期时间": "Expiration time",
|
||||
@@ -584,7 +585,7 @@
|
||||
"确定是否要修复数据库一致性?": "Are you sure you want to repair database consistency?",
|
||||
"进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "When performing this operation, it may cause channel access errors. Please only use it when there is a problem with the database.",
|
||||
"当前没有可用的启用令牌,请确认是否有令牌处于启用状态!": "There are currently no enablement tokens available, please confirm if one is enabled!",
|
||||
"令牌管理": "Token Management",
|
||||
"令牌管理": "API Keys",
|
||||
"使用日志": "Usage log",
|
||||
"Midjourney日志": "Midjourney",
|
||||
"数据看板": "Dashboard",
|
||||
@@ -1789,7 +1790,7 @@
|
||||
"将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
|
||||
"自定义模型名称": "Custom model name",
|
||||
"启用全部密钥": "Enable all keys",
|
||||
"以充值价格显示": "Show with recharge price",
|
||||
"以充值价格显示": "Recharge price",
|
||||
"美元汇率(非充值汇率,仅用于定价页面换算)": "USD exchange rate (not recharge rate, only used for pricing page conversion)",
|
||||
"美元汇率": "USD exchange rate",
|
||||
"隐藏操作项": "Hide actions",
|
||||
@@ -1810,5 +1811,111 @@
|
||||
"图片输入: {{imageRatio}}": "Image input: {{imageRatio}}",
|
||||
"系统提示覆盖": "System prompt override",
|
||||
"模型: {{ratio}}": "Model: {{ratio}}",
|
||||
"专属倍率": "Exclusive group ratio"
|
||||
"专属倍率": "Exclusive group ratio",
|
||||
"模型管理": "Models",
|
||||
"匹配类型": "Matching type",
|
||||
"描述": "Description",
|
||||
"供应商": "Vendor",
|
||||
"端点": "Endpoint",
|
||||
"已绑定渠道": "Bound channels",
|
||||
"更新时间": "Update time",
|
||||
"未配置模型": "No model configured",
|
||||
"预填组管理": "Pre-filled group management",
|
||||
"搜索供应商": "Search vendor",
|
||||
"新增供应商": "Add vendor",
|
||||
"创建新的模型": "Create new model",
|
||||
"更新模型信息": "Update model information",
|
||||
"请输入模型名称,如:gpt-4": "Please enter the model name, such as: gpt-4",
|
||||
"设置模型的基本信息": "Set the basic information of the model",
|
||||
"名称匹配类型": "Name matching type",
|
||||
"根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含": "Find model metadata based on model name and matching rules, priority: exact > prefix > suffix > contains",
|
||||
"请选择名称匹配类型": "Please select the name matching type",
|
||||
"请输入模型描述": "Please enter the model description",
|
||||
"输入标签或使用\",\"分隔多个标签": "Enter tags or use \",\" to separate multiple tags",
|
||||
"选择模型供应商": "Select model vendor",
|
||||
"端点映射": "Endpoint mapping",
|
||||
"可视化": "Visualization",
|
||||
"手动编辑": "Manual editing",
|
||||
"暂无数据,点击下方按钮添加键值对": "No data, click the button below to add key-value pairs",
|
||||
"添加键值对": "Add key-value pair",
|
||||
"留空则使用默认端点;支持 {path, method}": "Leave blank to use the default endpoint; supports {path, method}",
|
||||
"未配置的模型列表": "Models not configured",
|
||||
"个未配置模型": "models not configured",
|
||||
"组列表": "Group list",
|
||||
"管理模型、标签、端点等预填组": "Manage model, tag, endpoint, etc. pre-filled groups",
|
||||
"新建组": "New group",
|
||||
"组名": "Group name",
|
||||
"项目内容": "Item content",
|
||||
"创建新的预填组": "Create new pre-filled group",
|
||||
"更新预填组": "Update pre-filled group",
|
||||
"设置预填组的基本信息": "Set the basic information of the pre-filled group",
|
||||
"请输入组名": "Please enter the group name",
|
||||
"请输入组描述": "Please enter the group description",
|
||||
"项目": "Item",
|
||||
"输入项目名称,按回车添加": "Enter the item name, press Enter to add",
|
||||
"键为端点类型,值为路径和方法对象": "The key is the endpoint type, the value is the path and method object",
|
||||
"模型组": "Model group",
|
||||
"标签组": "Tag group",
|
||||
"端点组": "Endpoint group",
|
||||
"供应商名称": "Vendor name",
|
||||
"请输入供应商名称,如:OpenAI": "Please enter the vendor name, such as: OpenAI",
|
||||
"请输入供应商描述": "Please enter the vendor description",
|
||||
"供应商图标": "Vendor icon",
|
||||
"请输入图标名称,如:OpenAI、Claude.Color": "Please enter the icon name, such as: OpenAI, Claude.Color",
|
||||
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ": "The icon uses the @lobehub/icons library, such as: OpenAI, Claude.Color, supports chain parameters: OpenAI.Avatar.type={'platform'}, OpenRouter.Avatar.shape={'square'}, query all available icons please ",
|
||||
"请点击我": "Please click me",
|
||||
"精确": "Exact",
|
||||
"前缀": "Prefix",
|
||||
"后缀": "Suffix",
|
||||
"包含": "Contains",
|
||||
"全部供应商": "All vendors",
|
||||
"筛选": "Filter",
|
||||
"显示设置": "Display settings",
|
||||
"可用令牌分组": "Available token groups",
|
||||
"端点类型": "Endpoint type",
|
||||
"全部分组": "All groups",
|
||||
"全部类型": "All types",
|
||||
"全部端点": "All endpoints",
|
||||
"全部标签": "All tags",
|
||||
"显示倍率": "Show ratio",
|
||||
"表格视图": "Table view",
|
||||
"模型的详细描述和基本特性": "Detailed description and basic characteristics of the model",
|
||||
"API端点": "API endpoints",
|
||||
"模型支持的接口端点信息": "Model supported API endpoint information",
|
||||
"分组价格": "Group price",
|
||||
"不同用户分组的价格信息": "Price information for different user groups",
|
||||
"auto分组调用链路": "auto group call chain",
|
||||
"查看所有可用的AI模型供应商,包括众多知名供应商的模型。": "View all available AI model suppliers, including models from many well-known suppliers.",
|
||||
"包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。": "Includes AI models from unknown or unmarked suppliers, which may come from small suppliers or open-source projects.",
|
||||
"该供应商提供多种AI模型,适用于不同的应用场景。": "This supplier provides multiple AI models, suitable for different application scenarios.",
|
||||
"未知供应商": "Unknown",
|
||||
"共 {{count}} 个模型": "{{count}} models",
|
||||
"倍率信息": "Ratio information",
|
||||
"倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略": "The ratio is used to calculate the final price of different models in the system. If you do not understand the ratio, please ignore it.",
|
||||
"多密钥管理": "Multi-key management",
|
||||
"总密钥数": "Total key count",
|
||||
"随机模式": "Random mode",
|
||||
"轮询模式": "Polling mode",
|
||||
"手动禁用": "Manually disabled",
|
||||
"自动禁用": "Auto disabled",
|
||||
"暂无密钥数据": "No key data",
|
||||
"请检查渠道配置或刷新重试": "Please check the channel configuration or refresh and try again",
|
||||
"全部状态": "All status",
|
||||
"索引": "Index",
|
||||
"禁用原因": "Disable reason",
|
||||
"禁用时间": "Disable time",
|
||||
"确定要启用所有密钥吗?": "Are you sure you want to enable all keys?",
|
||||
"确定要禁用所有的密钥吗?": "Are you sure you want to disable all keys?",
|
||||
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
|
||||
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
|
||||
"删除自动禁用密钥": "Delete auto disabled keys",
|
||||
"图标": "Icon",
|
||||
"模型图标": "Model icon",
|
||||
"请输入图标名称": "Please enter the icon name",
|
||||
"精确名称匹配": "Exact name matching",
|
||||
"前缀名称匹配": "Prefix name matching",
|
||||
"后缀名称匹配": "Suffix name matching",
|
||||
"包含名称匹配": "Contains name matching",
|
||||
"展开更多": "Expand more",
|
||||
"已切换至最优倍率视图,每个模型使用其最低倍率分组": "Switched to the optimal ratio view, each model uses its lowest ratio group"
|
||||
}
|
||||
@@ -593,6 +593,60 @@ html:not(.dark) .blur-ball-teal {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* ==================== 卡片马卡龙模糊球(类封装) ==================== */
|
||||
/* 使用方式:给容器加上 with-pastel-balls 类即可,无需在 JSX 中插入额外节点 */
|
||||
.with-pastel-balls {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 默认变量(明亮模式) */
|
||||
--pb1: #ffd1dc;
|
||||
/* 粉 */
|
||||
--pb2: #e5d4ff;
|
||||
/* 薰衣草 */
|
||||
--pb3: #d1fff6;
|
||||
/* 薄荷 */
|
||||
--pb4: #ffe5d9;
|
||||
/* 桃 */
|
||||
--pb-opacity: 0.55;
|
||||
--pb-blur: 60px;
|
||||
}
|
||||
|
||||
.with-pastel-balls::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(circle at -5% -10%, var(--pb1) 0%, transparent 60%),
|
||||
radial-gradient(circle at 105% -10%, var(--pb2) 0%, transparent 55%),
|
||||
radial-gradient(circle at 5% 110%, var(--pb3) 0%, transparent 55%),
|
||||
radial-gradient(circle at 105% 110%, var(--pb4) 0%, transparent 50%);
|
||||
filter: blur(var(--pb-blur));
|
||||
opacity: var(--pb-opacity);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* 暗黑模式下更柔和的色彩和透明度 */
|
||||
html.dark .with-pastel-balls {
|
||||
/* 使用与明亮模式一致的“刚才那组”马卡龙色,但整体更柔和 */
|
||||
--pb1: #ffd1dc;
|
||||
/* 粉 */
|
||||
--pb2: #e5d4ff;
|
||||
/* 薰衣草 */
|
||||
--pb3: #d1fff6;
|
||||
/* 薄荷 */
|
||||
--pb4: #ffe5d9;
|
||||
/* 桃 */
|
||||
--pb-opacity: 0.36;
|
||||
--pb-blur: 65px;
|
||||
}
|
||||
|
||||
/* 暗黑模式下用更柔和的混合模式避免突兀的高亮 */
|
||||
html.dark .with-pastel-balls::before {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* ==================== 表格卡片滚动设置 ==================== */
|
||||
.table-scroll-card {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user