🤓feat: the model management module
Merge pull request #1452 from QuantumNous/refactor/model-pricing
This commit is contained in:
32
common/endpoint_defaults.go
Normal file
32
common/endpoint_defaults.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package common
|
||||
|
||||
import "one-api/constant"
|
||||
|
||||
// EndpointInfo 描述单个端点的默认请求信息
|
||||
// path: 上游路径
|
||||
// method: HTTP 请求方式,例如 POST/GET
|
||||
// 目前均为 POST,后续可扩展
|
||||
//
|
||||
// json 标签用于直接序列化到 API 输出
|
||||
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||
|
||||
type EndpointInfo struct {
|
||||
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"},
|
||||
}
|
||||
|
||||
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
}
|
||||
27
controller/missing_models.go
Normal file
27
controller/missing_models.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"one-api/model"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": missing,
|
||||
})
|
||||
}
|
||||
178
controller/model_meta.go
Normal file
178
controller/model_meta.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"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)
|
||||
|
||||
// 统计供应商计数(全部数据,不受分页影响)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchModelsMeta 搜索模型列表
|
||||
func SearchModelsMeta(c *gin.Context) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
// 辅助函数:填充 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)
|
||||
}
|
||||
90
controller/prefill_group.go
Normal file
90
controller/prefill_group.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -41,9 +41,11 @@ func GetPricing(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"data": pricing,
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
})
|
||||
"vendors": model.GetVendors(),
|
||||
"group_ratio": groupRatio,
|
||||
"usable_group": usableGroup,
|
||||
"supported_endpoint": model.GetSupportedEndpointMap(),
|
||||
})
|
||||
}
|
||||
|
||||
func ResetModelRatio(c *gin.Context) {
|
||||
|
||||
124
controller/vendor_meta.go
Normal file
124
controller/vendor_meta.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -250,6 +250,9 @@ func migrateDB() error {
|
||||
&TopUp{},
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Model{},
|
||||
&Vendor{},
|
||||
&PrefillGroup{},
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
@@ -278,6 +281,9 @@ func migrateDBFast() error {
|
||||
{&TopUp{}, "TopUp"},
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Model{}, "Model"},
|
||||
{&Vendor{}, "Vendor"},
|
||||
{&PrefillGroup{}, "PrefillGroup"},
|
||||
{&Setup{}, "Setup"},
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
|
||||
30
model/missing_models.go
Normal file
30
model/missing_models.go
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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{}{}
|
||||
}
|
||||
|
||||
// 3. 收集缺失模型
|
||||
var missing []string
|
||||
for _, name := range models {
|
||||
if _, ok := existingSet[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
34
model/model_extra.go
Normal file
34
model/model_extra.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
|
||||
// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。
|
||||
func GetModelEnableGroups(modelName string) []string {
|
||||
// 确保缓存最新
|
||||
GetPricing()
|
||||
|
||||
if modelName == "" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
modelEnableGroupsLock.RLock()
|
||||
quota, ok := modelQuotaTypeMap[modelName]
|
||||
modelEnableGroupsLock.RUnlock()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return quota
|
||||
}
|
||||
204
model/model_meta.go
Normal file
204
model/model_meta.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Model 用于存储模型的元数据,例如描述、标签等
|
||||
// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
|
||||
// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
|
||||
// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展
|
||||
// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
|
||||
// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
|
||||
//
|
||||
// 该表设计遵循第三范式(3NF):
|
||||
// 1. 每一列都与主键(Id 或 ModelName)直接相关
|
||||
// 2. 不存在部分依赖(ModelName 是唯一键)
|
||||
// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列)
|
||||
// 这样既保证了数据一致性,也方便后期扩展
|
||||
|
||||
// 模型名称匹配规则
|
||||
const (
|
||||
NameRuleExact = iota // 0 精确匹配
|
||||
NameRulePrefix // 1 前缀匹配
|
||||
NameRuleContains // 2 包含匹配
|
||||
NameRuleSuffix // 3 后缀匹配
|
||||
)
|
||||
|
||||
type BoundChannel struct {
|
||||
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"`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Delete 软删除模型记录
|
||||
func (mi *Model) Delete() 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetAllModels 分页获取所有模型元数据
|
||||
func GetAllModels(offset int, limit int) ([]*Model, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
126
model/prefill_group.go
Normal file
126
model/prefill_group.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql/driver"
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
|
||||
// Name 字段保持唯一,用于在前端下拉框中展示。
|
||||
// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。
|
||||
// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例:
|
||||
// ["gpt-4o", "gpt-3.5-turbo"]
|
||||
// 设计遵循 3NF,避免冗余,提供灵活扩展能力。
|
||||
|
||||
// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取
|
||||
type JSONValue json.RawMessage
|
||||
|
||||
// Value 实现 driver.Valuer 接口,用于数据库写入
|
||||
func (j JSONValue) Value() (driver.Value, error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
|
||||
func (j JSONValue) MarshalJSON() ([]byte, error) {
|
||||
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
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// Insert 新建组
|
||||
func (g *PrefillGroup) Insert() 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
|
||||
}
|
||||
|
||||
// Update 更新组
|
||||
func (g *PrefillGroup) Update() error {
|
||||
g.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(g).Error
|
||||
}
|
||||
|
||||
// DeleteByID 根据 ID 删除组
|
||||
func DeletePrefillGroupByID(id int) 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
|
||||
}
|
||||
286
model/pricing.go
286
model/pricing.go
@@ -1,30 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Pricing struct {
|
||||
ModelName string `json:"model_name"`
|
||||
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"`
|
||||
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 {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
pricingMap []Pricing
|
||||
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)
|
||||
modelQuotaTypeMap = make(map[string]int)
|
||||
modelEnableGroupsLock = sync.RWMutex{}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -46,6 +66,15 @@ func GetPricing() []Pricing {
|
||||
return pricingMap
|
||||
}
|
||||
|
||||
// GetVendors 返回当前定价接口使用到的供应商信息
|
||||
func GetVendors() []PricingVendor {
|
||||
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
|
||||
// 保证先刷新一次
|
||||
GetPricing()
|
||||
}
|
||||
return vendorsList
|
||||
}
|
||||
|
||||
func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
|
||||
if model == "" {
|
||||
return make([]constant.EndpointType, 0)
|
||||
@@ -65,6 +94,77 @@ func updatePricing() {
|
||||
common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
|
||||
return
|
||||
}
|
||||
// 预加载模型元数据与供应商一次,避免循环查询
|
||||
var allMeta []Model
|
||||
_ = DB.Find(&allMeta).Error
|
||||
metaMap := make(map[string]*Model)
|
||||
prefixList := make([]*Model, 0)
|
||||
suffixList := make([]*Model, 0)
|
||||
containsList := make([]*Model, 0)
|
||||
for i := range allMeta {
|
||||
m := &allMeta[i]
|
||||
if m.NameRule == NameRuleExact {
|
||||
metaMap[m.ModelName] = m
|
||||
} else {
|
||||
switch m.NameRule {
|
||||
case NameRulePrefix:
|
||||
prefixList = append(prefixList, m)
|
||||
case NameRuleSuffix:
|
||||
suffixList = append(suffixList, m)
|
||||
case NameRuleContains:
|
||||
containsList = append(containsList, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将非精确规则模型匹配到 metaMap
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range containsList {
|
||||
for _, pricingModel := range enableAbilities {
|
||||
if strings.Contains(pricingModel.Model, m.ModelName) {
|
||||
if _, exists := metaMap[pricingModel.Model]; !exists {
|
||||
metaMap[pricingModel.Model] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载供应商
|
||||
var vendors []Vendor
|
||||
_ = DB.Find(&vendors).Error
|
||||
vendorMap := make(map[int]*Vendor)
|
||||
for i := range vendors {
|
||||
vendorMap[vendors[i].Id] = &vendors[i]
|
||||
}
|
||||
|
||||
// 构建对前端友好的供应商列表
|
||||
vendorsList = make([]PricingVendor, 0, len(vendors))
|
||||
for _, v := range vendors {
|
||||
vendorsList = append(vendorsList, PricingVendor{
|
||||
ID: v.Id,
|
||||
Name: v.Name,
|
||||
Description: v.Description,
|
||||
Icon: v.Icon,
|
||||
})
|
||||
}
|
||||
|
||||
modelGroupsMap := make(map[string]*types.Set[string])
|
||||
|
||||
for _, ability := range enableAbilities {
|
||||
@@ -79,20 +179,34 @@ func updatePricing() {
|
||||
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
||||
modelSupportEndpointsStr := make(map[string][]string)
|
||||
|
||||
for _, ability := range enableAbilities {
|
||||
endpoints, ok := modelSupportEndpointsStr[ability.Model]
|
||||
if !ok {
|
||||
endpoints = make([]string, 0)
|
||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
|
||||
for model, endpoints := range modelSupportEndpointsStr {
|
||||
@@ -102,26 +216,92 @@ func updatePricing() {
|
||||
supportedEndpoints = append(supportedEndpoints, endpointType)
|
||||
}
|
||||
modelSupportEndpointTypes[model] = supportedEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
pricingMap = make([]Pricing, 0)
|
||||
for model, groups := range modelGroupsMap {
|
||||
pricing := Pricing{
|
||||
ModelName: model,
|
||||
EnableGroup: groups.Items(),
|
||||
SupportedEndpointTypes: modelSupportEndpointTypes[model],
|
||||
}
|
||||
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)
|
||||
}
|
||||
lastGetPricingTime = time.Now()
|
||||
// 构建全局 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],
|
||||
}
|
||||
|
||||
// 补充模型元数据(描述、标签、供应商、状态)
|
||||
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)
|
||||
}
|
||||
|
||||
// 刷新缓存映射,供高并发快速查询
|
||||
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()
|
||||
}
|
||||
|
||||
// GetSupportedEndpointMap 返回全局端点到路径的映射
|
||||
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
|
||||
return supportedEndpointMap
|
||||
}
|
||||
|
||||
14
model/pricing_refresh.go
Normal file
14
model/pricing_refresh.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
// RefreshPricing 强制立即重新计算与定价相关的缓存。
|
||||
// 该方法用于需要最新数据的内部管理 API,
|
||||
// 因此会绕过默认的 1 分钟延迟刷新。
|
||||
func RefreshPricing() {
|
||||
updatePricingLock.Lock()
|
||||
defer updatePricingLock.Unlock()
|
||||
|
||||
modelSupportEndpointsLock.Lock()
|
||||
defer modelSupportEndpointsLock.Unlock()
|
||||
|
||||
updatePricing()
|
||||
}
|
||||
88
model/vendor_meta.go
Normal file
88
model/vendor_meta.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Vendor 用于存储供应商信息,供模型引用
|
||||
// Name 唯一,用于在模型中关联
|
||||
// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染
|
||||
// Status 预留字段,1 表示启用
|
||||
// 本表同样遵循 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"`
|
||||
}
|
||||
|
||||
// Insert 创建新的供应商记录
|
||||
func (v *Vendor) Insert() 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
|
||||
}
|
||||
|
||||
// Update 更新供应商记录
|
||||
func (v *Vendor) Update() error {
|
||||
v.UpdatedTime = common.GetTimestamp()
|
||||
return DB.Save(v).Error
|
||||
}
|
||||
|
||||
// Delete 软删除供应商
|
||||
func (v *Vendor) Delete() 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
|
||||
}
|
||||
|
||||
// GetAllVendors 获取全部供应商(分页)
|
||||
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
|
||||
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
|
||||
}
|
||||
@@ -179,6 +179,16 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||
prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup)
|
||||
prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
|
||||
}
|
||||
|
||||
mjRoute := apiRouter.Group("/mj")
|
||||
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
|
||||
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
|
||||
@@ -188,5 +198,28 @@ func SetApiRouter(router *gin.Engine) {
|
||||
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||
}
|
||||
|
||||
vendorRoute := apiRouter.Group("/vendors")
|
||||
vendorRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
vendorRoute.GET("/", controller.GetAllVendors)
|
||||
vendorRoute.GET("/search", controller.SearchVendors)
|
||||
vendorRoute.GET("/:id", controller.GetVendorMeta)
|
||||
vendorRoute.POST("/", controller.CreateVendorMeta)
|
||||
vendorRoute.PUT("/", controller.UpdateVendorMeta)
|
||||
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
|
||||
}
|
||||
|
||||
modelsRoute := apiRouter.Group("/models")
|
||||
modelsRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
modelsRoute.GET("/missing", controller.GetMissingModels)
|
||||
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
||||
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
||||
modelsRoute.GET("/:id", controller.GetModelMeta)
|
||||
modelsRoute.POST("/", controller.CreateModelMeta)
|
||||
modelsRoute.PUT("/", controller.UpdateModelMeta)
|
||||
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link';
|
||||
import Midjourney from './pages/Midjourney';
|
||||
import Pricing from './pages/Pricing/index.js';
|
||||
import Task from './pages/Task/index.js';
|
||||
import ModelPage from './pages/Model/index.js';
|
||||
import Playground from './pages/Playground/index.js';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||
@@ -71,6 +72,14 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/models'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ModelPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
|
||||
@@ -1,622 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Space,
|
||||
Button,
|
||||
Form,
|
||||
Card,
|
||||
Typography,
|
||||
Banner,
|
||||
Row,
|
||||
Col,
|
||||
InputNumber,
|
||||
Switch,
|
||||
Select,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCode,
|
||||
IconEdit,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
field,
|
||||
label,
|
||||
placeholder,
|
||||
extraText,
|
||||
showClear = true,
|
||||
template,
|
||||
templateLabel,
|
||||
editorType = 'keyValue', // keyValue, object, region
|
||||
autosize = true,
|
||||
rules = [],
|
||||
formApi = null,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 初始化JSON数据
|
||||
const [jsonData, setJsonData] = useState(() => {
|
||||
// 初始化时解析JSON数据
|
||||
if (value && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
const [editMode, setEditMode] = useState(() => {
|
||||
// 如果初始JSON数据的键数量大于10个,则默认使用手动模式
|
||||
if (value && 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有效)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不更新jsonData
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newData) => {
|
||||
setJsonData(newData);
|
||||
setJsonError('');
|
||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field]);
|
||||
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
onChange?.(newValue);
|
||||
// 验证JSON格式
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
// 预先准备可视化数据,但不立即应用
|
||||
// 这样切换到可视化模式时数据已经准备好了
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
}
|
||||
} else {
|
||||
setJsonError('');
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
// 从可视化模式切换到手动模式
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
// 从手动模式切换到可视化模式,需要验证JSON
|
||||
try {
|
||||
const parsed = value && value.trim() ? JSON.parse(value) : {};
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不切换模式
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newData = { ...jsonData };
|
||||
const keys = Object.keys(newData);
|
||||
let newKey = 'key';
|
||||
let counter = 1;
|
||||
while (newData.hasOwnProperty(newKey)) {
|
||||
newKey = `key${counter}`;
|
||||
counter++;
|
||||
}
|
||||
newData[newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((keyToRemove) => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[keyToRemove];
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((oldKey, newKey) => {
|
||||
if (oldKey === newKey) return;
|
||||
const newData = { ...jsonData };
|
||||
const value = newData[oldKey];
|
||||
delete newData[oldKey];
|
||||
newData[newKey] = value;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((key, newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = newValue;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
if (template) {
|
||||
const templateString = JSON.stringify(template, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, templateString);
|
||||
}
|
||||
|
||||
// 无论哪种模式都要更新值
|
||||
onChange?.(templateString);
|
||||
|
||||
// 如果是可视化模式,同时更新jsonData
|
||||
if (editMode === 'visual') {
|
||||
setJsonData(template);
|
||||
}
|
||||
|
||||
// 清除错误状态
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, editMode, formApi, field]);
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||||
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数据,请检查格式')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<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('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={10}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('键名')}</Text>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('值')}</Text>
|
||||
<Input
|
||||
placeholder={t('值')}
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加键值对')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染对象编辑器(用于复杂JSON)
|
||||
const renderObjectEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<IconSetting size={32} />
|
||||
</div>
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无参数,点击下方按钮添加请求参数')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.map(([key, value], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={8}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('参数名')}</Text>
|
||||
<Input
|
||||
placeholder={t('参数名')}
|
||||
value={key}
|
||||
onChange={(newKey) => updateKey(key, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={13}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
|
||||
{renderValueInput(key, value)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(key)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加参数')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染参数值输入控件
|
||||
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)}
|
||||
size="small"
|
||||
/>
|
||||
<Text type="tertiary" size="small" className="ml-2">
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(key, newValue)}
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
step={key === 'temperature' ? 0.1 : 1}
|
||||
precision={key === 'temperature' ? 2 : 0}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串类型或其他类型
|
||||
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 !== '' && newValue !== '0') {
|
||||
convertedValue = Number(newValue);
|
||||
}
|
||||
|
||||
updateValue(key, convertedValue);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染区域编辑器(特殊格式)
|
||||
const renderRegionEditor = () => {
|
||||
const entries = Object.entries(jsonData);
|
||||
const defaultEntry = entries.find(([key]) => key === 'default');
|
||||
const modelEntries = entries.filter(([key]) => key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* 默认区域 */}
|
||||
<Card className="!p-2 !border-blue-200 !bg-blue-50">
|
||||
<div className="flex items-center mb-1">
|
||||
<Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultEntry ? defaultEntry[1] : ''}
|
||||
onChange={(value) => updateValue('default', value)}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 模型专用区域 */}
|
||||
<div className="space-y-1">
|
||||
<Text strong size="small">{t('模型专用区域')}</Text>
|
||||
{modelEntries.map(([modelName, region], index) => (
|
||||
<Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={10}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('模型名称')}</Text>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={modelName}
|
||||
onChange={(newKey) => updateKey(modelName, newKey)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<div className="space-y-1">
|
||||
<Text type="tertiary" size="small">{t('区域')}</Text>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={region}
|
||||
onChange={(newValue) => updateValue(modelName, newValue)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
size="small"
|
||||
onClick={() => removeKeyValue(modelName)}
|
||||
className="hover:bg-red-50"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="shadow-sm hover:shadow-md transition-shadow px-4"
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可视化编辑器
|
||||
const renderVisualEditor = () => {
|
||||
switch (editorType) {
|
||||
case 'region':
|
||||
return renderRegionEditor();
|
||||
case 'object':
|
||||
return renderObjectEditor();
|
||||
case 'keyValue':
|
||||
default:
|
||||
return renderKeyValueEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Label统一显示在上方 */}
|
||||
{label && (
|
||||
<div className="flex items-center">
|
||||
<Text className="text-sm font-medium text-gray-900">{label}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑模式切换 */}
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode === 'visual' && (
|
||||
<Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
|
||||
{t('可视化模式')}
|
||||
</Text>
|
||||
)}
|
||||
{editMode === 'manual' && (
|
||||
<Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
|
||||
{t('手动编辑模式')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
className="!text-semi-color-primary hover:bg-blue-50 text-xs"
|
||||
>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Space size="tight">
|
||||
<Button
|
||||
size="small"
|
||||
type={editMode === 'visual' ? 'primary' : 'tertiary'}
|
||||
icon={<IconEdit />}
|
||||
onClick={toggleEditMode}
|
||||
disabled={editMode === 'manual' && hasJsonError}
|
||||
className={editMode === 'visual' ? 'shadow-sm' : ''}
|
||||
>
|
||||
{t('可视化')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type={editMode === 'manual' ? 'primary' : 'tertiary'}
|
||||
icon={<IconCode />}
|
||||
onClick={toggleEditMode}
|
||||
className={editMode === 'manual' ? 'shadow-sm' : ''}
|
||||
>
|
||||
{t('手动编辑')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="!rounded-md text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑器内容 */}
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
<Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
|
||||
{renderVisualEditor()}
|
||||
</Card>
|
||||
{/* 可视化模式下的额外文本显示在下方 */}
|
||||
{extraText && (
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
{extraText}
|
||||
</div>
|
||||
)}
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form.TextArea
|
||||
field={field}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleManualChange}
|
||||
showClear={showClear}
|
||||
rows={Math.max(8, value ? value.split('\n').length : 8)}
|
||||
rules={rules}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 额外文本在手动编辑模式下显示 */}
|
||||
{extraText && editMode === 'manual' && (
|
||||
<div className="text-xs text-gray-600">
|
||||
{extraText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
@@ -112,6 +112,7 @@ const CardPro = ({
|
||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='outline'
|
||||
block
|
||||
>
|
||||
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
@@ -40,25 +41,8 @@ const CardTable = ({
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const getRowKey = (record, index) => {
|
||||
if (typeof rowKey === 'function') return rowKey(record);
|
||||
|
||||
669
web/src/components/common/ui/JSONEditor.js
Normal file
669
web/src/components/common/ui/JSONEditor.js
Normal file
@@ -0,0 +1,669 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Banner,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Switch,
|
||||
TextArea,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCode,
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
field,
|
||||
label,
|
||||
placeholder,
|
||||
extraText,
|
||||
extraFooter,
|
||||
showClear = true,
|
||||
template,
|
||||
templateLabel,
|
||||
editorType = 'keyValue',
|
||||
rules = [],
|
||||
formApi = null,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 初始化JSON数据
|
||||
const [jsonData, setJsonData] = useState(() => {
|
||||
// 初始化时解析JSON数据
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return '';
|
||||
});
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
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有效)
|
||||
useEffect(() => {
|
||||
try {
|
||||
let parsed = {};
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不更新jsonData
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
|
||||
else setManualText('');
|
||||
}
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newData) => {
|
||||
setJsonData(newData);
|
||||
setJsonError('');
|
||||
const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
|
||||
|
||||
// 通过formApi设置值(如果提供的话)
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field]);
|
||||
|
||||
// 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
JSON.parse(newValue);
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// 无效 JSON 时不回传,避免外部值把输入重置
|
||||
}
|
||||
} else {
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
// 从可视化模式切换到手动模式
|
||||
setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
// 从手动模式切换到可视化模式,需要验证JSON
|
||||
try {
|
||||
let parsed = {};
|
||||
if (manualText && manualText.trim()) {
|
||||
parsed = JSON.parse(manualText);
|
||||
} else if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setJsonData(parsed);
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
// JSON格式错误时不切换模式
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value, manualText, jsonData]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newData = { ...jsonData };
|
||||
const keys = Object.keys(newData);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (newData.hasOwnProperty(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newData[newKey] = '';
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((keyToRemove) => {
|
||||
const newData = { ...jsonData };
|
||||
delete newData[keyToRemove];
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, 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 updateValue = useCallback((key, newValue) => {
|
||||
const newData = { ...jsonData };
|
||||
newData[key] = newValue;
|
||||
handleVisualChange(newData);
|
||||
}, [jsonData, 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);
|
||||
onChange?.(templateString);
|
||||
|
||||
// 清除错误状态
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, editMode, formApi, field]);
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
if (typeof jsonData !== 'object' || jsonData === null) {
|
||||
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数据,请检查格式')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const entries = Object.entries(jsonData);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{entries.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
onClick={addKeyValue}
|
||||
>
|
||||
{t('添加键值对')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 添加嵌套对象
|
||||
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');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 默认区域 */}
|
||||
<Form.Slot label={t('默认区域')}>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultEntry ? defaultEntry[1] : ''}
|
||||
onChange={(value) => updateValue('default', value)}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Slot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可视化编辑器
|
||||
const renderVisualEditor = () => {
|
||||
switch (editorType) {
|
||||
case 'region':
|
||||
return renderRegionEditor();
|
||||
case 'object':
|
||||
case 'keyValue':
|
||||
default:
|
||||
return renderKeyValueEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||
|
||||
return (
|
||||
<Form.Slot label={label}>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<Tabs
|
||||
type="slash"
|
||||
activeKey={editMode}
|
||||
onChange={(key) => {
|
||||
if (key === 'manual' && editMode === 'visual') {
|
||||
setEditMode('manual');
|
||||
} else if (key === 'visual' && editMode === 'manual') {
|
||||
toggleEditMode();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabPane tab={t('可视化')} itemKey="visual" />
|
||||
<TabPane tab={t('手动编辑')} itemKey="manual" />
|
||||
</Tabs>
|
||||
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
size="small"
|
||||
>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className="!rounded-2xl"
|
||||
>
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑器内容 */}
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
{renderVisualEditor()}
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<TextArea
|
||||
placeholder={placeholder}
|
||||
value={manualText}
|
||||
onChange={handleManualChange}
|
||||
showClear={showClear}
|
||||
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
|
||||
/>
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 额外文本显示在卡片底部 */}
|
||||
{extraText && (
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type="tertiary" size="small">{extraText}</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{extraFooter && (
|
||||
<div className="mt-1">
|
||||
{extraFooter}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Form.Slot>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
60
web/src/components/common/ui/RenderUtils.jsx
Normal file
60
web/src/components/common/ui/RenderUtils.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 通用渲染函数:限制项目数量显示,支持popover展开
|
||||
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
|
||||
if (!items || items.length === 0) return '-';
|
||||
const displayItems = items.slice(0, maxDisplay);
|
||||
const remainingItems = items.slice(maxDisplay);
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{displayItems.map((item, idx) => renderItem(item, idx))}
|
||||
{remainingItems.length > 0 && (
|
||||
<Popover
|
||||
content={
|
||||
<div className='p-2'>
|
||||
<Space spacing={1} wrap>
|
||||
{remainingItems.map((item, idx) => renderItem(item, idx))}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
position='top'
|
||||
>
|
||||
<Tag size='small' shape='circle' color='grey'>
|
||||
+{remainingItems.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染描述字段,长文本支持tooltip
|
||||
export const renderDescription = (text, maxWidth = 200) => {
|
||||
return (
|
||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
257
web/src/components/common/ui/SelectableButtonGroup.jsx
Normal file
257
web/src/components/common/ui/SelectableButtonGroup.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
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, useRef } from 'react';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
* 通用可选择按钮组组件
|
||||
*
|
||||
* @param {string} title 标题
|
||||
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
|
||||
* @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
|
||||
* @param {(value:any)=>void} onChange 选择改变回调
|
||||
* @param {function} t i18n
|
||||
* @param {object} style 额外样式
|
||||
* @param {boolean} collapsible 是否支持折叠,默认true
|
||||
* @param {number} collapseHeight 折叠时的高度,默认200
|
||||
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
|
||||
* @param {boolean} loading 是否处于加载状态
|
||||
*/
|
||||
const SelectableButtonGroup = ({
|
||||
title,
|
||||
items = [],
|
||||
activeValue,
|
||||
onChange,
|
||||
t = (v) => v,
|
||||
style = {},
|
||||
collapsible = true,
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(6);
|
||||
const isMobile = useIsMobile();
|
||||
const perRow = 3;
|
||||
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
|
||||
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const maskStyle = isOpen
|
||||
? {}
|
||||
: {
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const linkStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
bottom: -10,
|
||||
fontWeight: 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const renderSkeletonButtons = () => {
|
||||
|
||||
const placeholder = (
|
||||
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '8px'
|
||||
}}>
|
||||
{withCheckbox && (
|
||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{
|
||||
width: `${60 + (index % 3) * 20}px`,
|
||||
height: 14
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const contentElement = showSkeleton ? renderSkeletonButtons() : (
|
||||
<Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
|
||||
{items.map((item) => {
|
||||
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
|
||||
if (withCheckbox) {
|
||||
return (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={item.value}
|
||||
>
|
||||
<Button
|
||||
onClick={() => { /* disabled */ }}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={() => onChange(item.value)}
|
||||
disabled={isDisabled}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
}
|
||||
style={{ width: '100%', cursor: 'default' }}
|
||||
>
|
||||
{item.icon && (
|
||||
<span style={{ marginRight: 4 }}>{item.icon}</span>
|
||||
)}
|
||||
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
|
||||
{item.tagCount !== undefined && (
|
||||
<Tag
|
||||
color='white'
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
{...(isMobile
|
||||
? { span: 12 }
|
||||
: { span: 8 }
|
||||
)}
|
||||
key={item.value}
|
||||
>
|
||||
<Button
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
icon={item.icon}
|
||||
disabled={isDisabled}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
|
||||
{item.tagCount !== undefined && (
|
||||
<Tag
|
||||
color='white'
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{title && (
|
||||
<Divider margin="12px" align="left">
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Divider>
|
||||
)}
|
||||
{needCollapse && !showSkeleton ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
|
||||
{contentElement}
|
||||
</Collapsible>
|
||||
{isOpen ? null : (
|
||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
||||
<IconChevronDown size="small" />
|
||||
<span>{t('展开更多')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div onClick={toggle} style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto'
|
||||
}}>
|
||||
<IconChevronUp size="small" />
|
||||
<span>{t('收起')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
contentElement
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableButtonGroup;
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile.js';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js';
|
||||
|
||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
@@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
const isLoading = useMinimumLoadingTime(loading);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
@@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('定价'),
|
||||
text: t('模型广场'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
@@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status !== undefined) {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogoLoaded(false);
|
||||
if (!logo) return;
|
||||
@@ -467,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={handleNoticeClose}
|
||||
|
||||
@@ -42,7 +42,7 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname.startsWith('/console');
|
||||
const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
|
||||
import { getLucideIcon } from '../../helpers/render.js';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ const routerMap = {
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
models: '/console/models',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
@@ -127,6 +128,12 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
|
||||
const adminItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('模型管理'),
|
||||
itemKey: 'models',
|
||||
to: '/console/models',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('渠道管理'),
|
||||
itemKey: 'channel',
|
||||
@@ -244,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
}
|
||||
}, [collapsed]);
|
||||
|
||||
// 获取菜单项对应的颜色
|
||||
const getItemColor = (itemKey) => {
|
||||
switch (itemKey) {
|
||||
case 'detail': return sidebarIconColors.dashboard;
|
||||
case 'playground': return sidebarIconColors.terminal;
|
||||
case 'chat': return sidebarIconColors.message;
|
||||
case 'token': return sidebarIconColors.key;
|
||||
case 'log': return sidebarIconColors.chart;
|
||||
case 'midjourney': return sidebarIconColors.image;
|
||||
case 'task': return sidebarIconColors.check;
|
||||
case 'topup': return sidebarIconColors.credit;
|
||||
case 'channel': return sidebarIconColors.layers;
|
||||
case 'redemption': return sidebarIconColors.gift;
|
||||
case 'user':
|
||||
case 'personal': return sidebarIconColors.user;
|
||||
case 'setting': return sidebarIconColors.settings;
|
||||
default:
|
||||
// 处理聊天项
|
||||
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
|
||||
return 'currentColor';
|
||||
}
|
||||
};
|
||||
// 选中高亮颜色(统一)
|
||||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
||||
|
||||
// 渲染自定义菜单项
|
||||
const renderNavItem = (item) => {
|
||||
@@ -273,7 +260,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
if (item.className === 'tableHiddle') return null;
|
||||
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
@@ -300,7 +287,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
const renderSubItem = (item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Sub
|
||||
@@ -321,7 +308,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
>
|
||||
{item.items.map((subItem) => {
|
||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
||||
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
|
||||
const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import JSONEditor from '../../../common/JSONEditor';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -1174,27 +1174,27 @@ const EditChannelModal = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEdit && isMultiKeyChannel && (
|
||||
<Form.Select
|
||||
field='key_mode'
|
||||
label={t('密钥更新模式')}
|
||||
placeholder={t('请选择密钥更新模式')}
|
||||
optionList={[
|
||||
{ label: t('追加到现有密钥'), value: 'append' },
|
||||
{ label: t('覆盖现有密钥'), value: 'replace' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={keyMode}
|
||||
onChange={(value) => setKeyMode(value)}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{keyMode === 'replace'
|
||||
? t('覆盖模式:将完全替换现有的所有密钥')
|
||||
: t('追加模式:将新密钥添加到现有密钥列表末尾')
|
||||
}
|
||||
</Text>
|
||||
{isEdit && isMultiKeyChannel && (
|
||||
<Form.Select
|
||||
field='key_mode'
|
||||
label={t('密钥更新模式')}
|
||||
placeholder={t('请选择密钥更新模式')}
|
||||
optionList={[
|
||||
{ label: t('追加到现有密钥'), value: 'append' },
|
||||
{ label: t('覆盖现有密钥'), value: 'replace' },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
value={keyMode}
|
||||
onChange={(value) => setKeyMode(value)}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{keyMode === 'replace'
|
||||
? t('覆盖模式:将完全替换现有的所有密钥')
|
||||
: t('追加模式:将新密钥添加到现有密钥列表末尾')
|
||||
}
|
||||
/>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{batch && multiToSingle && (
|
||||
<>
|
||||
@@ -1247,11 +1247,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="region"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('设置默认地区和特定模型的专用地区')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('设置默认地区和特定模型的专用地区')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1520,11 +1516,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="keyValue"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('键为请求中的模型名称,值为要替换的模型名称')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1628,11 +1620,7 @@ const EditChannelModal = (props) => {
|
||||
templateLabel={t('填入模板')}
|
||||
editorType="keyValue"
|
||||
formApi={formApiRef.current}
|
||||
extraText={
|
||||
<Text type="tertiary" size="small">
|
||||
{t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
|
||||
</Text>
|
||||
}
|
||||
extraText={t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ const ModelTestModal = ({
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{currentTestChannel.name} {t('渠道的模型测试')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" className="!text-xs flex items-center">
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/*
|
||||
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 { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Popover } from '@douyinfe/semi-ui';
|
||||
import { renderModelTag, stringToColor } from '../../../helpers';
|
||||
|
||||
function renderQuotaType(type, t) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvailable(available, t) {
|
||||
return available ? (
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
|
||||
}
|
||||
position='top'
|
||||
key={available}
|
||||
className="bg-green-50"
|
||||
>
|
||||
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
|
||||
</Popover>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag
|
||||
key={endpoint}
|
||||
color={stringToColor(endpoint)}
|
||||
shape='circle'
|
||||
>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const getModelPricingColumns = ({
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('可用性'),
|
||||
dataIndex: 'available',
|
||||
render: (text, record, index) => {
|
||||
return renderAvailable(record.enable_groups.includes(selectedGroup), t);
|
||||
},
|
||||
sorter: (a, b) => {
|
||||
const aAvailable = a.enable_groups.includes(selectedGroup);
|
||||
const bAvailable = b.enable_groups.includes(selectedGroup);
|
||||
return Number(aAvailable) - Number(bAvailable);
|
||||
},
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text), t);
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{text.map((group) => {
|
||||
if (usableGroup[group]) {
|
||||
if (group === selectedGroup) {
|
||||
return (
|
||||
<Tag key={group} color='blue' shape='circle' prefixIcon={<IconVerify />}>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag
|
||||
key={group}
|
||||
color='blue'
|
||||
shape='circle'
|
||||
onClick={() => handleGroupClick(group)}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('分组倍率')}:{groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t('模型价格')}</span>
|
||||
{/* 计费单位切换 */}
|
||||
<Switch
|
||||
checked={tokenUnit === 'K'}
|
||||
onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
|
||||
checkedText="K"
|
||||
uncheckedText="M"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_price',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
if (record.quota_type === 0) {
|
||||
let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
|
||||
let completionRatioPriceUSD =
|
||||
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
|
||||
|
||||
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
||||
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
||||
|
||||
let displayInput = displayPrice(inputRatioPriceUSD);
|
||||
let displayCompletion = displayPrice(completionRatioPriceUSD);
|
||||
|
||||
const divisor = unitDivisor;
|
||||
const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
|
||||
const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
|
||||
|
||||
displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
|
||||
displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('提示')} {displayInput} / 1{unitLabel} tokens
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全')} {displayCompletion} / 1{unitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
|
||||
let displayVal = displayPrice(priceUSD);
|
||||
content = (
|
||||
<div className="text-gray-700">
|
||||
{t('模型价格')}:{displayVal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
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, { useMemo } from 'react';
|
||||
import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelPricingFilters = ({
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
t
|
||||
}) => {
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<Card className="!rounded-xl mb-6" bordered={false}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
>
|
||||
{t('复制选中模型')}
|
||||
</Button>
|
||||
|
||||
{/* 充值价格显示开关 */}
|
||||
<Space align="center">
|
||||
<span>{t('以充值价格显示')}</span>
|
||||
<Switch
|
||||
checked={showWithRecharge}
|
||||
onChange={setShowWithRecharge}
|
||||
size="small"
|
||||
/>
|
||||
{showWithRecharge && (
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
size="small"
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="USD">USD ($)</Select.Option>
|
||||
<Select.Option value="CNY">CNY (¥)</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]);
|
||||
|
||||
return SearchAndActions;
|
||||
};
|
||||
|
||||
export default ModelPricingFilters;
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
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 { Card } from '@douyinfe/semi-ui';
|
||||
import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
const ModelPricingHeader = ({
|
||||
userState,
|
||||
groupRatio,
|
||||
selectedGroup,
|
||||
models,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
|
||||
<IconLayers size="extra-large" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
|
||||
{t('模型定价')}
|
||||
</div>
|
||||
<div className="text-sm text-white/80">
|
||||
{userState.user ? (
|
||||
<div className="flex items-center">
|
||||
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
|
||||
<span className="truncate">
|
||||
{t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t('未登录,使用默认分组倍率:')}{groupRatio['default']}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">
|
||||
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费说明 */}
|
||||
<div className="mt-4 sm:mt-5">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
|
||||
<span>
|
||||
{t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingHeader;
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
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 { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
|
||||
|
||||
const ModelPricingTabs = ({
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
modelCategories,
|
||||
categoryCounts,
|
||||
availableCategories,
|
||||
t
|
||||
}) => {
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={key => setActiveKey(key)}
|
||||
className="mt-2"
|
||||
>
|
||||
{Object.entries(modelCategories)
|
||||
.filter(([key]) => availableCategories.includes(key))
|
||||
.map(([key, category]) => {
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={activeKey === key ? 'red' : 'grey'}
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingTabs;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
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 { Tooltip } from '@douyinfe/semi-ui';
|
||||
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
const PricingDisplaySettings = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading = false,
|
||||
t
|
||||
}) => {
|
||||
const items = [
|
||||
{
|
||||
value: 'recharge',
|
||||
label: t('以充值价格显示')
|
||||
},
|
||||
{
|
||||
value: 'ratio',
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
{t('显示倍率')}
|
||||
<Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
|
||||
<IconHelpCircle
|
||||
size="small"
|
||||
style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'tableView',
|
||||
label: t('表格视图')
|
||||
},
|
||||
{
|
||||
value: 'tokenUnit',
|
||||
label: t('按K显示单位')
|
||||
}
|
||||
];
|
||||
|
||||
const currencyItems = [
|
||||
{ value: 'USD', label: 'USD ($)' },
|
||||
{ value: 'CNY', label: 'CNY (¥)' }
|
||||
];
|
||||
|
||||
const handleChange = (value) => {
|
||||
switch (value) {
|
||||
case 'recharge':
|
||||
setShowWithRecharge(!showWithRecharge);
|
||||
break;
|
||||
case 'ratio':
|
||||
setShowRatio(!showRatio);
|
||||
break;
|
||||
case 'tableView':
|
||||
setViewMode(viewMode === 'table' ? 'card' : 'table');
|
||||
break;
|
||||
case 'tokenUnit':
|
||||
setTokenUnit(tokenUnit === 'K' ? 'M' : 'K');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveValues = () => {
|
||||
const activeValues = [];
|
||||
if (showWithRecharge) activeValues.push('recharge');
|
||||
if (showRatio) activeValues.push('ratio');
|
||||
if (viewMode === 'table') activeValues.push('tableView');
|
||||
if (tokenUnit === 'K') activeValues.push('tokenUnit');
|
||||
return activeValues;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SelectableButtonGroup
|
||||
title={t('显示设置')}
|
||||
items={items}
|
||||
activeValue={getActiveValues()}
|
||||
onChange={handleChange}
|
||||
withCheckbox
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{showWithRecharge && (
|
||||
<SelectableButtonGroup
|
||||
title={t('货币单位')}
|
||||
items={currencyItems}
|
||||
activeValue={currency}
|
||||
onChange={setCurrency}
|
||||
collapsible={false}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingDisplaySettings;
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
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'} filterEndpointType 当前值
|
||||
* @param {Function} setFilterEndpointType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], allModels = [], loading = false, t }) => {
|
||||
// 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllEndpointTypes = () => {
|
||||
const endpointTypes = new Set();
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) {
|
||||
model.supported_endpoint_types.forEach(endpoint => {
|
||||
endpointTypes.add(endpoint);
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(endpointTypes).sort();
|
||||
};
|
||||
|
||||
// 计算每个端点类型的模型数量
|
||||
const getEndpointTypeCount = (endpointType) => {
|
||||
if (endpointType === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
return models.filter(model =>
|
||||
model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.includes(endpointType)
|
||||
).length;
|
||||
};
|
||||
|
||||
// 端点类型显示名称映射
|
||||
const getEndpointTypeLabel = (endpointType) => {
|
||||
return endpointType;
|
||||
};
|
||||
|
||||
const availableEndpointTypes = getAllEndpointTypes();
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all'), disabled: models.length === 0 },
|
||||
...availableEndpointTypes.map(endpointType => {
|
||||
const count = getEndpointTypeCount(endpointType);
|
||||
return ({
|
||||
value: endpointType,
|
||||
label: getEndpointTypeLabel(endpointType),
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('端点类型')}
|
||||
items={items}
|
||||
activeValue={filterEndpointType}
|
||||
onChange={setFilterEndpointType}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingEndpointTypes;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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} filterGroup 当前选中的分组,'all' 表示不过滤
|
||||
* @param {Function} setFilterGroup 设置选中分组
|
||||
* @param {Record<string, any>} usableGroup 后端返回的可用分组对象
|
||||
* @param {Record<string, number>} groupRatio 分组倍率对象
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRatio = {}, models = [], loading = false, t }) => {
|
||||
const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')];
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const modelCount = g === 'all'
|
||||
? models.length
|
||||
: models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
|
||||
let ratioDisplay = '';
|
||||
if (g === 'all') {
|
||||
ratioDisplay = t('全部');
|
||||
} else {
|
||||
const ratio = groupRatio[g];
|
||||
if (ratio !== undefined && ratio !== null) {
|
||||
ratioDisplay = `x${ratio}`;
|
||||
} else {
|
||||
ratioDisplay = 'x1';
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: g,
|
||||
label: g === 'all' ? t('全部分组') : g,
|
||||
tagCount: ratioDisplay,
|
||||
disabled: modelCount === 0
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('可用令牌分组')}
|
||||
items={items}
|
||||
activeValue={filterGroup}
|
||||
onChange={setFilterGroup}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingGroups;
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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'|0|1} filterQuotaType 当前值
|
||||
* @param {Function} setFilterQuotaType setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], loading = false, t }) => {
|
||||
const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
|
||||
{ value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
|
||||
{ value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('计费类型')}
|
||||
items={items}
|
||||
activeValue={filterQuotaType}
|
||||
onChange={setFilterQuotaType}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingQuotaTypes;
|
||||
119
web/src/components/table/model-pricing/filter/PricingVendors.jsx
Normal file
119
web/src/components/table/model-pricing/filter/PricingVendors.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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';
|
||||
import { getLobeHubIcon } from '../../../../helpers';
|
||||
|
||||
/**
|
||||
* 供应商筛选组件
|
||||
* @param {string|'all'} filterVendor 当前值
|
||||
* @param {Function} setFilterVendor setter
|
||||
* @param {Array} models 模型列表
|
||||
* @param {Array} allModels 所有模型列表(用于获取全部供应商)
|
||||
* @param {boolean} loading 是否加载中
|
||||
* @param {Function} t i18n
|
||||
*/
|
||||
const PricingVendors = ({ filterVendor, setFilterVendor, models = [], allModels = [], loading = false, t }) => {
|
||||
// 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models)
|
||||
const getAllVendors = React.useMemo(() => {
|
||||
const vendors = new Set();
|
||||
const vendorIcons = new Map();
|
||||
let hasUnknownVendor = false;
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.vendor_name) {
|
||||
vendors.add(model.vendor_name);
|
||||
if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) {
|
||||
vendorIcons.set(model.vendor_name, model.vendor_icon);
|
||||
}
|
||||
} else {
|
||||
hasUnknownVendor = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
vendors: Array.from(vendors).sort(),
|
||||
vendorIcons,
|
||||
hasUnknownVendor
|
||||
};
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算每个供应商的模型数量(基于当前过滤后的 models)
|
||||
const getVendorCount = React.useCallback((vendor) => {
|
||||
if (vendor === 'all') {
|
||||
return models.length;
|
||||
}
|
||||
if (vendor === 'unknown') {
|
||||
return models.filter(model => !model.vendor_name).length;
|
||||
}
|
||||
return models.filter(model => model.vendor_name === vendor).length;
|
||||
}, [models]);
|
||||
|
||||
// 生成供应商选项
|
||||
const items = React.useMemo(() => {
|
||||
const result = [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('全部供应商'),
|
||||
tagCount: getVendorCount('all'),
|
||||
disabled: models.length === 0
|
||||
}
|
||||
];
|
||||
|
||||
// 添加所有已知供应商
|
||||
getAllVendors.vendors.forEach(vendor => {
|
||||
const count = getVendorCount(vendor);
|
||||
const icon = getAllVendors.vendorIcons.get(vendor);
|
||||
result.push({
|
||||
value: vendor,
|
||||
label: vendor,
|
||||
icon: icon ? getLobeHubIcon(icon, 16) : null,
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
});
|
||||
|
||||
// 如果系统中存在未知供应商,添加"未知供应商"选项
|
||||
if (getAllVendors.hasUnknownVendor) {
|
||||
const count = getVendorCount('unknown');
|
||||
result.push({
|
||||
value: 'unknown',
|
||||
label: t('未知供应商'),
|
||||
tagCount: count,
|
||||
disabled: count === 0
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [getAllVendors, getVendorCount, t]);
|
||||
|
||||
return (
|
||||
<SelectableButtonGroup
|
||||
title={t('供应商')}
|
||||
items={items}
|
||||
activeValue={filterVendor}
|
||||
onChange={setFilterVendor}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendors;
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
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 { Layout, Card, ImagePreview } from '@douyinfe/semi-ui';
|
||||
import ModelPricingTabs from './ModelPricingTabs.jsx';
|
||||
import ModelPricingFilters from './ModelPricingFilters.jsx';
|
||||
import ModelPricingTable from './ModelPricingTable.jsx';
|
||||
import ModelPricingHeader from './ModelPricingHeader.jsx';
|
||||
import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
|
||||
|
||||
const ModelPricingPage = () => {
|
||||
const modelPricingData = useModelPricingData();
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-full">
|
||||
{/* 主卡片容器 */}
|
||||
<Card bordered={false} className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部状态卡片 */}
|
||||
<ModelPricingHeader {...modelPricingData} />
|
||||
|
||||
{/* 模型分类 Tabs */}
|
||||
<div className="mb-6">
|
||||
<ModelPricingTabs {...modelPricingData} />
|
||||
|
||||
{/* 搜索和表格区域 */}
|
||||
<ModelPricingFilters {...modelPricingData} />
|
||||
<ModelPricingTable {...modelPricingData} />
|
||||
</div>
|
||||
|
||||
{/* 倍率说明图预览 */}
|
||||
<ImagePreview
|
||||
src={modelPricingData.modalImageUrl}
|
||||
visible={modelPricingData.isModalOpenurl}
|
||||
onVisibleChange={(visible) => modelPricingData.setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingPage;
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
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 { Layout, ImagePreview } from '@douyinfe/semi-ui';
|
||||
import PricingSidebar from './PricingSidebar';
|
||||
import PricingContent from './content/PricingContent';
|
||||
import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
|
||||
import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const PricingPage = () => {
|
||||
const pricingData = useModelPricingData();
|
||||
const { Sider, Content } = Layout;
|
||||
const isMobile = useIsMobile();
|
||||
const [showRatio, setShowRatio] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState('card');
|
||||
const allProps = {
|
||||
...pricingData,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<Layout className="pricing-layout">
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="pricing-scroll-hide pricing-sidebar"
|
||||
width={460}
|
||||
>
|
||||
<PricingSidebar {...allProps} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Content
|
||||
className="pricing-scroll-hide pricing-content"
|
||||
>
|
||||
<PricingContent
|
||||
{...allProps}
|
||||
isMobile={isMobile}
|
||||
sidebarProps={allProps}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<ImagePreview
|
||||
src={pricingData.modalImageUrl}
|
||||
visible={pricingData.isModalOpenurl}
|
||||
onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
|
||||
/>
|
||||
|
||||
<ModelDetailSideSheet
|
||||
visible={pricingData.showModelDetail}
|
||||
onClose={pricingData.closeModelDetail}
|
||||
modelData={pricingData.selectedModel}
|
||||
selectedGroup={pricingData.selectedGroup}
|
||||
groupRatio={pricingData.groupRatio}
|
||||
usableGroup={pricingData.usableGroup}
|
||||
currency={pricingData.currency}
|
||||
tokenUnit={pricingData.tokenUnit}
|
||||
displayPrice={pricingData.displayPrice}
|
||||
showRatio={allProps.showRatio}
|
||||
vendorsMap={pricingData.vendorsMap}
|
||||
endpointMap={pricingData.endpointMap}
|
||||
t={pricingData.t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
157
web/src/components/table/model-pricing/layout/PricingSidebar.jsx
Normal file
157
web/src/components/table/model-pricing/layout/PricingSidebar.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
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 { Button } from '@douyinfe/semi-ui';
|
||||
import PricingGroups from '../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../filter/PricingVendors';
|
||||
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const PricingSidebar = ({
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
t,
|
||||
...categoryProps
|
||||
}) => {
|
||||
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
searchValue: categoryProps.searchValue,
|
||||
});
|
||||
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange,
|
||||
setShowWithRecharge,
|
||||
setCurrency,
|
||||
setShowRatio,
|
||||
setViewMode,
|
||||
setFilterGroup,
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-lg font-semibold text-gray-800">
|
||||
{t('筛选')}
|
||||
</div>
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
onClick={handleResetFilters}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PricingDisplaySettings
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingSidebar;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
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 PricingTopSection from '../header/PricingTopSection';
|
||||
import PricingView from './PricingView';
|
||||
|
||||
const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
|
||||
return (
|
||||
<div className={isMobile ? "pricing-content-mobile" : "pricing-scroll-hide"}>
|
||||
{/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
|
||||
<div className="pricing-search-header">
|
||||
<PricingTopSection {...props} isMobile={isMobile} sidebarProps={sidebarProps} />
|
||||
</div>
|
||||
|
||||
{/* 可滚动的内容区域 */}
|
||||
<div className={isMobile ? "pricing-view-container-mobile" : "pricing-view-container"}>
|
||||
<PricingView {...props} viewMode={sidebarProps.viewMode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingContent;
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 PricingTable from '../../view/table/PricingTable';
|
||||
import PricingCardView from '../../view/card/PricingCardView';
|
||||
|
||||
const PricingView = ({
|
||||
viewMode = 'table',
|
||||
...props
|
||||
}) => {
|
||||
return viewMode === 'card' ?
|
||||
<PricingCardView {...props} /> :
|
||||
<PricingTable {...props} />;
|
||||
};
|
||||
|
||||
export default PricingView;
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
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, { useMemo, useState } from 'react';
|
||||
import { Input, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
|
||||
import PricingFilterModal from '../../modal/PricingFilterModal';
|
||||
import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
|
||||
|
||||
const PricingTopSection = ({
|
||||
selectedRowKeys,
|
||||
copyText,
|
||||
handleChange,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
isMobile,
|
||||
sidebarProps,
|
||||
filterVendor,
|
||||
models,
|
||||
filteredModels,
|
||||
loading,
|
||||
t
|
||||
}) => {
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Button
|
||||
theme='outline'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
|
||||
{/* 移动端筛选按钮 */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
icon={<IconFilter />}
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
>
|
||||
{t('筛选')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText, isMobile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 供应商介绍区域(含骨架屏) */}
|
||||
<PricingVendorIntroWithSkeleton
|
||||
loading={loading}
|
||||
filterVendor={filterVendor}
|
||||
models={filteredModels}
|
||||
allModels={models}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 搜索和操作区域 */}
|
||||
{SearchAndActions}
|
||||
|
||||
{/* 移动端筛选Modal */}
|
||||
{isMobile && (
|
||||
<PricingFilterModal
|
||||
visible={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
sidebarProps={sidebarProps}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingTopSection;
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
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, useMemo } from 'react';
|
||||
import { Card, Tag, Avatar, AvatarGroup, Typography } from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const PricingVendorIntro = ({
|
||||
filterVendor,
|
||||
models = [],
|
||||
allModels = [],
|
||||
t
|
||||
}) => {
|
||||
// 轮播动效状态(只对全部供应商生效)
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
|
||||
// 获取所有供应商信息
|
||||
const vendorInfo = useMemo(() => {
|
||||
const vendors = new Map();
|
||||
let unknownCount = 0;
|
||||
|
||||
(allModels.length > 0 ? allModels : models).forEach(model => {
|
||||
if (model.vendor_name) {
|
||||
if (!vendors.has(model.vendor_name)) {
|
||||
vendors.set(model.vendor_name, {
|
||||
name: model.vendor_name,
|
||||
icon: model.vendor_icon,
|
||||
description: model.vendor_description,
|
||||
count: 0
|
||||
});
|
||||
}
|
||||
vendors.get(model.vendor_name).count++;
|
||||
} else {
|
||||
unknownCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (unknownCount > 0) {
|
||||
vendorList.push({
|
||||
name: 'unknown',
|
||||
icon: null,
|
||||
description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'),
|
||||
count: unknownCount
|
||||
});
|
||||
}
|
||||
|
||||
return vendorList;
|
||||
}, [allModels, models]);
|
||||
|
||||
// 计算当前过滤器的模型数量
|
||||
const currentModelCount = models.length;
|
||||
|
||||
// 设置轮播定时器(只对全部供应商且有足够头像时生效)
|
||||
useEffect(() => {
|
||||
if (filterVendor !== 'all' || vendorInfo.length <= 3) {
|
||||
setCurrentOffset(0); // 重置偏移
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentOffset(prev => (prev + 1) % vendorInfo.length);
|
||||
}, 2000); // 每2秒切换一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [filterVendor, vendorInfo.length]);
|
||||
|
||||
// 获取供应商描述信息(从后端数据中)
|
||||
const getVendorDescription = (vendorKey) => {
|
||||
if (vendorKey === 'all') {
|
||||
return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。');
|
||||
}
|
||||
if (vendorKey === 'unknown') {
|
||||
return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。');
|
||||
}
|
||||
const vendor = vendorInfo.find(v => v.name === vendorKey);
|
||||
return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。');
|
||||
};
|
||||
|
||||
// 为全部供应商创建特殊的头像组合
|
||||
const renderAllVendorsAvatar = () => {
|
||||
// 重新排列数组,让当前偏移量的头像在第一位
|
||||
const rotatedVendors = vendorInfo.length > 3 ? [
|
||||
...vendorInfo.slice(currentOffset),
|
||||
...vendorInfo.slice(0, currentOffset)
|
||||
] : vendorInfo;
|
||||
|
||||
// 如果没有供应商,显示占位符
|
||||
if (vendorInfo.length === 0) {
|
||||
return (
|
||||
<div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
<Avatar size="default" color="transparent">
|
||||
AI
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
|
||||
<AvatarGroup
|
||||
maxCount={4}
|
||||
size="default"
|
||||
overlapFrom='end'
|
||||
key={currentOffset}
|
||||
renderMore={(restNumber) => (
|
||||
<Avatar
|
||||
size="default"
|
||||
style={{ backgroundColor: 'transparent', color: 'var(--semi-color-text-0)' }}
|
||||
alt={`${restNumber} more vendors`}
|
||||
>
|
||||
{`+${restNumber}`}
|
||||
</Avatar>
|
||||
)}
|
||||
>
|
||||
{rotatedVendors.map((vendor) => (
|
||||
<Avatar
|
||||
key={vendor.name}
|
||||
size="default"
|
||||
color="transparent"
|
||||
alt={vendor.name === 'unknown' ? t('未知供应商') : vendor.name}
|
||||
>
|
||||
{vendor.icon ?
|
||||
getLobeHubIcon(vendor.icon, 20) :
|
||||
(vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase())
|
||||
}
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 为具体供应商渲染单个图标
|
||||
const renderVendorAvatar = (vendor) => (
|
||||
<div className="w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center">
|
||||
{vendor.icon ?
|
||||
getLobeHubIcon(vendor.icon, 40) :
|
||||
<Avatar size="large" color="transparent">
|
||||
{vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是全部供应商
|
||||
if (filterVendor === 'all') {
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 全部供应商的头像组合 */}
|
||||
<div className="flex-shrink-0">
|
||||
{renderAllVendorsAvatar()}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{t('全部供应商')}</h2>
|
||||
<Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
|
||||
{t('共 {{count}} 个模型', { count: currentModelCount })}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
|
||||
ellipsis={{
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
collapsible: true,
|
||||
collapseText: t('收起'),
|
||||
expandText: t('展开')
|
||||
}}
|
||||
>
|
||||
{getVendorDescription('all')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 具体供应商
|
||||
const currentVendor = vendorInfo.find(v => v.name === filterVendor);
|
||||
if (!currentVendor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name;
|
||||
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
|
||||
<div className="flex items-start space-x-3 md:space-x-4">
|
||||
{/* 供应商图标 */}
|
||||
<div className="flex-shrink-0">
|
||||
{renderVendorAvatar(currentVendor)}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{vendorDisplayName}</h2>
|
||||
<Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
|
||||
{t('共 {{count}} 个模型', { count: currentModelCount })}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
|
||||
ellipsis={{
|
||||
rows: 2,
|
||||
expandable: true,
|
||||
collapsible: true,
|
||||
collapseText: t('收起'),
|
||||
expandText: t('展开')
|
||||
}}
|
||||
>
|
||||
{currentVendor.description || getVendorDescription(currentVendor.name)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntro;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const PricingVendorIntroSkeleton = ({
|
||||
isAllVendors = false
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className='mb-4'>
|
||||
<Card className="!rounded-2xl" 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">
|
||||
{isAllVendors ? (
|
||||
<div className="flex items-center">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton.Avatar
|
||||
key={index}
|
||||
active
|
||||
size="default"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginRight: index < 3 ? -8 : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton.Avatar active size="large" style={{ width: 40, height: 40, borderRadius: 8 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 供应商信息骨架 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
|
||||
<Skeleton.Title active style={{ width: 120, height: 24, marginBottom: 0 }} />
|
||||
<Skeleton.Button active size="small" style={{ width: 80, height: 20, borderRadius: 12 }} />
|
||||
</div>
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={2}
|
||||
style={{ marginBottom: 0 }}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntroSkeleton;
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 PricingVendorIntro from './PricingVendorIntro';
|
||||
import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
const PricingVendorIntroWithSkeleton = ({
|
||||
loading = false,
|
||||
filterVendor,
|
||||
models,
|
||||
allModels,
|
||||
t
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingVendorIntroSkeleton
|
||||
isAllVendors={filterVendor === 'all'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingVendorIntro
|
||||
filterVendor={filterVendor}
|
||||
models={models}
|
||||
allModels={allModels}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingVendorIntroWithSkeleton;
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 {
|
||||
SideSheet,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconClose,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import ModelHeader from './components/ModelHeader';
|
||||
import ModelBasicInfo from './components/ModelBasicInfo';
|
||||
import ModelEndpoints from './components/ModelEndpoints';
|
||||
import ModelPricingTable from './components/ModelPricingTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelDetailSideSheet = ({
|
||||
visible,
|
||||
onClose,
|
||||
modelData,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
vendorsMap,
|
||||
endpointMap,
|
||||
t,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement="right"
|
||||
title={<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />}
|
||||
bodyStyle={{
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderBottom: '1px solid var(--semi-color-border)'
|
||||
}}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
closeIcon={
|
||||
<Button
|
||||
className="semi-button-tertiary semi-button-size-small semi-button-borderless"
|
||||
type="button"
|
||||
icon={<IconClose />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="p-2">
|
||||
{!modelData && (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Text type="secondary">{t('加载中...')}</Text>
|
||||
</div>
|
||||
)}
|
||||
{modelData && (
|
||||
<>
|
||||
<ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
|
||||
<ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
|
||||
<ModelPricingTable
|
||||
modelData={modelData}
|
||||
selectedGroup={selectedGroup}
|
||||
groupRatio={groupRatio}
|
||||
currency={currency}
|
||||
tokenUnit={tokenUnit}
|
||||
displayPrice={displayPrice}
|
||||
showRatio={showRatio}
|
||||
usableGroup={usableGroup}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDetailSideSheet;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 { Modal } from '@douyinfe/semi-ui';
|
||||
import { resetPricingFilters } from '../../../../helpers/utils';
|
||||
import FilterModalContent from './components/FilterModalContent';
|
||||
import FilterModalFooter from './components/FilterModalFooter';
|
||||
|
||||
const PricingFilterModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
sidebarProps,
|
||||
t
|
||||
}) => {
|
||||
const handleResetFilters = () =>
|
||||
resetPricingFilters({
|
||||
handleChange: sidebarProps.handleChange,
|
||||
setShowWithRecharge: sidebarProps.setShowWithRecharge,
|
||||
setCurrency: sidebarProps.setCurrency,
|
||||
setShowRatio: sidebarProps.setShowRatio,
|
||||
setViewMode: sidebarProps.setViewMode,
|
||||
setFilterGroup: sidebarProps.setFilterGroup,
|
||||
setFilterQuotaType: sidebarProps.setFilterQuotaType,
|
||||
setFilterEndpointType: sidebarProps.setFilterEndpointType,
|
||||
setFilterVendor: sidebarProps.setFilterVendor,
|
||||
setCurrentPage: sidebarProps.setCurrentPage,
|
||||
setTokenUnit: sidebarProps.setTokenUnit,
|
||||
});
|
||||
|
||||
const footer = (
|
||||
<FilterModalFooter
|
||||
onReset={handleResetFilters}
|
||||
onConfirm={onClose}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('筛选')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={footer}
|
||||
style={{ width: '100%', height: '100%', margin: 0 }}
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 160px)',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}}
|
||||
>
|
||||
<FilterModalContent sidebarProps={sidebarProps} t={t} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingFilterModal;
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
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 PricingDisplaySettings from '../../filter/PricingDisplaySettings';
|
||||
import PricingGroups from '../../filter/PricingGroups';
|
||||
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
|
||||
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
|
||||
import PricingVendors from '../../filter/PricingVendors';
|
||||
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
|
||||
|
||||
const FilterModalContent = ({ sidebarProps, t }) => {
|
||||
const {
|
||||
showWithRecharge,
|
||||
setShowWithRecharge,
|
||||
currency,
|
||||
setCurrency,
|
||||
handleChange,
|
||||
setActiveKey,
|
||||
showRatio,
|
||||
setShowRatio,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
loading,
|
||||
...categoryProps
|
||||
} = sidebarProps;
|
||||
|
||||
const {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
} = usePricingFilterCounts({
|
||||
models: categoryProps.models,
|
||||
filterGroup,
|
||||
filterQuotaType,
|
||||
filterEndpointType,
|
||||
filterVendor,
|
||||
searchValue: sidebarProps.searchValue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<PricingDisplaySettings
|
||||
showWithRecharge={showWithRecharge}
|
||||
setShowWithRecharge={setShowWithRecharge}
|
||||
currency={currency}
|
||||
setCurrency={setCurrency}
|
||||
showRatio={showRatio}
|
||||
setShowRatio={setShowRatio}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
tokenUnit={tokenUnit}
|
||||
setTokenUnit={setTokenUnit}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingVendors
|
||||
filterVendor={filterVendor}
|
||||
setFilterVendor={setFilterVendor}
|
||||
models={vendorModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingGroups
|
||||
filterGroup={filterGroup}
|
||||
setFilterGroup={setFilterGroup}
|
||||
usableGroup={categoryProps.usableGroup}
|
||||
groupRatio={categoryProps.groupRatio}
|
||||
models={groupCountModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingQuotaTypes
|
||||
filterQuotaType={filterQuotaType}
|
||||
setFilterQuotaType={setFilterQuotaType}
|
||||
models={quotaTypeModels}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PricingEndpointTypes
|
||||
filterEndpointType={filterEndpointType}
|
||||
setFilterEndpointType={setFilterEndpointType}
|
||||
models={endpointTypeModels}
|
||||
allModels={categoryProps.models}
|
||||
loading={loading}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalContent;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const FilterModalFooter = ({ onReset, onConfirm, t }) => {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
theme="outline"
|
||||
type='tertiary'
|
||||
onClick={onReset}
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterModalFooter;
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui';
|
||||
import { IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型描述(使用后端真实数据)
|
||||
const getModelDescription = () => {
|
||||
if (!modelData) return t('暂无模型描述');
|
||||
|
||||
// 优先使用后端提供的描述
|
||||
if (modelData.description) {
|
||||
return modelData.description;
|
||||
}
|
||||
|
||||
// 如果没有描述但有供应商描述,显示供应商信息
|
||||
if (modelData.vendor_description) {
|
||||
return t('供应商信息:') + modelData.vendor_description;
|
||||
}
|
||||
|
||||
return t('暂无模型描述');
|
||||
};
|
||||
|
||||
// 获取模型标签
|
||||
const getModelTags = () => {
|
||||
const tags = [];
|
||||
|
||||
if (modelData?.tags) {
|
||||
const customTags = modelData.tags.split(',').filter(tag => tag.trim());
|
||||
customTags.forEach(tag => {
|
||||
const tagText = tag.trim();
|
||||
tags.push({ text: tagText, color: stringToColor(tagText) });
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="blue" className="mr-2 shadow-md">
|
||||
<IconInfoCircle size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('基本信息')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型的详细描述和基本特性')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
<p className="mb-4">{getModelDescription()}</p>
|
||||
{getModelTags().length > 0 && (
|
||||
<Space wrap>
|
||||
{getModelTags().map((tag, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
color={tag.color}
|
||||
shape="circle"
|
||||
size="small"
|
||||
>
|
||||
{tag.text}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelBasicInfo;
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { Card, Avatar, Typography, Badge } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
|
||||
const renderAPIEndpoints = () => {
|
||||
if (!modelData) return null;
|
||||
|
||||
const mapping = endpointMap;
|
||||
const types = modelData.supported_endpoint_types || [];
|
||||
|
||||
return types.map(type => {
|
||||
const info = mapping[type] || {};
|
||||
let path = info.path || '';
|
||||
// 如果路径中包含 {model} 占位符,替换为真实模型名称
|
||||
if (path.includes('{model}')) {
|
||||
const modelName = modelData.model_name || modelData.modelName || '';
|
||||
path = path.replaceAll('{model}', modelName);
|
||||
}
|
||||
const method = info.method || 'POST';
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className="flex items-center pr-5">
|
||||
<Badge dot type="success" className="mr-2" />
|
||||
{type}{path && ':'}
|
||||
{path && (
|
||||
<span className="text-gray-500 md:ml-1 break-all">
|
||||
{path}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{path && (
|
||||
<span className="text-gray-500 text-xs md:ml-1">
|
||||
{method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0 mb-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="purple" className="mr-2 shadow-md">
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('API端点')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('模型支持的接口端点信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderAPIEndpoints()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelEndpoints;
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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 { Typography, Toast, Avatar } from '@douyinfe/semi-ui';
|
||||
import { getLobeHubIcon } from '../../../../../helpers';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
const CARD_STYLES = {
|
||||
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
||||
icon: "w-8 h-8 flex items-center justify-center",
|
||||
};
|
||||
|
||||
const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
|
||||
// 获取模型图标(使用供应商图标)
|
||||
const getModelIcon = () => {
|
||||
// 优先使用供应商图标
|
||||
if (modelData?.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(modelData.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称的前两个字符
|
||||
const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI';
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size="large"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{getModelIcon()}
|
||||
<div className="ml-3 font-normal">
|
||||
<Paragraph
|
||||
className="!mb-0 !text-lg !font-medium"
|
||||
copyable={{
|
||||
content: modelData?.model_name || '',
|
||||
onCopy: () => Toast.success({ content: t('已复制模型名称') })
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-60 font-bold">{modelData?.model_name || t('未知模型')}</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelHeader;
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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 { Card, Avatar, Typography, Table, Tag, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
|
||||
import { calculateModelPrice } from '../../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelPricingTable = ({
|
||||
modelData,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
usableGroup,
|
||||
t,
|
||||
}) => {
|
||||
// 获取分组介绍
|
||||
const getGroupDescription = (groupName) => {
|
||||
const descriptions = {
|
||||
'default': t('默认分组,适用于普通用户'),
|
||||
'ssvip': t('超级VIP分组,享受最优惠价格'),
|
||||
'openai官-优质': t('OpenAI官方优质分组,最快最稳,支持o1、realtime等'),
|
||||
'origin': t('企业分组,OpenAI&Claude官方原价,不升价本分组稳定性可用性'),
|
||||
'vip': t('VIP分组,享受优惠价格'),
|
||||
'premium': t('高级分组,稳定可靠'),
|
||||
'enterprise': t('企业级分组,专业服务'),
|
||||
};
|
||||
return descriptions[groupName] || t('用户分组');
|
||||
};
|
||||
|
||||
const renderGroupPriceTable = () => {
|
||||
const availableGroups = Object.keys(usableGroup || {}).filter(g => g !== '');
|
||||
if (availableGroups.length === 0) {
|
||||
availableGroups.push('default');
|
||||
}
|
||||
|
||||
// 准备表格数据
|
||||
const tableData = availableGroups.map(group => {
|
||||
const priceData = modelData ? calculateModelPrice({
|
||||
record: modelData,
|
||||
selectedGroup: group,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
}) : { inputPrice: '-', outputPrice: '-', price: '-' };
|
||||
|
||||
// 获取分组倍率
|
||||
const groupRatioValue = groupRatio && groupRatio[group] ? groupRatio[group] : 1;
|
||||
|
||||
return {
|
||||
key: group,
|
||||
group: group,
|
||||
description: getGroupDescription(group),
|
||||
ratio: groupRatioValue,
|
||||
billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'),
|
||||
inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
|
||||
outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-',
|
||||
fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
|
||||
};
|
||||
});
|
||||
|
||||
// 定义表格列
|
||||
const columns = [
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text, record) => (
|
||||
<Tooltip content={record.description} position="top">
|
||||
<Tag color="white" size="small" shape="circle" className="cursor-help">
|
||||
{text}{t('分组')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 如果显示倍率,添加倍率列
|
||||
if (showRatio) {
|
||||
columns.push({
|
||||
title: t('倍率'),
|
||||
dataIndex: 'ratio',
|
||||
render: (text) => (
|
||||
<Tag color="white" size="small" shape="circle">
|
||||
{text}x
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加计费类型列
|
||||
columns.push({
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'billingType',
|
||||
render: (text) => (
|
||||
<Tag color={text === t('按量计费') ? 'violet' : 'teal'} size="small" shape="circle">
|
||||
{text}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
|
||||
// 根据计费类型添加价格列
|
||||
if (modelData?.quota_type === 0) {
|
||||
// 按量计费
|
||||
columns.push(
|
||||
{
|
||||
title: t('提示'),
|
||||
dataIndex: 'inputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('补全'),
|
||||
dataIndex: 'outputPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
|
||||
</>
|
||||
),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// 按次计费
|
||||
columns.push({
|
||||
title: t('价格'),
|
||||
dataIndex: 'fixedPrice',
|
||||
render: (text) => (
|
||||
<>
|
||||
<div className="font-semibold text-orange-600">{text}</div>
|
||||
<div className="text-xs text-gray-500">/ 次</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered={false}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="!rounded-2xl shadow-sm border-0">
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar size="small" color="orange" className="mr-2 shadow-md">
|
||||
<IconCoinMoneyStroked size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className="text-lg font-medium">{t('分组价格')}</Text>
|
||||
<div className="text-xs text-gray-600">{t('不同用户分组的价格信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderGroupPriceTable()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricingTable;
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
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 { Card, Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const PricingCardSkeleton = ({
|
||||
skeletonCount = 10,
|
||||
rowSelection = false,
|
||||
showRatio = false
|
||||
}) => {
|
||||
const placeholder = (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="!rounded-2xl border border-gray-200"
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{/* 模型图标骨架 */}
|
||||
<div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
|
||||
<Skeleton.Avatar
|
||||
size="large"
|
||||
style={{ width: 48, height: 48, borderRadius: 16 }}
|
||||
/>
|
||||
</div>
|
||||
{/* 模型名称和价格区域 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 模型名称骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${120 + (index % 3) * 30}px`,
|
||||
height: 20,
|
||||
marginBottom: 8
|
||||
}}
|
||||
/>
|
||||
{/* 价格信息骨架 */}
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: `${160 + (index % 4) * 20}px`,
|
||||
height: 20,
|
||||
marginBottom: 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{/* 复制按钮骨架 */}
|
||||
<Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 4 }} />
|
||||
{/* 勾选框骨架 */}
|
||||
{rowSelection && (
|
||||
<Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 2 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述骨架 */}
|
||||
<div className="mb-4">
|
||||
<Skeleton.Paragraph
|
||||
rows={2}
|
||||
style={{ marginBottom: 0 }}
|
||||
title={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签区域骨架 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
|
||||
<Skeleton.Button
|
||||
key={tagIndex}
|
||||
size="small"
|
||||
style={{
|
||||
width: 64,
|
||||
height: 20,
|
||||
borderRadius: 10
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 倍率信息骨架(可选) */}
|
||||
{showRatio && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
<Skeleton.Title
|
||||
style={{ width: 60, height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
<Skeleton.Button size="small" style={{ width: 14, height: 14, borderRadius: 7 }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }).map((_, ratioIndex) => (
|
||||
<Skeleton.Title
|
||||
key={ratioIndex}
|
||||
style={{ width: '100%', height: 12, marginBottom: 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页骨架 */}
|
||||
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
|
||||
<Skeleton.Button style={{ width: 300, height: 32 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCardSkeleton;
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
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 { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers';
|
||||
import PricingCardSkeleton from './PricingCardSkeleton';
|
||||
import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
|
||||
import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
|
||||
|
||||
const CARD_STYLES = {
|
||||
container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
|
||||
icon: "w-8 h-8 flex items-center justify-center",
|
||||
selected: "border-blue-500 bg-blue-50",
|
||||
default: "border-gray-200 hover:border-gray-300"
|
||||
};
|
||||
|
||||
const PricingCardView = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
t,
|
||||
selectedRowKeys = [],
|
||||
setSelectedRowKeys,
|
||||
openModelDetail,
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
|
||||
const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
|
||||
|
||||
const handleCheckboxChange = (model, checked) => {
|
||||
if (!setSelectedRowKeys) return;
|
||||
const modelKey = getModelKey(model);
|
||||
const newKeys = checked
|
||||
? Array.from(new Set([...selectedRowKeys, modelKey]))
|
||||
: selectedRowKeys.filter((key) => key !== modelKey);
|
||||
setSelectedRowKeys(newKeys);
|
||||
rowSelection?.onChange?.(newKeys, null);
|
||||
};
|
||||
|
||||
// 获取模型图标
|
||||
const getModelIcon = (model) => {
|
||||
if (!model || !model.model_name) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar size='large'>?</Avatar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 优先使用供应商图标
|
||||
if (model.vendor_icon) {
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<div className={CARD_STYLES.icon}>
|
||||
{getLobeHubIcon(model.vendor_icon, 32)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有供应商图标,使用模型名称生成头像
|
||||
|
||||
const avatarText = model.model_name.slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div className={CARD_STYLES.container}>
|
||||
<Avatar
|
||||
size="large"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取模型描述
|
||||
const getModelDescription = (record) => {
|
||||
return record.description || '';
|
||||
};
|
||||
|
||||
// 渲染价格信息
|
||||
const renderPriceInfo = (record) => {
|
||||
const priceData = calculateModelPrice({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
});
|
||||
return formatPriceInfo(priceData, t);
|
||||
};
|
||||
|
||||
// 渲染标签
|
||||
const renderTags = (record) => {
|
||||
// 计费类型标签(左边)
|
||||
const billingType = record.quota_type === 1 ? 'teal' : 'violet';
|
||||
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
|
||||
const billingTag = (
|
||||
<Tag key="billing" shape='circle' color={billingType} size='small'>
|
||||
{billingText}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
// 自定义标签(右边)
|
||||
const customTags = [];
|
||||
if (record.tags) {
|
||||
const tagArr = record.tags.split(',').filter(Boolean);
|
||||
tagArr.forEach((tg, idx) => {
|
||||
customTags.push(
|
||||
<Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
|
||||
{tg}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{billingTag}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{renderLimitedItems({
|
||||
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
|
||||
renderItem: (item, idx) => item.element,
|
||||
maxDisplay: 3
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 显示骨架屏
|
||||
if (showSkeleton) {
|
||||
return (
|
||||
<PricingCardSkeleton
|
||||
rowSelection={!!rowSelection}
|
||||
showRatio={showRatio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!filteredModels || filteredModels.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
|
||||
{paginatedModels.map((model, index) => {
|
||||
const modelKey = getModelKey(model);
|
||||
const isSelected = selectedRowKeys.includes(modelKey);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={modelKey || index}
|
||||
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
|
||||
bodyStyle={{ height: '100%' }}
|
||||
onClick={() => openModelDetail && openModelDetail(model)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{getModelIcon(model)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 truncate">
|
||||
{model.model_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs mt-1">
|
||||
{renderPriceInfo(model)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
icon={<IconCopy />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyText(model.model_name);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 选择框 */}
|
||||
{rowSelection && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCheckboxChange(model, e.target.checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型描述 - 占据剩余空间 */}
|
||||
<div className="flex-1 mb-4">
|
||||
<p
|
||||
className="text-xs line-clamp-2 leading-relaxed"
|
||||
style={{ color: 'var(--semi-color-text-2)' }}
|
||||
>
|
||||
{getModelDescription(model)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 底部区域 */}
|
||||
<div className="mt-auto">
|
||||
{/* 标签区域 */}
|
||||
<div className="mb-3">
|
||||
{renderTags(model)}
|
||||
</div>
|
||||
|
||||
{/* 倍率信息(可选) */}
|
||||
{showRatio && (
|
||||
<div
|
||||
className="pt-3 border-t border-dashed"
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
<span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
|
||||
<div>
|
||||
{t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
|
||||
</div>
|
||||
<div>
|
||||
{t('分组')}: {groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{filteredModels.length > 0 && (
|
||||
<div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={filteredModels.length}
|
||||
showSizeChanger={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={(page) => setCurrentPage(page)}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingCardView;
|
||||
@@ -23,82 +23,88 @@ import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelPricingColumns } from './ModelPricingColumnDefs.js';
|
||||
import { getPricingTableColumns } from './PricingTableColumns';
|
||||
|
||||
const ModelPricingTable = ({
|
||||
const PricingTable = ({
|
||||
filteredModels,
|
||||
loading,
|
||||
rowSelection,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
filteredValue,
|
||||
handleGroupClick,
|
||||
searchValue,
|
||||
showRatio,
|
||||
compactMode = false,
|
||||
openModelDetail,
|
||||
t
|
||||
}) => {
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getModelPricingColumns({
|
||||
return getPricingTableColumns({
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
showRatio,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
selectedGroup,
|
||||
usableGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
showWithRecharge,
|
||||
tokenUnit,
|
||||
setTokenUnit,
|
||||
displayPrice,
|
||||
handleGroupClick,
|
||||
showRatio,
|
||||
]);
|
||||
|
||||
// 更新列定义中的 filteredValue
|
||||
const tableColumns = useMemo(() => {
|
||||
return columns.map(column => {
|
||||
// 更新列定义中的 searchValue
|
||||
const processedColumns = useMemo(() => {
|
||||
const cols = columns.map(column => {
|
||||
if (column.dataIndex === 'model_name') {
|
||||
return {
|
||||
...column,
|
||||
filteredValue
|
||||
filteredValue: searchValue ? [searchValue] : []
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
}, [columns, filteredValue]);
|
||||
|
||||
// Remove fixed property when in compact mode (mobile view)
|
||||
if (compactMode) {
|
||||
return cols.map(({ fixed, ...rest }) => rest);
|
||||
}
|
||||
return cols;
|
||||
}, [columns, searchValue, compactMode]);
|
||||
|
||||
const ModelTable = useMemo(() => (
|
||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
columns={processedColumns}
|
||||
dataSource={filteredModels}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
className="custom-table"
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openModelDetail && openModelDetail(record),
|
||||
style: { cursor: 'pointer' }
|
||||
})}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
@@ -116,9 +122,9 @@ const ModelPricingTable = ({
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]);
|
||||
), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, openModelDetail, t, compactMode]);
|
||||
|
||||
return ModelTable;
|
||||
};
|
||||
|
||||
export default ModelPricingTable;
|
||||
export default PricingTable;
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
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 { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers';
|
||||
import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils';
|
||||
|
||||
function renderQuotaType(type, t) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
// Render vendor name
|
||||
const renderVendor = (vendorName, vendorIcon, t) => {
|
||||
if (!vendorName) return '-';
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={getLobeHubIcon(vendorIcon || 'Layers', 14)}>
|
||||
{vendorName}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render tags list using RenderUtils
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(tag => tag.trim());
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag key={idx} color={stringToColor(tag.trim())} shape='circle' size='small'>
|
||||
{tag.trim()}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3
|
||||
});
|
||||
};
|
||||
|
||||
function renderSupportedEndpoints(endpoints) {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Space wrap>
|
||||
{endpoints.map((endpoint, idx) => (
|
||||
<Tag
|
||||
key={endpoint}
|
||||
color={stringToColor(endpoint)}
|
||||
shape='circle'
|
||||
>
|
||||
{endpoint}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const getPricingTableColumns = ({
|
||||
t,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
copyText,
|
||||
setModalImageUrl,
|
||||
setIsModalOpenurl,
|
||||
currency,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
showRatio,
|
||||
}) => {
|
||||
const endpointColumn = {
|
||||
title: t('可用端点类型'),
|
||||
dataIndex: 'supported_endpoint_types',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
return renderSupportedEndpoints(text);
|
||||
},
|
||||
};
|
||||
|
||||
const modelNameColumn = {
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
};
|
||||
|
||||
const quotaColumn = {
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text), t);
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
};
|
||||
|
||||
const descriptionColumn = {
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
};
|
||||
|
||||
const tagsColumn = {
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
};
|
||||
|
||||
const vendorColumn = {
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_name',
|
||||
render: (text, record) => renderVendor(text, record.vendor_icon, t),
|
||||
};
|
||||
|
||||
const baseColumns = [modelNameColumn, vendorColumn, descriptionColumn, tagsColumn, quotaColumn];
|
||||
|
||||
const ratioColumn = {
|
||||
title: () => (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
const completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
const content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('分组倍率')}:{groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
},
|
||||
};
|
||||
|
||||
const priceColumn = {
|
||||
title: t('模型价格'),
|
||||
dataIndex: 'model_price',
|
||||
render: (text, record, index) => {
|
||||
const priceData = calculateModelPrice({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency
|
||||
});
|
||||
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="text-gray-700">
|
||||
{t('模型价格')}:{priceData.price}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const columns = [...baseColumns];
|
||||
if (showRatio) {
|
||||
columns.push(ratioColumn);
|
||||
}
|
||||
columns.push(priceColumn);
|
||||
columns.push(endpointColumn);
|
||||
return columns;
|
||||
};
|
||||
170
web/src/components/table/models/ModelsActions.jsx
Normal file
170
web/src/components/table/models/ModelsActions.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
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 } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal.jsx';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal.jsx';
|
||||
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification.jsx';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showMissingModal, setShowMissingModal] = useState(false);
|
||||
const [showGroupManagement, setShowGroupManagement] = useState(false);
|
||||
const [showAddPrefill, setShowAddPrefill] = useState(false);
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = () => {
|
||||
batchDeleteModels();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
// Handle clear selection
|
||||
const handleClearSelected = () => {
|
||||
setSelectedKeys([]);
|
||||
};
|
||||
|
||||
// Handle add selected models to prefill group
|
||||
const handleCopyNames = async () => {
|
||||
const text = selectedKeys.map(m => m.model_name).join(',');
|
||||
if (!text) return;
|
||||
const ok = await copy(text);
|
||||
if (ok) {
|
||||
showSuccess(t('已复制模型名称'));
|
||||
} else {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToPrefill = () => {
|
||||
// Prepare initial data
|
||||
const items = selectedKeys.map((m) => m.model_name);
|
||||
setPrefillInit({ id: undefined, type: 'model', items });
|
||||
setShowAddPrefill(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
type="primary"
|
||||
className="flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
setEditingModel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
className="flex-1 md:flex-initial"
|
||||
size="small"
|
||||
onClick={() => setShowMissingModal(true)}
|
||||
>
|
||||
{t('未配置模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="secondary"
|
||||
className="flex-1 md:flex-initial"
|
||||
size="small"
|
||||
onClick={() => setShowGroupManagement(true)}
|
||||
>
|
||||
{t('预填组管理')}
|
||||
</Button>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectionNotification
|
||||
selectedKeys={selectedKeys}
|
||||
t={t}
|
||||
onDelete={handleDeleteSelectedModels}
|
||||
onAddPrefill={handleAddToPrefill}
|
||||
onClear={handleClearSelected}
|
||||
onCopy={handleCopyNames}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('批量删除模型')}
|
||||
visible={showDeleteModal}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={handleConfirmDelete}
|
||||
type="warning"
|
||||
>
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<MissingModelsModal
|
||||
visible={showMissingModal}
|
||||
onClose={() => setShowMissingModal(false)}
|
||||
onConfigureModel={(name) => {
|
||||
setEditingModel({ id: undefined, model_name: name });
|
||||
setShowEdit(true);
|
||||
setShowMissingModal(false);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<PrefillGroupManagement
|
||||
visible={showGroupManagement}
|
||||
onClose={() => setShowGroupManagement(false)}
|
||||
/>
|
||||
|
||||
<EditPrefillGroupModal
|
||||
visible={showAddPrefill}
|
||||
onClose={() => setShowAddPrefill(false)}
|
||||
editingGroup={prefillInit}
|
||||
onSuccess={() => setShowAddPrefill(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsActions;
|
||||
303
web/src/components/table/models/ModelsColumnDefs.js
Normal file
303
web/src/components/table/models/ModelsColumnDefs.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
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 { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
getLobeHubIcon,
|
||||
stringToColor
|
||||
} from '../../../helpers';
|
||||
import { renderLimitedItems, renderDescription } from '../../common/ui/RenderUtils';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Render timestamp
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render vendor column with icon
|
||||
const renderVendorTag = (vendorId, vendorMap, t) => {
|
||||
if (!vendorId || !vendorMap[vendorId]) return '-';
|
||||
const v = vendorMap[vendorId];
|
||||
return (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
|
||||
>
|
||||
{v.name}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render groups (enable_groups)
|
||||
const renderGroups = (groups) => {
|
||||
if (!groups || groups.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: groups,
|
||||
renderItem: (g, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(g)}>
|
||||
{g}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render tags
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(Boolean);
|
||||
return renderLimitedItems({
|
||||
items: tagsArr,
|
||||
renderItem: (tag, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(tag)}>
|
||||
{tag}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render endpoints (supports object map or legacy array)
|
||||
const renderEndpoints = (value) => {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const keys = Object.keys(parsed || {});
|
||||
if (keys.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: parsed,
|
||||
renderItem: (ep, idx) => (
|
||||
<Tag key={idx} color="white" size="small" shape='circle'>
|
||||
{ep}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
return value || '-';
|
||||
} catch (_) {
|
||||
return value || '-';
|
||||
}
|
||||
};
|
||||
|
||||
// Render quota type
|
||||
const renderQuotaType = (qt, t) => {
|
||||
if (qt === 1) {
|
||||
return (
|
||||
<Tag color='teal' size='small' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (qt === 0) {
|
||||
return (
|
||||
<Tag color='violet' size='small' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return qt ?? '-';
|
||||
};
|
||||
|
||||
// Render bound channels
|
||||
const renderBoundChannels = (channels) => {
|
||||
if (!channels || channels.length === 0) return '-';
|
||||
return renderLimitedItems({
|
||||
items: channels,
|
||||
renderItem: (c, idx) => (
|
||||
<Tag key={idx} color="white" size="small" shape='circle'>
|
||||
{c.name}({c.type})
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Render operations column
|
||||
const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingModel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此模型?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageModel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
// 名称匹配类型渲染
|
||||
const renderNameRule = (rule, t) => {
|
||||
const map = {
|
||||
0: { color: 'green', label: t('精确') },
|
||||
1: { color: 'blue', label: t('前缀') },
|
||||
2: { color: 'orange', label: t('包含') },
|
||||
3: { color: 'purple', label: t('后缀') },
|
||||
};
|
||||
const cfg = map[rule];
|
||||
if (!cfg) return '-';
|
||||
return (
|
||||
<Tag color={cfg.color} size="small" shape='circle'>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export const getModelsColumns = ({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text) => (
|
||||
<Text copyable onClick={(e) => e.stopPropagation()}>
|
||||
{text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('匹配类型'),
|
||||
dataIndex: 'name_rule',
|
||||
render: (val) => renderNameRule(val, t),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: (text) => renderDescription(text, 200),
|
||||
},
|
||||
{
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_id',
|
||||
render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
|
||||
},
|
||||
{
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
},
|
||||
{
|
||||
title: t('端点'),
|
||||
dataIndex: 'endpoints',
|
||||
render: renderEndpoints,
|
||||
},
|
||||
{
|
||||
title: t('已绑定渠道'),
|
||||
dataIndex: 'bound_channels',
|
||||
render: renderBoundChannels,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: renderGroups,
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (qt) => renderQuotaType(qt, t),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('更新时间'),
|
||||
dataIndex: 'updated_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => renderOperations(
|
||||
text,
|
||||
record,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
manageModel,
|
||||
refresh,
|
||||
t
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { Typography } from '@douyinfe/semi-ui';
|
||||
import { Layers } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-green-500">
|
||||
<Layers size={16} className="mr-2" />
|
||||
<Text>{t('模型管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsDescription;
|
||||
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
// Handle form reset and immediate search
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
searchModels();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => {
|
||||
setFormApi(api);
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
onSubmit={searchModels}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchVendor"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索供应商')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="tertiary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleReset}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsFilters;
|
||||
104
web/src/components/table/models/ModelsTable.jsx
Normal file
104
web/src/components/table/models/ModelsTable.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelsColumns } from './ModelsColumnDefs.js';
|
||||
|
||||
const ModelsTable = (modelsData) => {
|
||||
const {
|
||||
models,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
compactMode,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getModelsColumns({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
});
|
||||
}, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode ? columns.map(col => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
}) : columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={models}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: modelCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTable;
|
||||
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const ModelsTabs = ({
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
vendorCounts,
|
||||
vendors,
|
||||
loadModels,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
setShowAddVendor,
|
||||
setShowEditVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
t
|
||||
}) => {
|
||||
const handleTabChange = (key) => {
|
||||
setActiveVendorKey(key);
|
||||
setActivePage(1);
|
||||
loadModels(1, pageSize, key);
|
||||
};
|
||||
|
||||
const handleEditVendor = (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
setEditingVendor(vendor);
|
||||
setShowEditVendor(true);
|
||||
};
|
||||
|
||||
const handleDeleteVendor = async (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
try {
|
||||
const res = await API.delete(`/api/vendors/${vendor.id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('供应商删除成功'));
|
||||
// 如果删除的是当前选中的供应商,切换到"全部"
|
||||
if (activeVendorKey === String(vendor.id)) {
|
||||
setActiveVendorKey('all');
|
||||
loadModels(1, pageSize, 'all');
|
||||
} else {
|
||||
loadModels(activePage, pageSize, activeVendorKey);
|
||||
}
|
||||
loadVendors(); // 重新加载供应商列表
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeVendorKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className="mb-2"
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => setShowAddVendor(true)}
|
||||
>
|
||||
{t('新增供应商')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TabPane
|
||||
itemKey="all"
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{t('全部')}
|
||||
<Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle'>
|
||||
{vendorCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{vendors.map((vendor) => {
|
||||
const key = String(vendor.id);
|
||||
const count = vendorCounts[vendor.id] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
|
||||
{vendor.name}
|
||||
<Tag color={activeVendorKey === key ? 'red' : 'grey'} shape='circle'>
|
||||
{count}
|
||||
</Tag>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit />}
|
||||
onClick={(e) => handleEditVendor(vendor, e)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }),
|
||||
onOk: () => handleDeleteVendor(vendor, e),
|
||||
okText: t('删除'),
|
||||
cancelText: t('取消'),
|
||||
type: 'warning',
|
||||
okType: 'danger',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
onClickOutSide={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="outline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTabs;
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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, { useEffect } from 'react';
|
||||
import { Notification, Button, Space, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
// 固定通知 ID,保持同一个实例即可避免闪烁
|
||||
const NOTICE_ID = 'models-batch-actions';
|
||||
|
||||
/**
|
||||
* SelectionNotification 选择通知组件
|
||||
* 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知
|
||||
* 2. 当 selectedKeys 清空时关闭通知
|
||||
*/
|
||||
const SelectionNotification = ({ selectedKeys = [], t, onDelete, onAddPrefill, onClear, onCopy }) => {
|
||||
// 根据选中数量决定显示/隐藏或更新通知
|
||||
useEffect(() => {
|
||||
const selectedCount = selectedKeys.length;
|
||||
|
||||
if (selectedCount > 0) {
|
||||
const titleNode = (
|
||||
<Space wrap>
|
||||
<span>{t('批量操作')}</span>
|
||||
<Typography.Text type="tertiary" size="small">{t('已选择 {{count}} 个模型', { count: selectedCount })}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Space wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="solid"
|
||||
onClick={onClear}
|
||||
>
|
||||
{t('取消全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={onAddPrefill}
|
||||
>
|
||||
{t('加入预填组')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
onClick={onCopy}
|
||||
>
|
||||
{t('复制名称')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t('删除所选')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
// 使用相同 id 更新通知(若已存在则就地更新,不存在则创建)
|
||||
Notification.info({
|
||||
id: NOTICE_ID,
|
||||
title: titleNode,
|
||||
content,
|
||||
duration: 0, // 不自动关闭
|
||||
position: 'bottom',
|
||||
showClose: false,
|
||||
});
|
||||
} else {
|
||||
// 取消全部勾选时关闭通知
|
||||
Notification.close(NOTICE_ID);
|
||||
}
|
||||
}, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]);
|
||||
|
||||
// 卸载时确保关闭通知
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Notification.close(NOTICE_ID);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null; // 该组件不渲染可见内容
|
||||
};
|
||||
|
||||
export default SelectionNotification;
|
||||
142
web/src/components/table/models/index.jsx
Normal file
142
web/src/components/table/models/index.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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 CardPro from '../../common/ui/CardPro';
|
||||
import ModelsTable from './ModelsTable.jsx';
|
||||
import ModelsActions from './ModelsActions.jsx';
|
||||
import ModelsFilters from './ModelsFilters.jsx';
|
||||
import ModelsTabs from './ModelsTabs.jsx';
|
||||
import EditModelModal from './modals/EditModelModal.jsx';
|
||||
import EditVendorModal from './modals/EditVendorModal.jsx';
|
||||
import { useModelsData } from '../../../hooks/models/useModelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const ModelsPage = () => {
|
||||
const modelsData = useModelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingModel,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// Description state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor state
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditModelModal
|
||||
refresh={refresh}
|
||||
editingModel={editingModel}
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<EditVendorModal
|
||||
visible={showAddVendor || showEditVendor}
|
||||
handleClose={() => {
|
||||
setShowAddVendor(false);
|
||||
setShowEditVendor(false);
|
||||
setEditingVendor({ id: undefined });
|
||||
}}
|
||||
editingVendor={showEditVendor ? editingVendor : { id: undefined }}
|
||||
refresh={() => {
|
||||
loadVendors();
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type="type3"
|
||||
tabsArea={<ModelsTabs {...modelsData} />}
|
||||
actionsArea={
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
|
||||
<ModelsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-full lg:w-auto order-1 md:order-2">
|
||||
<ModelsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchModels={searchModels}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: modelsData.activePage,
|
||||
pageSize: modelsData.pageSize,
|
||||
total: modelsData.modelCount,
|
||||
onPageChange: modelsData.handlePageChange,
|
||||
onPageSizeChange: modelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: modelsData.t,
|
||||
})}
|
||||
t={modelsData.t}
|
||||
>
|
||||
<ModelsTable {...modelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPage;
|
||||
441
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
441
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
@@ -0,0 +1,441 @@
|
||||
/*
|
||||
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, useRef, useMemo } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, FileText } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const nameRuleOptions = [
|
||||
{ label: '精确名称匹配', value: 0 },
|
||||
{ label: '前缀名称匹配', value: 1 },
|
||||
{ label: '包含名称匹配', value: 2 },
|
||||
{ label: '后缀名称匹配', value: 3 },
|
||||
];
|
||||
|
||||
const EditModelModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = props.editingModel && props.editingModel.id !== undefined;
|
||||
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
|
||||
|
||||
// 供应商列表
|
||||
const [vendors, setVendors] = useState([]);
|
||||
|
||||
// 预填组(标签、端点)
|
||||
const [tagGroups, setTagGroups] = useState([]);
|
||||
const [endpointGroups, setEndpointGroups] = useState([]);
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预填组(标签、端点)
|
||||
const fetchPrefillGroups = async () => {
|
||||
try {
|
||||
const [tagRes, endpointRes] = await Promise.all([
|
||||
API.get('/api/prefill_group?type=tag'),
|
||||
API.get('/api/prefill_group?type=endpoint'),
|
||||
]);
|
||||
if (tagRes?.data?.success) {
|
||||
setTagGroups(tagRes.data.data || []);
|
||||
}
|
||||
if (endpointRes?.data?.success) {
|
||||
setEndpointGroups(endpointRes.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
fetchVendors();
|
||||
fetchPrefillGroups();
|
||||
}
|
||||
}, [props.visiable]);
|
||||
|
||||
const getInitValues = () => ({
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
description: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
vendor_icon: '',
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!isEdit || !props.editingModel.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/models/${props.editingModel.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 处理tags
|
||||
if (data.tags) {
|
||||
data.tags = data.tags.split(',').filter(Boolean);
|
||||
} else {
|
||||
data.tags = [];
|
||||
}
|
||||
// endpoints 保持原始 JSON 字符串,若为空设为空串
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadModel();
|
||||
} else {
|
||||
formApiRef.current?.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = props.editingModel.id;
|
||||
const res = await API.put('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型创建成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新模型信息') : t('创建新的模型')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<Save size={16} />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<FileText size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置模型的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='name_rule'
|
||||
label={t('名称匹配类型')}
|
||||
placeholder={t('请选择名称匹配类型')}
|
||||
optionList={nameRuleOptions.map(o => ({ label: t(o.label), value: o.value }))}
|
||||
rules={[{ required: true, message: t('请选择名称匹配类型') }]}
|
||||
extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入模型描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field='tags'
|
||||
label={t('标签')}
|
||||
placeholder={t('输入标签或使用","分隔多个标签')}
|
||||
addOnBlur
|
||||
showClear
|
||||
onChange={(newTags) => {
|
||||
if (!formApiRef.current) return;
|
||||
const normalize = (tags) => {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [...new Set(tags.flatMap(tag => tag.split(',').map(t => t.trim()).filter(Boolean)))];
|
||||
};
|
||||
const normalized = normalize(newTags);
|
||||
formApiRef.current.setValue('tags', normalized);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
{...(tagGroups.length > 0 && {
|
||||
extraText: (
|
||||
<Space wrap>
|
||||
{tagGroups.map(group => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
if (formApiRef.current) {
|
||||
const currentTags = formApiRef.current.getValue('tags') || [];
|
||||
const newTags = [...currentTags, ...(group.items || [])];
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
formApiRef.current.setValue('tags', uniqueTags);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='vendor_id'
|
||||
label={t('供应商')}
|
||||
placeholder={t('选择模型供应商')}
|
||||
optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
|
||||
filter
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
const vendorInfo = vendors.find(v => v.id === value);
|
||||
if (vendorInfo && formApiRef.current) {
|
||||
formApiRef.current.setValue('vendor', vendorInfo.name);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<JSONEditor
|
||||
field='endpoints'
|
||||
label={t('端点映射')}
|
||||
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||
value={values.endpoints}
|
||||
onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
|
||||
formApi={formApiRef.current}
|
||||
editorType='object'
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
||||
extraFooter={endpointGroups.length > 0 && (
|
||||
<Space wrap>
|
||||
{endpointGroups.map(group => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
try {
|
||||
const current = formApiRef.current?.getValue('endpoints') || '';
|
||||
let base = {};
|
||||
if (current && current.trim()) base = JSON.parse(current);
|
||||
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||
const merged = { ...base, ...groupObj };
|
||||
formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
|
||||
} catch (e) {
|
||||
try {
|
||||
const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
|
||||
formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
|
||||
} catch { }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModelModal;
|
||||
259
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
259
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
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, useRef, useEffect } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconLayers,
|
||||
IconSave,
|
||||
IconClose,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const isEdit = editingGroup && editingGroup.id !== undefined;
|
||||
|
||||
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
|
||||
|
||||
// 当外部传入的编辑组类型变化时同步 selectedType
|
||||
useEffect(() => {
|
||||
setSelectedType(editingGroup?.type || 'tag');
|
||||
}, [editingGroup?.type]);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
];
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
};
|
||||
if (values.type === 'endpoint') {
|
||||
submitData.items = values.items || '';
|
||||
} else {
|
||||
submitData.items = Array.isArray(values.items) ? values.items : [];
|
||||
}
|
||||
|
||||
if (editingGroup.id) {
|
||||
submitData.id = editingGroup.id;
|
||||
const res = await API.put('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement="left"
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新预填组') : t('创建新的预填组')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 600}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={onClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
initValues={{
|
||||
name: editingGroup?.name || '',
|
||||
type: editingGroup?.type || 'tag',
|
||||
description: editingGroup?.description || '',
|
||||
items: (() => {
|
||||
try {
|
||||
if (editingGroup?.type === 'endpoint') {
|
||||
// 保持原始字符串
|
||||
return typeof editingGroup?.items === 'string'
|
||||
? editingGroup.items
|
||||
: JSON.stringify(editingGroup.items || {}, null, 2);
|
||||
}
|
||||
return Array.isArray(editingGroup?.items)
|
||||
? editingGroup.items
|
||||
: [];
|
||||
} catch {
|
||||
return editingGroup?.type === 'endpoint' ? '' : [];
|
||||
}
|
||||
})(),
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置预填组的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="name"
|
||||
label={t('组名')}
|
||||
placeholder={t('请输入组名')}
|
||||
rules={[{ required: true, message: t('请输入组名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field="type"
|
||||
label={t('类型')}
|
||||
placeholder={t('选择组类型')}
|
||||
optionList={typeOptions}
|
||||
rules={[{ required: true, message: t('请选择组类型') }]}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(val) => setSelectedType(val)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入组描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{selectedType === 'endpoint' ? (
|
||||
<JSONEditor
|
||||
field="items"
|
||||
label={t('端点映射')}
|
||||
value={formRef.current?.getValue('items') ?? (typeof editingGroup?.items === 'string' ? editingGroup.items : JSON.stringify(editingGroup.items || {}, null, 2))}
|
||||
onChange={(val) => formRef.current?.setValue('items', val)}
|
||||
editorType='object'
|
||||
placeholder={'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('键为端点类型,值为路径和方法对象')}
|
||||
/>
|
||||
) : (
|
||||
<Form.TagInput
|
||||
field="items"
|
||||
label={t('项目')}
|
||||
placeholder={t('输入项目名称,按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPrefillGroupModal;
|
||||
190
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
190
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEdit = editingVendor && editingVendor.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
formApiRef.current?.reset();
|
||||
};
|
||||
|
||||
const loadVendor = async () => {
|
||||
if (!isEdit || !editingVendor.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/vendors/${editingVendor.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 将数字状态转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载供应商信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
loadVendor();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [visible, editingVendor?.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 转换 status 为数字
|
||||
const submitData = {
|
||||
...values,
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = editingVendor.id;
|
||||
const res = await API.put('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商更新成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商创建成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? t('编辑供应商') : t('新增供应商')}
|
||||
visible={visible}
|
||||
onOk={() => formApiRef.current?.submitForm()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="name"
|
||||
label={t('供应商名称')}
|
||||
placeholder={t('请输入供应商名称,如:OpenAI')}
|
||||
rules={[{ required: true, message: t('请输入供应商名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入供应商描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="icon"
|
||||
label={t('供应商图标')}
|
||||
placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')}
|
||||
extraText={
|
||||
<span>
|
||||
{t('图标使用@lobehub/icons库,查询所有可用图标 ')}
|
||||
<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.Switch
|
||||
field="status"
|
||||
label={t('状态')}
|
||||
initValue={true}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVendorModal;
|
||||
179
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
179
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
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, { useEffect, useState } from 'react';
|
||||
import { Modal, Table, Spin, Button, Typography, Empty, Input } from '@douyinfe/semi-ui';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const MissingModelsModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfigureModel,
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [missingModels, setMissingModels] = useState([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const fetchMissing = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/models/missing');
|
||||
if (res.data.success) {
|
||||
setMissingModels(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (_) {
|
||||
showError(t('获取未配置模型失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchMissing();
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
setMissingModels([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 过滤和分页逻辑
|
||||
const filteredModels = missingModels.filter((model) =>
|
||||
model.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
|
||||
const dataSource = (() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredModels.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model,
|
||||
}));
|
||||
})();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className="flex items-center">
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => onConfigureModel(record.model)}
|
||||
>
|
||||
{t('配置')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
|
||||
{t('未配置的模型列表')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
{t('共')} {missingModels.length} {t('个未配置模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
size={isMobile ? 'full-width' : 'medium'}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{missingModels.length === 0 && !loading ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无缺失模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="missing-models-content">
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center justify-end gap-2 w-full mb-4">
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="!w-full"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{filteredModels.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 100, height: 100 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 100, height: 100 }} />}
|
||||
description={searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')}
|
||||
style={{ padding: 20 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissingModelsModal;
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconLayers,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess, stringToColor } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
import EditPrefillGroupModal from './EditPrefillGroupModal';
|
||||
import { renderLimitedItems, renderDescription } from '../../../common/ui/RenderUtils';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PrefillGroupManagement = ({ visible, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState({ id: undefined });
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
];
|
||||
|
||||
// 加载组列表
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/prefill_group');
|
||||
if (res.data.success) {
|
||||
setGroups(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message || t('获取组列表失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取组列表失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 删除组
|
||||
const deleteGroup = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/prefill_group/${id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('删除成功'));
|
||||
loadGroups();
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑组
|
||||
const handleEdit = (group = {}) => {
|
||||
setEditingGroup(group);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// 关闭编辑
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingGroup({ id: undefined });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 编辑成功回调
|
||||
const handleEditSuccess = () => {
|
||||
closeEdit();
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: t('组名'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
<Tag color="white" shape="circle" size="small">
|
||||
{typeOptions.find(opt => opt.value === record.type)?.label || record.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (text) => renderDescription(text, 150),
|
||||
},
|
||||
{
|
||||
title: t('项目内容'),
|
||||
dataIndex: 'items',
|
||||
key: 'items',
|
||||
render: (items, record) => {
|
||||
try {
|
||||
if (record.type === 'endpoint') {
|
||||
const obj = typeof items === 'string' ? JSON.parse(items || '{}') : (items || {});
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return <Text type="tertiary">{t('暂无项目')}</Text>;
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
const itemsArray = typeof items === 'string' ? JSON.parse(items) : items;
|
||||
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
|
||||
return <Text type="tertiary">{t('暂无项目')}</Text>;
|
||||
}
|
||||
return renderLimitedItems({
|
||||
items: itemsArray,
|
||||
renderItem: (item, idx) => (
|
||||
<Tag key={idx} size="small" shape='circle' color={stringToColor(item)}>
|
||||
{item}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
} catch {
|
||||
return <Text type="tertiary">{t('数据格式错误')}</Text>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('确定删除此组?')}
|
||||
onConfirm={() => deleteGroup(record.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadGroups();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement="left"
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('预填组管理')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 800}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('组列表')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('管理模型、标签、端点等预填组')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button
|
||||
type="primary"
|
||||
theme='solid'
|
||||
size="small"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => handleEdit()}
|
||||
>
|
||||
{t('新建组')}
|
||||
</Button>
|
||||
</div>
|
||||
{groups.length > 0 ? (
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
rowKey="id"
|
||||
hidePagination={true}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无预填组')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
{/* 编辑组件 */}
|
||||
<EditPrefillGroupModal
|
||||
visible={showEdit}
|
||||
onClose={closeEdit}
|
||||
editingGroup={editingGroup}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrefillGroupManagement;
|
||||
@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { Tag, Space, Skeleton } from '@douyinfe/semi-ui';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
const LogsActions = ({
|
||||
stat,
|
||||
@@ -30,27 +31,9 @@ const LogsActions = ({
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
const [showSkeleton, setShowSkeleton] = useState(loadingStat);
|
||||
const showSkeleton = useMinimumLoadingTime(loadingStat);
|
||||
const needSkeleton = !showStat || showSkeleton;
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingStat) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loadingStat]);
|
||||
|
||||
// Skeleton placeholder layout (three tag-size blocks)
|
||||
const placeholder = (
|
||||
<Space>
|
||||
<Skeleton.Title style={{ width: 108, height: 21, borderRadius: 6 }} />
|
||||
|
||||
@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import i18next from 'i18next';
|
||||
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
|
||||
import { copy, showSuccess } from './utils';
|
||||
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import * as LobeIcons from '@lobehub/icons';
|
||||
import {
|
||||
OpenAI,
|
||||
Claude,
|
||||
@@ -69,28 +70,15 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
CircleUser,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 侧边栏图标颜色映射
|
||||
export const sidebarIconColors = {
|
||||
dashboard: '#10B981', // 绿色
|
||||
terminal: '#10B981', // 绿色
|
||||
message: '#06B6D4', // 青色
|
||||
key: '#3B82F6', // 蓝色
|
||||
chart: '#F59E0B', // 琥珀色
|
||||
image: '#EC4899', // 粉色
|
||||
check: '#F59E0B', // 琥珀色
|
||||
credit: '#F97316', // 橙色
|
||||
layers: '#EF4444', // 红色
|
||||
gift: '#F43F5E', // 玫红色
|
||||
user: '#10B981', // 绿色
|
||||
settings: '#F97316', // 橙色
|
||||
};
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
||||
const iconColor = selected ? SELECTED_COLOR : 'currentColor';
|
||||
const commonProps = {
|
||||
size,
|
||||
strokeWidth,
|
||||
@@ -103,70 +91,70 @@ export function getLucideIcon(key, selected = false) {
|
||||
return (
|
||||
<LayoutDashboard
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.dashboard : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'playground':
|
||||
return (
|
||||
<TerminalSquare
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.terminal : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'chat':
|
||||
return (
|
||||
<MessageSquare
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.message : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'token':
|
||||
return (
|
||||
<Key
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.key : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'log':
|
||||
return (
|
||||
<BarChart3
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.chart : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'midjourney':
|
||||
return (
|
||||
<ImageIcon
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.image : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'task':
|
||||
return (
|
||||
<CheckSquare
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.check : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'topup':
|
||||
return (
|
||||
<CreditCard
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.credit : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'channel':
|
||||
return (
|
||||
<Layers
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.layers : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'redemption':
|
||||
return (
|
||||
<Gift
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.gift : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'user':
|
||||
@@ -174,21 +162,28 @@ export function getLucideIcon(key, selected = false) {
|
||||
return (
|
||||
<User
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.user : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'models':
|
||||
return (
|
||||
<Package
|
||||
{...commonProps}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'setting':
|
||||
return (
|
||||
<Settings
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.settings : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<CircleUser
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.user : 'currentColor'}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -422,6 +417,37 @@ export function getChannelIcon(channelType) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图标名称动态获取 LobeHub 图标组件
|
||||
* @param {string} iconName - 图标名称
|
||||
* @param {number} size - 图标大小,默认为 14
|
||||
* @returns {JSX.Element} - 对应的图标组件或 Avatar
|
||||
*/
|
||||
export function getLobeHubIcon(iconName, size = 14) {
|
||||
if (typeof iconName === 'string') iconName = iconName.trim();
|
||||
// 如果没有图标名称,返回 Avatar
|
||||
if (!iconName) {
|
||||
return <Avatar size="extra-extra-small">?</Avatar>;
|
||||
}
|
||||
|
||||
let IconComponent;
|
||||
|
||||
if (iconName.includes('.')) {
|
||||
const [base, variant] = iconName.split('.');
|
||||
const BaseIcon = LobeIcons[base];
|
||||
IconComponent = BaseIcon ? BaseIcon[variant] : undefined;
|
||||
} else {
|
||||
IconComponent = LobeIcons[iconName];
|
||||
}
|
||||
|
||||
if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) {
|
||||
return <IconComponent size={size} />;
|
||||
}
|
||||
|
||||
const firstLetter = iconName.charAt(0).toUpperCase();
|
||||
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
|
||||
}
|
||||
|
||||
// 颜色列表
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -891,13 +917,13 @@ export function renderQuota(quota, digits = 2) {
|
||||
if (displayInCurrency) {
|
||||
const result = quota / quotaPerUnit;
|
||||
const fixedResult = result.toFixed(digits);
|
||||
|
||||
|
||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
||||
const minValue = Math.pow(10, -digits);
|
||||
return '$' + minValue.toFixed(digits);
|
||||
}
|
||||
|
||||
|
||||
return '$' + fixedResult;
|
||||
}
|
||||
return renderNumber(quota);
|
||||
|
||||
@@ -572,6 +572,72 @@ export const selectFilter = (input, option) => {
|
||||
return valueText.includes(keyword) || labelText.includes(keyword);
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// 模型定价计算工具函数
|
||||
export const calculateModelPrice = ({
|
||||
record,
|
||||
selectedGroup,
|
||||
groupRatio,
|
||||
tokenUnit,
|
||||
displayPrice,
|
||||
currency,
|
||||
precision = 4
|
||||
}) => {
|
||||
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 unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
||||
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
||||
|
||||
const rawDisplayInput = displayPrice(inputRatioPriceUSD);
|
||||
const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
|
||||
|
||||
const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
|
||||
|
||||
return {
|
||||
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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化价格信息(用于卡片视图)
|
||||
export const formatPriceInfo = (priceData, t) => {
|
||||
if (priceData.isPerToken) {
|
||||
return (
|
||||
<>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('提示')} {priceData.inputPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('补全')} {priceData.completionPrice}/{priceData.unitLabel}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span style={{ color: 'var(--semi-color-text-1)' }}>
|
||||
{t('模型价格')} {priceData.price}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// CardPro 分页配置函数
|
||||
// 用于创建 CardPro 的 paginationArea 配置
|
||||
@@ -620,3 +686,45 @@ export const createCardProPagination = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 模型定价筛选条件默认值
|
||||
const DEFAULT_PRICING_FILTERS = {
|
||||
search: '',
|
||||
showWithRecharge: false,
|
||||
currency: 'USD',
|
||||
showRatio: false,
|
||||
viewMode: 'card',
|
||||
tokenUnit: 'M',
|
||||
filterGroup: 'all',
|
||||
filterQuotaType: 'all',
|
||||
filterEndpointType: 'all',
|
||||
filterVendor: 'all',
|
||||
currentPage: 1,
|
||||
};
|
||||
|
||||
// 重置模型定价筛选条件
|
||||
export const resetPricingFilters = ({
|
||||
handleChange,
|
||||
setShowWithRecharge,
|
||||
setCurrency,
|
||||
setShowRatio,
|
||||
setViewMode,
|
||||
setFilterGroup,
|
||||
setFilterQuotaType,
|
||||
setFilterEndpointType,
|
||||
setFilterVendor,
|
||||
setCurrentPage,
|
||||
setTokenUnit,
|
||||
}) => {
|
||||
handleChange?.(DEFAULT_PRICING_FILTERS.search);
|
||||
setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge);
|
||||
setCurrency?.(DEFAULT_PRICING_FILTERS.currency);
|
||||
setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio);
|
||||
setViewMode?.(DEFAULT_PRICING_FILTERS.viewMode);
|
||||
setTokenUnit?.(DEFAULT_PRICING_FILTERS.tokenUnit);
|
||||
setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup);
|
||||
setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
|
||||
setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
|
||||
setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
|
||||
setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
|
||||
};
|
||||
|
||||
50
web/src/hooks/common/useMinimumLoadingTime.js
Normal file
50
web/src/hooks/common/useMinimumLoadingTime.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
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 { useState, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:确保骨架屏至少显示指定的时间
|
||||
* @param {boolean} loading - 实际的加载状态
|
||||
* @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms
|
||||
* @returns {boolean} showSkeleton - 是否显示骨架屏的状态
|
||||
*/
|
||||
export const useMinimumLoadingTime = (loading, minimumTime = 1000) => {
|
||||
const [showSkeleton, setShowSkeleton] = useState(loading);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
loadingStartRef.current = Date.now();
|
||||
setShowSkeleton(true);
|
||||
} else {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, minimumTime - elapsed);
|
||||
|
||||
if (remaining === 0) {
|
||||
setShowSkeleton(false);
|
||||
} else {
|
||||
const timer = setTimeout(() => setShowSkeleton(false), remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [loading, minimumTime]);
|
||||
|
||||
return showSkeleton;
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import { API, isAdmin, showError, timestamp2string } from '../../helpers';
|
||||
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
|
||||
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
|
||||
|
||||
export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,6 +36,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [greetingVisible, setGreetingVisible] = useState(false);
|
||||
const [searchModalVisible, setSearchModalVisible] = useState(false);
|
||||
const showLoading = useMinimumLoadingTime(loading);
|
||||
|
||||
// ========== 输入状态 ==========
|
||||
const [inputs, setInputs] = useState({
|
||||
@@ -145,7 +147,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
// ========== API 调用函数 ==========
|
||||
const loadQuotaData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
let url = '';
|
||||
const { start_timestamp, end_timestamp, username } = inputs;
|
||||
@@ -177,11 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
return [];
|
||||
}
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remainingTime = Math.max(0, 500 - elapsed);
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, remainingTime);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
|
||||
|
||||
@@ -246,7 +243,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||
|
||||
return {
|
||||
// 基础状态
|
||||
loading,
|
||||
loading: showLoading,
|
||||
greetingVisible,
|
||||
searchModalVisible,
|
||||
|
||||
|
||||
@@ -19,28 +19,36 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import { useState, useEffect, useContext, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, copy, showError, showInfo, showSuccess, getModelCategories } from '../../helpers';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
|
||||
export const useModelPricingData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [filteredValue, setFilteredValue] = useState([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const compositionRef = useRef({ isComposition: false });
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState('default');
|
||||
const [activeKey, setActiveKey] = 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 [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
const [showWithRecharge, setShowWithRecharge] = useState(false);
|
||||
const [tokenUnit, setTokenUnit] = useState('M');
|
||||
const [models, setModels] = useState([]);
|
||||
const [vendorsMap, setVendorsMap] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [groupRatio, setGroupRatio] = useState({});
|
||||
const [usableGroup, setUsableGroup] = useState({});
|
||||
const [endpointMap, setEndpointMap] = useState({});
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
@@ -49,53 +57,58 @@ export const useModelPricingData = () => {
|
||||
const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]);
|
||||
const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]);
|
||||
|
||||
const modelCategories = getModelCategories(t);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = {};
|
||||
if (models.length > 0) {
|
||||
counts['all'] = models.length;
|
||||
Object.entries(modelCategories).forEach(([key, category]) => {
|
||||
if (key !== 'all') {
|
||||
counts[key] = models.filter(model => category.filter(model)).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}, [models, modelCategories]);
|
||||
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!models.length) return ['all'];
|
||||
return Object.entries(modelCategories).filter(([key, category]) => {
|
||||
if (key === 'all') return true;
|
||||
return models.some(model => category.filter(model));
|
||||
}).map(([key]) => key);
|
||||
}, [models]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
if (activeKey !== 'all') {
|
||||
result = result.filter(model => modelCategories[activeKey].filter(model));
|
||||
// 分组筛选
|
||||
if (filterGroup !== 'all') {
|
||||
result = result.filter(model => model.enable_groups.includes(filterGroup));
|
||||
}
|
||||
|
||||
if (filteredValue.length > 0) {
|
||||
const searchTerm = filteredValue[0].toLowerCase();
|
||||
// 计费类型筛选
|
||||
if (filterQuotaType !== 'all') {
|
||||
result = result.filter(model => model.quota_type === filterQuotaType);
|
||||
}
|
||||
|
||||
// 端点类型筛选
|
||||
if (filterEndpointType !== 'all') {
|
||||
result = result.filter(model =>
|
||||
model.model_name.toLowerCase().includes(searchTerm)
|
||||
model.supported_endpoint_types &&
|
||||
model.supported_endpoint_types.includes(filterEndpointType)
|
||||
);
|
||||
}
|
||||
|
||||
// 供应商筛选
|
||||
if (filterVendor !== 'all') {
|
||||
if (filterVendor === 'unknown') {
|
||||
result = result.filter(model => !model.vendor_name);
|
||||
} else {
|
||||
result = result.filter(model => model.vendor_name === filterVendor);
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchValue.length > 0) {
|
||||
const searchTerm = searchValue.toLowerCase();
|
||||
result = result.filter(model =>
|
||||
(model.model_name && model.model_name.toLowerCase().includes(searchTerm)) ||
|
||||
(model.description && model.description.toLowerCase().includes(searchTerm)) ||
|
||||
(model.tags && model.tags.toLowerCase().includes(searchTerm)) ||
|
||||
(model.vendor_name && model.vendor_name.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeKey, models, filteredValue]);
|
||||
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
setSelectedRowKeys(keys);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[selectedRowKeys],
|
||||
);
|
||||
|
||||
const displayPrice = (usdPrice) => {
|
||||
@@ -110,10 +123,18 @@ export const useModelPricingData = () => {
|
||||
return `$${priceInUSD.toFixed(3)}`;
|
||||
};
|
||||
|
||||
const setModelsFormat = (models, groupRatio) => {
|
||||
const setModelsFormat = (models, groupRatio, vendorMap) => {
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
models[i].key = models[i].model_name;
|
||||
models[i].group_ratio = groupRatio[models[i].model_name];
|
||||
const m = models[i];
|
||||
m.key = m.model_name;
|
||||
m.group_ratio = groupRatio[m.model_name];
|
||||
|
||||
if (m.vendor_id && vendorMap[m.vendor_id]) {
|
||||
const vendor = vendorMap[m.vendor_id];
|
||||
m.vendor_name = vendor.name;
|
||||
m.vendor_icon = vendor.icon;
|
||||
m.vendor_description = vendor.description;
|
||||
}
|
||||
}
|
||||
models.sort((a, b) => {
|
||||
return a.quota_type - b.quota_type;
|
||||
@@ -139,12 +160,21 @@ export const useModelPricingData = () => {
|
||||
setLoading(true);
|
||||
let url = '/api/pricing';
|
||||
const res = await API.get(url);
|
||||
const { success, message, data, group_ratio, usable_group } = res.data;
|
||||
const { success, message, data, vendors, group_ratio, usable_group, supported_endpoint } = res.data;
|
||||
if (success) {
|
||||
setGroupRatio(group_ratio);
|
||||
setUsableGroup(usable_group);
|
||||
setSelectedGroup(userState.user ? userState.user.group : 'default');
|
||||
setModelsFormat(data, group_ratio);
|
||||
// 构建供应商 Map 方便查找
|
||||
const vendorMap = {};
|
||||
if (Array.isArray(vendors)) {
|
||||
vendors.forEach(v => {
|
||||
vendorMap[v.id] = v;
|
||||
});
|
||||
}
|
||||
setVendorsMap(vendorMap);
|
||||
setEndpointMap(supported_endpoint || {});
|
||||
setModelsFormat(data, group_ratio, vendorMap);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -167,8 +197,8 @@ export const useModelPricingData = () => {
|
||||
if (compositionRef.current.isComposition) {
|
||||
return;
|
||||
}
|
||||
const newFilteredValue = value ? [value] : [];
|
||||
setFilteredValue(newFilteredValue);
|
||||
const newSearchValue = value ? value : '';
|
||||
setSearchValue(newSearchValue);
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
@@ -178,12 +208,14 @@ export const useModelPricingData = () => {
|
||||
const handleCompositionEnd = (event) => {
|
||||
compositionRef.current.isComposition = false;
|
||||
const value = event.target.value;
|
||||
const newFilteredValue = value ? [value] : [];
|
||||
setFilteredValue(newFilteredValue);
|
||||
const newSearchValue = value ? value : '';
|
||||
setSearchValue(newSearchValue);
|
||||
};
|
||||
|
||||
const handleGroupClick = (group) => {
|
||||
setSelectedGroup(group);
|
||||
// 同时将分组过滤设置为该分组
|
||||
setFilterGroup(group);
|
||||
showInfo(
|
||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||||
group: group,
|
||||
@@ -192,14 +224,29 @@ export const useModelPricingData = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const openModelDetail = (model) => {
|
||||
setSelectedModel(model);
|
||||
setShowModelDetail(true);
|
||||
};
|
||||
|
||||
const closeModelDetail = () => {
|
||||
setShowModelDetail(false);
|
||||
setSelectedModel(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().then();
|
||||
}, []);
|
||||
|
||||
// 当筛选条件变化时重置到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filteredValue,
|
||||
setFilteredValue,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
selectedRowKeys,
|
||||
setSelectedRowKeys,
|
||||
modalImageUrl,
|
||||
@@ -208,10 +255,22 @@ export const useModelPricingData = () => {
|
||||
setIsModalOpenurl,
|
||||
selectedGroup,
|
||||
setSelectedGroup,
|
||||
activeKey,
|
||||
setActiveKey,
|
||||
showModelDetail,
|
||||
setShowModelDetail,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
filterGroup,
|
||||
setFilterGroup,
|
||||
filterQuotaType,
|
||||
setFilterQuotaType,
|
||||
filterEndpointType,
|
||||
setFilterEndpointType,
|
||||
filterVendor,
|
||||
setFilterVendor,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
currency,
|
||||
setCurrency,
|
||||
showWithRecharge,
|
||||
@@ -222,16 +281,17 @@ export const useModelPricingData = () => {
|
||||
loading,
|
||||
groupRatio,
|
||||
usableGroup,
|
||||
endpointMap,
|
||||
|
||||
// 计算属性
|
||||
priceRate,
|
||||
usdExchangeRate,
|
||||
modelCategories,
|
||||
categoryCounts,
|
||||
availableCategories,
|
||||
filteredModels,
|
||||
rowSelection,
|
||||
|
||||
// 供应商
|
||||
vendorsMap,
|
||||
|
||||
// 用户和状态
|
||||
userState,
|
||||
statusState,
|
||||
@@ -244,6 +304,8 @@ export const useModelPricingData = () => {
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
handleGroupClick,
|
||||
openModelDetail,
|
||||
closeModelDetail,
|
||||
|
||||
// 引用
|
||||
compositionRef,
|
||||
|
||||
131
web/src/hooks/model-pricing/usePricingFilterCounts.js
Normal file
131
web/src/hooks/model-pricing/usePricingFilterCounts.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
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 { useMemo } from 'react';
|
||||
|
||||
export const usePricingFilterCounts = ({
|
||||
models = [],
|
||||
filterGroup = 'all',
|
||||
filterQuotaType = 'all',
|
||||
filterEndpointType = 'all',
|
||||
filterVendor = '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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 (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) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
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 {
|
||||
quotaTypeModels,
|
||||
endpointTypeModels,
|
||||
vendorModels,
|
||||
groupCountModels,
|
||||
};
|
||||
};
|
||||
384
web/src/hooks/models/useModelsData.js
Normal file
384
web/src/hooks/models/useModelsData.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
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 { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useModelsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('models');
|
||||
|
||||
// State management
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [modelCount, setModelCount] = useState(0);
|
||||
|
||||
// Modal states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
// Row selection
|
||||
const [selectedKeys, setSelectedKeys] = useState([]);
|
||||
const rowSelection = {
|
||||
getCheckboxProps: (record) => ({
|
||||
name: record.model_name,
|
||||
}),
|
||||
selectedRowKeys: selectedKeys.map((model) => model.id),
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
};
|
||||
|
||||
// Form initial values
|
||||
const formInitValues = {
|
||||
searchKeyword: '',
|
||||
searchVendor: '',
|
||||
};
|
||||
|
||||
// ---------- helpers ----------
|
||||
// Safely extract array items from API payload
|
||||
const extractItems = (payload) => {
|
||||
const items = payload?.items || payload || [];
|
||||
return Array.isArray(items) ? items : [];
|
||||
};
|
||||
|
||||
// Form API reference
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
// Get form values helper function
|
||||
const getFormValues = () => formApi?.getValues() || formInitValues;
|
||||
|
||||
// Close edit modal
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingModel({ id: undefined });
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Set model format with key field
|
||||
const setModelFormat = (models) => {
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
models[i].key = models[i].id;
|
||||
}
|
||||
setModels(models);
|
||||
};
|
||||
|
||||
// Vendor list
|
||||
const [vendors, setVendors] = useState([]);
|
||||
const [vendorCounts, setVendorCounts] = useState({});
|
||||
const [activeVendorKey, setActiveVendorKey] = useState('all');
|
||||
const [showAddVendor, setShowAddVendor] = useState(false);
|
||||
const [showEditVendor, setShowEditVendor] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState({ id: undefined });
|
||||
|
||||
const vendorMap = useMemo(() => {
|
||||
const map = {};
|
||||
vendors.forEach(v => {
|
||||
map[v.id] = v;
|
||||
});
|
||||
return map;
|
||||
}, [vendors]);
|
||||
|
||||
// Load vendor list
|
||||
const loadVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000');
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Load models data
|
||||
const loadModels = async (page = 1, size = pageSize, vendorKey = activeVendorKey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/models/?p=${page}&page_size=${size}`;
|
||||
if (vendorKey && vendorKey !== 'all') {
|
||||
// Filter by vendor ID
|
||||
url = `/api/models/search?vendor=${vendorKey}&p=${page}&page_size=${size}`;
|
||||
}
|
||||
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = extractItems(data);
|
||||
setActivePage(data.page || page);
|
||||
setModelCount(data.total || newPageData.length);
|
||||
setModelFormat(newPageData);
|
||||
|
||||
if (data.vendor_counts) {
|
||||
const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0);
|
||||
setVendorCounts({ ...data.vendor_counts, all: sumAll });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
setModels([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('获取模型列表失败'));
|
||||
setModels([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async (page = activePage) => {
|
||||
await loadModels(page, pageSize);
|
||||
};
|
||||
|
||||
// Search models with keyword and vendor
|
||||
const searchModels = async () => {
|
||||
const { searchKeyword = '', searchVendor = '' } = getFormValues();
|
||||
|
||||
if (searchKeyword === '' && searchVendor === '') {
|
||||
// If keyword is blank, load models instead
|
||||
await loadModels(1, pageSize);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = extractItems(data);
|
||||
setActivePage(data.page || 1);
|
||||
setModelCount(data.total || newPageData.length);
|
||||
setModelFormat(newPageData);
|
||||
if (data.vendor_counts) {
|
||||
const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0);
|
||||
setVendorCounts({ ...data.vendor_counts, all: sumAll });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
setModels([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showError(t('搜索模型失败'));
|
||||
setModels([]);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
// Manage model (enable/disable/delete)
|
||||
const manageModel = async (id, action, record) => {
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/models/${id}`);
|
||||
break;
|
||||
case 'enable':
|
||||
res = await API.put('/api/models/?status_only=true', { id, status: 1 });
|
||||
break;
|
||||
case 'disable':
|
||||
res = await API.put('/api/models/?status_only=true', { id, status: 0 });
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
if (action === 'delete') {
|
||||
await refresh();
|
||||
} else {
|
||||
// Update local state for enable/disable
|
||||
setModels(prevModels =>
|
||||
prevModels.map(model =>
|
||||
model.id === id ? { ...model, status: action === 'enable' ? 1 : 0 } : model
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadModels(page, pageSize, activeVendorKey);
|
||||
};
|
||||
|
||||
// Reload models when activeVendorKey changes
|
||||
useEffect(() => {
|
||||
loadModels(1, pageSize, activeVendorKey);
|
||||
}, [activeVendorKey]);
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = async (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadModels(1, size, activeVendorKey);
|
||||
};
|
||||
|
||||
// Handle row click and styling
|
||||
const handleRow = (record, index) => {
|
||||
const rowStyle = record.status !== 1 ? {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
} : {};
|
||||
|
||||
return {
|
||||
...rowStyle,
|
||||
onClick: (event) => {
|
||||
// Don't trigger row selection when clicking on buttons
|
||||
if (event.target.closest('button, .semi-button')) {
|
||||
return;
|
||||
}
|
||||
const newSelectedKeys = selectedKeys.some(item => item.id === record.id)
|
||||
? selectedKeys.filter(item => item.id !== record.id)
|
||||
: [...selectedKeys, record];
|
||||
setSelectedKeys(newSelectedKeys);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Batch delete models
|
||||
const batchDeleteModels = async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deletePromises = selectedKeys.map(model =>
|
||||
API.delete(`/api/models/${model.id}`)
|
||||
);
|
||||
|
||||
const results = await Promise.all(deletePromises);
|
||||
let successCount = 0;
|
||||
|
||||
results.forEach((res, index) => {
|
||||
if (res.data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
showError(`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(t(`成功删除 ${successCount} 个模型`));
|
||||
setSelectedKeys([]);
|
||||
await refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('批量删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Copy text helper
|
||||
const copyText = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showSuccess(t('复制成功'));
|
||||
} catch (error) {
|
||||
console.error('Copy failed:', error);
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadVendors();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
models,
|
||||
loading,
|
||||
searching,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
|
||||
// Selection state
|
||||
selectedKeys,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
setSelectedKeys,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
editingModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
closeEdit,
|
||||
|
||||
// Form state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
|
||||
// Actions
|
||||
loadModels,
|
||||
searchModels,
|
||||
refresh,
|
||||
manageModel,
|
||||
batchDeleteModels,
|
||||
copyText,
|
||||
|
||||
// Pagination
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor data
|
||||
vendors,
|
||||
vendorMap,
|
||||
vendorCounts,
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -699,7 +699,6 @@
|
||||
"个": "indivual",
|
||||
"倍率是本站的计算方式,不同模型有着不同的倍率,并非官方价格的多少倍,请务必知晓。": "The magnification is the calculation method of this website. Different models have different magnifications, which are not multiples of the official price. Please be sure to know.",
|
||||
"所有各厂聊天模型请统一使用OpenAI方式请求,支持OpenAI官方库<br/>Claude()Claude官方格式请求": "Please use the OpenAI method to request all chat models from each factory, and support the OpenAI official library<br/>Claude()Claude official format request",
|
||||
"复制选中模型": "Copy selected model",
|
||||
"分组说明": "Group description",
|
||||
"倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.",
|
||||
"点击查看倍率说明": "Click to view the magnification description",
|
||||
@@ -951,7 +950,7 @@
|
||||
"黑夜模式": "Dark mode",
|
||||
"管理员设置": "Admin",
|
||||
"待更新": "To be updated",
|
||||
"定价": "Pricing",
|
||||
"模型广场": "Pricing",
|
||||
"支付中..": "Paying",
|
||||
"查看图片": "View pictures",
|
||||
"并发限制": "Concurrency limit",
|
||||
@@ -1196,6 +1195,7 @@
|
||||
"兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?",
|
||||
"兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "The redemption code will be downloaded as a text file, with the filename being the redemption code name.",
|
||||
"模型价格": "Model price",
|
||||
"按K显示单位": "Display in K units",
|
||||
"可用分组": "Available groups",
|
||||
"您的默认分组为:{{group}},分组倍率为:{{ratio}}": "Your default group is: {{group}}, group ratio: {{ratio}}",
|
||||
"按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "The cost of pay-as-you-go = Group ratio × Model ratio × (Prompt token number + Completion token number × Completion ratio) / 500000 (Unit: USD)",
|
||||
|
||||
@@ -53,6 +53,7 @@ code {
|
||||
|
||||
/* ==================== 导航和侧边栏样式 ==================== */
|
||||
/* 导航项样式 */
|
||||
.semi-tagInput,
|
||||
.semi-input-textarea-wrapper,
|
||||
.semi-navigation-sub-title,
|
||||
.semi-chat-inputBox-sendButton,
|
||||
@@ -391,7 +392,8 @@ code {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 隐藏卡片内容区域的滚动条 */
|
||||
/* 隐藏内容区域滚动条 */
|
||||
.pricing-scroll-hide,
|
||||
.model-test-scroll,
|
||||
.card-content-scroll,
|
||||
.model-settings-scroll,
|
||||
@@ -403,6 +405,7 @@ code {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.pricing-scroll-hide::-webkit-scrollbar,
|
||||
.model-test-scroll::-webkit-scrollbar,
|
||||
.card-content-scroll::-webkit-scrollbar,
|
||||
.model-settings-scroll::-webkit-scrollbar,
|
||||
@@ -615,4 +618,59 @@ html:not(.dark) .blur-ball-teal {
|
||||
height: calc(100vh - 77px);
|
||||
max-height: calc(100vh - 77px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 模型定价页面布局 ==================== */
|
||||
.pricing-layout {
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.pricing-sidebar {
|
||||
min-width: 460px;
|
||||
max-width: 460px;
|
||||
height: calc(100vh - 60px);
|
||||
background-color: var(--semi-color-bg-0);
|
||||
border-right: 1px solid var(--semi-color-border);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pricing-content {
|
||||
height: calc(100vh - 60px);
|
||||
background-color: var(--semi-color-bg-0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pricing-pagination-divider {
|
||||
border-color: var(--semi-color-border);
|
||||
}
|
||||
|
||||
.pricing-content-mobile {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pricing-search-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--semi-color-border);
|
||||
background-color: var(--semi-color-bg-0);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.pricing-view-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pricing-view-container-mobile {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
12
web/src/pages/Model/index.js
Normal file
12
web/src/pages/Model/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ModelsTable from '../../components/table/models';
|
||||
|
||||
const ModelPage = () => {
|
||||
return (
|
||||
<div className="mt-[60px] px-2">
|
||||
<ModelsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPage;
|
||||
@@ -18,12 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ModelPricingPage from '../../components/table/model-pricing';
|
||||
import ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage';
|
||||
|
||||
const Pricing = () => (
|
||||
<div className="mt-[60px] px-2">
|
||||
<>
|
||||
<ModelPricingPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
|
||||
Reference in New Issue
Block a user