Commit dfcee356 by 同語 Committed by GitHub

🤓feat: the model management module

Merge pull request #1452 from QuantumNous/refactor/model-pricing
parents e69b5c45 70719d4a
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
}
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,
})
}
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)
}
package controller
import (
"strconv"
"one-api/common"
"one-api/model"
"github.com/gin-gonic/gin"
)
// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
func GetPrefillGroups(c *gin.Context) {
groupType := c.Query("type")
groups, err := model.GetAllPrefillGroups(groupType)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, groups)
}
// CreatePrefillGroup 创建新的预填组
func CreatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Name == "" || g.Type == "" {
common.ApiErrorMsg(c, "组名称和类型不能为空")
return
}
// 创建前检查名称
if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Insert(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// UpdatePrefillGroup 更新预填组
func UpdatePrefillGroup(c *gin.Context) {
var g model.PrefillGroup
if err := c.ShouldBindJSON(&g); err != nil {
common.ApiError(c, err)
return
}
if g.Id == 0 {
common.ApiErrorMsg(c, "缺少组 ID")
return
}
// 名称冲突检查
if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
common.ApiError(c, err)
return
} else if dup {
common.ApiErrorMsg(c, "组名称已存在")
return
}
if err := g.Update(); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, &g)
}
// DeletePrefillGroup 删除预填组
func DeletePrefillGroup(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}
if err := model.DeletePrefillGroupByID(id); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}
...@@ -41,9 +41,11 @@ func GetPricing(c *gin.Context) { ...@@ -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) {
......
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)
}
\ No newline at end of file
...@@ -250,6 +250,9 @@ func migrateDB() error { ...@@ -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 { ...@@ -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"},
......
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
}
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
}
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
}
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
}
package model
// RefreshPricing 强制立即重新计算与定价相关的缓存。
// 该方法用于需要最新数据的内部管理 API,
// 因此会绕过默认的 1 分钟延迟刷新。
func RefreshPricing() {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
}
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
}
\ No newline at end of file
...@@ -179,6 +179,16 @@ func SetApiRouter(router *gin.Engine) { ...@@ -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) { ...@@ -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)
}
} }
} }
...@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link'; ...@@ -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';
...@@ -72,6 +73,14 @@ function App() { ...@@ -72,6 +73,14 @@ function App() {
} }
/> />
<Route <Route
path='/console/models'
element={
<PrivateRoute>
<ModelPage />
</PrivateRoute>
}
/>
<Route
path='/console/channel' path='/console/channel'
element={ element={
<PrivateRoute> <PrivateRoute>
......
...@@ -112,6 +112,7 @@ const CardPro = ({ ...@@ -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('显示操作项')}
......
...@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@ ...@@ -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 = ({ ...@@ -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);
......
/*
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>
);
};
\ No newline at end of file
/*
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;
\ No newline at end of file
...@@ -52,6 +52,7 @@ import { ...@@ -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 }) => { ...@@ -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 }) => { ...@@ -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 }) => { ...@@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
to: '/console', to: '/console',
}, },
{ {
text: t('定价'), text: t('模型广场'),
itemKey: 'pricing', itemKey: 'pricing',
to: '/pricing', to: '/pricing',
}, },
...@@ -217,17 +219,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { ...@@ -217,17 +219,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
}, [i18n]); }, [i18n]);
useEffect(() => { useEffect(() => {
if (statusState?.status !== undefined) {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = Math.max(0, 500 - elapsed);
const timer = setTimeout(() => {
setIsLoading(false);
}, remaining);
return () => clearTimeout(timer);
}
}, [statusState?.status]);
useEffect(() => {
setLogoLoaded(false); setLogoLoaded(false);
if (!logo) return; if (!logo) return;
const img = new Image(); const img = new Image();
...@@ -467,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { ...@@ -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}
......
...@@ -42,7 +42,7 @@ const PageLayout = () => { ...@@ -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') &&
......
...@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -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 = { ...@@ -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',
}; };
...@@ -128,6 +129,12 @@ const SiderBar = ({ onNavigate = () => { } }) => { ...@@ -128,6 +129,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',
to: '/channel', to: '/channel',
...@@ -244,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => { ...@@ -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 = () => { } }) => { ...@@ -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 = () => { } }) => { ...@@ -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 = () => { } }) => { ...@@ -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
......
...@@ -48,7 +48,7 @@ import { ...@@ -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) => { ...@@ -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) => { ...@@ -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) => { ...@@ -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) => { ...@@ -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>
......
...@@ -175,7 +175,7 @@ const ModelTestModal = ({ ...@@ -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>
......
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
...@@ -17,71 +17,93 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,71 +17,93 @@ 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, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui'; import { Input, Button } from '@douyinfe/semi-ui';
import { IconSearch, IconCopy } from '@douyinfe/semi-icons'; import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
import PricingFilterModal from '../../modal/PricingFilterModal';
import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
const ModelPricingFilters = ({ const PricingTopSection = ({
selectedRowKeys, selectedRowKeys,
copyText, copyText,
showWithRecharge,
setShowWithRecharge,
currency,
setCurrency,
handleChange, handleChange,
handleCompositionStart, handleCompositionStart,
handleCompositionEnd, handleCompositionEnd,
isMobile,
sidebarProps,
filterVendor,
models,
filteredModels,
loading,
t t
}) => { }) => {
const [showFilterModal, setShowFilterModal] = useState(false);
const SearchAndActions = useMemo(() => ( const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" bordered={false}> <div className="flex items-center gap-4 w-full">
<div className="flex flex-wrap items-center gap-4"> {/* 搜索框 */}
<div className="flex-1 min-w-[200px]"> <div className="flex-1">
<Input <Input
prefix={<IconSearch />} prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')} placeholder={t('模糊搜索模型名称')}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onChange={handleChange} onChange={handleChange}
showClear showClear
/> />
</div> </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 <Button
theme='light' theme="outline"
type='primary' type='tertiary'
icon={<IconCopy />} icon={<IconFilter />}
onClick={() => copyText(selectedRowKeys)} onClick={() => setShowFilterModal(true)}
disabled={selectedRowKeys.length === 0}
className="!bg-blue-500 hover:!bg-blue-600 text-white"
> >
{t('复制选中模型')} {t('筛选')}
</Button> </Button>
)}
</div>
), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText, isMobile]);
{/* 充值价格显示开关 */} return (
<Space align="center"> <>
<span>{t('以充值价格显示')}</span> {/* 供应商介绍区域(含骨架屏) */}
<Switch <PricingVendorIntroWithSkeleton
checked={showWithRecharge} loading={loading}
onChange={setShowWithRecharge} filterVendor={filterVendor}
size="small" models={filteredModels}
/> allModels={models}
{showWithRecharge && ( t={t}
<Select />
value={currency}
onChange={setCurrency} {/* 搜索和操作区域 */}
size="small" {SearchAndActions}
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; {/* 移动端筛选Modal */}
{isMobile && (
<PricingFilterModal
visible={showFilterModal}
onClose={() => setShowFilterModal(false)}
sidebarProps={sidebarProps}
t={t}
/>
)}
</>
);
}; };
export default ModelPricingFilters; export default PricingTopSection;
\ No newline at end of file \ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
...@@ -23,82 +23,88 @@ import { ...@@ -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 = ({ ...@@ -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;
\ No newline at end of file \ No newline at end of file
/*
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;
\ No newline at end of file
/*
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
),
},
];
};
\ No newline at end of file
/*
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;
\ No newline at end of file
/*
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;
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment