🤓feat: the model management module

Merge pull request #1452 from QuantumNous/refactor/model-pricing
This commit is contained in:
同語
2025-08-08 04:23:12 +08:00
committed by GitHub
82 changed files with 8471 additions and 1519 deletions

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

View 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
View 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)
}

View 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)
}

View File

@@ -41,9 +41,11 @@ func GetPricing(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"success": true, "success": true,
"data": pricing, "data": pricing,
"group_ratio": groupRatio, "vendors": model.GetVendors(),
"usable_group": usableGroup, "group_ratio": groupRatio,
}) "usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
})
} }
func ResetModelRatio(c *gin.Context) { func ResetModelRatio(c *gin.Context) {

124
controller/vendor_meta.go Normal file
View 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)
}

View File

@@ -250,6 +250,9 @@ func migrateDB() error {
&TopUp{}, &TopUp{},
&QuotaData{}, &QuotaData{},
&Task{}, &Task{},
&Model{},
&Vendor{},
&PrefillGroup{},
&Setup{}, &Setup{},
&TwoFA{}, &TwoFA{},
&TwoFABackupCode{}, &TwoFABackupCode{},
@@ -278,6 +281,9 @@ func migrateDBFast() error {
{&TopUp{}, "TopUp"}, {&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"}, {&QuotaData{}, "QuotaData"},
{&Task{}, "Task"}, {&Task{}, "Task"},
{&Model{}, "Model"},
{&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"}, {&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"}, {&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"}, {&TwoFABackupCode{}, "TwoFABackupCode"},

30
model/missing_models.go Normal file
View 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
View 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
View 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
View 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
}

View File

@@ -1,30 +1,50 @@
package model package model
import ( import (
"fmt" "encoding/json"
"one-api/common" "fmt"
"one-api/constant" "strings"
"one-api/setting/ratio_setting"
"one-api/types" "one-api/common"
"sync" "one-api/constant"
"time" "one-api/setting/ratio_setting"
"one-api/types"
"sync"
"time"
) )
type Pricing struct { type Pricing struct {
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
QuotaType int `json:"quota_type"` Description string `json:"description,omitempty"`
ModelRatio float64 `json:"model_ratio"` Tags string `json:"tags,omitempty"`
ModelPrice float64 `json:"model_price"` VendorID int `json:"vendor_id,omitempty"`
OwnerBy string `json:"owner_by"` QuotaType int `json:"quota_type"`
CompletionRatio float64 `json:"completion_ratio"` ModelRatio float64 `json:"model_ratio"`
EnableGroup []string `json:"enable_groups"` ModelPrice float64 `json:"model_price"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` 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 ( var (
pricingMap []Pricing pricingMap []Pricing
lastGetPricingTime time.Time vendorsList []PricingVendor
updatePricingLock sync.Mutex 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 ( var (
@@ -46,6 +66,15 @@ func GetPricing() []Pricing {
return pricingMap 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 { func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
if model == "" { if model == "" {
return make([]constant.EndpointType, 0) return make([]constant.EndpointType, 0)
@@ -65,6 +94,77 @@ func updatePricing() {
common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
return 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]) modelGroupsMap := make(map[string]*types.Set[string])
for _, ability := range enableAbilities { for _, ability := range enableAbilities {
@@ -79,20 +179,34 @@ func updatePricing() {
//这里使用切片而不是Set因为一个模型可能支持多个端点类型并且第一个端点是优先使用端点 //这里使用切片而不是Set因为一个模型可能支持多个端点类型并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string) modelSupportEndpointsStr := make(map[string][]string)
for _, ability := range enableAbilities { // 先根据已有能力填充原生端点
endpoints, ok := modelSupportEndpointsStr[ability.Model] for _, ability := range enableAbilities {
if !ok { endpoints := modelSupportEndpointsStr[ability.Model]
endpoints = make([]string, 0) channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
modelSupportEndpointsStr[ability.Model] = endpoints for _, channelType := range channelTypes {
} if !common.StringsContains(endpoints, string(channelType)) {
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) endpoints = append(endpoints, string(channelType))
for _, channelType := range channelTypes { }
if !common.StringsContains(endpoints, string(channelType)) { }
endpoints = append(endpoints, string(channelType)) modelSupportEndpointsStr[ability.Model] = endpoints
} }
}
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) modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr { for model, endpoints := range modelSupportEndpointsStr {
@@ -102,26 +216,92 @@ func updatePricing() {
supportedEndpoints = append(supportedEndpoints, endpointType) supportedEndpoints = append(supportedEndpoints, endpointType)
} }
modelSupportEndpointTypes[model] = supportedEndpoints modelSupportEndpointTypes[model] = supportedEndpoints
} }
pricingMap = make([]Pricing, 0) // 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
for model, groups := range modelGroupsMap { supportedEndpointMap = make(map[string]common.EndpointInfo)
pricing := Pricing{ // 1. 默认端点
ModelName: model, for _, endpoints := range modelSupportEndpointTypes {
EnableGroup: groups.Items(), for _, et := range endpoints {
SupportedEndpointTypes: modelSupportEndpointTypes[model], if info, ok := common.GetDefaultEndpointInfo(et); ok {
} if _, exists := supportedEndpointMap[string(et)]; !exists {
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) supportedEndpointMap[string(et)] = info
if findPrice { }
pricing.ModelPrice = modelPrice }
pricing.QuotaType = 1 }
} else { }
modelRatio, _, _ := ratio_setting.GetModelRatio(model) // 2. 自定义端点models 表)覆盖默认
pricing.ModelRatio = modelRatio for _, meta := range metaMap {
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) if strings.TrimSpace(meta.Endpoints) == "" {
pricing.QuotaType = 0 continue
} }
pricingMap = append(pricingMap, pricing) var raw map[string]interface{}
} if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
lastGetPricingTime = time.Now() 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
View 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
View 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
}

View File

@@ -179,6 +179,16 @@ func SetApiRouter(router *gin.Engine) {
{ {
groupRoute.GET("/", controller.GetGroups) 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 := apiRouter.Group("/mj")
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
@@ -188,5 +198,28 @@ func SetApiRouter(router *gin.Engine) {
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) 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)
}
} }
} }

View File

@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link';
import Midjourney from './pages/Midjourney'; import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js'; import Pricing from './pages/Pricing/index.js';
import Task from './pages/Task/index.js'; import Task from './pages/Task/index.js';
import ModelPage from './pages/Model/index.js';
import Playground from './pages/Playground/index.js'; import Playground from './pages/Playground/index.js';
import OAuth2Callback from './components/auth/OAuth2Callback.js'; import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/settings/PersonalSetting.js'; import PersonalSetting from './components/settings/PersonalSetting.js';
@@ -71,6 +72,14 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route
path='/console/models'
element={
<PrivateRoute>
<ModelPage />
</PrivateRoute>
}
/>
<Route <Route
path='/console/channel' path='/console/channel'
element={ element={

View File

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

View File

@@ -112,6 +112,7 @@ const CardPro = ({
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />} icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type="tertiary" type="tertiary"
size="small" size="small"
theme='outline'
block block
> >
{showMobileActions ? t('隐藏操作项') : t('显示操作项')} {showMobileActions ? t('隐藏操作项') : t('显示操作项')}

View File

@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile'; import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
/** /**
* CardTable 响应式表格组件 * CardTable 响应式表格组件
@@ -40,25 +41,8 @@ const CardTable = ({
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { t } = useTranslation(); const { t } = useTranslation();
const [showSkeleton, setShowSkeleton] = useState(loading); const showSkeleton = useMinimumLoadingTime(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 getRowKey = (record, index) => { const getRowKey = (record, index) => {
if (typeof rowKey === 'function') return rowKey(record); if (typeof rowKey === 'function') return rowKey(record);

View 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;

View 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>
);
};

View 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;

View File

@@ -52,6 +52,7 @@ import {
import { StatusContext } from '../../context/Status/index.js'; import { StatusContext } from '../../context/Status/index.js';
import { useIsMobile } from '../../hooks/common/useIsMobile.js'; import { useIsMobile } from '../../hooks/common/useIsMobile.js';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js';
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const [statusState, statusDispatch] = useContext(StatusContext); const [statusState, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [collapsed, toggleCollapsed] = useSidebarCollapsed(); const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [isLoading, setIsLoading] = useState(true);
const [logoLoaded, setLogoLoaded] = useState(false); const [logoLoaded, setLogoLoaded] = useState(false);
let navigate = useNavigate(); let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language); const [currentLang, setCurrentLang] = useState(i18n.language);
@@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const location = useLocation(); const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false); const [noticeVisible, setNoticeVisible] = useState(false);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const loadingStartRef = useRef(Date.now());
const loading = statusState?.status === undefined;
const isLoading = useMinimumLoadingTime(loading);
const systemName = getSystemName(); const systemName = getSystemName();
const logo = getLogo(); const logo = getLogo();
@@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
to: '/console', to: '/console',
}, },
{ {
text: t('定价'), text: t('模型广场'),
itemKey: 'pricing', itemKey: 'pricing',
to: '/pricing', to: '/pricing',
}, },
@@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
}; };
}, [i18n]); }, [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(() => { useEffect(() => {
setLogoLoaded(false); setLogoLoaded(false);
if (!logo) return; if (!logo) return;
@@ -467,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
}; };
return ( 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 <NoticeModal
visible={noticeVisible} visible={noticeVisible}
onClose={handleNoticeClose} onClose={handleNoticeClose}

View File

@@ -42,7 +42,7 @@ const PageLayout = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const location = useLocation(); const location = useLocation();
const shouldHideFooter = location.pathname.startsWith('/console'); const shouldHideFooter = location.pathname.startsWith('/console') || location.pathname === '/pricing';
const shouldInnerPadding = location.pathname.includes('/console') && const shouldInnerPadding = location.pathname.includes('/console') &&
!location.pathname.startsWith('/console/chat') && !location.pathname.startsWith('/console/chat') &&

View File

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; import { getLucideIcon } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
import { import {
@@ -49,6 +49,7 @@ const routerMap = {
detail: '/console', detail: '/console',
pricing: '/pricing', pricing: '/pricing',
task: '/console/task', task: '/console/task',
models: '/console/models',
playground: '/console/playground', playground: '/console/playground',
personal: '/console/personal', personal: '/console/personal',
}; };
@@ -127,6 +128,12 @@ const SiderBar = ({ onNavigate = () => { } }) => {
const adminItems = useMemo( const adminItems = useMemo(
() => [ () => [
{
text: t('模型管理'),
itemKey: 'models',
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
},
{ {
text: t('渠道管理'), text: t('渠道管理'),
itemKey: 'channel', itemKey: 'channel',
@@ -244,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => {
} }
}, [collapsed]); }, [collapsed]);
// 获取菜单项对应的颜色 // 选中高亮颜色(统一)
const getItemColor = (itemKey) => { const SELECTED_COLOR = 'var(--semi-color-primary)';
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 renderNavItem = (item) => { const renderNavItem = (item) => {
@@ -273,7 +260,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
if (item.className === 'tableHiddle') return null; if (item.className === 'tableHiddle') return null;
const isSelected = selectedKeys.includes(item.itemKey); const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; const textColor = isSelected ? SELECTED_COLOR : 'inherit';
return ( return (
<Nav.Item <Nav.Item
@@ -300,7 +287,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
const renderSubItem = (item) => { const renderSubItem = (item) => {
if (item.items && item.items.length > 0) { if (item.items && item.items.length > 0) {
const isSelected = selectedKeys.includes(item.itemKey); const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; const textColor = isSelected ? SELECTED_COLOR : 'inherit';
return ( return (
<Nav.Sub <Nav.Sub
@@ -321,7 +308,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
> >
{item.items.map((subItem) => { {item.items.map((subItem) => {
const isSubSelected = selectedKeys.includes(subItem.itemKey); const isSubSelected = selectedKeys.includes(subItem.itemKey);
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit'; const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
return ( return (
<Nav.Item <Nav.Item

View File

@@ -48,7 +48,7 @@ import {
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal'; import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/JSONEditor'; import JSONEditor from '../../../common/ui/JSONEditor';
import { import {
IconSave, IconSave,
IconClose, IconClose,
@@ -1174,27 +1174,27 @@ const EditChannelModal = (props) => {
</> </>
)} )}
{isEdit && isMultiKeyChannel && ( {isEdit && isMultiKeyChannel && (
<Form.Select <Form.Select
field='key_mode' field='key_mode'
label={t('密钥更新模式')} label={t('密钥更新模式')}
placeholder={t('请选择密钥更新模式')} placeholder={t('请选择密钥更新模式')}
optionList={[ optionList={[
{ label: t('追加到现有密钥'), value: 'append' }, { label: t('追加到现有密钥'), value: 'append' },
{ label: t('覆盖现有密钥'), value: 'replace' }, { label: t('覆盖现有密钥'), value: 'replace' },
]} ]}
style={{ width: '100%' }} style={{ width: '100%' }}
value={keyMode} value={keyMode}
onChange={(value) => setKeyMode(value)} onChange={(value) => setKeyMode(value)}
extraText={ extraText={
<Text type="tertiary" size="small"> <Text type="tertiary" size="small">
{keyMode === 'replace' {keyMode === 'replace'
? t('覆盖模式:将完全替换现有的所有密钥') ? t('覆盖模式:将完全替换现有的所有密钥')
: t('追加模式:将新密钥添加到现有密钥列表末尾') : t('追加模式:将新密钥添加到现有密钥列表末尾')
}
</Text>
} }
/> </Text>
}
/>
)} )}
{batch && multiToSingle && ( {batch && multiToSingle && (
<> <>
@@ -1247,11 +1247,7 @@ const EditChannelModal = (props) => {
templateLabel={t('填入模板')} templateLabel={t('填入模板')}
editorType="region" editorType="region"
formApi={formApiRef.current} formApi={formApiRef.current}
extraText={ extraText={t('设置默认地区和特定模型的专用地区')}
<Text type="tertiary" size="small">
{t('设置默认地区和特定模型的专用地区')}
</Text>
}
/> />
)} )}
@@ -1520,11 +1516,7 @@ const EditChannelModal = (props) => {
templateLabel={t('填入模板')} templateLabel={t('填入模板')}
editorType="keyValue" editorType="keyValue"
formApi={formApiRef.current} formApi={formApiRef.current}
extraText={ extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
<Text type="tertiary" size="small">
{t('键为请求中的模型名称,值为要替换的模型名称')}
</Text>
}
/> />
</Card> </Card>
@@ -1628,11 +1620,7 @@ const EditChannelModal = (props) => {
templateLabel={t('填入模板')} templateLabel={t('填入模板')}
editorType="keyValue" editorType="keyValue"
formApi={formApiRef.current} formApi={formApiRef.current}
extraText={ extraText={t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
<Text type="tertiary" size="small">
{t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
</Text>
}
/> />
</Card> </Card>

View File

@@ -175,7 +175,7 @@ const ModelTestModal = ({
<Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base"> <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
{currentTestChannel.name} {t('渠道的模型测试')} {currentTestChannel.name} {t('渠道的模型测试')}
</Typography.Text> </Typography.Text>
<Typography.Text type="tertiary" className="!text-xs flex items-center"> <Typography.Text type="tertiary" size="small">
{t('共')} {currentTestChannel.models.split(',').length} {t('个模型')} {t('共')} {currentTestChannel.models.split(',').length} {t('个模型')}
</Typography.Text> </Typography.Text>
</div> </div>

View File

@@ -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;
},
},
];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 { 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;

View File

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

View File

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

View File

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

View 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 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;

View File

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

View File

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

View File

@@ -23,82 +23,88 @@ import {
IllustrationNoResult, IllustrationNoResult,
IllustrationNoResultDark IllustrationNoResultDark
} from '@douyinfe/semi-illustrations'; } from '@douyinfe/semi-illustrations';
import { getModelPricingColumns } from './ModelPricingColumnDefs.js'; import { getPricingTableColumns } from './PricingTableColumns';
const ModelPricingTable = ({ const PricingTable = ({
filteredModels, filteredModels,
loading, loading,
rowSelection, rowSelection,
pageSize, pageSize,
setPageSize, setPageSize,
selectedGroup, selectedGroup,
usableGroup,
groupRatio, groupRatio,
copyText, copyText,
setModalImageUrl, setModalImageUrl,
setIsModalOpenurl, setIsModalOpenurl,
currency, currency,
showWithRecharge,
tokenUnit, tokenUnit,
setTokenUnit, setTokenUnit,
displayPrice, displayPrice,
filteredValue, searchValue,
handleGroupClick, showRatio,
compactMode = false,
openModelDetail,
t t
}) => { }) => {
const columns = useMemo(() => { const columns = useMemo(() => {
return getModelPricingColumns({ return getPricingTableColumns({
t, t,
selectedGroup, selectedGroup,
usableGroup,
groupRatio, groupRatio,
copyText, copyText,
setModalImageUrl, setModalImageUrl,
setIsModalOpenurl, setIsModalOpenurl,
currency, currency,
showWithRecharge,
tokenUnit, tokenUnit,
setTokenUnit,
displayPrice, displayPrice,
handleGroupClick, showRatio,
}); });
}, [ }, [
t, t,
selectedGroup, selectedGroup,
usableGroup,
groupRatio, groupRatio,
copyText, copyText,
setModalImageUrl, setModalImageUrl,
setIsModalOpenurl, setIsModalOpenurl,
currency, currency,
showWithRecharge,
tokenUnit, tokenUnit,
setTokenUnit,
displayPrice, displayPrice,
handleGroupClick, showRatio,
]); ]);
// filteredValue // searchValue
const tableColumns = useMemo(() => { const processedColumns = useMemo(() => {
return columns.map(column => { const cols = columns.map(column => {
if (column.dataIndex === 'model_name') { if (column.dataIndex === 'model_name') {
return { return {
...column, ...column,
filteredValue filteredValue: searchValue ? [searchValue] : []
}; };
} }
return column; 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(() => ( const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" bordered={false}> <Card className="!rounded-xl overflow-hidden" bordered={false}>
<Table <Table
columns={tableColumns} columns={processedColumns}
dataSource={filteredModels} dataSource={filteredModels}
loading={loading} loading={loading}
rowSelection={rowSelection} rowSelection={rowSelection}
className="custom-table" className="custom-table"
scroll={compactMode ? undefined : { x: 'max-content' }}
onRow={(record) => ({
onClick: () => openModelDetail && openModelDetail(record),
style: { cursor: 'pointer' }
})}
empty={ empty={
<Empty <Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />} image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
@@ -116,9 +122,9 @@ const ModelPricingTable = ({
}} }}
/> />
</Card> </Card>
), [filteredModels, loading, tableColumns, rowSelection, pageSize, setPageSize, t]); ), [filteredModels, loading, processedColumns, rowSelection, pageSize, setPageSize, openModelDetail, t, compactMode]);
return ModelTable; return ModelTable;
}; };
export default ModelPricingTable; export default PricingTable;

View File

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

View 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;

View 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
),
},
];
};

View 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;

View 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;

View 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;

View 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;

View 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, { 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View File

@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { Tag, Space, Skeleton } from '@douyinfe/semi-ui';
import { renderQuota } from '../../../helpers'; import { renderQuota } from '../../../helpers';
import CompactModeToggle from '../../common/ui/CompactModeToggle'; import CompactModeToggle from '../../common/ui/CompactModeToggle';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
const LogsActions = ({ const LogsActions = ({
stat, stat,
@@ -30,27 +31,9 @@ const LogsActions = ({
setCompactMode, setCompactMode,
t, t,
}) => { }) => {
const [showSkeleton, setShowSkeleton] = useState(loadingStat); const showSkeleton = useMinimumLoadingTime(loadingStat);
const needSkeleton = !showStat || showSkeleton; 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 = ( const placeholder = (
<Space> <Space>
<Skeleton.Title style={{ width: 108, height: 21, borderRadius: 6 }} /> <Skeleton.Title style={{ width: 108, height: 21, borderRadius: 6 }} />

View File

@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import i18next from 'i18next'; 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 { copy, showSuccess } from './utils';
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js'; import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
import { visit } from 'unist-util-visit'; import { visit } from 'unist-util-visit';
import * as LobeIcons from '@lobehub/icons';
import { import {
OpenAI, OpenAI,
Claude, Claude,
@@ -69,28 +70,15 @@ import {
User, User,
Settings, Settings,
CircleUser, CircleUser,
Package,
} from 'lucide-react'; } 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图标组件 // 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) { export function getLucideIcon(key, selected = false) {
const size = 16; const size = 16;
const strokeWidth = 2; const strokeWidth = 2;
const SELECTED_COLOR = 'var(--semi-color-primary)';
const iconColor = selected ? SELECTED_COLOR : 'currentColor';
const commonProps = { const commonProps = {
size, size,
strokeWidth, strokeWidth,
@@ -103,70 +91,70 @@ export function getLucideIcon(key, selected = false) {
return ( return (
<LayoutDashboard <LayoutDashboard
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.dashboard : 'currentColor'} color={iconColor}
/> />
); );
case 'playground': case 'playground':
return ( return (
<TerminalSquare <TerminalSquare
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.terminal : 'currentColor'} color={iconColor}
/> />
); );
case 'chat': case 'chat':
return ( return (
<MessageSquare <MessageSquare
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.message : 'currentColor'} color={iconColor}
/> />
); );
case 'token': case 'token':
return ( return (
<Key <Key
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.key : 'currentColor'} color={iconColor}
/> />
); );
case 'log': case 'log':
return ( return (
<BarChart3 <BarChart3
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.chart : 'currentColor'} color={iconColor}
/> />
); );
case 'midjourney': case 'midjourney':
return ( return (
<ImageIcon <ImageIcon
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.image : 'currentColor'} color={iconColor}
/> />
); );
case 'task': case 'task':
return ( return (
<CheckSquare <CheckSquare
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.check : 'currentColor'} color={iconColor}
/> />
); );
case 'topup': case 'topup':
return ( return (
<CreditCard <CreditCard
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.credit : 'currentColor'} color={iconColor}
/> />
); );
case 'channel': case 'channel':
return ( return (
<Layers <Layers
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.layers : 'currentColor'} color={iconColor}
/> />
); );
case 'redemption': case 'redemption':
return ( return (
<Gift <Gift
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.gift : 'currentColor'} color={iconColor}
/> />
); );
case 'user': case 'user':
@@ -174,21 +162,28 @@ export function getLucideIcon(key, selected = false) {
return ( return (
<User <User
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.user : 'currentColor'} color={iconColor}
/>
);
case 'models':
return (
<Package
{...commonProps}
color={iconColor}
/> />
); );
case 'setting': case 'setting':
return ( return (
<Settings <Settings
{...commonProps} {...commonProps}
color={selected ? sidebarIconColors.settings : 'currentColor'} color={iconColor}
/> />
); );
default: default:
return ( return (
<CircleUser <CircleUser
{...commonProps} {...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 = [ const colors = [
'amber', 'amber',
@@ -891,13 +917,13 @@ export function renderQuota(quota, digits = 2) {
if (displayInCurrency) { if (displayInCurrency) {
const result = quota / quotaPerUnit; const result = quota / quotaPerUnit;
const fixedResult = result.toFixed(digits); const fixedResult = result.toFixed(digits);
// 如果 toFixed 后结果为 0 但原始值不为 0显示最小值 // 如果 toFixed 后结果为 0 但原始值不为 0显示最小值
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) { if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
const minValue = Math.pow(10, -digits); const minValue = Math.pow(10, -digits);
return '$' + minValue.toFixed(digits); return '$' + minValue.toFixed(digits);
} }
return '$' + fixedResult; return '$' + fixedResult;
} }
return renderNumber(quota); return renderNumber(quota);

View File

@@ -572,6 +572,72 @@ export const selectFilter = (input, option) => {
return valueText.includes(keyword) || labelText.includes(keyword); 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 分页配置函数
// 用于创建 CardPro 的 paginationArea 配置 // 用于创建 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);
};

View 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;
};

View File

@@ -24,6 +24,7 @@ import { API, isAdmin, showError, timestamp2string } from '../../helpers';
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard'; import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
import { TIME_OPTIONS } from '../../constants/dashboard.constants'; import { TIME_OPTIONS } from '../../constants/dashboard.constants';
import { useIsMobile } from '../common/useIsMobile'; import { useIsMobile } from '../common/useIsMobile';
import { useMinimumLoadingTime } from '../common/useMinimumLoadingTime';
export const useDashboardData = (userState, userDispatch, statusState) => { export const useDashboardData = (userState, userDispatch, statusState) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -35,6 +36,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [greetingVisible, setGreetingVisible] = useState(false); const [greetingVisible, setGreetingVisible] = useState(false);
const [searchModalVisible, setSearchModalVisible] = useState(false); const [searchModalVisible, setSearchModalVisible] = useState(false);
const showLoading = useMinimumLoadingTime(loading);
// ========== 输入状态 ========== // ========== 输入状态 ==========
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
@@ -145,7 +147,6 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
// ========== API 调用函数 ========== // ========== API 调用函数 ==========
const loadQuotaData = useCallback(async () => { const loadQuotaData = useCallback(async () => {
setLoading(true); setLoading(true);
const startTime = Date.now();
try { try {
let url = ''; let url = '';
const { start_timestamp, end_timestamp, username } = inputs; const { start_timestamp, end_timestamp, username } = inputs;
@@ -177,11 +178,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
return []; return [];
} }
} finally { } finally {
const elapsed = Date.now() - startTime; setLoading(false);
const remainingTime = Math.max(0, 500 - elapsed);
setTimeout(() => {
setLoading(false);
}, remainingTime);
} }
}, [inputs, dataExportDefaultTime, isAdminUser, now]); }, [inputs, dataExportDefaultTime, isAdminUser, now]);
@@ -246,7 +243,7 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
return { return {
// 基础状态 // 基础状态
loading, loading: showLoading,
greetingVisible, greetingVisible,
searchModalVisible, searchModalVisible,

View File

@@ -19,28 +19,36 @@ For commercial licensing, please contact support@quantumnous.com
import { useState, useEffect, useContext, useRef, useMemo } from 'react'; import { useState, useEffect, useContext, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; 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 { Modal } from '@douyinfe/semi-ui';
import { UserContext } from '../../context/User/index.js'; import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js'; import { StatusContext } from '../../context/Status/index.js';
export const useModelPricingData = () => { export const useModelPricingData = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]); const [searchValue, setSearchValue] = useState('');
const compositionRef = useRef({ isComposition: false }); const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState(''); const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false); const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default'); 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 [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD'); const [currency, setCurrency] = useState('USD');
const [showWithRecharge, setShowWithRecharge] = useState(false); const [showWithRecharge, setShowWithRecharge] = useState(false);
const [tokenUnit, setTokenUnit] = useState('M'); const [tokenUnit, setTokenUnit] = useState('M');
const [models, setModels] = useState([]); const [models, setModels] = useState([]);
const [vendorsMap, setVendorsMap] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groupRatio, setGroupRatio] = useState({}); const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({}); const [usableGroup, setUsableGroup] = useState({});
const [endpointMap, setEndpointMap] = useState({});
const [statusState] = useContext(StatusContext); const [statusState] = useContext(StatusContext);
const [userState] = useContext(UserContext); const [userState] = useContext(UserContext);
@@ -49,53 +57,58 @@ export const useModelPricingData = () => {
const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]); const priceRate = useMemo(() => statusState?.status?.price ?? 1, [statusState]);
const usdExchangeRate = useMemo(() => statusState?.status?.usd_exchange_rate ?? priceRate, [statusState, priceRate]); 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(() => { const filteredModels = useMemo(() => {
let result = models; 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 => 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; return result;
}, [activeKey, models, filteredValue]); }, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
const rowSelection = useMemo( const rowSelection = useMemo(
() => ({ () => ({
onChange: (selectedRowKeys, selectedRows) => { selectedRowKeys,
setSelectedRowKeys(selectedRowKeys); onChange: (keys) => {
setSelectedRowKeys(keys);
}, },
}), }),
[], [selectedRowKeys],
); );
const displayPrice = (usdPrice) => { const displayPrice = (usdPrice) => {
@@ -110,10 +123,18 @@ export const useModelPricingData = () => {
return `$${priceInUSD.toFixed(3)}`; return `$${priceInUSD.toFixed(3)}`;
}; };
const setModelsFormat = (models, groupRatio) => { const setModelsFormat = (models, groupRatio, vendorMap) => {
for (let i = 0; i < models.length; i++) { for (let i = 0; i < models.length; i++) {
models[i].key = models[i].model_name; const m = models[i];
models[i].group_ratio = groupRatio[models[i].model_name]; 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) => { models.sort((a, b) => {
return a.quota_type - b.quota_type; return a.quota_type - b.quota_type;
@@ -139,12 +160,21 @@ export const useModelPricingData = () => {
setLoading(true); setLoading(true);
let url = '/api/pricing'; let url = '/api/pricing';
const res = await API.get(url); 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) { if (success) {
setGroupRatio(group_ratio); setGroupRatio(group_ratio);
setUsableGroup(usable_group); setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default'); 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 { } else {
showError(message); showError(message);
} }
@@ -167,8 +197,8 @@ export const useModelPricingData = () => {
if (compositionRef.current.isComposition) { if (compositionRef.current.isComposition) {
return; return;
} }
const newFilteredValue = value ? [value] : []; const newSearchValue = value ? value : '';
setFilteredValue(newFilteredValue); setSearchValue(newSearchValue);
}; };
const handleCompositionStart = () => { const handleCompositionStart = () => {
@@ -178,12 +208,14 @@ export const useModelPricingData = () => {
const handleCompositionEnd = (event) => { const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false; compositionRef.current.isComposition = false;
const value = event.target.value; const value = event.target.value;
const newFilteredValue = value ? [value] : []; const newSearchValue = value ? value : '';
setFilteredValue(newFilteredValue); setSearchValue(newSearchValue);
}; };
const handleGroupClick = (group) => { const handleGroupClick = (group) => {
setSelectedGroup(group); setSelectedGroup(group);
// 同时将分组过滤设置为该分组
setFilterGroup(group);
showInfo( showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', { t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group, group: group,
@@ -192,14 +224,29 @@ export const useModelPricingData = () => {
); );
}; };
const openModelDetail = (model) => {
setSelectedModel(model);
setShowModelDetail(true);
};
const closeModelDetail = () => {
setShowModelDetail(false);
setSelectedModel(null);
};
useEffect(() => { useEffect(() => {
refresh().then(); refresh().then();
}, []); }, []);
// 当筛选条件变化时重置到第一页
useEffect(() => {
setCurrentPage(1);
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
return { return {
// 状态 // 状态
filteredValue, searchValue,
setFilteredValue, setSearchValue,
selectedRowKeys, selectedRowKeys,
setSelectedRowKeys, setSelectedRowKeys,
modalImageUrl, modalImageUrl,
@@ -208,10 +255,22 @@ export const useModelPricingData = () => {
setIsModalOpenurl, setIsModalOpenurl,
selectedGroup, selectedGroup,
setSelectedGroup, setSelectedGroup,
activeKey, showModelDetail,
setActiveKey, setShowModelDetail,
selectedModel,
setSelectedModel,
filterGroup,
setFilterGroup,
filterQuotaType,
setFilterQuotaType,
filterEndpointType,
setFilterEndpointType,
filterVendor,
setFilterVendor,
pageSize, pageSize,
setPageSize, setPageSize,
currentPage,
setCurrentPage,
currency, currency,
setCurrency, setCurrency,
showWithRecharge, showWithRecharge,
@@ -222,16 +281,17 @@ export const useModelPricingData = () => {
loading, loading,
groupRatio, groupRatio,
usableGroup, usableGroup,
endpointMap,
// 计算属性 // 计算属性
priceRate, priceRate,
usdExchangeRate, usdExchangeRate,
modelCategories,
categoryCounts,
availableCategories,
filteredModels, filteredModels,
rowSelection, rowSelection,
// 供应商
vendorsMap,
// 用户和状态 // 用户和状态
userState, userState,
statusState, statusState,
@@ -244,6 +304,8 @@ export const useModelPricingData = () => {
handleCompositionStart, handleCompositionStart,
handleCompositionEnd, handleCompositionEnd,
handleGroupClick, handleGroupClick,
openModelDetail,
closeModelDetail,
// 引用 // 引用
compositionRef, compositionRef,

View 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,
};
};

View 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,
};
};

View File

@@ -699,7 +699,6 @@
"个": "indivual", "个": "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.", "倍率是本站的计算方式,不同模型有着不同的倍率,并非官方价格的多少倍,请务必知晓。": "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", "所有各厂聊天模型请统一使用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", "分组说明": "Group description",
"倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.", "倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.",
"点击查看倍率说明": "Click to view the magnification description", "点击查看倍率说明": "Click to view the magnification description",
@@ -951,7 +950,7 @@
"黑夜模式": "Dark mode", "黑夜模式": "Dark mode",
"管理员设置": "Admin", "管理员设置": "Admin",
"待更新": "To be updated", "待更新": "To be updated",
"定价": "Pricing", "模型广场": "Pricing",
"支付中..": "Paying", "支付中..": "Paying",
"查看图片": "View pictures", "查看图片": "View pictures",
"并发限制": "Concurrency limit", "并发限制": "Concurrency limit",
@@ -1196,6 +1195,7 @@
"兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?", "兑换码创建成功,是否下载兑换码?": "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.", "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "The redemption code will be downloaded as a text file, with the filename being the redemption code name.",
"模型价格": "Model price", "模型价格": "Model price",
"按K显示单位": "Display in K units",
"可用分组": "Available groups", "可用分组": "Available groups",
"您的默认分组为:{{group}},分组倍率为:{{ratio}}": "Your default group is: {{group}}, group ratio: {{ratio}}", "您的默认分组为:{{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)", "按量计费费用 = 分组倍率 × 模型倍率 × 提示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)",

View File

@@ -53,6 +53,7 @@ code {
/* ==================== 导航和侧边栏样式 ==================== */ /* ==================== 导航和侧边栏样式 ==================== */
/* 导航项样式 */ /* 导航项样式 */
.semi-tagInput,
.semi-input-textarea-wrapper, .semi-input-textarea-wrapper,
.semi-navigation-sub-title, .semi-navigation-sub-title,
.semi-chat-inputBox-sendButton, .semi-chat-inputBox-sendButton,
@@ -391,7 +392,8 @@ code {
background: transparent; background: transparent;
} }
/* 隐藏卡片内容区域滚动条 */ /* 隐藏内容区域滚动条 */
.pricing-scroll-hide,
.model-test-scroll, .model-test-scroll,
.card-content-scroll, .card-content-scroll,
.model-settings-scroll, .model-settings-scroll,
@@ -403,6 +405,7 @@ code {
scrollbar-width: none; scrollbar-width: none;
} }
.pricing-scroll-hide::-webkit-scrollbar,
.model-test-scroll::-webkit-scrollbar, .model-test-scroll::-webkit-scrollbar,
.card-content-scroll::-webkit-scrollbar, .card-content-scroll::-webkit-scrollbar,
.model-settings-scroll::-webkit-scrollbar, .model-settings-scroll::-webkit-scrollbar,
@@ -615,4 +618,59 @@ html:not(.dark) .blur-ball-teal {
height: calc(100vh - 77px); height: calc(100vh - 77px);
max-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;
} }

View 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;

View File

@@ -18,12 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React from 'react';
import ModelPricingPage from '../../components/table/model-pricing'; import ModelPricingPage from '../../components/table/model-pricing/layout/PricingPage';
const Pricing = () => ( const Pricing = () => (
<div className="mt-[60px] px-2"> <>
<ModelPricingPage /> <ModelPricingPage />
</div> </>
); );
export default Pricing; export default Pricing;