Просмотр исходного кода

🤓feat: the model management module

Merge pull request #1452 from QuantumNous/refactor/model-pricing
同語 7 месяцев назад
Родитель
Сommit
ac158e227e
82 измененных файлов с 8470 добавлено и 1518 удалено
  1. 32 0
      common/endpoint_defaults.go
  2. 27 0
      controller/missing_models.go
  3. 178 0
      controller/model_meta.go
  4. 90 0
      controller/prefill_group.go
  5. 5 3
      controller/pricing.go
  6. 124 0
      controller/vendor_meta.go
  7. 6 0
      model/main.go
  8. 30 0
      model/missing_models.go
  9. 34 0
      model/model_extra.go
  10. 204 0
      model/model_meta.go
  11. 126 0
      model/prefill_group.go
  12. 233 53
      model/pricing.go
  13. 14 0
      model/pricing_refresh.go
  14. 88 0
      model/vendor_meta.go
  15. 33 0
      router/api-router.go
  16. 9 0
      web/src/App.js
  17. 0 622
      web/src/components/common/JSONEditor.js
  18. 1 0
      web/src/components/common/ui/CardPro.js
  19. 3 19
      web/src/components/common/ui/CardTable.js
  20. 669 0
      web/src/components/common/ui/JSONEditor.js
  21. 60 0
      web/src/components/common/ui/RenderUtils.jsx
  22. 257 0
      web/src/components/common/ui/SelectableButtonGroup.jsx
  23. 6 15
      web/src/components/layout/HeaderBar.js
  24. 1 1
      web/src/components/layout/PageLayout.js
  25. 13 26
      web/src/components/layout/SiderBar.js
  26. 24 36
      web/src/components/table/channels/modals/EditChannelModal.jsx
  27. 1 1
      web/src/components/table/channels/modals/ModelTestModal.jsx
  28. 0 261
      web/src/components/table/model-pricing/ModelPricingColumnDefs.js
  29. 0 87
      web/src/components/table/model-pricing/ModelPricingFilters.jsx
  30. 0 123
      web/src/components/table/model-pricing/ModelPricingHeader.jsx
  31. 0 67
      web/src/components/table/model-pricing/ModelPricingTabs.jsx
  32. 127 0
      web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx
  33. 88 0
      web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx
  34. 71 0
      web/src/components/table/model-pricing/filter/PricingGroups.jsx
  35. 52 0
      web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx
  36. 119 0
      web/src/components/table/model-pricing/filter/PricingVendors.jsx
  37. 0 66
      web/src/components/table/model-pricing/index.jsx
  38. 90 0
      web/src/components/table/model-pricing/layout/PricingPage.jsx
  39. 157 0
      web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  40. 40 0
      web/src/components/table/model-pricing/layout/content/PricingContent.jsx
  41. 33 0
      web/src/components/table/model-pricing/layout/content/PricingView.jsx
  42. 109 0
      web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
  43. 247 0
      web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
  44. 75 0
      web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx
  45. 52 0
      web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx
  46. 105 0
      web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx
  47. 75 0
      web/src/components/table/model-pricing/modal/PricingFilterModal.jsx
  48. 124 0
      web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx
  49. 44 0
      web/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx
  50. 92 0
      web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx
  51. 83 0
      web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx
  52. 83 0
      web/src/components/table/model-pricing/modal/components/ModelHeader.jsx
  53. 190 0
      web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  54. 137 0
      web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx
  55. 326 0
      web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  56. 29 23
      web/src/components/table/model-pricing/view/table/PricingTable.jsx
  57. 230 0
      web/src/components/table/model-pricing/view/table/PricingTableColumns.js
  58. 170 0
      web/src/components/table/models/ModelsActions.jsx
  59. 303 0
      web/src/components/table/models/ModelsColumnDefs.js
  60. 44 0
      web/src/components/table/models/ModelsDescription.jsx
  61. 106 0
      web/src/components/table/models/ModelsFilters.jsx
  62. 104 0
      web/src/components/table/models/ModelsTable.jsx
  63. 169 0
      web/src/components/table/models/ModelsTabs.jsx
  64. 106 0
      web/src/components/table/models/components/SelectionNotification.jsx
  65. 142 0
      web/src/components/table/models/index.jsx
  66. 441 0
      web/src/components/table/models/modals/EditModelModal.jsx
  67. 259 0
      web/src/components/table/models/modals/EditPrefillGroupModal.jsx
  68. 190 0
      web/src/components/table/models/modals/EditVendorModal.jsx
  69. 179 0
      web/src/components/table/models/modals/MissingModelsModal.jsx
  70. 285 0
      web/src/components/table/models/modals/PrefillGroupManagement.jsx
  71. 3 20
      web/src/components/table/usage-logs/UsageLogsActions.jsx
  72. 58 32
      web/src/helpers/render.js
  73. 108 0
      web/src/helpers/utils.js
  74. 50 0
      web/src/hooks/common/useMinimumLoadingTime.js
  75. 4 7
      web/src/hooks/dashboard/useDashboardData.js
  76. 112 50
      web/src/hooks/model-pricing/useModelPricingData.js
  77. 131 0
      web/src/hooks/model-pricing/usePricingFilterCounts.js
  78. 384 0
      web/src/hooks/models/useModelsData.js
  79. 2 2
      web/src/i18n/locales/en.json
  80. 59 1
      web/src/index.css
  81. 12 0
      web/src/pages/Model/index.js
  82. 3 3
      web/src/pages/Pricing/index.js

+ 32 - 0
common/endpoint_defaults.go

@@ -0,0 +1,32 @@
+package common
+
+import "one-api/constant"
+
+// EndpointInfo 描述单个端点的默认请求信息
+// path: 上游路径
+// method: HTTP 请求方式,例如 POST/GET
+// 目前均为 POST,后续可扩展
+//
+// json 标签用于直接序列化到 API 输出
+// 例如:{"path":"/v1/chat/completions","method":"POST"}
+
+type EndpointInfo struct {
+    Path   string `json:"path"`
+    Method string `json:"method"`
+}
+
+// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
+var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
+    constant.EndpointTypeOpenAI:          {Path: "/v1/chat/completions", Method: "POST"},
+    constant.EndpointTypeOpenAIResponse:  {Path: "/v1/responses", Method: "POST"},
+    constant.EndpointTypeAnthropic:       {Path: "/v1/messages", Method: "POST"},
+    constant.EndpointTypeGemini:          {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
+    constant.EndpointTypeJinaRerank:      {Path: "/rerank", Method: "POST"},
+    constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
+}
+
+// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
+func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
+    info, ok := defaultEndpointInfoMap[et]
+    return info, ok
+}

+ 27 - 0
controller/missing_models.go

@@ -0,0 +1,27 @@
+package controller
+
+import (
+    "net/http"
+    "one-api/model"
+
+    "github.com/gin-gonic/gin"
+)
+
+// GetMissingModels returns the list of model names that are referenced by channels
+// but do not have corresponding records in the models meta table.
+// This helps administrators quickly discover models that need configuration.
+func GetMissingModels(c *gin.Context) {
+    missing, err := model.GetMissingModels()
+    if err != nil {
+        c.JSON(http.StatusOK, gin.H{
+            "success": false,
+            "message": err.Error(),
+        })
+        return
+    }
+
+    c.JSON(http.StatusOK, gin.H{
+        "success": true,
+        "data":    missing,
+    })
+}

+ 178 - 0
controller/model_meta.go

@@ -0,0 +1,178 @@
+package controller
+
+import (
+    "encoding/json"
+    "strconv"
+
+    "one-api/common"
+    "one-api/model"
+
+    "github.com/gin-gonic/gin"
+)
+
+// GetAllModelsMeta 获取模型列表(分页)
+func GetAllModelsMeta(c *gin.Context) {
+
+    pageInfo := common.GetPageQuery(c)
+    modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    // 填充附加字段
+    for _, m := range modelsMeta {
+        fillModelExtra(m)
+    }
+    var total int64
+    model.DB.Model(&model.Model{}).Count(&total)
+
+    // 统计供应商计数(全部数据,不受分页影响)
+    vendorCounts, _ := model.GetVendorModelCounts()
+
+    pageInfo.SetTotal(int(total))
+    pageInfo.SetItems(modelsMeta)
+    common.ApiSuccess(c, gin.H{
+        "items":         modelsMeta,
+        "total":         total,
+        "page":          pageInfo.GetPage(),
+        "page_size":     pageInfo.GetPageSize(),
+        "vendor_counts": vendorCounts,
+    })
+}
+
+// SearchModelsMeta 搜索模型列表
+func SearchModelsMeta(c *gin.Context) {
+
+    keyword := c.Query("keyword")
+    vendor := c.Query("vendor")
+    pageInfo := common.GetPageQuery(c)
+
+    modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    for _, m := range modelsMeta {
+        fillModelExtra(m)
+    }
+    pageInfo.SetTotal(int(total))
+    pageInfo.SetItems(modelsMeta)
+    common.ApiSuccess(c, pageInfo)
+}
+
+// GetModelMeta 根据 ID 获取单条模型信息
+func GetModelMeta(c *gin.Context) {
+    idStr := c.Param("id")
+    id, err := strconv.Atoi(idStr)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    var m model.Model
+    if err := model.DB.First(&m, id).Error; err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    fillModelExtra(&m)
+    common.ApiSuccess(c, &m)
+}
+
+// CreateModelMeta 新建模型
+func CreateModelMeta(c *gin.Context) {
+    var m model.Model
+    if err := c.ShouldBindJSON(&m); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if m.ModelName == "" {
+        common.ApiErrorMsg(c, "模型名称不能为空")
+        return
+    }
+    // 名称冲突检查
+    if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
+        common.ApiError(c, err)
+        return
+    } else if dup {
+        common.ApiErrorMsg(c, "模型名称已存在")
+        return
+    }
+
+    if err := m.Insert(); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    model.RefreshPricing()
+    common.ApiSuccess(c, &m)
+}
+
+// UpdateModelMeta 更新模型
+func UpdateModelMeta(c *gin.Context) {
+    statusOnly := c.Query("status_only") == "true"
+
+    var m model.Model
+    if err := c.ShouldBindJSON(&m); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if m.Id == 0 {
+        common.ApiErrorMsg(c, "缺少模型 ID")
+        return
+    }
+
+    if statusOnly {
+        // 只更新状态,防止误清空其他字段
+        if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
+            common.ApiError(c, err)
+            return
+        }
+    } else {
+        // 名称冲突检查
+        if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
+            common.ApiError(c, err)
+            return
+        } else if dup {
+            common.ApiErrorMsg(c, "模型名称已存在")
+            return
+        }
+
+        if err := m.Update(); err != nil {
+            common.ApiError(c, err)
+            return
+        }
+    }
+    model.RefreshPricing()
+    common.ApiSuccess(c, &m)
+}
+
+// DeleteModelMeta 删除模型
+func DeleteModelMeta(c *gin.Context) {
+    idStr := c.Param("id")
+    id, err := strconv.Atoi(idStr)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    model.RefreshPricing()
+    common.ApiSuccess(c, nil)
+}
+
+// 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups
+func fillModelExtra(m *model.Model) {
+    if m.Endpoints == "" {
+        eps := model.GetModelSupportEndpointTypes(m.ModelName)
+        if b, err := json.Marshal(eps); err == nil {
+            m.Endpoints = string(b)
+        }
+    }
+    if channels, err := model.GetBoundChannels(m.ModelName); err == nil {
+        m.BoundChannels = channels
+    }
+    // 填充启用分组
+    m.EnableGroups = model.GetModelEnableGroups(m.ModelName)
+    // 填充计费类型
+    m.QuotaType = model.GetModelQuotaType(m.ModelName)
+}

+ 90 - 0
controller/prefill_group.go

@@ -0,0 +1,90 @@
+package controller
+
+import (
+    "strconv"
+
+    "one-api/common"
+    "one-api/model"
+
+    "github.com/gin-gonic/gin"
+)
+
+// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
+func GetPrefillGroups(c *gin.Context) {
+    groupType := c.Query("type")
+    groups, err := model.GetAllPrefillGroups(groupType)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, groups)
+}
+
+// CreatePrefillGroup 创建新的预填组
+func CreatePrefillGroup(c *gin.Context) {
+    var g model.PrefillGroup
+    if err := c.ShouldBindJSON(&g); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if g.Name == "" || g.Type == "" {
+        common.ApiErrorMsg(c, "组名称和类型不能为空")
+        return
+    }
+    // 创建前检查名称
+    if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
+        common.ApiError(c, err)
+        return
+    } else if dup {
+        common.ApiErrorMsg(c, "组名称已存在")
+        return
+    }
+
+    if err := g.Insert(); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, &g)
+}
+
+// UpdatePrefillGroup 更新预填组
+func UpdatePrefillGroup(c *gin.Context) {
+    var g model.PrefillGroup
+    if err := c.ShouldBindJSON(&g); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if g.Id == 0 {
+        common.ApiErrorMsg(c, "缺少组 ID")
+        return
+    }
+    // 名称冲突检查
+    if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
+        common.ApiError(c, err)
+        return
+    } else if dup {
+        common.ApiErrorMsg(c, "组名称已存在")
+        return
+    }
+
+    if err := g.Update(); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, &g)
+}
+
+// DeletePrefillGroup 删除预填组
+func DeletePrefillGroup(c *gin.Context) {
+    idStr := c.Param("id")
+    id, err := strconv.Atoi(idStr)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if err := model.DeletePrefillGroupByID(id); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, nil)
+}

+ 5 - 3
controller/pricing.go

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

+ 124 - 0
controller/vendor_meta.go

@@ -0,0 +1,124 @@
+package controller
+
+import (
+    "strconv"
+
+    "one-api/common"
+    "one-api/model"
+
+    "github.com/gin-gonic/gin"
+)
+
+// GetAllVendors 获取供应商列表(分页)
+func GetAllVendors(c *gin.Context) {
+    pageInfo := common.GetPageQuery(c)
+    vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    var total int64
+    model.DB.Model(&model.Vendor{}).Count(&total)
+    pageInfo.SetTotal(int(total))
+    pageInfo.SetItems(vendors)
+    common.ApiSuccess(c, pageInfo)
+}
+
+// SearchVendors 搜索供应商
+func SearchVendors(c *gin.Context) {
+    keyword := c.Query("keyword")
+    pageInfo := common.GetPageQuery(c)
+    vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    pageInfo.SetTotal(int(total))
+    pageInfo.SetItems(vendors)
+    common.ApiSuccess(c, pageInfo)
+}
+
+// GetVendorMeta 根据 ID 获取供应商
+func GetVendorMeta(c *gin.Context) {
+    idStr := c.Param("id")
+    id, err := strconv.Atoi(idStr)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    v, err := model.GetVendorByID(id)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, v)
+}
+
+// CreateVendorMeta 新建供应商
+func CreateVendorMeta(c *gin.Context) {
+    var v model.Vendor
+    if err := c.ShouldBindJSON(&v); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if v.Name == "" {
+        common.ApiErrorMsg(c, "供应商名称不能为空")
+        return
+    }
+    // 创建前先检查名称
+    if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
+        common.ApiError(c, err)
+        return
+    } else if dup {
+        common.ApiErrorMsg(c, "供应商名称已存在")
+        return
+    }
+
+    if err := v.Insert(); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, &v)
+}
+
+// UpdateVendorMeta 更新供应商
+func UpdateVendorMeta(c *gin.Context) {
+    var v model.Vendor
+    if err := c.ShouldBindJSON(&v); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if v.Id == 0 {
+        common.ApiErrorMsg(c, "缺少供应商 ID")
+        return
+    }
+    // 名称冲突检查
+    if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
+        common.ApiError(c, err)
+        return
+    } else if dup {
+        common.ApiErrorMsg(c, "供应商名称已存在")
+        return
+    }
+
+    if err := v.Update(); err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, &v)
+}
+
+// DeleteVendorMeta 删除供应商
+func DeleteVendorMeta(c *gin.Context) {
+    idStr := c.Param("id")
+    id, err := strconv.Atoi(idStr)
+    if err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
+        common.ApiError(c, err)
+        return
+    }
+    common.ApiSuccess(c, nil)
+}

+ 6 - 0
model/main.go

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

+ 30 - 0
model/missing_models.go

@@ -0,0 +1,30 @@
+package model
+
+// GetMissingModels returns model names that are referenced in the system
+func GetMissingModels() ([]string, error) {
+    // 1. 获取所有已启用模型(去重)
+    models := GetEnabledModels()
+    if len(models) == 0 {
+        return []string{}, nil
+    }
+
+    // 2. 查询已有的元数据模型名
+    var existing []string
+    if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
+        return nil, err
+    }
+
+    existingSet := make(map[string]struct{}, len(existing))
+    for _, e := range existing {
+        existingSet[e] = struct{}{}
+    }
+
+    // 3. 收集缺失模型
+    var missing []string
+    for _, name := range models {
+        if _, ok := existingSet[name]; !ok {
+            missing = append(missing, name)
+        }
+    }
+    return missing, nil
+}

+ 34 - 0
model/model_extra.go

@@ -0,0 +1,34 @@
+package model
+
+// GetModelEnableGroups 返回指定模型名称可用的用户分组列表。
+// 使用在 updatePricing() 中维护的缓存映射,O(1) 读取,适合高并发场景。
+func GetModelEnableGroups(modelName string) []string {
+    // 确保缓存最新
+    GetPricing()
+
+    if modelName == "" {
+        return make([]string, 0)
+    }
+
+    modelEnableGroupsLock.RLock()
+    groups, ok := modelEnableGroups[modelName]
+    modelEnableGroupsLock.RUnlock()
+    if !ok {
+        return make([]string, 0)
+    }
+    return groups
+}
+
+// GetModelQuotaType 返回指定模型的计费类型(quota_type)。
+// 同样使用缓存映射,避免每次遍历定价切片。
+func GetModelQuotaType(modelName string) int {
+    GetPricing()
+
+    modelEnableGroupsLock.RLock()
+    quota, ok := modelQuotaTypeMap[modelName]
+    modelEnableGroupsLock.RUnlock()
+    if !ok {
+        return 0
+    }
+    return quota
+}

+ 204 - 0
model/model_meta.go

@@ -0,0 +1,204 @@
+package model
+
+import (
+    "one-api/common"
+    "strconv"
+    "strings"
+
+    "gorm.io/gorm"
+)
+
+// Model 用于存储模型的元数据,例如描述、标签等
+// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
+// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
+// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展
+// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
+// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
+//
+// 该表设计遵循第三范式(3NF):
+// 1. 每一列都与主键(Id 或 ModelName)直接相关
+// 2. 不存在部分依赖(ModelName 是唯一键)
+// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列)
+// 这样既保证了数据一致性,也方便后期扩展
+
+// 模型名称匹配规则
+const (
+    NameRuleExact = iota // 0 精确匹配
+    NameRulePrefix       // 1 前缀匹配
+    NameRuleContains     // 2 包含匹配
+    NameRuleSuffix       // 3 后缀匹配
+)
+
+type BoundChannel struct {
+    Name string `json:"name"`
+    Type int    `json:"type"`
+}
+
+type Model struct {
+    Id          int            `json:"id"`
+    ModelName   string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name,where:deleted_at IS NULL"`
+    Description string         `json:"description,omitempty" gorm:"type:text"`
+    Tags        string         `json:"tags,omitempty" gorm:"type:varchar(255)"`
+    VendorID    int            `json:"vendor_id,omitempty" gorm:"index"`
+    Endpoints   string         `json:"endpoints,omitempty" gorm:"type:text"`
+    Status      int            `json:"status" gorm:"default:1"`
+    CreatedTime int64          `json:"created_time" gorm:"bigint"`
+    UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
+    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"`
+
+    BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
+    EnableGroups []string       `json:"enable_groups,omitempty" gorm:"-"`
+    QuotaType    int            `json:"quota_type" gorm:"-"`
+    NameRule     int            `json:"name_rule" gorm:"default:0"`
+}
+
+// Insert 创建新的模型元数据记录
+func (mi *Model) Insert() error {
+    now := common.GetTimestamp()
+    mi.CreatedTime = now
+    mi.UpdatedTime = now
+    return DB.Create(mi).Error
+}
+
+// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID)
+func IsModelNameDuplicated(id int, name string) (bool, error) {
+    if name == "" {
+        return false, nil
+    }
+    var cnt int64
+    err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
+    return cnt > 0, err
+}
+
+// Update 更新现有模型记录
+func (mi *Model) Update() error {
+    // 仅更新需要变更的字段,避免覆盖 CreatedTime
+    mi.UpdatedTime = common.GetTimestamp()
+
+    // 排除 created_time,其余字段自动更新,避免新增字段时需要维护列表
+    return DB.Model(&Model{}).Where("id = ?", mi.Id).Omit("created_time").Updates(mi).Error
+}
+
+// Delete 软删除模型记录
+func (mi *Model) Delete() error {
+    return DB.Delete(mi).Error
+}
+
+// GetModelByName 根据模型名称查询元数据
+func GetModelByName(name string) (*Model, error) {
+    var mi Model
+    err := DB.Where("model_name = ?", name).First(&mi).Error
+    if err != nil {
+        return nil, err
+    }
+    return &mi, nil
+}
+
+// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响)
+func GetVendorModelCounts() (map[int64]int64, error) {
+    var stats []struct {
+        VendorID int64
+        Count    int64
+    }
+    if err := DB.Model(&Model{}).
+        Select("vendor_id as vendor_id, count(*) as count").
+        Group("vendor_id").
+        Scan(&stats).Error; err != nil {
+        return nil, err
+    }
+    m := make(map[int64]int64, len(stats))
+    for _, s := range stats {
+        m[s.VendorID] = s.Count
+    }
+    return m, nil
+}
+
+// GetAllModels 分页获取所有模型元数据
+func GetAllModels(offset int, limit int) ([]*Model, error) {
+    var models []*Model
+    err := DB.Offset(offset).Limit(limit).Find(&models).Error
+    return models, err
+}
+
+// GetBoundChannels 查询支持该模型的渠道(名称+类型)
+func GetBoundChannels(modelName string) ([]BoundChannel, error) {
+    var channels []BoundChannel
+    err := DB.Table("channels").
+        Select("channels.name, channels.type").
+        Joins("join abilities on abilities.channel_id = channels.id").
+        Where("abilities.model = ? AND abilities.enabled = ?", modelName, true).
+        Group("channels.id").
+        Scan(&channels).Error
+    return channels, err
+}
+
+// FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含
+func FindModelByNameWithRule(name string) (*Model, error) {
+    // 1. 精确匹配
+    if m, err := GetModelByName(name); err == nil {
+        return m, nil
+    }
+    // 2. 规则匹配
+    var models []*Model
+    if err := DB.Where("name_rule <> ?", NameRuleExact).Find(&models).Error; err != nil {
+        return nil, err
+    }
+    var prefixMatch, suffixMatch, containsMatch *Model
+    for _, m := range models {
+        switch m.NameRule {
+        case NameRulePrefix:
+            if strings.HasPrefix(name, m.ModelName) {
+                if prefixMatch == nil || len(m.ModelName) > len(prefixMatch.ModelName) {
+                    prefixMatch = m
+                }
+            }
+        case NameRuleSuffix:
+            if strings.HasSuffix(name, m.ModelName) {
+                if suffixMatch == nil || len(m.ModelName) > len(suffixMatch.ModelName) {
+                    suffixMatch = m
+                }
+            }
+        case NameRuleContains:
+            if strings.Contains(name, m.ModelName) {
+                if containsMatch == nil || len(m.ModelName) > len(containsMatch.ModelName) {
+                    containsMatch = m
+                }
+            }
+        }
+    }
+    if prefixMatch != nil {
+        return prefixMatch, nil
+    }
+    if suffixMatch != nil {
+        return suffixMatch, nil
+    }
+    if containsMatch != nil {
+        return containsMatch, nil
+    }
+    return nil, gorm.ErrRecordNotFound
+}
+
+// SearchModels 根据关键词和供应商搜索模型,支持分页
+func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
+    var models []*Model
+    db := DB.Model(&Model{})
+    if keyword != "" {
+        like := "%" + keyword + "%"
+        db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
+    }
+    if vendor != "" {
+        // 如果是数字,按供应商 ID 精确匹配;否则按名称模糊匹配
+        if vid, err := strconv.Atoi(vendor); err == nil {
+            db = db.Where("models.vendor_id = ?", vid)
+        } else {
+            db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
+        }
+    }
+    var total int64
+    err := db.Count(&total).Error
+    if err != nil {
+        return nil, 0, err
+    }
+    err = db.Offset(offset).Limit(limit).Order("models.id DESC").Find(&models).Error
+    return models, total, err
+}

+ 126 - 0
model/prefill_group.go

@@ -0,0 +1,126 @@
+package model
+
+import (
+    "encoding/json"
+    "database/sql/driver"
+    "one-api/common"
+
+    "gorm.io/gorm"
+)
+
+// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。
+// Name 字段保持唯一,用于在前端下拉框中展示。
+// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。
+// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例:
+// ["gpt-4o", "gpt-3.5-turbo"]
+// 设计遵循 3NF,避免冗余,提供灵活扩展能力。
+
+// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取
+type JSONValue json.RawMessage
+
+// Value 实现 driver.Valuer 接口,用于数据库写入
+func (j JSONValue) Value() (driver.Value, error) {
+    if j == nil {
+        return nil, nil
+    }
+    return []byte(j), nil
+}
+
+// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
+func (j *JSONValue) Scan(value interface{}) error {
+    switch v := value.(type) {
+    case nil:
+        *j = nil
+        return nil
+    case []byte:
+        // 拷贝底层字节,避免保留底层缓冲区
+        b := make([]byte, len(v))
+        copy(b, v)
+        *j = JSONValue(b)
+        return nil
+    case string:
+        *j = JSONValue([]byte(v))
+        return nil
+    default:
+        // 其他类型尝试序列化为 JSON
+        b, err := json.Marshal(v)
+        if err != nil {
+            return err
+        }
+        *j = JSONValue(b)
+        return nil
+    }
+}
+
+// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致
+func (j JSONValue) MarshalJSON() ([]byte, error) {
+    if j == nil {
+        return []byte("null"), nil
+    }
+    return j, nil
+}
+
+// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致
+func (j *JSONValue) UnmarshalJSON(data []byte) error {
+    if data == nil {
+        *j = nil
+        return nil
+    }
+    b := make([]byte, len(data))
+    copy(b, data)
+    *j = JSONValue(b)
+    return nil
+}
+
+type PrefillGroup struct {
+    Id          int            `json:"id"`
+    Name        string         `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"`
+    Type        string         `json:"type" gorm:"size:32;index;not null"`
+    Items       JSONValue      `json:"items" gorm:"type:json"`
+    Description string         `json:"description,omitempty" gorm:"type:varchar(255)"`
+    CreatedTime int64          `json:"created_time" gorm:"bigint"`
+    UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
+    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// Insert 新建组
+func (g *PrefillGroup) Insert() error {
+    now := common.GetTimestamp()
+    g.CreatedTime = now
+    g.UpdatedTime = now
+    return DB.Create(g).Error
+}
+
+// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID)
+func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) {
+    if name == "" {
+        return false, nil
+    }
+    var cnt int64
+    err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
+    return cnt > 0, err
+}
+
+// Update 更新组
+func (g *PrefillGroup) Update() error {
+    g.UpdatedTime = common.GetTimestamp()
+    return DB.Save(g).Error
+}
+
+// DeleteByID 根据 ID 删除组
+func DeletePrefillGroupByID(id int) error {
+    return DB.Delete(&PrefillGroup{}, id).Error
+}
+
+// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部)
+func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) {
+    var groups []*PrefillGroup
+    query := DB.Model(&PrefillGroup{})
+    if groupType != "" {
+        query = query.Where("type = ?", groupType)
+    }
+    if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil {
+        return nil, err
+    }
+    return groups, nil
+}

+ 233 - 53
model/pricing.go

@@ -1,30 +1,50 @@
 package model
 
 import (
-	"fmt"
-	"one-api/common"
-	"one-api/constant"
-	"one-api/setting/ratio_setting"
-	"one-api/types"
-	"sync"
-	"time"
+    "encoding/json"
+    "fmt"
+    "strings"
+
+    "one-api/common"
+    "one-api/constant"
+    "one-api/setting/ratio_setting"
+    "one-api/types"
+    "sync"
+    "time"
 )
 
 type Pricing struct {
-	ModelName              string                  `json:"model_name"`
-	QuotaType              int                     `json:"quota_type"`
-	ModelRatio             float64                 `json:"model_ratio"`
-	ModelPrice             float64                 `json:"model_price"`
-	OwnerBy                string                  `json:"owner_by"`
-	CompletionRatio        float64                 `json:"completion_ratio"`
-	EnableGroup            []string                `json:"enable_groups"`
-	SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
+    ModelName              string                  `json:"model_name"`
+    Description            string                  `json:"description,omitempty"`
+    Tags                   string                  `json:"tags,omitempty"`
+    VendorID               int                     `json:"vendor_id,omitempty"`
+    QuotaType              int                     `json:"quota_type"`
+    ModelRatio             float64                 `json:"model_ratio"`
+    ModelPrice             float64                 `json:"model_price"`
+    OwnerBy                string                  `json:"owner_by"`
+    CompletionRatio        float64                 `json:"completion_ratio"`
+    EnableGroup            []string                `json:"enable_groups"`
+    SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
+}
+
+type PricingVendor struct {
+	ID          int    `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description,omitempty"`
+	Icon        string `json:"icon,omitempty"`
 }
 
 var (
-	pricingMap         []Pricing
-	lastGetPricingTime time.Time
-	updatePricingLock  sync.Mutex
+    pricingMap         []Pricing
+    vendorsList        []PricingVendor
+    supportedEndpointMap map[string]common.EndpointInfo
+    lastGetPricingTime time.Time
+    updatePricingLock  sync.Mutex
+
+	// 缓存映射:模型名 -> 启用分组 / 计费类型
+	modelEnableGroups     = make(map[string][]string)
+	modelQuotaTypeMap     = make(map[string]int)
+	modelEnableGroupsLock = sync.RWMutex{}
 )
 
 var (
@@ -46,6 +66,15 @@ func GetPricing() []Pricing {
 	return pricingMap
 }
 
+// GetVendors 返回当前定价接口使用到的供应商信息
+func GetVendors() []PricingVendor {
+	if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
+		// 保证先刷新一次
+		GetPricing()
+	}
+	return vendorsList
+}
+
 func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
 	if model == "" {
 		return make([]constant.EndpointType, 0)
@@ -65,6 +94,77 @@ func updatePricing() {
 		common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
 		return
 	}
+	// 预加载模型元数据与供应商一次,避免循环查询
+	var allMeta []Model
+	_ = DB.Find(&allMeta).Error
+	metaMap := make(map[string]*Model)
+	prefixList := make([]*Model, 0)
+	suffixList := make([]*Model, 0)
+	containsList := make([]*Model, 0)
+	for i := range allMeta {
+		m := &allMeta[i]
+		if m.NameRule == NameRuleExact {
+			metaMap[m.ModelName] = m
+		} else {
+			switch m.NameRule {
+			case NameRulePrefix:
+				prefixList = append(prefixList, m)
+			case NameRuleSuffix:
+				suffixList = append(suffixList, m)
+			case NameRuleContains:
+				containsList = append(containsList, m)
+			}
+		}
+	}
+
+	// 将非精确规则模型匹配到 metaMap
+	for _, m := range prefixList {
+		for _, pricingModel := range enableAbilities {
+			if strings.HasPrefix(pricingModel.Model, m.ModelName) {
+                if _, exists := metaMap[pricingModel.Model]; !exists {
+                    metaMap[pricingModel.Model] = m
+                }
+            }
+		}
+	}
+	for _, m := range suffixList {
+		for _, pricingModel := range enableAbilities {
+			if strings.HasSuffix(pricingModel.Model, m.ModelName) {
+                if _, exists := metaMap[pricingModel.Model]; !exists {
+                    metaMap[pricingModel.Model] = m
+                }
+            }
+		}
+	}
+	for _, m := range containsList {
+		for _, pricingModel := range enableAbilities {
+			if strings.Contains(pricingModel.Model, m.ModelName) {
+				if _, exists := metaMap[pricingModel.Model]; !exists {
+					metaMap[pricingModel.Model] = m
+				}
+			}
+		}
+	}
+
+	// 预加载供应商
+	var vendors []Vendor
+	_ = DB.Find(&vendors).Error
+	vendorMap := make(map[int]*Vendor)
+	for i := range vendors {
+		vendorMap[vendors[i].Id] = &vendors[i]
+	}
+
+	// 构建对前端友好的供应商列表
+	vendorsList = make([]PricingVendor, 0, len(vendors))
+	for _, v := range vendors {
+		vendorsList = append(vendorsList, PricingVendor{
+			ID:          v.Id,
+			Name:        v.Name,
+			Description: v.Description,
+			Icon:        v.Icon,
+		})
+	}
+
 	modelGroupsMap := make(map[string]*types.Set[string])
 
 	for _, ability := range enableAbilities {
@@ -79,20 +179,34 @@ func updatePricing() {
 	//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
 	modelSupportEndpointsStr := make(map[string][]string)
 
-	for _, ability := range enableAbilities {
-		endpoints, ok := modelSupportEndpointsStr[ability.Model]
-		if !ok {
-			endpoints = make([]string, 0)
-			modelSupportEndpointsStr[ability.Model] = endpoints
-		}
-		channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
-		for _, channelType := range channelTypes {
-			if !common.StringsContains(endpoints, string(channelType)) {
-				endpoints = append(endpoints, string(channelType))
-			}
-		}
-		modelSupportEndpointsStr[ability.Model] = endpoints
-	}
+    // 先根据已有能力填充原生端点
+    for _, ability := range enableAbilities {
+        endpoints := modelSupportEndpointsStr[ability.Model]
+        channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
+        for _, channelType := range channelTypes {
+            if !common.StringsContains(endpoints, string(channelType)) {
+                endpoints = append(endpoints, string(channelType))
+            }
+        }
+        modelSupportEndpointsStr[ability.Model] = endpoints
+    }
+
+    // 再补充模型自定义端点
+    for modelName, meta := range metaMap {
+        if strings.TrimSpace(meta.Endpoints) == "" {
+            continue
+        }
+        var raw map[string]interface{}
+        if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
+            endpoints := modelSupportEndpointsStr[modelName]
+            for k := range raw {
+                if !common.StringsContains(endpoints, k) {
+                    endpoints = append(endpoints, k)
+                }
+            }
+            modelSupportEndpointsStr[modelName] = endpoints
+        }
+    }
 
 	modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
 	for model, endpoints := range modelSupportEndpointsStr {
@@ -102,26 +216,92 @@ func updatePricing() {
 			supportedEndpoints = append(supportedEndpoints, endpointType)
 		}
 		modelSupportEndpointTypes[model] = supportedEndpoints
-	}
+    }
 
-	pricingMap = make([]Pricing, 0)
-	for model, groups := range modelGroupsMap {
-		pricing := Pricing{
-			ModelName:              model,
-			EnableGroup:            groups.Items(),
-			SupportedEndpointTypes: modelSupportEndpointTypes[model],
-		}
-		modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
-		if findPrice {
-			pricing.ModelPrice = modelPrice
-			pricing.QuotaType = 1
-		} else {
-			modelRatio, _, _ := ratio_setting.GetModelRatio(model)
-			pricing.ModelRatio = modelRatio
-			pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
-			pricing.QuotaType = 0
-		}
-		pricingMap = append(pricingMap, pricing)
-	}
-	lastGetPricingTime = time.Now()
+    // 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
+    supportedEndpointMap = make(map[string]common.EndpointInfo)
+    // 1. 默认端点
+    for _, endpoints := range modelSupportEndpointTypes {
+        for _, et := range endpoints {
+            if info, ok := common.GetDefaultEndpointInfo(et); ok {
+                if _, exists := supportedEndpointMap[string(et)]; !exists {
+                    supportedEndpointMap[string(et)] = info
+                }
+            }
+        }
+    }
+    // 2. 自定义端点(models 表)覆盖默认
+    for _, meta := range metaMap {
+        if strings.TrimSpace(meta.Endpoints) == "" {
+            continue
+        }
+        var raw map[string]interface{}
+        if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
+            for k, v := range raw {
+                switch val := v.(type) {
+                case string:
+                    supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
+                case map[string]interface{}:
+                    ep := common.EndpointInfo{Method: "POST"}
+                    if p, ok := val["path"].(string); ok {
+                        ep.Path = p
+                    }
+                    if m, ok := val["method"].(string); ok {
+                        ep.Method = strings.ToUpper(m)
+                    }
+                    supportedEndpointMap[k] = ep
+                default:
+                    // ignore unsupported types
+                }
+            }
+        }
+    }
+
+    pricingMap = make([]Pricing, 0)
+    for model, groups := range modelGroupsMap {
+        pricing := Pricing{
+            ModelName:              model,
+            EnableGroup:            groups.Items(),
+            SupportedEndpointTypes: modelSupportEndpointTypes[model],
+        }
+
+        // 补充模型元数据(描述、标签、供应商、状态)
+        if meta, ok := metaMap[model]; ok {
+            // 若模型被禁用(status!=1),则直接跳过,不返回给前端
+            if meta.Status != 1 {
+                continue
+            }
+            pricing.Description = meta.Description
+            pricing.Tags = meta.Tags
+            pricing.VendorID = meta.VendorID
+        }
+        modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
+        if findPrice {
+            pricing.ModelPrice = modelPrice
+            pricing.QuotaType = 1
+        } else {
+            modelRatio, _, _ := ratio_setting.GetModelRatio(model)
+            pricing.ModelRatio = modelRatio
+            pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
+            pricing.QuotaType = 0
+        }
+        pricingMap = append(pricingMap, pricing)
+    }
+
+    // 刷新缓存映射,供高并发快速查询
+    modelEnableGroupsLock.Lock()
+    modelEnableGroups = make(map[string][]string)
+    modelQuotaTypeMap = make(map[string]int)
+    for _, p := range pricingMap {
+        modelEnableGroups[p.ModelName] = p.EnableGroup
+        modelQuotaTypeMap[p.ModelName] = p.QuotaType
+    }
+    modelEnableGroupsLock.Unlock()
+
+    lastGetPricingTime = time.Now()
+}
+
+// GetSupportedEndpointMap 返回全局端点到路径的映射
+func GetSupportedEndpointMap() map[string]common.EndpointInfo {
+    return supportedEndpointMap
 }

+ 14 - 0
model/pricing_refresh.go

@@ -0,0 +1,14 @@
+package model
+
+// RefreshPricing 强制立即重新计算与定价相关的缓存。
+// 该方法用于需要最新数据的内部管理 API,
+// 因此会绕过默认的 1 分钟延迟刷新。
+func RefreshPricing() {
+    updatePricingLock.Lock()
+    defer updatePricingLock.Unlock()
+
+    modelSupportEndpointsLock.Lock()
+    defer modelSupportEndpointsLock.Unlock()
+
+    updatePricing()
+}

+ 88 - 0
model/vendor_meta.go

@@ -0,0 +1,88 @@
+package model
+
+import (
+    "one-api/common"
+
+    "gorm.io/gorm"
+)
+
+// Vendor 用于存储供应商信息,供模型引用
+// Name 唯一,用于在模型中关联
+// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染
+// Status 预留字段,1 表示启用
+// 本表同样遵循 3NF 设计范式
+
+type Vendor struct {
+    Id          int            `json:"id"`
+    Name        string         `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name,where:deleted_at IS NULL"`
+    Description string         `json:"description,omitempty" gorm:"type:text"`
+    Icon        string         `json:"icon,omitempty" gorm:"type:varchar(128)"`
+    Status      int            `json:"status" gorm:"default:1"`
+    CreatedTime int64          `json:"created_time" gorm:"bigint"`
+    UpdatedTime int64          `json:"updated_time" gorm:"bigint"`
+    DeletedAt   gorm.DeletedAt `json:"-" gorm:"index"`
+}
+
+// Insert 创建新的供应商记录
+func (v *Vendor) Insert() error {
+    now := common.GetTimestamp()
+    v.CreatedTime = now
+    v.UpdatedTime = now
+    return DB.Create(v).Error
+}
+
+// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID)
+func IsVendorNameDuplicated(id int, name string) (bool, error) {
+    if name == "" {
+        return false, nil
+    }
+    var cnt int64
+    err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error
+    return cnt > 0, err
+}
+
+// Update 更新供应商记录
+func (v *Vendor) Update() error {
+    v.UpdatedTime = common.GetTimestamp()
+    return DB.Save(v).Error
+}
+
+// Delete 软删除供应商
+func (v *Vendor) Delete() error {
+    return DB.Delete(v).Error
+}
+
+// GetVendorByID 根据 ID 获取供应商
+func GetVendorByID(id int) (*Vendor, error) {
+    var v Vendor
+    err := DB.First(&v, id).Error
+    if err != nil {
+        return nil, err
+    }
+    return &v, nil
+}
+
+// GetAllVendors 获取全部供应商(分页)
+func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
+    var vendors []*Vendor
+    err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
+    return vendors, err
+}
+
+// SearchVendors 按关键字搜索供应商
+func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) {
+    db := DB.Model(&Vendor{})
+    if keyword != "" {
+        like := "%" + keyword + "%"
+        db = db.Where("name LIKE ? OR description LIKE ?", like, like)
+    }
+    var total int64
+    if err := db.Count(&total).Error; err != nil {
+        return nil, 0, err
+    }
+    var vendors []*Vendor
+    if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
+        return nil, 0, err
+    }
+    return vendors, total, nil
+}

+ 33 - 0
router/api-router.go

@@ -179,6 +179,16 @@ func SetApiRouter(router *gin.Engine) {
 		{
 			groupRoute.GET("/", controller.GetGroups)
 		}
+
+		prefillGroupRoute := apiRouter.Group("/prefill_group")
+		prefillGroupRoute.Use(middleware.AdminAuth())
+		{
+			prefillGroupRoute.GET("/", controller.GetPrefillGroups)
+			prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
+			prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup)
+			prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
+		}
+
 		mjRoute := apiRouter.Group("/mj")
 		mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
 		mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
@@ -188,5 +198,28 @@ func SetApiRouter(router *gin.Engine) {
 			taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
 			taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
 		}
+
+		vendorRoute := apiRouter.Group("/vendors")
+        vendorRoute.Use(middleware.AdminAuth())
+        {
+            vendorRoute.GET("/", controller.GetAllVendors)
+            vendorRoute.GET("/search", controller.SearchVendors)
+            vendorRoute.GET("/:id", controller.GetVendorMeta)
+            vendorRoute.POST("/", controller.CreateVendorMeta)
+            vendorRoute.PUT("/", controller.UpdateVendorMeta)
+            vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
+        }
+
+        modelsRoute := apiRouter.Group("/models")
+		modelsRoute.Use(middleware.AdminAuth())
+		{
+			modelsRoute.GET("/missing", controller.GetMissingModels)
+            modelsRoute.GET("/", controller.GetAllModelsMeta)
+            modelsRoute.GET("/search", controller.SearchModelsMeta)
+			modelsRoute.GET("/:id", controller.GetModelMeta)
+			modelsRoute.POST("/", controller.CreateModelMeta)
+			modelsRoute.PUT("/", controller.UpdateModelMeta)
+			modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
+		}
 	}
 }

+ 9 - 0
web/src/App.js

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

+ 0 - 622
web/src/components/common/JSONEditor.js

@@ -1,622 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import {
-  Space,
-  Button,
-  Form,
-  Card,
-  Typography,
-  Banner,
-  Row,
-  Col,
-  InputNumber,
-  Switch,
-  Select,
-  Input,
-} from '@douyinfe/semi-ui';
-import {
-  IconCode,
-  IconEdit,
-  IconPlus,
-  IconDelete,
-  IconSetting,
-} from '@douyinfe/semi-icons';
-
-const { Text } = Typography;
-
-const JSONEditor = ({
-  value = '',
-  onChange,
-  field,
-  label,
-  placeholder,
-  extraText,
-  showClear = true,
-  template,
-  templateLabel,
-  editorType = 'keyValue', // keyValue, object, region
-  autosize = true,
-  rules = [],
-  formApi = null,
-  ...props
-}) => {
-  const { t } = useTranslation();
-  
-  // 初始化JSON数据
-  const [jsonData, setJsonData] = useState(() => {
-    // 初始化时解析JSON数据
-    if (value && value.trim()) {
-      try {
-        const parsed = JSON.parse(value);
-        return parsed;
-      } catch (error) {
-        return {};
-      }
-    }
-    return {};
-  });
-  
-  // 根据键数量决定默认编辑模式
-  const [editMode, setEditMode] = useState(() => {
-    // 如果初始JSON数据的键数量大于10个,则默认使用手动模式
-    if (value && value.trim()) {
-      try {
-        const parsed = JSON.parse(value);
-        const keyCount = Object.keys(parsed).length;
-        return keyCount > 10 ? 'manual' : 'visual';
-      } catch (error) {
-        // JSON无效时默认显示手动编辑模式
-        return 'manual';
-      }
-    }
-    return 'visual';
-  });
-  const [jsonError, setJsonError] = useState('');
-
-  // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
-  useEffect(() => {
-    try {
-      const parsed = value && value.trim() ? JSON.parse(value) : {};
-      setJsonData(parsed);
-      setJsonError('');
-    } catch (error) {
-      console.log('JSON解析失败:', error.message);
-      setJsonError(error.message);
-      // JSON格式错误时不更新jsonData
-    }
-  }, [value]);
-
-
-  // 处理可视化编辑的数据变化
-  const handleVisualChange = useCallback((newData) => {
-    setJsonData(newData);
-    setJsonError('');
-    const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
-    
-    // 通过formApi设置值(如果提供的话)
-    if (formApi && field) {
-      formApi.setValue(field, jsonString);
-    }
-    
-    onChange?.(jsonString);
-  }, [onChange, formApi, field]);
-
-  // 处理手动编辑的数据变化
-  const handleManualChange = useCallback((newValue) => {
-    onChange?.(newValue);
-    // 验证JSON格式
-    if (newValue && newValue.trim()) {
-      try {
-        const parsed = JSON.parse(newValue);
-        setJsonError('');
-        // 预先准备可视化数据,但不立即应用
-        // 这样切换到可视化模式时数据已经准备好了
-      } catch (error) {
-        setJsonError(error.message);
-      }
-    } else {
-      setJsonError('');
-    }
-  }, [onChange]);
-
-  // 切换编辑模式
-  const toggleEditMode = useCallback(() => {
-    if (editMode === 'visual') {
-      // 从可视化模式切换到手动模式
-      setEditMode('manual');
-    } else {
-      // 从手动模式切换到可视化模式,需要验证JSON
-      try {
-        const parsed = value && value.trim() ? JSON.parse(value) : {};
-        setJsonData(parsed);
-        setJsonError('');
-        setEditMode('visual');
-      } catch (error) {
-        setJsonError(error.message);
-        // JSON格式错误时不切换模式
-        return;
-      }
-    }
-  }, [editMode, value]);
-
-  // 添加键值对
-  const addKeyValue = useCallback(() => {
-    const newData = { ...jsonData };
-    const keys = Object.keys(newData);
-    let newKey = 'key';
-    let counter = 1;
-    while (newData.hasOwnProperty(newKey)) {
-      newKey = `key${counter}`;
-      counter++;
-    }
-    newData[newKey] = '';
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
-
-  // 删除键值对
-  const removeKeyValue = useCallback((keyToRemove) => {
-    const newData = { ...jsonData };
-    delete newData[keyToRemove];
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
-
-  // 更新键名
-  const updateKey = useCallback((oldKey, newKey) => {
-    if (oldKey === newKey) return;
-    const newData = { ...jsonData };
-    const value = newData[oldKey];
-    delete newData[oldKey];
-    newData[newKey] = value;
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
-
-  // 更新值
-  const updateValue = useCallback((key, newValue) => {
-    const newData = { ...jsonData };
-    newData[key] = newValue;
-    handleVisualChange(newData);
-  }, [jsonData, handleVisualChange]);
-
-  // 填入模板
-  const fillTemplate = useCallback(() => {
-    if (template) {
-      const templateString = JSON.stringify(template, null, 2);
-      
-      // 通过formApi设置值(如果提供的话)
-      if (formApi && field) {
-        formApi.setValue(field, templateString);
-      }
-      
-      // 无论哪种模式都要更新值
-      onChange?.(templateString);
-      
-      // 如果是可视化模式,同时更新jsonData
-      if (editMode === 'visual') {
-        setJsonData(template);
-      }
-      
-      // 清除错误状态
-      setJsonError('');
-    }
-  }, [template, onChange, editMode, formApi, field]);
-
-  // 渲染键值对编辑器
-  const renderKeyValueEditor = () => {
-    if (typeof jsonData !== 'object' || jsonData === null) {
-      return (
-        <div className="text-center py-6 px-4">
-          <div className="text-gray-400 mb-2">
-            <IconCode size={32} />
-          </div>
-          <Text type="tertiary" className="text-gray-500 text-sm">
-            {t('无效的JSON数据,请检查格式')}
-          </Text>
-        </div>
-      );
-    }
-    const entries = Object.entries(jsonData);
-    
-    return (
-      <div className="space-y-1">
-        {entries.length === 0 && (
-          <div className="text-center py-6 px-4">
-            <div className="text-gray-400 mb-2">
-              <IconCode size={32} />
-            </div>
-            <Text type="tertiary" className="text-gray-500 text-sm">
-              {t('暂无数据,点击下方按钮添加键值对')}
-            </Text>
-          </div>
-        )}
-        
-        {entries.map(([key, value], index) => (
-          <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
-            <Row gutter={12} align="middle">
-              <Col span={10}>
-                <div className="space-y-1">
-                  <Text type="tertiary" size="small">{t('键名')}</Text>
-                  <Input
-                    placeholder={t('键名')}
-                    value={key}
-                    onChange={(newKey) => updateKey(key, newKey)}
-                    size="small"
-                  />
-                </div>
-              </Col>
-              <Col span={11}>
-                <div className="space-y-1">
-                  <Text type="tertiary" size="small">{t('值')}</Text>
-                  <Input
-                    placeholder={t('值')}
-                    value={value}
-                    onChange={(newValue) => updateValue(key, newValue)}
-                    size="small"
-                  />
-                </div>
-              </Col>
-              <Col span={3}>
-                <div className="flex justify-center pt-4">
-                  <Button
-                    icon={<IconDelete />}
-                    type="danger"
-                    theme="borderless"
-                    size="small"
-                    onClick={() => removeKeyValue(key)}
-                    className="hover:bg-red-50"
-                  />
-                </div>
-              </Col>
-            </Row>
-          </Card>
-        ))}
-        
-        <div className="flex justify-center pt-1">
-          <Button
-            icon={<IconPlus />}
-            onClick={addKeyValue}
-            size="small"
-            theme="solid"
-            type="primary"
-            className="shadow-sm hover:shadow-md transition-shadow px-4"
-          >
-            {t('添加键值对')}
-          </Button>
-        </div>
-      </div>
-    );
-  };
-
-  // 渲染对象编辑器(用于复杂JSON)
-  const renderObjectEditor = () => {
-    const entries = Object.entries(jsonData);
-    
-    return (
-      <div className="space-y-1">
-        {entries.length === 0 && (
-          <div className="text-center py-6 px-4">
-            <div className="text-gray-400 mb-2">
-              <IconSetting size={32} />
-            </div>
-            <Text type="tertiary" className="text-gray-500 text-sm">
-              {t('暂无参数,点击下方按钮添加请求参数')}
-            </Text>
-          </div>
-        )}
-        
-        {entries.map(([key, value], index) => (
-          <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
-            <Row gutter={12} align="middle">
-              <Col span={8}>
-                <div className="space-y-1">
-                  <Text type="tertiary" size="small">{t('参数名')}</Text>
-                  <Input
-                    placeholder={t('参数名')}
-                    value={key}
-                    onChange={(newKey) => updateKey(key, newKey)}
-                    size="small"
-                  />
-                </div>
-              </Col>
-              <Col span={13}>
-                <div className="space-y-1">
-                  <Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
-                  {renderValueInput(key, value)}
-                </div>
-              </Col>
-              <Col span={3}>
-                <div className="flex justify-center pt-4">
-                  <Button
-                    icon={<IconDelete />}
-                    type="danger"
-                    theme="borderless"
-                    size="small"
-                    onClick={() => removeKeyValue(key)}
-                    className="hover:bg-red-50"
-                  />
-                </div>
-              </Col>
-            </Row>
-          </Card>
-        ))}
-        
-        <div className="flex justify-center pt-1">
-          <Button
-            icon={<IconPlus />}
-            onClick={addKeyValue}
-            size="small"
-            theme="solid"
-            type="primary"
-            className="shadow-sm hover:shadow-md transition-shadow px-4"
-          >
-            {t('添加参数')}
-          </Button>
-        </div>
-      </div>
-    );
-  };
-
-  // 渲染参数值输入控件
-  const renderValueInput = (key, value) => {
-    const valueType = typeof value;
-    
-    if (valueType === 'boolean') {
-      return (
-        <div className="flex items-center">
-          <Switch
-            checked={value}
-            onChange={(newValue) => updateValue(key, newValue)}
-            size="small"
-          />
-          <Text type="tertiary" size="small" className="ml-2">
-            {value ? t('true') : t('false')}
-          </Text>
-        </div>
-      );
-    }
-    
-    if (valueType === 'number') {
-      return (
-        <InputNumber
-          value={value}
-          onChange={(newValue) => updateValue(key, newValue)}
-          size="small"
-          style={{ width: '100%' }}
-          step={key === 'temperature' ? 0.1 : 1}
-          precision={key === 'temperature' ? 2 : 0}
-          placeholder={t('输入数字')}
-        />
-      );
-    }
-    
-    // 字符串类型或其他类型
-    return (
-      <Input
-        placeholder={t('参数值')}
-        value={String(value)}
-        onChange={(newValue) => {
-          // 尝试转换为适当的类型
-          let convertedValue = newValue;
-          if (newValue === 'true') convertedValue = true;
-          else if (newValue === 'false') convertedValue = false;
-          else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
-            convertedValue = Number(newValue);
-          }
-          
-          updateValue(key, convertedValue);
-        }}
-        size="small"
-      />
-    );
-  };
-
-  // 渲染区域编辑器(特殊格式)
-  const renderRegionEditor = () => {
-    const entries = Object.entries(jsonData);
-    const defaultEntry = entries.find(([key]) => key === 'default');
-    const modelEntries = entries.filter(([key]) => key !== 'default');
-    
-    return (
-      <div className="space-y-1">
-        {/* 默认区域 */}
-        <Card className="!p-2 !border-blue-200 !bg-blue-50">
-          <div className="flex items-center mb-1">
-            <Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
-          </div>
-          <Input
-            placeholder={t('默认区域,如: us-central1')}
-            value={defaultEntry ? defaultEntry[1] : ''}
-            onChange={(value) => updateValue('default', value)}
-            size="small"
-          />
-        </Card>
-        
-        {/* 模型专用区域 */}
-        <div className="space-y-1">
-          <Text strong size="small">{t('模型专用区域')}</Text>
-          {modelEntries.map(([modelName, region], index) => (
-            <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
-              <Row gutter={12} align="middle">
-                <Col span={10}>
-                  <div className="space-y-1">
-                    <Text type="tertiary" size="small">{t('模型名称')}</Text>
-                    <Input
-                      placeholder={t('模型名称')}
-                      value={modelName}
-                      onChange={(newKey) => updateKey(modelName, newKey)}
-                      size="small"
-                    />
-                  </div>
-                </Col>
-                <Col span={11}>
-                  <div className="space-y-1">
-                    <Text type="tertiary" size="small">{t('区域')}</Text>
-                    <Input
-                      placeholder={t('区域')}
-                      value={region}
-                      onChange={(newValue) => updateValue(modelName, newValue)}
-                      size="small"
-                    />
-                  </div>
-                </Col>
-                <Col span={3}>
-                  <div className="flex justify-center pt-4">
-                    <Button
-                      icon={<IconDelete />}
-                      type="danger"
-                      theme="borderless"
-                      size="small"
-                      onClick={() => removeKeyValue(modelName)}
-                      className="hover:bg-red-50"
-                    />
-                  </div>
-                </Col>
-              </Row>
-            </Card>
-          ))}
-          
-          <div className="flex justify-center pt-1">
-            <Button
-              icon={<IconPlus />}
-              onClick={addKeyValue}
-              size="small"
-              theme="solid"
-              type="primary"
-              className="shadow-sm hover:shadow-md transition-shadow px-4"
-            >
-              {t('添加模型区域')}
-            </Button>
-          </div>
-        </div>
-      </div>
-    );
-  };
-
-  // 渲染可视化编辑器
-  const renderVisualEditor = () => {
-    switch (editorType) {
-      case 'region':
-        return renderRegionEditor();
-      case 'object':
-        return renderObjectEditor();
-      case 'keyValue':
-      default:
-        return renderKeyValueEditor();
-    }
-  };
-
-  const hasJsonError = jsonError && jsonError.trim() !== '';
-
-  return (
-    <div className="space-y-1">
-      {/* Label统一显示在上方 */}
-      {label && (
-        <div className="flex items-center">
-          <Text className="text-sm font-medium text-gray-900">{label}</Text>
-        </div>
-      )}
-      
-      {/* 编辑模式切换 */}
-      <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
-        <div className="flex items-center gap-2">
-          {editMode === 'visual' && (
-            <Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
-              {t('可视化模式')}
-            </Text>
-          )}
-          {editMode === 'manual' && (
-            <Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
-              {t('手动编辑模式')}
-            </Text>
-          )}
-        </div>
-        <div className="flex items-center gap-2">
-          {template && templateLabel && (
-            <Button
-              size="small"
-              type="tertiary"
-              onClick={fillTemplate}
-              className="!text-semi-color-primary hover:bg-blue-50 text-xs"
-            >
-              {templateLabel}
-            </Button>
-          )}
-          <Space size="tight">
-            <Button
-              size="small"
-              type={editMode === 'visual' ? 'primary' : 'tertiary'}
-              icon={<IconEdit />}
-              onClick={toggleEditMode}
-              disabled={editMode === 'manual' && hasJsonError}
-              className={editMode === 'visual' ? 'shadow-sm' : ''}
-            >
-              {t('可视化')}
-            </Button>
-            <Button
-              size="small"
-              type={editMode === 'manual' ? 'primary' : 'tertiary'}
-              icon={<IconCode />}
-              onClick={toggleEditMode}
-              className={editMode === 'manual' ? 'shadow-sm' : ''}
-            >
-              {t('手动编辑')}
-            </Button>
-          </Space>
-        </div>
-      </div>
-
-      {/* JSON错误提示 */}
-      {hasJsonError && (
-        <Banner
-          type="danger"
-          description={`JSON 格式错误: ${jsonError}`}
-          className="!rounded-md text-sm"
-        />
-      )}
-
-      {/* 编辑器内容 */}
-      {editMode === 'visual' ? (
-        <div>
-          <Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
-            {renderVisualEditor()}
-          </Card>
-          {/* 可视化模式下的额外文本显示在下方 */}
-          {extraText && (
-            <div className="text-xs text-gray-600 mt-0.5">
-              {extraText}
-            </div>
-          )}
-          {/* 隐藏的Form字段用于验证和数据绑定 */}
-          <Form.Input
-            field={field}
-            value={value}
-            rules={rules}
-            style={{ display: 'none' }}
-            noLabel={true}
-            {...props}
-          />
-        </div>
-      ) : (
-        <Form.TextArea
-          field={field}
-          placeholder={placeholder}
-          value={value}
-          onChange={handleManualChange}
-          showClear={showClear}
-          rows={Math.max(8, value ? value.split('\n').length : 8)}
-          rules={rules}
-          noLabel={true}
-          {...props}
-        />
-      )}
-
-      {/* 额外文本在手动编辑模式下显示 */}
-      {extraText && editMode === 'manual' && (
-        <div className="text-xs text-gray-600">
-          {extraText}
-        </div>
-      )}
-    </div>
-  );
-};
-
-export default JSONEditor; 

+ 1 - 0
web/src/components/common/ui/CardPro.js

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

+ 3 - 19
web/src/components/common/ui/CardTable.js

@@ -23,6 +23,7 @@ import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@
 import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
 import PropTypes from 'prop-types';
 import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
 
 /**
  * CardTable 响应式表格组件
@@ -40,25 +41,8 @@ const CardTable = ({
 }) => {
   const isMobile = useIsMobile();
   const { t } = useTranslation();
-
-  const [showSkeleton, setShowSkeleton] = useState(loading);
-  const loadingStartRef = useRef(Date.now());
-
-  useEffect(() => {
-    if (loading) {
-      loadingStartRef.current = Date.now();
-      setShowSkeleton(true);
-    } else {
-      const elapsed = Date.now() - loadingStartRef.current;
-      const remaining = Math.max(0, 500 - elapsed);
-      if (remaining === 0) {
-        setShowSkeleton(false);
-      } else {
-        const timer = setTimeout(() => setShowSkeleton(false), remaining);
-        return () => clearTimeout(timer);
-      }
-    }
-  }, [loading]);
+  
+  const showSkeleton = useMinimumLoadingTime(loading);
 
   const getRowKey = (record, index) => {
     if (typeof rowKey === 'function') return rowKey(record);

+ 669 - 0
web/src/components/common/ui/JSONEditor.js

@@ -0,0 +1,669 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Button,
+  Form,
+  Typography,
+  Banner,
+  Tabs,
+  TabPane,
+  Card,
+  Input,
+  InputNumber,
+  Switch,
+  TextArea,
+  Row,
+  Col,
+  Divider,
+} from '@douyinfe/semi-ui';
+import {
+  IconCode,
+  IconPlus,
+  IconDelete,
+  IconRefresh,
+} from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+const JSONEditor = ({
+  value = '',
+  onChange,
+  field,
+  label,
+  placeholder,
+  extraText,
+  extraFooter,
+  showClear = true,
+  template,
+  templateLabel,
+  editorType = 'keyValue',
+  rules = [],
+  formApi = null,
+  ...props
+}) => {
+  const { t } = useTranslation();
+
+  // 初始化JSON数据
+  const [jsonData, setJsonData] = useState(() => {
+    // 初始化时解析JSON数据
+    if (typeof value === 'string' && value.trim()) {
+      try {
+        const parsed = JSON.parse(value);
+        return parsed;
+      } catch (error) {
+        return {};
+      }
+    }
+    if (typeof value === 'object' && value !== null) {
+      return value;
+    }
+    return {};
+  });
+
+  // 手动模式下的本地文本缓冲,避免无效 JSON 时被外部值重置
+  const [manualText, setManualText] = useState(() => {
+    if (typeof value === 'string') return value;
+    if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
+    return '';
+  });
+
+  // 根据键数量决定默认编辑模式
+  const [editMode, setEditMode] = useState(() => {
+    // 如果初始JSON数据的键数量大于10个,则默认使用手动模式
+    if (typeof value === 'string' && value.trim()) {
+      try {
+        const parsed = JSON.parse(value);
+        const keyCount = Object.keys(parsed).length;
+        return keyCount > 10 ? 'manual' : 'visual';
+      } catch (error) {
+        // JSON无效时默认显示手动编辑模式
+        return 'manual';
+      }
+    }
+    return 'visual';
+  });
+  const [jsonError, setJsonError] = useState('');
+
+  // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
+  useEffect(() => {
+    try {
+      let parsed = {};
+      if (typeof value === 'string' && value.trim()) {
+        parsed = JSON.parse(value);
+      } else if (typeof value === 'object' && value !== null) {
+        parsed = value;
+      }
+      setJsonData(parsed);
+      setJsonError('');
+    } catch (error) {
+      console.log('JSON解析失败:', error.message);
+      setJsonError(error.message);
+      // JSON格式错误时不更新jsonData
+    }
+  }, [value]);
+
+  // 外部 value 变化时,若不在手动模式,则同步手动文本;在手动模式下不打断用户输入
+  useEffect(() => {
+    if (editMode !== 'manual') {
+      if (typeof value === 'string') setManualText(value);
+      else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
+      else setManualText('');
+    }
+  }, [value, editMode]);
+
+  // 处理可视化编辑的数据变化
+  const handleVisualChange = useCallback((newData) => {
+    setJsonData(newData);
+    setJsonError('');
+    const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
+
+    // 通过formApi设置值(如果提供的话)
+    if (formApi && field) {
+      formApi.setValue(field, jsonString);
+    }
+
+    onChange?.(jsonString);
+  }, [onChange, formApi, field]);
+
+  // 处理手动编辑的数据变化(无效 JSON 不阻断输入,也不立刻回传上游)
+  const handleManualChange = useCallback((newValue) => {
+    setManualText(newValue);
+    if (newValue && newValue.trim()) {
+      try {
+        JSON.parse(newValue);
+        setJsonError('');
+        onChange?.(newValue);
+      } catch (error) {
+        setJsonError(error.message);
+        // 无效 JSON 时不回传,避免外部值把输入重置
+      }
+    } else {
+      setJsonError('');
+      onChange?.('');
+    }
+  }, [onChange]);
+
+  // 切换编辑模式
+  const toggleEditMode = useCallback(() => {
+    if (editMode === 'visual') {
+      // 从可视化模式切换到手动模式
+      setManualText(Object.keys(jsonData).length === 0 ? '' : JSON.stringify(jsonData, null, 2));
+      setEditMode('manual');
+    } else {
+      // 从手动模式切换到可视化模式,需要验证JSON
+      try {
+        let parsed = {};
+        if (manualText && manualText.trim()) {
+          parsed = JSON.parse(manualText);
+        } else if (typeof value === 'string' && value.trim()) {
+          parsed = JSON.parse(value);
+        } else if (typeof value === 'object' && value !== null) {
+          parsed = value;
+        }
+        setJsonData(parsed);
+        setJsonError('');
+        setEditMode('visual');
+      } catch (error) {
+        setJsonError(error.message);
+        // JSON格式错误时不切换模式
+        return;
+      }
+    }
+  }, [editMode, value, manualText, jsonData]);
+
+  // 添加键值对
+  const addKeyValue = useCallback(() => {
+    const newData = { ...jsonData };
+    const keys = Object.keys(newData);
+    let counter = 1;
+    let newKey = `field_${counter}`;
+    while (newData.hasOwnProperty(newKey)) {
+      counter += 1;
+      newKey = `field_${counter}`;
+    }
+    newData[newKey] = '';
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 删除键值对
+  const removeKeyValue = useCallback((keyToRemove) => {
+    const newData = { ...jsonData };
+    delete newData[keyToRemove];
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 更新键名
+  const updateKey = useCallback((oldKey, newKey) => {
+    if (oldKey === newKey || !newKey) return;
+    const newData = {};
+    Object.entries(jsonData).forEach(([k, v]) => {
+      if (k === oldKey) {
+        newData[newKey] = v;
+      } else {
+        newData[k] = v;
+      }
+    });
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 更新值
+  const updateValue = useCallback((key, newValue) => {
+    const newData = { ...jsonData };
+    newData[key] = newValue;
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 填入模板
+  const fillTemplate = useCallback(() => {
+    if (template) {
+      const templateString = JSON.stringify(template, null, 2);
+
+      // 通过formApi设置值(如果提供的话)
+      if (formApi && field) {
+        formApi.setValue(field, templateString);
+      }
+
+      // 同步内部与外部值,避免出现杂字符
+      setManualText(templateString);
+      setJsonData(template);
+      onChange?.(templateString);
+
+      // 清除错误状态
+      setJsonError('');
+    }
+  }, [template, onChange, editMode, formApi, field]);
+
+  // 渲染键值对编辑器
+  const renderKeyValueEditor = () => {
+    if (typeof jsonData !== 'object' || jsonData === null) {
+      return (
+        <div className="text-center py-6 px-4">
+          <div className="text-gray-400 mb-2">
+            <IconCode size={32} />
+          </div>
+          <Text type="tertiary" className="text-gray-500 text-sm">
+            {t('无效的JSON数据,请检查格式')}
+          </Text>
+        </div>
+      );
+    }
+    const entries = Object.entries(jsonData);
+
+    return (
+      <div className="space-y-1">
+        {entries.length === 0 && (
+          <div className="text-center py-6 px-4">
+            <Text type="tertiary" className="text-gray-500 text-sm">
+              {t('暂无数据,点击下方按钮添加键值对')}
+            </Text>
+          </div>
+        )}
+
+        {entries.map(([key, value], index) => (
+          <Row key={index} gutter={8} align="middle">
+            <Col span={6}>
+              <Input
+                placeholder={t('键名')}
+                value={key}
+                onChange={(newKey) => updateKey(key, newKey)}
+              />
+            </Col>
+            <Col span={16}>
+              {renderValueInput(key, value)}
+            </Col>
+            <Col span={2}>
+              <Button
+                icon={<IconDelete />}
+                type="danger"
+                theme="borderless"
+                onClick={() => removeKeyValue(key)}
+                style={{ width: '100%' }}
+              />
+            </Col>
+          </Row>
+        ))}
+
+        <div className="mt-2 flex justify-center">
+          <Button
+            icon={<IconPlus />}
+            type="primary"
+            theme="outline"
+            onClick={addKeyValue}
+          >
+            {t('添加键值对')}
+          </Button>
+        </div>
+      </div>
+    );
+  };
+
+  // 添加嵌套对象
+  const flattenObject = useCallback((parentKey) => {
+    const newData = { ...jsonData };
+    let primitive = '';
+    const obj = newData[parentKey];
+    if (obj && typeof obj === 'object') {
+      const firstKey = Object.keys(obj)[0];
+      if (firstKey !== undefined) {
+        const firstVal = obj[firstKey];
+        if (typeof firstVal !== 'object') primitive = firstVal;
+      }
+    }
+    newData[parentKey] = primitive;
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  const addNestedObject = useCallback((parentKey) => {
+    const newData = { ...jsonData };
+    if (typeof newData[parentKey] !== 'object' || newData[parentKey] === null) {
+      newData[parentKey] = {};
+    }
+    const existingKeys = Object.keys(newData[parentKey]);
+    let counter = 1;
+    let newKey = `field_${counter}`;
+    while (newData[parentKey].hasOwnProperty(newKey)) {
+      counter += 1;
+      newKey = `field_${counter}`;
+    }
+    newData[parentKey][newKey] = '';
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 渲染参数值输入控件(支持嵌套)
+  const renderValueInput = (key, value) => {
+    const valueType = typeof value;
+
+    if (valueType === 'boolean') {
+      return (
+        <div className="flex items-center">
+          <Switch
+            checked={value}
+            onChange={(newValue) => updateValue(key, newValue)}
+          />
+          <Text type="tertiary" className="ml-2">
+            {value ? t('true') : t('false')}
+          </Text>
+        </div>
+      );
+    }
+
+    if (valueType === 'number') {
+      return (
+        <InputNumber
+          value={value}
+          onChange={(newValue) => updateValue(key, newValue)}
+          style={{ width: '100%' }}
+          step={key === 'temperature' ? 0.1 : 1}
+          precision={key === 'temperature' ? 2 : 0}
+          placeholder={t('输入数字')}
+        />
+      );
+    }
+
+    if (valueType === 'object' && value !== null) {
+      // 渲染嵌套对象
+      const entries = Object.entries(value);
+      return (
+        <Card className="!rounded-2xl">
+          {entries.length === 0 && (
+            <Text type="tertiary" className="text-gray-500 text-xs">
+              {t('空对象,点击下方加号添加字段')}
+            </Text>
+          )}
+
+          {entries.map(([nestedKey, nestedValue], index) => (
+            <Row key={index} gutter={4} align="middle" className="mb-1">
+              <Col span={8}>
+                <Input
+                  size="small"
+                  placeholder={t('键名')}
+                  value={nestedKey}
+                  onChange={(newKey) => {
+                    const newData = { ...jsonData };
+                    const oldValue = newData[key][nestedKey];
+                    delete newData[key][nestedKey];
+                    newData[key][newKey] = oldValue;
+                    handleVisualChange(newData);
+                  }}
+                />
+              </Col>
+              <Col span={14}>
+                {typeof nestedValue === 'object' && nestedValue !== null ? (
+                  <TextArea
+                    size="small"
+                    rows={2}
+                    value={JSON.stringify(nestedValue, null, 2)}
+                    onChange={(txt) => {
+                      try {
+                        const obj = txt.trim() ? JSON.parse(txt) : {};
+                        const newData = { ...jsonData };
+                        newData[key][nestedKey] = obj;
+                        handleVisualChange(newData);
+                      } catch {
+                        // ignore parse error
+                      }
+                    }}
+                  />
+                ) : (
+                  <Input
+                    size="small"
+                    placeholder={t('值')}
+                    value={String(nestedValue)}
+                    onChange={(newValue) => {
+                      const newData = { ...jsonData };
+                      let convertedValue = newValue;
+                      if (newValue === 'true') convertedValue = true;
+                      else if (newValue === 'false') convertedValue = false;
+                      else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
+                        convertedValue = Number(newValue);
+                      }
+                      newData[key][nestedKey] = convertedValue;
+                      handleVisualChange(newData);
+                    }}
+                  />
+                )}
+              </Col>
+              <Col span={2}>
+                <Button
+                  size="small"
+                  icon={<IconDelete />}
+                  type="danger"
+                  theme="borderless"
+                  onClick={() => {
+                    const newData = { ...jsonData };
+                    delete newData[key][nestedKey];
+                    handleVisualChange(newData);
+                  }}
+                  style={{ width: '100%' }}
+                />
+              </Col>
+            </Row>
+          ))}
+
+          <div className="flex justify-center mt-1 gap-2">
+            <Button
+              size="small"
+              icon={<IconPlus />}
+              type="tertiary"
+              onClick={() => addNestedObject(key)}
+            >
+              {t('添加字段')}
+            </Button>
+            <Button
+              size="small"
+              icon={<IconRefresh />}
+              type="tertiary"
+              onClick={() => flattenObject(key)}
+            >
+              {t('转换为值')}
+            </Button>
+          </div>
+        </Card>
+      );
+    }
+
+    // 字符串或其他原始类型
+    return (
+      <div className="flex items-center gap-1">
+        <Input
+          placeholder={t('参数值')}
+          value={String(value)}
+          onChange={(newValue) => {
+            let convertedValue = newValue;
+            if (newValue === 'true') convertedValue = true;
+            else if (newValue === 'false') convertedValue = false;
+            else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
+              convertedValue = Number(newValue);
+            }
+            updateValue(key, convertedValue);
+          }}
+        />
+        <Button
+          icon={<IconPlus />}
+          type="tertiary"
+          onClick={() => {
+            // 将当前值转换为对象
+            const newData = { ...jsonData };
+            newData[key] = { '1': value };
+            handleVisualChange(newData);
+          }}
+          title={t('转换为对象')}
+        />
+      </div>
+    );
+  };
+
+  // 渲染区域编辑器(特殊格式)
+  const renderRegionEditor = () => {
+    const entries = Object.entries(jsonData);
+    const defaultEntry = entries.find(([key]) => key === 'default');
+    const modelEntries = entries.filter(([key]) => key !== 'default');
+
+    return (
+      <div className="space-y-2">
+        {/* 默认区域 */}
+        <Form.Slot label={t('默认区域')}>
+          <Input
+            placeholder={t('默认区域,如: us-central1')}
+            value={defaultEntry ? defaultEntry[1] : ''}
+            onChange={(value) => updateValue('default', value)}
+          />
+        </Form.Slot>
+
+        {/* 模型专用区域 */}
+        <Form.Slot label={t('模型专用区域')}>
+          <div>
+            {modelEntries.map(([modelName, region], index) => (
+              <Row key={index} gutter={8} align="middle" className="mb-2">
+                <Col span={10}>
+                  <Input
+                    placeholder={t('模型名称')}
+                    value={modelName}
+                    onChange={(newKey) => updateKey(modelName, newKey)}
+                  />
+                </Col>
+                <Col span={12}>
+                  <Input
+                    placeholder={t('区域')}
+                    value={region}
+                    onChange={(newValue) => updateValue(modelName, newValue)}
+                  />
+                </Col>
+                <Col span={2}>
+                  <Button
+                    icon={<IconDelete />}
+                    type="danger"
+                    theme="borderless"
+                    onClick={() => removeKeyValue(modelName)}
+                    style={{ width: '100%' }}
+                  />
+                </Col>
+              </Row>
+            ))}
+
+            <div className="mt-2 flex justify-center">
+              <Button
+                icon={<IconPlus />}
+                onClick={addKeyValue}
+                type="primary"
+                theme="outline"
+              >
+                {t('添加模型区域')}
+              </Button>
+            </div>
+          </div>
+        </Form.Slot>
+      </div>
+    );
+  };
+
+  // 渲染可视化编辑器
+  const renderVisualEditor = () => {
+    switch (editorType) {
+      case 'region':
+        return renderRegionEditor();
+      case 'object':
+      case 'keyValue':
+      default:
+        return renderKeyValueEditor();
+    }
+  };
+
+  const hasJsonError = jsonError && jsonError.trim() !== '';
+
+  return (
+    <Form.Slot label={label}>
+      <Card
+        header={
+          <div className="flex justify-between items-center">
+            <Tabs
+              type="slash"
+              activeKey={editMode}
+              onChange={(key) => {
+                if (key === 'manual' && editMode === 'visual') {
+                  setEditMode('manual');
+                } else if (key === 'visual' && editMode === 'manual') {
+                  toggleEditMode();
+                }
+              }}
+            >
+              <TabPane tab={t('可视化')} itemKey="visual" />
+              <TabPane tab={t('手动编辑')} itemKey="manual" />
+            </Tabs>
+
+            {template && templateLabel && (
+              <Button
+                type="tertiary"
+                onClick={fillTemplate}
+                size="small"
+              >
+                {templateLabel}
+              </Button>
+            )}
+          </div>
+        }
+        headerStyle={{ padding: '12px 16px' }}
+        bodyStyle={{ padding: '16px' }}
+        className="!rounded-2xl"
+      >
+        {/* JSON错误提示 */}
+        {hasJsonError && (
+          <Banner
+            type="danger"
+            description={`JSON 格式错误: ${jsonError}`}
+            className="mb-3"
+          />
+        )}
+
+        {/* 编辑器内容 */}
+        {editMode === 'visual' ? (
+          <div>
+            {renderVisualEditor()}
+            {/* 隐藏的Form字段用于验证和数据绑定 */}
+            <Form.Input
+              field={field}
+              value={value}
+              rules={rules}
+              style={{ display: 'none' }}
+              noLabel={true}
+              {...props}
+            />
+          </div>
+        ) : (
+          <div>
+            <TextArea
+              placeholder={placeholder}
+              value={manualText}
+              onChange={handleManualChange}
+              showClear={showClear}
+              rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
+            />
+            {/* 隐藏的Form字段用于验证和数据绑定 */}
+            <Form.Input
+              field={field}
+              value={value}
+              rules={rules}
+              style={{ display: 'none' }}
+              noLabel={true}
+              {...props}
+            />
+          </div>
+        )}
+
+        {/* 额外文本显示在卡片底部 */}
+        {extraText && (
+          <Divider margin='12px' align='center'>
+            <Text type="tertiary" size="small">{extraText}</Text>
+          </Divider>
+        )}
+        {extraFooter && (
+          <div className="mt-1">
+            {extraFooter}
+          </div>
+        )}
+      </Card>
+    </Form.Slot>
+  );
+};
+
+export default JSONEditor; 

+ 60 - 0
web/src/components/common/ui/RenderUtils.jsx

@@ -0,0 +1,60 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
+
+const { Text } = Typography;
+
+// 通用渲染函数:限制项目数量显示,支持popover展开
+export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
+  if (!items || items.length === 0) return '-';
+  const displayItems = items.slice(0, maxDisplay);
+  const remainingItems = items.slice(maxDisplay);
+  return (
+    <Space spacing={1} wrap>
+      {displayItems.map((item, idx) => renderItem(item, idx))}
+      {remainingItems.length > 0 && (
+        <Popover
+          content={
+            <div className='p-2'>
+              <Space spacing={1} wrap>
+                {remainingItems.map((item, idx) => renderItem(item, idx))}
+              </Space>
+            </div>
+          }
+          position='top'
+        >
+          <Tag size='small' shape='circle' color='grey'>
+            +{remainingItems.length}
+          </Tag>
+        </Popover>
+      )}
+    </Space>
+  );
+}
+
+// 渲染描述字段,长文本支持tooltip
+export const renderDescription = (text, maxWidth = 200) => {
+  return (
+    <Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
+      {text || '-'}
+    </Text>
+  );
+};

+ 257 - 0
web/src/components/common/ui/SelectableButtonGroup.jsx

@@ -0,0 +1,257 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useRef } from 'react';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
+import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton } from '@douyinfe/semi-ui';
+import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
+
+/**
+ * 通用可选择按钮组组件
+ *
+ * @param {string} title 标题
+ * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
+ * @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
+ * @param {(value:any)=>void} onChange 选择改变回调
+ * @param {function} t i18n
+ * @param {object} style 额外样式
+ * @param {boolean} collapsible 是否支持折叠,默认true
+ * @param {number} collapseHeight 折叠时的高度,默认200
+ * @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
+ * @param {boolean} loading 是否处于加载状态
+ */
+const SelectableButtonGroup = ({
+  title,
+  items = [],
+  activeValue,
+  onChange,
+  t = (v) => v,
+  style = {},
+  collapsible = true,
+  collapseHeight = 200,
+  withCheckbox = false,
+  loading = false
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [skeletonCount] = useState(6);
+  const isMobile = useIsMobile();
+  const perRow = 3;
+  const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
+  const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
+  const showSkeleton = useMinimumLoadingTime(loading);
+
+  const contentRef = useRef(null);
+
+  const maskStyle = isOpen
+    ? {}
+    : {
+      WebkitMaskImage:
+        'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
+    };
+
+  const toggle = () => {
+    setIsOpen(!isOpen);
+  };
+
+  const linkStyle = {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    textAlign: 'center',
+    bottom: -10,
+    fontWeight: 400,
+    cursor: 'pointer',
+    fontSize: '12px',
+    color: 'var(--semi-color-text-2)',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: 4,
+  };
+
+  const renderSkeletonButtons = () => {
+
+    const placeholder = (
+      <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }}>
+        {Array.from({ length: skeletonCount }).map((_, index) => (
+          <Col
+            {...(isMobile
+              ? { span: 12 }
+              : { span: 8 }
+            )}
+            key={index}
+          >
+            <div style={{
+              width: '100%',
+              height: '32px',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'flex-start',
+              border: '1px solid var(--semi-color-border)',
+              borderRadius: 'var(--semi-border-radius-medium)',
+              padding: '0 12px',
+              gap: '8px'
+            }}>
+              {withCheckbox && (
+                <Skeleton.Title active style={{ width: 14, height: 14 }} />
+              )}
+              <Skeleton.Title
+                active
+                style={{
+                  width: `${60 + (index % 3) * 20}px`,
+                  height: 14
+                }}
+              />
+            </div>
+          </Col>
+        ))}
+      </Row>
+    );
+
+    return (
+      <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
+    );
+  };
+
+  const contentElement = showSkeleton ? renderSkeletonButtons() : (
+    <Row gutter={[8, 8]} style={{ lineHeight: '32px', ...style }} ref={contentRef}>
+      {items.map((item) => {
+        const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
+        const isActive = Array.isArray(activeValue)
+          ? activeValue.includes(item.value)
+          : activeValue === item.value;
+
+        if (withCheckbox) {
+          return (
+            <Col
+              {...(isMobile
+                ? { span: 12 }
+                : { span: 8 }
+              )}
+              key={item.value}
+            >
+              <Button
+                onClick={() => { /* disabled */ }}
+                theme={isActive ? 'light' : 'outline'}
+                type={isActive ? 'primary' : 'tertiary'}
+                disabled={isDisabled}
+                icon={
+                  <Checkbox
+                    checked={isActive}
+                    onChange={() => onChange(item.value)}
+                    disabled={isDisabled}
+                    style={{ pointerEvents: 'auto' }}
+                  />
+                }
+                style={{ width: '100%', cursor: 'default' }}
+              >
+                {item.icon && (
+                  <span style={{ marginRight: 4 }}>{item.icon}</span>
+                )}
+                <span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
+                {item.tagCount !== undefined && (
+                  <Tag
+                    color='white'
+                    shape="circle"
+                    size="small"
+                  >
+                    {item.tagCount}
+                  </Tag>
+                )}
+              </Button>
+            </Col>
+          );
+        }
+
+        return (
+          <Col
+            {...(isMobile
+              ? { span: 12 }
+              : { span: 8 }
+            )}
+            key={item.value}
+          >
+            <Button
+              onClick={() => onChange(item.value)}
+              theme={isActive ? 'light' : 'outline'}
+              type={isActive ? 'primary' : 'tertiary'}
+              icon={item.icon}
+              disabled={isDisabled}
+              style={{ width: '100%' }}
+            >
+              <span style={{ marginRight: item.tagCount !== undefined ? 4 : 0 }}>{item.label}</span>
+              {item.tagCount !== undefined && (
+                <Tag
+                  color='white'
+                  shape="circle"
+                  size="small"
+                >
+                  {item.tagCount}
+                </Tag>
+              )}
+            </Button>
+          </Col>
+        );
+      })}
+    </Row>
+  );
+
+  return (
+    <div className="mb-8">
+      {title && (
+        <Divider margin="12px" align="left">
+          {showSkeleton ? (
+            <Skeleton.Title active style={{ width: 80, height: 14 }} />
+          ) : (
+            title
+          )}
+        </Divider>
+      )}
+      {needCollapse && !showSkeleton ? (
+        <div style={{ position: 'relative' }}>
+          <Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
+            {contentElement}
+          </Collapsible>
+          {isOpen ? null : (
+            <div onClick={toggle} style={{ ...linkStyle }}>
+              <IconChevronDown size="small" />
+              <span>{t('展开更多')}</span>
+            </div>
+          )}
+          {isOpen && (
+            <div onClick={toggle} style={{
+              ...linkStyle,
+              position: 'static',
+              marginTop: 8,
+              bottom: 'auto'
+            }}>
+              <IconChevronUp size="small" />
+              <span>{t('收起')}</span>
+            </div>
+          )}
+        </div>
+      ) : (
+        contentElement
+      )}
+    </div>
+  );
+};
+
+export default SelectableButtonGroup; 

+ 6 - 15
web/src/components/layout/HeaderBar.js

@@ -52,6 +52,7 @@ import {
 import { StatusContext } from '../../context/Status/index.js';
 import { useIsMobile } from '../../hooks/common/useIsMobile.js';
 import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
+import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime.js';
 
 const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const { t, i18n } = useTranslation();
@@ -59,7 +60,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const [statusState, statusDispatch] = useContext(StatusContext);
   const isMobile = useIsMobile();
   const [collapsed, toggleCollapsed] = useSidebarCollapsed();
-  const [isLoading, setIsLoading] = useState(true);
   const [logoLoaded, setLogoLoaded] = useState(false);
   let navigate = useNavigate();
   const [currentLang, setCurrentLang] = useState(i18n.language);
@@ -67,7 +67,9 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const location = useLocation();
   const [noticeVisible, setNoticeVisible] = useState(false);
   const [unreadCount, setUnreadCount] = useState(0);
-  const loadingStartRef = useRef(Date.now());
+
+  const loading = statusState?.status === undefined;
+  const isLoading = useMinimumLoadingTime(loading);
 
   const systemName = getSystemName();
   const logo = getLogo();
@@ -128,7 +130,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
       to: '/console',
     },
     {
-      text: t('定价'),
+      text: t('模型广场'),
       itemKey: 'pricing',
       to: '/pricing',
     },
@@ -216,17 +218,6 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
     };
   }, [i18n]);
 
-  useEffect(() => {
-    if (statusState?.status !== undefined) {
-      const elapsed = Date.now() - loadingStartRef.current;
-      const remaining = Math.max(0, 500 - elapsed);
-      const timer = setTimeout(() => {
-        setIsLoading(false);
-      }, remaining);
-      return () => clearTimeout(timer);
-    }
-  }, [statusState?.status]);
-
   useEffect(() => {
     setLogoLoaded(false);
     if (!logo) return;
@@ -467,7 +458,7 @@ const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   };
 
   return (
-    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
+    <header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg" style={{ borderBottom: '1px solid var(--semi-color-border)' }}>
       <NoticeModal
         visible={noticeVisible}
         onClose={handleNoticeClose}

+ 1 - 1
web/src/components/layout/PageLayout.js

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

+ 13 - 26
web/src/components/layout/SiderBar.js

@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
 import React, { useEffect, useMemo, useState } from 'react';
 import { Link, useLocation } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
-import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
+import { getLucideIcon } from '../../helpers/render.js';
 import { ChevronLeft } from 'lucide-react';
 import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed.js';
 import {
@@ -49,6 +49,7 @@ const routerMap = {
   detail: '/console',
   pricing: '/pricing',
   task: '/console/task',
+  models: '/console/models',
   playground: '/console/playground',
   personal: '/console/personal',
 };
@@ -127,6 +128,12 @@ const SiderBar = ({ onNavigate = () => { } }) => {
 
   const adminItems = useMemo(
     () => [
+      {
+        text: t('模型管理'),
+        itemKey: 'models',
+        to: '/console/models',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
       {
         text: t('渠道管理'),
         itemKey: 'channel',
@@ -244,28 +251,8 @@ const SiderBar = ({ onNavigate = () => { } }) => {
     }
   }, [collapsed]);
 
-  // 获取菜单项对应的颜色
-  const getItemColor = (itemKey) => {
-    switch (itemKey) {
-      case 'detail': return sidebarIconColors.dashboard;
-      case 'playground': return sidebarIconColors.terminal;
-      case 'chat': return sidebarIconColors.message;
-      case 'token': return sidebarIconColors.key;
-      case 'log': return sidebarIconColors.chart;
-      case 'midjourney': return sidebarIconColors.image;
-      case 'task': return sidebarIconColors.check;
-      case 'topup': return sidebarIconColors.credit;
-      case 'channel': return sidebarIconColors.layers;
-      case 'redemption': return sidebarIconColors.gift;
-      case 'user':
-      case 'personal': return sidebarIconColors.user;
-      case 'setting': return sidebarIconColors.settings;
-      default:
-        // 处理聊天项
-        if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
-        return 'currentColor';
-    }
-  };
+  // 选中高亮颜色(统一)
+  const SELECTED_COLOR = 'var(--semi-color-primary)';
 
   // 渲染自定义菜单项
   const renderNavItem = (item) => {
@@ -273,7 +260,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
     if (item.className === 'tableHiddle') return null;
 
     const isSelected = selectedKeys.includes(item.itemKey);
-    const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
+    const textColor = isSelected ? SELECTED_COLOR : 'inherit';
 
     return (
       <Nav.Item
@@ -300,7 +287,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
   const renderSubItem = (item) => {
     if (item.items && item.items.length > 0) {
       const isSelected = selectedKeys.includes(item.itemKey);
-      const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
+      const textColor = isSelected ? SELECTED_COLOR : 'inherit';
 
       return (
         <Nav.Sub
@@ -321,7 +308,7 @@ const SiderBar = ({ onNavigate = () => { } }) => {
         >
           {item.items.map((subItem) => {
             const isSubSelected = selectedKeys.includes(subItem.itemKey);
-            const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
+            const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
 
             return (
               <Nav.Item

+ 24 - 36
web/src/components/table/channels/modals/EditChannelModal.jsx

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

+ 1 - 1
web/src/components/table/channels/modals/ModelTestModal.jsx

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

+ 0 - 261
web/src/components/table/model-pricing/ModelPricingColumnDefs.js

@@ -1,261 +0,0 @@
-/*
-Copyright (C) 2025 QuantumNous
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React from 'react';
-import { Tag, Space, Tooltip, Switch } from '@douyinfe/semi-ui';
-import { IconVerify, IconHelpCircle } from '@douyinfe/semi-icons';
-import { Popover } from '@douyinfe/semi-ui';
-import { renderModelTag, stringToColor } from '../../../helpers';
-
-function renderQuotaType(type, t) {
-  switch (type) {
-    case 1:
-      return (
-        <Tag color='teal' shape='circle'>
-          {t('按次计费')}
-        </Tag>
-      );
-    case 0:
-      return (
-        <Tag color='violet' shape='circle'>
-          {t('按量计费')}
-        </Tag>
-      );
-    default:
-      return t('未知');
-  }
-}
-
-function renderAvailable(available, t) {
-  return available ? (
-    <Popover
-      content={
-        <div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
-      }
-      position='top'
-      key={available}
-      className="bg-green-50"
-    >
-      <IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
-    </Popover>
-  ) : null;
-}
-
-function renderSupportedEndpoints(endpoints) {
-  if (!endpoints || endpoints.length === 0) {
-    return null;
-  }
-  return (
-    <Space wrap>
-      {endpoints.map((endpoint, idx) => (
-        <Tag
-          key={endpoint}
-          color={stringToColor(endpoint)}
-          shape='circle'
-        >
-          {endpoint}
-        </Tag>
-      ))}
-    </Space>
-  );
-}
-
-export const getModelPricingColumns = ({
-  t,
-  selectedGroup,
-  usableGroup,
-  groupRatio,
-  copyText,
-  setModalImageUrl,
-  setIsModalOpenurl,
-  currency,
-  showWithRecharge,
-  tokenUnit,
-  setTokenUnit,
-  displayPrice,
-  handleGroupClick,
-}) => {
-  return [
-    {
-      title: t('可用性'),
-      dataIndex: 'available',
-      render: (text, record, index) => {
-        return renderAvailable(record.enable_groups.includes(selectedGroup), t);
-      },
-      sorter: (a, b) => {
-        const aAvailable = a.enable_groups.includes(selectedGroup);
-        const bAvailable = b.enable_groups.includes(selectedGroup);
-        return Number(aAvailable) - Number(bAvailable);
-      },
-      defaultSortOrder: 'descend',
-    },
-    {
-      title: t('可用端点类型'),
-      dataIndex: 'supported_endpoint_types',
-      render: (text, record, index) => {
-        return renderSupportedEndpoints(text);
-      },
-    },
-    {
-      title: t('模型名称'),
-      dataIndex: 'model_name',
-      render: (text, record, index) => {
-        return renderModelTag(text, {
-          onClick: () => {
-            copyText(text);
-          }
-        });
-      },
-      onFilter: (value, record) =>
-        record.model_name.toLowerCase().includes(value.toLowerCase()),
-    },
-    {
-      title: t('计费类型'),
-      dataIndex: 'quota_type',
-      render: (text, record, index) => {
-        return renderQuotaType(parseInt(text), t);
-      },
-      sorter: (a, b) => a.quota_type - b.quota_type,
-    },
-    {
-      title: t('可用分组'),
-      dataIndex: 'enable_groups',
-      render: (text, record, index) => {
-        return (
-          <Space wrap>
-            {text.map((group) => {
-              if (usableGroup[group]) {
-                if (group === selectedGroup) {
-                  return (
-                    <Tag key={group} color='blue' shape='circle' prefixIcon={<IconVerify />}>
-                      {group}
-                    </Tag>
-                  );
-                } else {
-                  return (
-                    <Tag
-                      key={group}
-                      color='blue'
-                      shape='circle'
-                      onClick={() => handleGroupClick(group)}
-                      className="cursor-pointer hover:opacity-80 transition-opacity"
-                    >
-                      {group}
-                    </Tag>
-                  );
-                }
-              }
-            })}
-          </Space>
-        );
-      },
-    },
-    {
-      title: () => (
-        <div className="flex items-center space-x-1">
-          <span>{t('倍率')}</span>
-          <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
-            <IconHelpCircle
-              className="text-blue-500 cursor-pointer"
-              onClick={() => {
-                setModalImageUrl('/ratio.png');
-                setIsModalOpenurl(true);
-              }}
-            />
-          </Tooltip>
-        </div>
-      ),
-      dataIndex: 'model_ratio',
-      render: (text, record, index) => {
-        let content = text;
-        let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
-        content = (
-          <div className="space-y-1">
-            <div className="text-gray-700">
-              {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
-            </div>
-            <div className="text-gray-700">
-              {t('补全倍率')}:
-              {record.quota_type === 0 ? completionRatio : t('无')}
-            </div>
-            <div className="text-gray-700">
-              {t('分组倍率')}:{groupRatio[selectedGroup]}
-            </div>
-          </div>
-        );
-        return content;
-      },
-    },
-    {
-      title: (
-        <div className="flex items-center space-x-2">
-          <span>{t('模型价格')}</span>
-          {/* 计费单位切换 */}
-          <Switch
-            checked={tokenUnit === 'K'}
-            onChange={(checked) => setTokenUnit(checked ? 'K' : 'M')}
-            checkedText="K"
-            uncheckedText="M"
-          />
-        </div>
-      ),
-      dataIndex: 'model_price',
-      render: (text, record, index) => {
-        let content = text;
-        if (record.quota_type === 0) {
-          let inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
-          let completionRatioPriceUSD =
-            record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
-
-          const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
-          const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
-
-          let displayInput = displayPrice(inputRatioPriceUSD);
-          let displayCompletion = displayPrice(completionRatioPriceUSD);
-
-          const divisor = unitDivisor;
-          const numInput = parseFloat(displayInput.replace(/[^0-9.]/g, '')) / divisor;
-          const numCompletion = parseFloat(displayCompletion.replace(/[^0-9.]/g, '')) / divisor;
-
-          displayInput = `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(3)}`;
-          displayCompletion = `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(3)}`;
-          content = (
-            <div className="space-y-1">
-              <div className="text-gray-700">
-                {t('提示')} {displayInput} / 1{unitLabel} tokens
-              </div>
-              <div className="text-gray-700">
-                {t('补全')} {displayCompletion} / 1{unitLabel} tokens
-              </div>
-            </div>
-          );
-        } else {
-          let priceUSD = parseFloat(text) * groupRatio[selectedGroup];
-          let displayVal = displayPrice(priceUSD);
-          content = (
-            <div className="text-gray-700">
-              {t('模型价格')}:{displayVal}
-            </div>
-          );
-        }
-        return content;
-      },
-    },
-  ];
-}; 

+ 0 - 87
web/src/components/table/model-pricing/ModelPricingFilters.jsx

@@ -1,87 +0,0 @@
-/*
-Copyright (C) 2025 QuantumNous
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React, { useMemo } from 'react';
-import { Card, Input, Button, Space, Switch, Select } from '@douyinfe/semi-ui';
-import { IconSearch, IconCopy } from '@douyinfe/semi-icons';
-
-const ModelPricingFilters = ({
-  selectedRowKeys,
-  copyText,
-  showWithRecharge,
-  setShowWithRecharge,
-  currency,
-  setCurrency,
-  handleChange,
-  handleCompositionStart,
-  handleCompositionEnd,
-  t
-}) => {
-  const SearchAndActions = useMemo(() => (
-    <Card className="!rounded-xl mb-6" bordered={false}>
-      <div className="flex flex-wrap items-center gap-4">
-        <div className="flex-1 min-w-[200px]">
-          <Input
-            prefix={<IconSearch />}
-            placeholder={t('模糊搜索模型名称')}
-            onCompositionStart={handleCompositionStart}
-            onCompositionEnd={handleCompositionEnd}
-            onChange={handleChange}
-            showClear
-          />
-        </div>
-        <Button
-          theme='light'
-          type='primary'
-          icon={<IconCopy />}
-          onClick={() => copyText(selectedRowKeys)}
-          disabled={selectedRowKeys.length === 0}
-          className="!bg-blue-500 hover:!bg-blue-600 text-white"
-        >
-          {t('复制选中模型')}
-        </Button>
-
-        {/* 充值价格显示开关 */}
-        <Space align="center">
-          <span>{t('以充值价格显示')}</span>
-          <Switch
-            checked={showWithRecharge}
-            onChange={setShowWithRecharge}
-            size="small"
-          />
-          {showWithRecharge && (
-            <Select
-              value={currency}
-              onChange={setCurrency}
-              size="small"
-              style={{ width: 100 }}
-            >
-              <Select.Option value="USD">USD ($)</Select.Option>
-              <Select.Option value="CNY">CNY (¥)</Select.Option>
-            </Select>
-          )}
-        </Space>
-      </div>
-    </Card>
-  ), [selectedRowKeys, t, showWithRecharge, currency, handleCompositionStart, handleCompositionEnd, handleChange, copyText, setShowWithRecharge, setCurrency]);
-
-  return SearchAndActions;
-};
-
-export default ModelPricingFilters; 

+ 0 - 123
web/src/components/table/model-pricing/ModelPricingHeader.jsx

@@ -1,123 +0,0 @@
-/*
-Copyright (C) 2025 QuantumNous
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React from 'react';
-import { Card } from '@douyinfe/semi-ui';
-import { IconVerify, IconLayers, IconInfoCircle } from '@douyinfe/semi-icons';
-import { AlertCircle } from 'lucide-react';
-
-const ModelPricingHeader = ({
-  userState,
-  groupRatio,
-  selectedGroup,
-  models,
-  t
-}) => {
-  return (
-    <Card
-      className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
-      style={{
-        background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
-        position: 'relative'
-      }}
-      bodyStyle={{ padding: 0 }}
-    >
-      <div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
-        <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
-          <div className="flex items-start">
-            <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
-              <IconLayers size="extra-large" className="text-white" />
-            </div>
-            <div className="flex-1 min-w-0">
-              <div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
-                {t('模型定价')}
-              </div>
-              <div className="text-sm text-white/80">
-                {userState.user ? (
-                  <div className="flex items-center">
-                    <IconVerify className="mr-1.5 flex-shrink-0" size="small" />
-                    <span className="truncate">
-                      {t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
-                    </span>
-                  </div>
-                ) : (
-                  <div className="flex items-center">
-                    <AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
-                    <span className="truncate">
-                      {t('未登录,使用默认分组倍率:')}{groupRatio['default']}
-                    </span>
-                  </div>
-                )}
-              </div>
-            </div>
-          </div>
-
-          <div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
-            <div
-              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-              style={{ backdropFilter: 'blur(10px)' }}
-            >
-              <div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
-              <div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
-            </div>
-            <div
-              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-              style={{ backdropFilter: 'blur(10px)' }}
-            >
-              <div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
-              <div className="text-sm sm:text-base font-semibold">
-                {models.filter(m => m.enable_groups.includes(selectedGroup)).length}
-              </div>
-            </div>
-            <div
-              className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
-              style={{ backdropFilter: 'blur(10px)' }}
-            >
-              <div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
-              <div className="text-sm sm:text-base font-semibold">2</div>
-            </div>
-          </div>
-        </div>
-
-        {/* 计费说明 */}
-        <div className="mt-4 sm:mt-5">
-          <div className="flex items-start">
-            <div
-              className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
-              style={{
-                backgroundColor: 'rgba(255, 255, 255, 0.2)',
-                color: 'white',
-                backdropFilter: 'blur(10px)'
-              }}
-            >
-              <IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
-              <span>
-                {t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
-              </span>
-            </div>
-          </div>
-        </div>
-
-        <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
-      </div>
-    </Card>
-  );
-};
-
-export default ModelPricingHeader; 

+ 0 - 67
web/src/components/table/model-pricing/ModelPricingTabs.jsx

@@ -1,67 +0,0 @@
-/*
-Copyright (C) 2025 QuantumNous
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React from 'react';
-import { Tabs, TabPane, Tag } from '@douyinfe/semi-ui';
-
-const ModelPricingTabs = ({
-  activeKey,
-  setActiveKey,
-  modelCategories,
-  categoryCounts,
-  availableCategories,
-  t
-}) => {
-  return (
-    <Tabs
-      activeKey={activeKey}
-      type="card"
-      collapsible
-      onChange={key => setActiveKey(key)}
-      className="mt-2"
-    >
-      {Object.entries(modelCategories)
-        .filter(([key]) => availableCategories.includes(key))
-        .map(([key, category]) => {
-          const modelCount = categoryCounts[key] || 0;
-
-          return (
-            <TabPane
-              tab={
-                <span className="flex items-center gap-2">
-                  {category.icon && <span className="w-4 h-4">{category.icon}</span>}
-                  {category.label}
-                  <Tag
-                    color={activeKey === key ? 'red' : 'grey'}
-                    shape='circle'
-                  >
-                    {modelCount}
-                  </Tag>
-                </span>
-              }
-              itemKey={key}
-              key={key}
-            />
-          );
-        })}
-    </Tabs>
-  );
-};
-
-export default ModelPricingTabs; 

+ 127 - 0
web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx

@@ -0,0 +1,127 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Tooltip } from '@douyinfe/semi-ui';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+import { IconHelpCircle } from '@douyinfe/semi-icons';
+
+const PricingDisplaySettings = ({
+  showWithRecharge,
+  setShowWithRecharge,
+  currency,
+  setCurrency,
+  showRatio,
+  setShowRatio,
+  viewMode,
+  setViewMode,
+  tokenUnit,
+  setTokenUnit,
+  loading = false,
+  t
+}) => {
+  const items = [
+    {
+      value: 'recharge',
+      label: t('以充值价格显示')
+    },
+    {
+      value: 'ratio',
+      label: (
+        <span className="flex items-center gap-1">
+          {t('显示倍率')}
+          <Tooltip content={t('倍率是用于系统计算不同模型的最终价格用的,如果您不理解倍率,请忽略')}>
+            <IconHelpCircle
+              size="small"
+              style={{ color: 'var(--semi-color-text-2)', cursor: 'help' }}
+            />
+          </Tooltip>
+        </span>
+      ),
+    },
+    {
+      value: 'tableView',
+      label: t('表格视图')
+    },
+    {
+      value: 'tokenUnit',
+      label: t('按K显示单位')
+    }
+  ];
+
+  const currencyItems = [
+    { value: 'USD', label: 'USD ($)' },
+    { value: 'CNY', label: 'CNY (¥)' }
+  ];
+
+  const handleChange = (value) => {
+    switch (value) {
+      case 'recharge':
+        setShowWithRecharge(!showWithRecharge);
+        break;
+      case 'ratio':
+        setShowRatio(!showRatio);
+        break;
+      case 'tableView':
+        setViewMode(viewMode === 'table' ? 'card' : 'table');
+        break;
+      case 'tokenUnit':
+        setTokenUnit(tokenUnit === 'K' ? 'M' : 'K');
+        break;
+    }
+  };
+
+  const getActiveValues = () => {
+    const activeValues = [];
+    if (showWithRecharge) activeValues.push('recharge');
+    if (showRatio) activeValues.push('ratio');
+    if (viewMode === 'table') activeValues.push('tableView');
+    if (tokenUnit === 'K') activeValues.push('tokenUnit');
+    return activeValues;
+  };
+
+  return (
+    <div>
+      <SelectableButtonGroup
+        title={t('显示设置')}
+        items={items}
+        activeValue={getActiveValues()}
+        onChange={handleChange}
+        withCheckbox
+        collapsible={false}
+        loading={loading}
+        t={t}
+      />
+
+      {showWithRecharge && (
+        <SelectableButtonGroup
+          title={t('货币单位')}
+          items={currencyItems}
+          activeValue={currency}
+          onChange={setCurrency}
+          collapsible={false}
+          loading={loading}
+          t={t}
+        />
+      )}
+    </div>
+  );
+};
+
+export default PricingDisplaySettings; 

+ 88 - 0
web/src/components/table/model-pricing/filter/PricingEndpointTypes.jsx

@@ -0,0 +1,88 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+
+/**
+ * 端点类型筛选组件
+ * @param {string|'all'} filterEndpointType 当前值
+ * @param {Function} setFilterEndpointType setter
+ * @param {Array} models 模型列表
+ * @param {boolean} loading 是否加载中
+ * @param {Function} t i18n
+ */
+const PricingEndpointTypes = ({ filterEndpointType, setFilterEndpointType, models = [], allModels = [], loading = false, t }) => {
+  // 获取系统中所有端点类型(基于 allModels,如果未提供则退化为 models)
+  const getAllEndpointTypes = () => {
+    const endpointTypes = new Set();
+    (allModels.length > 0 ? allModels : models).forEach(model => {
+      if (model.supported_endpoint_types && Array.isArray(model.supported_endpoint_types)) {
+        model.supported_endpoint_types.forEach(endpoint => {
+          endpointTypes.add(endpoint);
+        });
+      }
+    });
+    return Array.from(endpointTypes).sort();
+  };
+
+  // 计算每个端点类型的模型数量
+  const getEndpointTypeCount = (endpointType) => {
+    if (endpointType === 'all') {
+      return models.length;
+    }
+    return models.filter(model =>
+      model.supported_endpoint_types &&
+      model.supported_endpoint_types.includes(endpointType)
+    ).length;
+  };
+
+  // 端点类型显示名称映射
+  const getEndpointTypeLabel = (endpointType) => {
+    return endpointType;
+  };
+
+  const availableEndpointTypes = getAllEndpointTypes();
+
+  const items = [
+    { value: 'all', label: t('全部端点'), tagCount: getEndpointTypeCount('all'), disabled: models.length === 0 },
+    ...availableEndpointTypes.map(endpointType => {
+      const count = getEndpointTypeCount(endpointType);
+      return ({
+        value: endpointType,
+        label: getEndpointTypeLabel(endpointType),
+        tagCount: count,
+        disabled: count === 0
+      });
+    })
+  ];
+
+  return (
+    <SelectableButtonGroup
+      title={t('端点类型')}
+      items={items}
+      activeValue={filterEndpointType}
+      onChange={setFilterEndpointType}
+      loading={loading}
+      t={t}
+    />
+  );
+};
+
+export default PricingEndpointTypes; 

+ 71 - 0
web/src/components/table/model-pricing/filter/PricingGroups.jsx

@@ -0,0 +1,71 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+
+/**
+ * 分组筛选组件
+ * @param {string} filterGroup 当前选中的分组,'all' 表示不过滤
+ * @param {Function} setFilterGroup 设置选中分组
+ * @param {Record<string, any>} usableGroup 后端返回的可用分组对象
+ * @param {Record<string, number>} groupRatio 分组倍率对象
+ * @param {Array} models 模型列表
+ * @param {boolean} loading 是否加载中
+ * @param {Function} t i18n
+ */
+const PricingGroups = ({ filterGroup, setFilterGroup, usableGroup = {}, groupRatio = {}, models = [], loading = false, t }) => {
+  const groups = ['all', ...Object.keys(usableGroup).filter(key => key !== '')];
+
+  const items = groups.map((g) => {
+    const modelCount = g === 'all'
+      ? models.length
+      : models.filter(m => m.enable_groups && m.enable_groups.includes(g)).length;
+    let ratioDisplay = '';
+    if (g === 'all') {
+      ratioDisplay = t('全部');
+    } else {
+      const ratio = groupRatio[g];
+      if (ratio !== undefined && ratio !== null) {
+        ratioDisplay = `x${ratio}`;
+      } else {
+        ratioDisplay = 'x1';
+      }
+    }
+    return {
+      value: g,
+      label: g === 'all' ? t('全部分组') : g,
+      tagCount: ratioDisplay,
+      disabled: modelCount === 0
+    };
+  });
+
+  return (
+    <SelectableButtonGroup
+      title={t('可用令牌分组')}
+      items={items}
+      activeValue={filterGroup}
+      onChange={setFilterGroup}
+      loading={loading}
+      t={t}
+    />
+  );
+};
+
+export default PricingGroups; 

+ 52 - 0
web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx

@@ -0,0 +1,52 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+
+/**
+ * 计费类型筛选组件
+ * @param {string|'all'|0|1} filterQuotaType 当前值
+ * @param {Function} setFilterQuotaType setter
+ * @param {Array} models 模型列表
+ * @param {boolean} loading 是否加载中
+ * @param {Function} t i18n
+ */
+const PricingQuotaTypes = ({ filterQuotaType, setFilterQuotaType, models = [], loading = false, t }) => {
+  const qtyCount = (type) => models.filter(m => type === 'all' ? true : m.quota_type === type).length;
+
+  const items = [
+    { value: 'all', label: t('全部类型'), tagCount: qtyCount('all') },
+    { value: 0, label: t('按量计费'), tagCount: qtyCount(0) },
+    { value: 1, label: t('按次计费'), tagCount: qtyCount(1) },
+  ];
+
+  return (
+    <SelectableButtonGroup
+      title={t('计费类型')}
+      items={items}
+      activeValue={filterQuotaType}
+      onChange={setFilterQuotaType}
+      loading={loading}
+      t={t}
+    />
+  );
+};
+
+export default PricingQuotaTypes; 

+ 119 - 0
web/src/components/table/model-pricing/filter/PricingVendors.jsx

@@ -0,0 +1,119 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
+import { getLobeHubIcon } from '../../../../helpers';
+
+/**
+ * 供应商筛选组件
+ * @param {string|'all'} filterVendor 当前值
+ * @param {Function} setFilterVendor setter
+ * @param {Array} models 模型列表
+ * @param {Array} allModels 所有模型列表(用于获取全部供应商)
+ * @param {boolean} loading 是否加载中
+ * @param {Function} t i18n
+ */
+const PricingVendors = ({ filterVendor, setFilterVendor, models = [], allModels = [], loading = false, t }) => {
+  // 获取系统中所有供应商(基于 allModels,如果未提供则退化为 models)
+  const getAllVendors = React.useMemo(() => {
+    const vendors = new Set();
+    const vendorIcons = new Map();
+    let hasUnknownVendor = false;
+
+    (allModels.length > 0 ? allModels : models).forEach(model => {
+      if (model.vendor_name) {
+        vendors.add(model.vendor_name);
+        if (model.vendor_icon && !vendorIcons.has(model.vendor_name)) {
+          vendorIcons.set(model.vendor_name, model.vendor_icon);
+        }
+      } else {
+        hasUnknownVendor = true;
+      }
+    });
+
+    return {
+      vendors: Array.from(vendors).sort(),
+      vendorIcons,
+      hasUnknownVendor
+    };
+  }, [allModels, models]);
+
+  // 计算每个供应商的模型数量(基于当前过滤后的 models)
+  const getVendorCount = React.useCallback((vendor) => {
+    if (vendor === 'all') {
+      return models.length;
+    }
+    if (vendor === 'unknown') {
+      return models.filter(model => !model.vendor_name).length;
+    }
+    return models.filter(model => model.vendor_name === vendor).length;
+  }, [models]);
+
+  // 生成供应商选项
+  const items = React.useMemo(() => {
+    const result = [
+      {
+        value: 'all',
+        label: t('全部供应商'),
+        tagCount: getVendorCount('all'),
+        disabled: models.length === 0
+      }
+    ];
+
+    // 添加所有已知供应商
+    getAllVendors.vendors.forEach(vendor => {
+      const count = getVendorCount(vendor);
+      const icon = getAllVendors.vendorIcons.get(vendor);
+      result.push({
+        value: vendor,
+        label: vendor,
+        icon: icon ? getLobeHubIcon(icon, 16) : null,
+        tagCount: count,
+        disabled: count === 0
+      });
+    });
+
+    // 如果系统中存在未知供应商,添加"未知供应商"选项
+    if (getAllVendors.hasUnknownVendor) {
+      const count = getVendorCount('unknown');
+      result.push({
+        value: 'unknown',
+        label: t('未知供应商'),
+        tagCount: count,
+        disabled: count === 0
+      });
+    }
+
+    return result;
+  }, [getAllVendors, getVendorCount, t]);
+
+  return (
+    <SelectableButtonGroup
+      title={t('供应商')}
+      items={items}
+      activeValue={filterVendor}
+      onChange={setFilterVendor}
+      loading={loading}
+      t={t}
+    />
+  );
+};
+
+export default PricingVendors;

+ 0 - 66
web/src/components/table/model-pricing/index.jsx

@@ -1,66 +0,0 @@
-/*
-Copyright (C) 2025 QuantumNous
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, either version 3 of the
-License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React from 'react';
-import { Layout, Card, ImagePreview } from '@douyinfe/semi-ui';
-import ModelPricingTabs from './ModelPricingTabs.jsx';
-import ModelPricingFilters from './ModelPricingFilters.jsx';
-import ModelPricingTable from './ModelPricingTable.jsx';
-import ModelPricingHeader from './ModelPricingHeader.jsx';
-import { useModelPricingData } from '../../../hooks/model-pricing/useModelPricingData.js';
-
-const ModelPricingPage = () => {
-  const modelPricingData = useModelPricingData();
-
-  return (
-    <div className="bg-gray-50">
-      <Layout>
-        <Layout.Content>
-          <div className="flex justify-center">
-            <div className="w-full">
-              {/* 主卡片容器 */}
-              <Card bordered={false} className="!rounded-2xl shadow-lg border-0">
-                {/* 顶部状态卡片 */}
-                <ModelPricingHeader {...modelPricingData} />
-
-                {/* 模型分类 Tabs */}
-                <div className="mb-6">
-                  <ModelPricingTabs {...modelPricingData} />
-
-                  {/* 搜索和表格区域 */}
-                  <ModelPricingFilters {...modelPricingData} />
-                  <ModelPricingTable {...modelPricingData} />
-                </div>
-
-                {/* 倍率说明图预览 */}
-                <ImagePreview
-                  src={modelPricingData.modalImageUrl}
-                  visible={modelPricingData.isModalOpenurl}
-                  onVisibleChange={(visible) => modelPricingData.setIsModalOpenurl(visible)}
-                />
-              </Card>
-            </div>
-          </div>
-        </Layout.Content>
-      </Layout>
-    </div>
-  );
-};
-
-export default ModelPricingPage; 

+ 90 - 0
web/src/components/table/model-pricing/layout/PricingPage.jsx

@@ -0,0 +1,90 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Layout, ImagePreview } from '@douyinfe/semi-ui';
+import PricingSidebar from './PricingSidebar';
+import PricingContent from './content/PricingContent';
+import ModelDetailSideSheet from '../modal/ModelDetailSideSheet';
+import { useModelPricingData } from '../../../../hooks/model-pricing/useModelPricingData';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const PricingPage = () => {
+  const pricingData = useModelPricingData();
+  const { Sider, Content } = Layout;
+  const isMobile = useIsMobile();
+  const [showRatio, setShowRatio] = React.useState(false);
+  const [viewMode, setViewMode] = React.useState('card');
+  const allProps = {
+    ...pricingData,
+    showRatio,
+    setShowRatio,
+    viewMode,
+    setViewMode
+  };
+
+  return (
+    <div className="bg-white">
+      <Layout className="pricing-layout">
+        {!isMobile && (
+          <Sider
+            className="pricing-scroll-hide pricing-sidebar"
+            width={460}
+          >
+            <PricingSidebar {...allProps} />
+          </Sider>
+        )}
+
+        <Content
+          className="pricing-scroll-hide pricing-content"
+        >
+          <PricingContent
+            {...allProps}
+            isMobile={isMobile}
+            sidebarProps={allProps}
+          />
+        </Content>
+      </Layout>
+
+      <ImagePreview
+        src={pricingData.modalImageUrl}
+        visible={pricingData.isModalOpenurl}
+        onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)}
+      />
+
+      <ModelDetailSideSheet
+        visible={pricingData.showModelDetail}
+        onClose={pricingData.closeModelDetail}
+        modelData={pricingData.selectedModel}
+        selectedGroup={pricingData.selectedGroup}
+        groupRatio={pricingData.groupRatio}
+        usableGroup={pricingData.usableGroup}
+        currency={pricingData.currency}
+        tokenUnit={pricingData.tokenUnit}
+        displayPrice={pricingData.displayPrice}
+        showRatio={allProps.showRatio}
+        vendorsMap={pricingData.vendorsMap}
+        endpointMap={pricingData.endpointMap}
+        t={pricingData.t}
+      />
+    </div>
+  );
+};
+
+export default PricingPage; 

+ 157 - 0
web/src/components/table/model-pricing/layout/PricingSidebar.jsx

@@ -0,0 +1,157 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+import PricingGroups from '../filter/PricingGroups';
+import PricingQuotaTypes from '../filter/PricingQuotaTypes';
+import PricingEndpointTypes from '../filter/PricingEndpointTypes';
+import PricingVendors from '../filter/PricingVendors';
+import PricingDisplaySettings from '../filter/PricingDisplaySettings';
+import { resetPricingFilters } from '../../../../helpers/utils';
+import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
+
+const PricingSidebar = ({
+  showWithRecharge,
+  setShowWithRecharge,
+  currency,
+  setCurrency,
+  handleChange,
+  setActiveKey,
+  showRatio,
+  setShowRatio,
+  viewMode,
+  setViewMode,
+  filterGroup,
+  setFilterGroup,
+  filterQuotaType,
+  setFilterQuotaType,
+  filterEndpointType,
+  setFilterEndpointType,
+  filterVendor,
+  setFilterVendor,
+  currentPage,
+  setCurrentPage,
+  tokenUnit,
+  setTokenUnit,
+  loading,
+  t,
+  ...categoryProps
+}) => {
+
+  const {
+    quotaTypeModels,
+    endpointTypeModels,
+    vendorModels,
+    groupCountModels,
+  } = usePricingFilterCounts({
+    models: categoryProps.models,
+    filterGroup,
+    filterQuotaType,
+    filterEndpointType,
+    filterVendor,
+    searchValue: categoryProps.searchValue,
+  });
+
+  const handleResetFilters = () =>
+    resetPricingFilters({
+      handleChange,
+      setShowWithRecharge,
+      setCurrency,
+      setShowRatio,
+      setViewMode,
+      setFilterGroup,
+      setFilterQuotaType,
+      setFilterEndpointType,
+      setFilterVendor,
+      setCurrentPage,
+      setTokenUnit,
+    });
+
+  return (
+    <div className="p-4">
+      <div className="flex items-center justify-between mb-6">
+        <div className="text-lg font-semibold text-gray-800">
+          {t('筛选')}
+        </div>
+        <Button
+          theme="outline"
+          type='tertiary'
+          onClick={handleResetFilters}
+          className="text-gray-500 hover:text-gray-700"
+        >
+          {t('重置')}
+        </Button>
+      </div>
+
+      <PricingDisplaySettings
+        showWithRecharge={showWithRecharge}
+        setShowWithRecharge={setShowWithRecharge}
+        currency={currency}
+        setCurrency={setCurrency}
+        showRatio={showRatio}
+        setShowRatio={setShowRatio}
+        viewMode={viewMode}
+        setViewMode={setViewMode}
+        tokenUnit={tokenUnit}
+        setTokenUnit={setTokenUnit}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingVendors
+        filterVendor={filterVendor}
+        setFilterVendor={setFilterVendor}
+        models={vendorModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingGroups
+        filterGroup={filterGroup}
+        setFilterGroup={setFilterGroup}
+        usableGroup={categoryProps.usableGroup}
+        groupRatio={categoryProps.groupRatio}
+        models={groupCountModels}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingQuotaTypes
+        filterQuotaType={filterQuotaType}
+        setFilterQuotaType={setFilterQuotaType}
+        models={quotaTypeModels}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingEndpointTypes
+        filterEndpointType={filterEndpointType}
+        setFilterEndpointType={setFilterEndpointType}
+        models={endpointTypeModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default PricingSidebar; 

+ 40 - 0
web/src/components/table/model-pricing/layout/content/PricingContent.jsx

@@ -0,0 +1,40 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import PricingTopSection from '../header/PricingTopSection';
+import PricingView from './PricingView';
+
+const PricingContent = ({ isMobile, sidebarProps, ...props }) => {
+  return (
+    <div className={isMobile ? "pricing-content-mobile" : "pricing-scroll-hide"}>
+      {/* 固定的顶部区域(分类介绍 + 搜索和操作) */}
+      <div className="pricing-search-header">
+        <PricingTopSection {...props} isMobile={isMobile} sidebarProps={sidebarProps} />
+      </div>
+
+      {/* 可滚动的内容区域 */}
+      <div className={isMobile ? "pricing-view-container-mobile" : "pricing-view-container"}>
+        <PricingView {...props} viewMode={sidebarProps.viewMode} />
+      </div>
+    </div>
+  );
+};
+
+export default PricingContent; 

+ 33 - 0
web/src/components/table/model-pricing/layout/content/PricingView.jsx

@@ -0,0 +1,33 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import PricingTable from '../../view/table/PricingTable';
+import PricingCardView from '../../view/card/PricingCardView';
+
+const PricingView = ({
+  viewMode = 'table',
+  ...props
+}) => {
+  return viewMode === 'card' ?
+    <PricingCardView {...props} /> :
+    <PricingTable {...props} />;
+};
+
+export default PricingView; 

+ 109 - 0
web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx

@@ -0,0 +1,109 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useMemo, useState } from 'react';
+import { Input, Button } from '@douyinfe/semi-ui';
+import { IconSearch, IconCopy, IconFilter } from '@douyinfe/semi-icons';
+import PricingFilterModal from '../../modal/PricingFilterModal';
+import PricingVendorIntroWithSkeleton from './PricingVendorIntroWithSkeleton';
+
+const PricingTopSection = ({
+  selectedRowKeys,
+  copyText,
+  handleChange,
+  handleCompositionStart,
+  handleCompositionEnd,
+  isMobile,
+  sidebarProps,
+  filterVendor,
+  models,
+  filteredModels,
+  loading,
+  t
+}) => {
+  const [showFilterModal, setShowFilterModal] = useState(false);
+
+  const SearchAndActions = useMemo(() => (
+    <div className="flex items-center gap-4 w-full">
+      {/* 搜索框 */}
+      <div className="flex-1">
+        <Input
+          prefix={<IconSearch />}
+          placeholder={t('模糊搜索模型名称')}
+          onCompositionStart={handleCompositionStart}
+          onCompositionEnd={handleCompositionEnd}
+          onChange={handleChange}
+          showClear
+        />
+      </div>
+
+      {/* 操作按钮 */}
+      <Button
+        theme='outline'
+        type='primary'
+        icon={<IconCopy />}
+        onClick={() => copyText(selectedRowKeys)}
+        disabled={selectedRowKeys.length === 0}
+        className="!bg-blue-500 hover:!bg-blue-600 text-white"
+      >
+        {t('复制')}
+      </Button>
+
+      {/* 移动端筛选按钮 */}
+      {isMobile && (
+        <Button
+          theme="outline"
+          type='tertiary'
+          icon={<IconFilter />}
+          onClick={() => setShowFilterModal(true)}
+        >
+          {t('筛选')}
+        </Button>
+      )}
+    </div>
+  ), [selectedRowKeys, t, handleCompositionStart, handleCompositionEnd, handleChange, copyText, isMobile]);
+
+  return (
+    <>
+      {/* 供应商介绍区域(含骨架屏) */}
+      <PricingVendorIntroWithSkeleton
+        loading={loading}
+        filterVendor={filterVendor}
+        models={filteredModels}
+        allModels={models}
+        t={t}
+      />
+
+      {/* 搜索和操作区域 */}
+      {SearchAndActions}
+
+      {/* 移动端筛选Modal */}
+      {isMobile && (
+        <PricingFilterModal
+          visible={showFilterModal}
+          onClose={() => setShowFilterModal(false)}
+          sidebarProps={sidebarProps}
+          t={t}
+        />
+      )}
+    </>
+  );
+};
+
+export default PricingTopSection; 

+ 247 - 0
web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx

@@ -0,0 +1,247 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useMemo } from 'react';
+import { Card, Tag, Avatar, AvatarGroup, Typography } from '@douyinfe/semi-ui';
+import { getLobeHubIcon } from '../../../../../helpers';
+
+const { Paragraph } = Typography;
+
+const PricingVendorIntro = ({
+  filterVendor,
+  models = [],
+  allModels = [],
+  t
+}) => {
+  // 轮播动效状态(只对全部供应商生效)
+  const [currentOffset, setCurrentOffset] = useState(0);
+
+  // 获取所有供应商信息
+  const vendorInfo = useMemo(() => {
+    const vendors = new Map();
+    let unknownCount = 0;
+
+    (allModels.length > 0 ? allModels : models).forEach(model => {
+      if (model.vendor_name) {
+        if (!vendors.has(model.vendor_name)) {
+          vendors.set(model.vendor_name, {
+            name: model.vendor_name,
+            icon: model.vendor_icon,
+            description: model.vendor_description,
+            count: 0
+          });
+        }
+        vendors.get(model.vendor_name).count++;
+      } else {
+        unknownCount++;
+      }
+    });
+
+    const vendorList = Array.from(vendors.values()).sort((a, b) => a.name.localeCompare(b.name));
+
+    if (unknownCount > 0) {
+      vendorList.push({
+        name: 'unknown',
+        icon: null,
+        description: t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。'),
+        count: unknownCount
+      });
+    }
+
+    return vendorList;
+  }, [allModels, models]);
+
+  // 计算当前过滤器的模型数量
+  const currentModelCount = models.length;
+
+  // 设置轮播定时器(只对全部供应商且有足够头像时生效)
+  useEffect(() => {
+    if (filterVendor !== 'all' || vendorInfo.length <= 3) {
+      setCurrentOffset(0); // 重置偏移
+      return;
+    }
+
+    const interval = setInterval(() => {
+      setCurrentOffset(prev => (prev + 1) % vendorInfo.length);
+    }, 2000); // 每2秒切换一次
+
+    return () => clearInterval(interval);
+  }, [filterVendor, vendorInfo.length]);
+
+  // 获取供应商描述信息(从后端数据中)
+  const getVendorDescription = (vendorKey) => {
+    if (vendorKey === 'all') {
+      return t('查看所有可用的AI模型供应商,包括众多知名供应商的模型。');
+    }
+    if (vendorKey === 'unknown') {
+      return t('包含来自未知或未标明供应商的AI模型,这些模型可能来自小型供应商或开源项目。');
+    }
+    const vendor = vendorInfo.find(v => v.name === vendorKey);
+    return vendor?.description || t('该供应商提供多种AI模型,适用于不同的应用场景。');
+  };
+
+  // 为全部供应商创建特殊的头像组合
+  const renderAllVendorsAvatar = () => {
+    // 重新排列数组,让当前偏移量的头像在第一位
+    const rotatedVendors = vendorInfo.length > 3 ? [
+      ...vendorInfo.slice(currentOffset),
+      ...vendorInfo.slice(0, currentOffset)
+    ] : vendorInfo;
+
+    // 如果没有供应商,显示占位符
+    if (vendorInfo.length === 0) {
+      return (
+        <div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
+          <Avatar size="default" color="transparent">
+            AI
+          </Avatar>
+        </div>
+      );
+    }
+
+    return (
+      <div className="min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
+        <AvatarGroup
+          maxCount={4}
+          size="default"
+          overlapFrom='end'
+          key={currentOffset}
+          renderMore={(restNumber) => (
+            <Avatar
+              size="default"
+              style={{ backgroundColor: 'transparent', color: 'var(--semi-color-text-0)' }}
+              alt={`${restNumber} more vendors`}
+            >
+              {`+${restNumber}`}
+            </Avatar>
+          )}
+        >
+          {rotatedVendors.map((vendor) => (
+            <Avatar
+              key={vendor.name}
+              size="default"
+              color="transparent"
+              alt={vendor.name === 'unknown' ? t('未知供应商') : vendor.name}
+            >
+              {vendor.icon ?
+                getLobeHubIcon(vendor.icon, 20) :
+                (vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase())
+              }
+            </Avatar>
+          ))}
+        </AvatarGroup>
+      </div>
+    );
+  };
+
+  // 为具体供应商渲染单个图标
+  const renderVendorAvatar = (vendor) => (
+    <div className="w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center">
+      {vendor.icon ?
+        getLobeHubIcon(vendor.icon, 40) :
+        <Avatar size="large" color="transparent">
+          {vendor.name === 'unknown' ? '?' : vendor.name.charAt(0).toUpperCase()}
+        </Avatar>
+      }
+    </div>
+  );
+
+  // 如果是全部供应商
+  if (filterVendor === 'all') {
+    return (
+      <div className='mb-4'>
+        <Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
+          <div className="flex items-start space-x-3 md:space-x-4">
+            {/* 全部供应商的头像组合 */}
+            <div className="flex-shrink-0">
+              {renderAllVendorsAvatar()}
+            </div>
+
+            {/* 供应商信息 */}
+            <div className="flex-1 min-w-0">
+              <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
+                <h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{t('全部供应商')}</h2>
+                <Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
+                  {t('共 {{count}} 个模型', { count: currentModelCount })}
+                </Tag>
+              </div>
+              <Paragraph
+                className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
+                ellipsis={{
+                  rows: 2,
+                  expandable: true,
+                  collapsible: true,
+                  collapseText: t('收起'),
+                  expandText: t('展开')
+                }}
+              >
+                {getVendorDescription('all')}
+              </Paragraph>
+            </div>
+          </div>
+        </Card>
+      </div>
+    );
+  }
+
+  // 具体供应商
+  const currentVendor = vendorInfo.find(v => v.name === filterVendor);
+  if (!currentVendor) {
+    return null;
+  }
+
+  const vendorDisplayName = currentVendor.name === 'unknown' ? t('未知供应商') : currentVendor.name;
+
+  return (
+    <div className='mb-4'>
+      <Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
+        <div className="flex items-start space-x-3 md:space-x-4">
+          {/* 供应商图标 */}
+          <div className="flex-shrink-0">
+            {renderVendorAvatar(currentVendor)}
+          </div>
+
+          {/* 供应商信息 */}
+          <div className="flex-1 min-w-0">
+            <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
+              <h2 className="text-lg sm:text-xl font-bold text-gray-900 truncate">{vendorDisplayName}</h2>
+              <Tag color="white" shape="circle" size="small" className="self-start sm:self-center">
+                {t('共 {{count}} 个模型', { count: currentModelCount })}
+              </Tag>
+            </div>
+            <Paragraph
+              className="text-xs sm:text-sm text-gray-600 leading-relaxed !mb-0"
+              ellipsis={{
+                rows: 2,
+                expandable: true,
+                collapsible: true,
+                collapseText: t('收起'),
+                expandText: t('展开')
+              }}
+            >
+              {currentVendor.description || getVendorDescription(currentVendor.name)}
+            </Paragraph>
+          </div>
+        </div>
+      </Card>
+    </div>
+  );
+};
+
+export default PricingVendorIntro;

+ 75 - 0
web/src/components/table/model-pricing/layout/header/PricingVendorIntroSkeleton.jsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Skeleton } from '@douyinfe/semi-ui';
+
+const PricingVendorIntroSkeleton = ({
+  isAllVendors = false
+}) => {
+  const placeholder = (
+    <div className='mb-4'>
+      <Card className="!rounded-2xl" bodyStyle={{ padding: '16px' }}>
+        <div className="flex items-start space-x-3 md:space-x-4">
+          {/* 供应商图标骨架 */}
+          <div className="flex-shrink-0 min-w-16 h-16 rounded-2xl bg-white shadow-md flex items-center justify-center px-2">
+            {isAllVendors ? (
+              <div className="flex items-center">
+                {Array.from({ length: 4 }).map((_, index) => (
+                  <Skeleton.Avatar
+                    key={index}
+                    active
+                    size="default"
+                    style={{
+                      width: 32,
+                      height: 32,
+                      marginRight: index < 3 ? -8 : 0,
+                    }}
+                  />
+                ))}
+              </div>
+            ) : (
+              <Skeleton.Avatar active size="large" style={{ width: 40, height: 40, borderRadius: 8 }} />
+            )}
+          </div>
+
+          {/* 供应商信息骨架 */}
+          <div className="flex-1 min-w-0">
+            <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
+              <Skeleton.Title active style={{ width: 120, height: 24, marginBottom: 0 }} />
+              <Skeleton.Button active size="small" style={{ width: 80, height: 20, borderRadius: 12 }} />
+            </div>
+            <Skeleton.Paragraph
+              active
+              rows={2}
+              style={{ marginBottom: 0 }}
+              title={false}
+            />
+          </div>
+        </div>
+      </Card>
+    </div>
+  );
+
+  return (
+    <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
+  );
+};
+
+export default PricingVendorIntroSkeleton;

+ 52 - 0
web/src/components/table/model-pricing/layout/header/PricingVendorIntroWithSkeleton.jsx

@@ -0,0 +1,52 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import PricingVendorIntro from './PricingVendorIntro';
+import PricingVendorIntroSkeleton from './PricingVendorIntroSkeleton';
+import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
+
+const PricingVendorIntroWithSkeleton = ({
+  loading = false,
+  filterVendor,
+  models,
+  allModels,
+  t
+}) => {
+  const showSkeleton = useMinimumLoadingTime(loading);
+
+  if (showSkeleton) {
+    return (
+      <PricingVendorIntroSkeleton
+        isAllVendors={filterVendor === 'all'}
+      />
+    );
+  }
+
+  return (
+    <PricingVendorIntro
+      filterVendor={filterVendor}
+      models={models}
+      allModels={allModels}
+      t={t}
+    />
+  );
+};
+
+export default PricingVendorIntroWithSkeleton;

+ 105 - 0
web/src/components/table/model-pricing/modal/ModelDetailSideSheet.jsx

@@ -0,0 +1,105 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import {
+  SideSheet,
+  Typography,
+  Button,
+} from '@douyinfe/semi-ui';
+import {
+  IconClose,
+} from '@douyinfe/semi-icons';
+
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+import ModelHeader from './components/ModelHeader';
+import ModelBasicInfo from './components/ModelBasicInfo';
+import ModelEndpoints from './components/ModelEndpoints';
+import ModelPricingTable from './components/ModelPricingTable';
+
+const { Text } = Typography;
+
+const ModelDetailSideSheet = ({
+  visible,
+  onClose,
+  modelData,
+  selectedGroup,
+  groupRatio,
+  currency,
+  tokenUnit,
+  displayPrice,
+  showRatio,
+  usableGroup,
+  vendorsMap,
+  endpointMap,
+  t,
+}) => {
+  const isMobile = useIsMobile();
+
+  return (
+    <SideSheet
+      placement="right"
+      title={<ModelHeader modelData={modelData} vendorsMap={vendorsMap} t={t} />}
+      bodyStyle={{
+        padding: '0',
+        display: 'flex',
+        flexDirection: 'column',
+        borderBottom: '1px solid var(--semi-color-border)'
+      }}
+      visible={visible}
+      width={isMobile ? '100%' : 600}
+      closeIcon={
+        <Button
+          className="semi-button-tertiary semi-button-size-small semi-button-borderless"
+          type="button"
+          icon={<IconClose />}
+          onClick={onClose}
+        />
+      }
+      onCancel={onClose}
+    >
+      <div className="p-2">
+        {!modelData && (
+          <div className="flex justify-center items-center py-10">
+            <Text type="secondary">{t('加载中...')}</Text>
+          </div>
+        )}
+        {modelData && (
+          <>
+            <ModelBasicInfo modelData={modelData} vendorsMap={vendorsMap} t={t} />
+            <ModelEndpoints modelData={modelData} endpointMap={endpointMap} t={t} />
+            <ModelPricingTable
+              modelData={modelData}
+              selectedGroup={selectedGroup}
+              groupRatio={groupRatio}
+              currency={currency}
+              tokenUnit={tokenUnit}
+              displayPrice={displayPrice}
+              showRatio={showRatio}
+              usableGroup={usableGroup}
+              t={t}
+            />
+          </>
+        )}
+      </div>
+    </SideSheet>
+  );
+};
+
+export default ModelDetailSideSheet; 

+ 75 - 0
web/src/components/table/model-pricing/modal/PricingFilterModal.jsx

@@ -0,0 +1,75 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+import { resetPricingFilters } from '../../../../helpers/utils';
+import FilterModalContent from './components/FilterModalContent';
+import FilterModalFooter from './components/FilterModalFooter';
+
+const PricingFilterModal = ({
+  visible,
+  onClose,
+  sidebarProps,
+  t
+}) => {
+  const handleResetFilters = () =>
+    resetPricingFilters({
+      handleChange: sidebarProps.handleChange,
+      setShowWithRecharge: sidebarProps.setShowWithRecharge,
+      setCurrency: sidebarProps.setCurrency,
+      setShowRatio: sidebarProps.setShowRatio,
+      setViewMode: sidebarProps.setViewMode,
+      setFilterGroup: sidebarProps.setFilterGroup,
+      setFilterQuotaType: sidebarProps.setFilterQuotaType,
+      setFilterEndpointType: sidebarProps.setFilterEndpointType,
+      setFilterVendor: sidebarProps.setFilterVendor,
+      setCurrentPage: sidebarProps.setCurrentPage,
+      setTokenUnit: sidebarProps.setTokenUnit,
+    });
+
+  const footer = (
+    <FilterModalFooter
+      onReset={handleResetFilters}
+      onConfirm={onClose}
+      t={t}
+    />
+  );
+
+  return (
+    <Modal
+      title={t('筛选')}
+      visible={visible}
+      onCancel={onClose}
+      footer={footer}
+      style={{ width: '100%', height: '100%', margin: 0 }}
+      bodyStyle={{
+        padding: 0,
+        height: 'calc(100vh - 160px)',
+        overflowY: 'auto',
+        scrollbarWidth: 'none',
+        msOverflowStyle: 'none'
+      }}
+    >
+      <FilterModalContent sidebarProps={sidebarProps} t={t} />
+    </Modal>
+  );
+};
+
+export default PricingFilterModal; 

+ 124 - 0
web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx

@@ -0,0 +1,124 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import PricingDisplaySettings from '../../filter/PricingDisplaySettings';
+import PricingGroups from '../../filter/PricingGroups';
+import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
+import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
+import PricingVendors from '../../filter/PricingVendors';
+import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
+
+const FilterModalContent = ({ sidebarProps, t }) => {
+  const {
+    showWithRecharge,
+    setShowWithRecharge,
+    currency,
+    setCurrency,
+    handleChange,
+    setActiveKey,
+    showRatio,
+    setShowRatio,
+    viewMode,
+    setViewMode,
+    filterGroup,
+    setFilterGroup,
+    filterQuotaType,
+    setFilterQuotaType,
+    filterEndpointType,
+    setFilterEndpointType,
+    filterVendor,
+    setFilterVendor,
+    tokenUnit,
+    setTokenUnit,
+    loading,
+    ...categoryProps
+  } = sidebarProps;
+
+  const {
+    quotaTypeModels,
+    endpointTypeModels,
+    vendorModels,
+    groupCountModels,
+  } = usePricingFilterCounts({
+    models: categoryProps.models,
+    filterGroup,
+    filterQuotaType,
+    filterEndpointType,
+    filterVendor,
+    searchValue: sidebarProps.searchValue,
+  });
+
+  return (
+    <div className="p-2">
+      <PricingDisplaySettings
+        showWithRecharge={showWithRecharge}
+        setShowWithRecharge={setShowWithRecharge}
+        currency={currency}
+        setCurrency={setCurrency}
+        showRatio={showRatio}
+        setShowRatio={setShowRatio}
+        viewMode={viewMode}
+        setViewMode={setViewMode}
+        tokenUnit={tokenUnit}
+        setTokenUnit={setTokenUnit}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingVendors
+        filterVendor={filterVendor}
+        setFilterVendor={setFilterVendor}
+        models={vendorModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingGroups
+        filterGroup={filterGroup}
+        setFilterGroup={setFilterGroup}
+        usableGroup={categoryProps.usableGroup}
+        groupRatio={categoryProps.groupRatio}
+        models={groupCountModels}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingQuotaTypes
+        filterQuotaType={filterQuotaType}
+        setFilterQuotaType={setFilterQuotaType}
+        models={quotaTypeModels}
+        loading={loading}
+        t={t}
+      />
+
+      <PricingEndpointTypes
+        filterEndpointType={filterEndpointType}
+        setFilterEndpointType={setFilterEndpointType}
+        models={endpointTypeModels}
+        allModels={categoryProps.models}
+        loading={loading}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default FilterModalContent; 

+ 44 - 0
web/src/components/table/model-pricing/modal/components/FilterModalFooter.jsx

@@ -0,0 +1,44 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button } from '@douyinfe/semi-ui';
+
+const FilterModalFooter = ({ onReset, onConfirm, t }) => {
+  return (
+    <div className="flex justify-end">
+      <Button
+        theme="outline"
+        type='tertiary'
+        onClick={onReset}
+      >
+        {t('重置')}
+      </Button>
+      <Button
+        theme="solid"
+        type="primary"
+        onClick={onConfirm}
+      >
+        {t('确定')}
+      </Button>
+    </div>
+  );
+};
+
+export default FilterModalFooter; 

+ 92 - 0
web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx

@@ -0,0 +1,92 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Avatar, Typography, Tag, Space } from '@douyinfe/semi-ui';
+import { IconInfoCircle } from '@douyinfe/semi-icons';
+import { stringToColor } from '../../../../../helpers';
+
+const { Text } = Typography;
+
+const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
+  // 获取模型描述(使用后端真实数据)
+  const getModelDescription = () => {
+    if (!modelData) return t('暂无模型描述');
+
+    // 优先使用后端提供的描述
+    if (modelData.description) {
+      return modelData.description;
+    }
+
+    // 如果没有描述但有供应商描述,显示供应商信息
+    if (modelData.vendor_description) {
+      return t('供应商信息:') + modelData.vendor_description;
+    }
+
+    return t('暂无模型描述');
+  };
+
+  // 获取模型标签
+  const getModelTags = () => {
+    const tags = [];
+
+    if (modelData?.tags) {
+      const customTags = modelData.tags.split(',').filter(tag => tag.trim());
+      customTags.forEach(tag => {
+        const tagText = tag.trim();
+        tags.push({ text: tagText, color: stringToColor(tagText) });
+      });
+    }
+
+    return tags;
+  };
+
+  return (
+    <Card className="!rounded-2xl shadow-sm border-0 mb-6">
+      <div className="flex items-center mb-4">
+        <Avatar size="small" color="blue" className="mr-2 shadow-md">
+          <IconInfoCircle size={16} />
+        </Avatar>
+        <div>
+          <Text className="text-lg font-medium">{t('基本信息')}</Text>
+          <div className="text-xs text-gray-600">{t('模型的详细描述和基本特性')}</div>
+        </div>
+      </div>
+      <div className="text-gray-600">
+        <p className="mb-4">{getModelDescription()}</p>
+        {getModelTags().length > 0 && (
+          <Space wrap>
+            {getModelTags().map((tag, index) => (
+              <Tag
+                key={index}
+                color={tag.color}
+                shape="circle"
+                size="small"
+              >
+                {tag.text}
+              </Tag>
+            ))}
+          </Space>
+        )}
+      </div>
+    </Card>
+  );
+};
+
+export default ModelBasicInfo; 

+ 83 - 0
web/src/components/table/model-pricing/modal/components/ModelEndpoints.jsx

@@ -0,0 +1,83 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Avatar, Typography, Badge } from '@douyinfe/semi-ui';
+import { IconLink } from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+const ModelEndpoints = ({ modelData, endpointMap = {}, t }) => {
+  const renderAPIEndpoints = () => {
+    if (!modelData) return null;
+
+    const mapping = endpointMap;
+    const types = modelData.supported_endpoint_types || [];
+
+    return types.map(type => {
+      const info = mapping[type] || {};
+      let path = info.path || '';
+      // 如果路径中包含 {model} 占位符,替换为真实模型名称
+      if (path.includes('{model}')) {
+        const modelName = modelData.model_name || modelData.modelName || '';
+        path = path.replaceAll('{model}', modelName);
+      }
+      const method = info.method || 'POST';
+      return (
+        <div
+          key={type}
+          className="flex justify-between border-b border-dashed last:border-0 py-2 last:pb-0"
+          style={{ borderColor: 'var(--semi-color-border)' }}
+        >
+          <span className="flex items-center pr-5">
+            <Badge dot type="success" className="mr-2" />
+            {type}{path && ':'}
+            {path && (
+              <span className="text-gray-500 md:ml-1 break-all">
+                {path}
+              </span>
+            )}
+          </span>
+          {path && (
+            <span className="text-gray-500 text-xs md:ml-1">
+              {method}
+            </span>
+          )}
+        </div>
+      );
+    });
+  };
+
+  return (
+    <Card className="!rounded-2xl shadow-sm border-0 mb-6">
+      <div className="flex items-center mb-4">
+        <Avatar size="small" color="purple" className="mr-2 shadow-md">
+          <IconLink size={16} />
+        </Avatar>
+        <div>
+          <Text className="text-lg font-medium">{t('API端点')}</Text>
+          <div className="text-xs text-gray-600">{t('模型支持的接口端点信息')}</div>
+        </div>
+      </div>
+      {renderAPIEndpoints()}
+    </Card>
+  );
+};
+
+export default ModelEndpoints; 

+ 83 - 0
web/src/components/table/model-pricing/modal/components/ModelHeader.jsx

@@ -0,0 +1,83 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Typography, Toast, Avatar } from '@douyinfe/semi-ui';
+import { getLobeHubIcon } from '../../../../../helpers';
+
+const { Paragraph } = Typography;
+
+const CARD_STYLES = {
+  container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
+  icon: "w-8 h-8 flex items-center justify-center",
+};
+
+const ModelHeader = ({ modelData, vendorsMap = {}, t }) => {
+  // 获取模型图标(使用供应商图标)
+  const getModelIcon = () => {
+    // 优先使用供应商图标
+    if (modelData?.vendor_icon) {
+      return (
+        <div className={CARD_STYLES.container}>
+          <div className={CARD_STYLES.icon}>
+            {getLobeHubIcon(modelData.vendor_icon, 32)}
+          </div>
+        </div>
+      );
+    }
+
+    // 如果没有供应商图标,使用模型名称的前两个字符
+    const avatarText = modelData?.model_name?.slice(0, 2).toUpperCase() || 'AI';
+    return (
+      <div className={CARD_STYLES.container}>
+        <Avatar
+          size="large"
+          style={{
+            width: 48,
+            height: 48,
+            borderRadius: 16,
+            fontSize: 16,
+            fontWeight: 'bold'
+          }}
+        >
+          {avatarText}
+        </Avatar>
+      </div>
+    );
+  };
+
+  return (
+    <div className="flex items-center">
+      {getModelIcon()}
+      <div className="ml-3 font-normal">
+        <Paragraph
+          className="!mb-0 !text-lg !font-medium"
+          copyable={{
+            content: modelData?.model_name || '',
+            onCopy: () => Toast.success({ content: t('已复制模型名称') })
+          }}
+        >
+          <span className="truncate max-w-60 font-bold">{modelData?.model_name || t('未知模型')}</span>
+        </Paragraph>
+      </div>
+    </div>
+  );
+};
+
+export default ModelHeader; 

+ 190 - 0
web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx

@@ -0,0 +1,190 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Avatar, Typography, Table, Tag, Tooltip } from '@douyinfe/semi-ui';
+import { IconCoinMoneyStroked } from '@douyinfe/semi-icons';
+import { calculateModelPrice } from '../../../../../helpers';
+
+const { Text } = Typography;
+
+const ModelPricingTable = ({
+  modelData,
+  selectedGroup,
+  groupRatio,
+  currency,
+  tokenUnit,
+  displayPrice,
+  showRatio,
+  usableGroup,
+  t,
+}) => {
+  // 获取分组介绍
+  const getGroupDescription = (groupName) => {
+    const descriptions = {
+      'default': t('默认分组,适用于普通用户'),
+      'ssvip': t('超级VIP分组,享受最优惠价格'),
+      'openai官-优质': t('OpenAI官方优质分组,最快最稳,支持o1、realtime等'),
+      'origin': t('企业分组,OpenAI&Claude官方原价,不升价本分组稳定性可用性'),
+      'vip': t('VIP分组,享受优惠价格'),
+      'premium': t('高级分组,稳定可靠'),
+      'enterprise': t('企业级分组,专业服务'),
+    };
+    return descriptions[groupName] || t('用户分组');
+  };
+
+  const renderGroupPriceTable = () => {
+    const availableGroups = Object.keys(usableGroup || {}).filter(g => g !== '');
+    if (availableGroups.length === 0) {
+      availableGroups.push('default');
+    }
+
+    // 准备表格数据
+    const tableData = availableGroups.map(group => {
+      const priceData = modelData ? calculateModelPrice({
+        record: modelData,
+        selectedGroup: group,
+        groupRatio,
+        tokenUnit,
+        displayPrice,
+        currency
+      }) : { inputPrice: '-', outputPrice: '-', price: '-' };
+
+      // 获取分组倍率
+      const groupRatioValue = groupRatio && groupRatio[group] ? groupRatio[group] : 1;
+
+      return {
+        key: group,
+        group: group,
+        description: getGroupDescription(group),
+        ratio: groupRatioValue,
+        billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'),
+        inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-',
+        outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-',
+        fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-',
+      };
+    });
+
+    // 定义表格列
+    const columns = [
+      {
+        title: t('分组'),
+        dataIndex: 'group',
+        render: (text, record) => (
+          <Tooltip content={record.description} position="top">
+            <Tag color="white" size="small" shape="circle" className="cursor-help">
+              {text}{t('分组')}
+            </Tag>
+          </Tooltip>
+        ),
+      },
+    ];
+
+    // 如果显示倍率,添加倍率列
+    if (showRatio) {
+      columns.push({
+        title: t('倍率'),
+        dataIndex: 'ratio',
+        render: (text) => (
+          <Tag color="white" size="small" shape="circle">
+            {text}x
+          </Tag>
+        ),
+      });
+    }
+
+    // 添加计费类型列
+    columns.push({
+      title: t('计费类型'),
+      dataIndex: 'billingType',
+      render: (text) => (
+        <Tag color={text === t('按量计费') ? 'violet' : 'teal'} size="small" shape="circle">
+          {text}
+        </Tag>
+      ),
+    });
+
+    // 根据计费类型添加价格列
+    if (modelData?.quota_type === 0) {
+      // 按量计费
+      columns.push(
+        {
+          title: t('提示'),
+          dataIndex: 'inputPrice',
+          render: (text) => (
+            <>
+              <div className="font-semibold text-orange-600">{text}</div>
+              <div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
+            </>
+          ),
+        },
+        {
+          title: t('补全'),
+          dataIndex: 'outputPrice',
+          render: (text) => (
+            <>
+              <div className="font-semibold text-orange-600">{text}</div>
+              <div className="text-xs text-gray-500">/ {tokenUnit === 'K' ? '1K' : '1M'} tokens</div>
+            </>
+          ),
+        }
+      );
+    } else {
+      // 按次计费
+      columns.push({
+        title: t('价格'),
+        dataIndex: 'fixedPrice',
+        render: (text) => (
+          <>
+            <div className="font-semibold text-orange-600">{text}</div>
+            <div className="text-xs text-gray-500">/ 次</div>
+          </>
+        ),
+      });
+    }
+
+    return (
+      <Table
+        dataSource={tableData}
+        columns={columns}
+        pagination={false}
+        size="small"
+        bordered={false}
+        className="!rounded-lg"
+      />
+    );
+  };
+
+  return (
+    <Card className="!rounded-2xl shadow-sm border-0">
+      <div className="flex items-center mb-4">
+        <Avatar size="small" color="orange" className="mr-2 shadow-md">
+          <IconCoinMoneyStroked size={16} />
+        </Avatar>
+        <div>
+          <Text className="text-lg font-medium">{t('分组价格')}</Text>
+          <div className="text-xs text-gray-600">{t('不同用户分组的价格信息')}</div>
+        </div>
+      </div>
+      {renderGroupPriceTable()}
+    </Card>
+  );
+};
+
+export default ModelPricingTable; 

+ 137 - 0
web/src/components/table/model-pricing/view/card/PricingCardSkeleton.jsx

@@ -0,0 +1,137 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Skeleton } from '@douyinfe/semi-ui';
+
+const PricingCardSkeleton = ({
+  skeletonCount = 10,
+  rowSelection = false,
+  showRatio = false
+}) => {
+  const placeholder = (
+    <div className="p-4">
+      <div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
+        {Array.from({ length: skeletonCount }).map((_, index) => (
+          <Card
+            key={index}
+            className="!rounded-2xl border border-gray-200"
+            bodyStyle={{ padding: '24px' }}
+          >
+            {/* 头部:图标 + 模型名称 + 操作按钮 */}
+            <div className="flex items-start justify-between mb-3">
+              <div className="flex items-start space-x-3 flex-1 min-w-0">
+                {/* 模型图标骨架 */}
+                <div className="w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-sm">
+                  <Skeleton.Avatar
+                    size="large"
+                    style={{ width: 48, height: 48, borderRadius: 16 }}
+                  />
+                </div>
+                {/* 模型名称和价格区域 */}
+                <div className="flex-1 min-w-0">
+                  {/* 模型名称骨架 */}
+                  <Skeleton.Title
+                    style={{
+                      width: `${120 + (index % 3) * 30}px`,
+                      height: 20,
+                      marginBottom: 8
+                    }}
+                  />
+                  {/* 价格信息骨架 */}
+                  <Skeleton.Title
+                    style={{
+                      width: `${160 + (index % 4) * 20}px`,
+                      height: 20,
+                      marginBottom: 0
+                    }}
+                  />
+                </div>
+              </div>
+
+              <div className="flex items-center space-x-2 ml-3">
+                {/* 复制按钮骨架 */}
+                <Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 4 }} />
+                {/* 勾选框骨架 */}
+                {rowSelection && (
+                  <Skeleton.Button size="small" style={{ width: 16, height: 16, borderRadius: 2 }} />
+                )}
+              </div>
+            </div>
+
+            {/* 模型描述骨架 */}
+            <div className="mb-4">
+              <Skeleton.Paragraph
+                rows={2}
+                style={{ marginBottom: 0 }}
+                title={false}
+              />
+            </div>
+
+            {/* 标签区域骨架 */}
+            <div className="flex flex-wrap gap-2">
+              {Array.from({ length: 2 + (index % 3) }).map((_, tagIndex) => (
+                <Skeleton.Button
+                  key={tagIndex}
+                  size="small"
+                  style={{
+                    width: 64,
+                    height: 20,
+                    borderRadius: 10
+                  }}
+                />
+              ))}
+            </div>
+
+            {/* 倍率信息骨架(可选) */}
+            {showRatio && (
+              <div className="mt-4 pt-3 border-t border-gray-100">
+                <div className="flex items-center space-x-1 mb-2">
+                  <Skeleton.Title
+                    style={{ width: 60, height: 12, marginBottom: 0 }}
+                  />
+                  <Skeleton.Button size="small" style={{ width: 14, height: 14, borderRadius: 7 }} />
+                </div>
+                <div className="grid grid-cols-3 gap-2">
+                  {Array.from({ length: 3 }).map((_, ratioIndex) => (
+                    <Skeleton.Title
+                      key={ratioIndex}
+                      style={{ width: '100%', height: 12, marginBottom: 0 }}
+                    />
+                  ))}
+                </div>
+              </div>
+            )}
+          </Card>
+        ))}
+      </div>
+
+      {/* 分页骨架 */}
+      <div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
+        <Skeleton.Button style={{ width: 300, height: 32 }} />
+      </div>
+    </div>
+  );
+
+  return (
+    <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
+  );
+};
+
+export default PricingCardSkeleton; 

+ 326 - 0
web/src/components/table/model-pricing/view/card/PricingCardView.jsx

@@ -0,0 +1,326 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Card, Tag, Tooltip, Checkbox, Empty, Pagination, Button, Avatar } from '@douyinfe/semi-ui';
+import { IconHelpCircle, IconCopy } from '@douyinfe/semi-icons';
+import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
+import { stringToColor, calculateModelPrice, formatPriceInfo, getLobeHubIcon } from '../../../../../helpers';
+import PricingCardSkeleton from './PricingCardSkeleton';
+import { useMinimumLoadingTime } from '../../../../../hooks/common/useMinimumLoadingTime';
+import { renderLimitedItems } from '../../../../common/ui/RenderUtils';
+
+const CARD_STYLES = {
+  container: "w-12 h-12 rounded-2xl flex items-center justify-center relative shadow-md",
+  icon: "w-8 h-8 flex items-center justify-center",
+  selected: "border-blue-500 bg-blue-50",
+  default: "border-gray-200 hover:border-gray-300"
+};
+
+const PricingCardView = ({
+  filteredModels,
+  loading,
+  rowSelection,
+  pageSize,
+  setPageSize,
+  currentPage,
+  setCurrentPage,
+  selectedGroup,
+  groupRatio,
+  copyText,
+  setModalImageUrl,
+  setIsModalOpenurl,
+  currency,
+  tokenUnit,
+  displayPrice,
+  showRatio,
+  t,
+  selectedRowKeys = [],
+  setSelectedRowKeys,
+  openModelDetail,
+}) => {
+  const showSkeleton = useMinimumLoadingTime(loading);
+  const startIndex = (currentPage - 1) * pageSize;
+  const paginatedModels = filteredModels.slice(startIndex, startIndex + pageSize);
+  const getModelKey = (model) => model.key ?? model.model_name ?? model.id;
+
+  const handleCheckboxChange = (model, checked) => {
+    if (!setSelectedRowKeys) return;
+    const modelKey = getModelKey(model);
+    const newKeys = checked
+      ? Array.from(new Set([...selectedRowKeys, modelKey]))
+      : selectedRowKeys.filter((key) => key !== modelKey);
+    setSelectedRowKeys(newKeys);
+    rowSelection?.onChange?.(newKeys, null);
+  };
+
+  // 获取模型图标
+  const getModelIcon = (model) => {
+    if (!model || !model.model_name) {
+      return (
+        <div className={CARD_STYLES.container}>
+          <Avatar size='large'>?</Avatar>
+        </div>
+      );
+    }
+    // 优先使用供应商图标
+    if (model.vendor_icon) {
+      return (
+        <div className={CARD_STYLES.container}>
+          <div className={CARD_STYLES.icon}>
+            {getLobeHubIcon(model.vendor_icon, 32)}
+          </div>
+        </div>
+      );
+    }
+
+    // 如果没有供应商图标,使用模型名称生成头像
+
+    const avatarText = model.model_name.slice(0, 2).toUpperCase();
+    return (
+      <div className={CARD_STYLES.container}>
+        <Avatar
+          size="large"
+          style={{
+            width: 48,
+            height: 48,
+            borderRadius: 16,
+            fontSize: 16,
+            fontWeight: 'bold'
+          }}
+        >
+          {avatarText}
+        </Avatar>
+      </div>
+    );
+  };
+
+  // 获取模型描述
+  const getModelDescription = (record) => {
+    return record.description || '';
+  };
+
+  // 渲染价格信息
+  const renderPriceInfo = (record) => {
+    const priceData = calculateModelPrice({
+      record,
+      selectedGroup,
+      groupRatio,
+      tokenUnit,
+      displayPrice,
+      currency
+    });
+    return formatPriceInfo(priceData, t);
+  };
+
+  // 渲染标签
+  const renderTags = (record) => {
+    // 计费类型标签(左边)
+    const billingType = record.quota_type === 1 ? 'teal' : 'violet';
+    const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
+    const billingTag = (
+      <Tag key="billing" shape='circle' color={billingType} size='small'>
+        {billingText}
+      </Tag>
+    );
+
+    // 自定义标签(右边)
+    const customTags = [];
+    if (record.tags) {
+      const tagArr = record.tags.split(',').filter(Boolean);
+      tagArr.forEach((tg, idx) => {
+        customTags.push(
+          <Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
+            {tg}
+          </Tag>
+        );
+      });
+    }
+
+    return (
+      <div className="flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          {billingTag}
+        </div>
+        <div className="flex items-center gap-1">
+          {renderLimitedItems({
+            items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
+            renderItem: (item, idx) => item.element,
+            maxDisplay: 3
+          })}
+        </div>
+      </div>
+    );
+  };
+
+  // 显示骨架屏
+  if (showSkeleton) {
+    return (
+      <PricingCardSkeleton
+        rowSelection={!!rowSelection}
+        showRatio={showRatio}
+      />
+    );
+  }
+
+  if (!filteredModels || filteredModels.length === 0) {
+    return (
+      <div className="flex justify-center items-center py-20">
+        <Empty
+          image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+          darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+          description={t('搜索无结果')}
+        />
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-4">
+      <div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
+        {paginatedModels.map((model, index) => {
+          const modelKey = getModelKey(model);
+          const isSelected = selectedRowKeys.includes(modelKey);
+
+          return (
+            <Card
+              key={modelKey || index}
+              className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
+              bodyStyle={{ height: '100%' }}
+              onClick={() => openModelDetail && openModelDetail(model)}
+            >
+              <div className="flex flex-col h-full">
+                {/* 头部:图标 + 模型名称 + 操作按钮 */}
+                <div className="flex items-start justify-between mb-3">
+                  <div className="flex items-start space-x-3 flex-1 min-w-0">
+                    {getModelIcon(model)}
+                    <div className="flex-1 min-w-0">
+                      <h3 className="text-lg font-bold text-gray-900 truncate">
+                        {model.model_name}
+                      </h3>
+                      <div className="flex items-center gap-3 text-xs mt-1">
+                        {renderPriceInfo(model)}
+                      </div>
+                    </div>
+                  </div>
+
+                  <div className="flex items-center space-x-2 ml-3">
+                    {/* 复制按钮 */}
+                    <Button
+                      size="small"
+                      type="tertiary"
+                      icon={<IconCopy />}
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        copyText(model.model_name);
+                      }}
+                    />
+
+                    {/* 选择框 */}
+                    {rowSelection && (
+                      <Checkbox
+                        checked={isSelected}
+                        onChange={(e) => {
+                          e.stopPropagation();
+                          handleCheckboxChange(model, e.target.checked);
+                        }}
+                      />
+                    )}
+                  </div>
+                </div>
+
+                {/* 模型描述 - 占据剩余空间 */}
+                <div className="flex-1 mb-4">
+                  <p
+                    className="text-xs line-clamp-2 leading-relaxed"
+                    style={{ color: 'var(--semi-color-text-2)' }}
+                  >
+                    {getModelDescription(model)}
+                  </p>
+                </div>
+
+                {/* 底部区域 */}
+                <div className="mt-auto">
+                  {/* 标签区域 */}
+                  <div className="mb-3">
+                    {renderTags(model)}
+                  </div>
+
+                  {/* 倍率信息(可选) */}
+                  {showRatio && (
+                    <div
+                      className="pt-3 border-t border-dashed"
+                      style={{ borderColor: 'var(--semi-color-border)' }}
+                    >
+                      <div className="flex items-center space-x-1 mb-2">
+                        <span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
+                        <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
+                          <IconHelpCircle
+                            className="text-blue-500 cursor-pointer"
+                            size="small"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              setModalImageUrl('/ratio.png');
+                              setIsModalOpenurl(true);
+                            }}
+                          />
+                        </Tooltip>
+                      </div>
+                      <div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
+                        <div>
+                          {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
+                        </div>
+                        <div>
+                          {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
+                        </div>
+                        <div>
+                          {t('分组')}: {groupRatio[selectedGroup]}
+                        </div>
+                      </div>
+                    </div>
+                  )}
+                </div>
+              </div>
+            </Card>
+          );
+        })}
+      </div>
+
+      {/* 分页 */}
+      {filteredModels.length > 0 && (
+        <div className="flex justify-center mt-6 pt-4 border-t pricing-pagination-divider">
+          <Pagination
+            currentPage={currentPage}
+            pageSize={pageSize}
+            total={filteredModels.length}
+            showSizeChanger={true}
+            pageSizeOptions={[10, 20, 50, 100]}
+            onPageChange={(page) => setCurrentPage(page)}
+            onPageSizeChange={(size) => {
+              setPageSize(size);
+              setCurrentPage(1);
+            }}
+          />
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default PricingCardView; 

+ 29 - 23
web/src/components/table/model-pricing/ModelPricingTable.jsx → web/src/components/table/model-pricing/view/table/PricingTable.jsx

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

+ 230 - 0
web/src/components/table/model-pricing/view/table/PricingTableColumns.js

@@ -0,0 +1,230 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Tag, Space, Tooltip } from '@douyinfe/semi-ui';
+import { IconHelpCircle } from '@douyinfe/semi-icons';
+import { renderModelTag, stringToColor, calculateModelPrice, getLobeHubIcon } from '../../../../../helpers';
+import { renderLimitedItems, renderDescription } from '../../../../common/ui/RenderUtils';
+
+function renderQuotaType(type, t) {
+  switch (type) {
+    case 1:
+      return (
+        <Tag color='teal' shape='circle'>
+          {t('按次计费')}
+        </Tag>
+      );
+    case 0:
+      return (
+        <Tag color='violet' shape='circle'>
+          {t('按量计费')}
+        </Tag>
+      );
+    default:
+      return t('未知');
+  }
+}
+
+// Render vendor name
+const renderVendor = (vendorName, vendorIcon, t) => {
+  if (!vendorName) return '-';
+  return (
+    <Tag color='white' shape='circle' prefixIcon={getLobeHubIcon(vendorIcon || 'Layers', 14)}>
+      {vendorName}
+    </Tag>
+  );
+};
+
+// Render tags list using RenderUtils
+const renderTags = (text) => {
+  if (!text) return '-';
+  const tagsArr = text.split(',').filter(tag => tag.trim());
+  return renderLimitedItems({
+    items: tagsArr,
+    renderItem: (tag, idx) => (
+      <Tag key={idx} color={stringToColor(tag.trim())} shape='circle' size='small'>
+        {tag.trim()}
+      </Tag>
+    ),
+    maxDisplay: 3
+  });
+};
+
+function renderSupportedEndpoints(endpoints) {
+  if (!endpoints || endpoints.length === 0) {
+    return null;
+  }
+  return (
+    <Space wrap>
+      {endpoints.map((endpoint, idx) => (
+        <Tag
+          key={endpoint}
+          color={stringToColor(endpoint)}
+          shape='circle'
+        >
+          {endpoint}
+        </Tag>
+      ))}
+    </Space>
+  );
+}
+
+export const getPricingTableColumns = ({
+  t,
+  selectedGroup,
+  groupRatio,
+  copyText,
+  setModalImageUrl,
+  setIsModalOpenurl,
+  currency,
+  tokenUnit,
+  displayPrice,
+  showRatio,
+}) => {
+  const endpointColumn = {
+    title: t('可用端点类型'),
+    dataIndex: 'supported_endpoint_types',
+    fixed: 'right',
+    render: (text, record, index) => {
+      return renderSupportedEndpoints(text);
+    },
+  };
+
+  const modelNameColumn = {
+    title: t('模型名称'),
+    dataIndex: 'model_name',
+    render: (text, record, index) => {
+      return renderModelTag(text, {
+        onClick: () => {
+          copyText(text);
+        }
+      });
+    },
+    onFilter: (value, record) =>
+      record.model_name.toLowerCase().includes(value.toLowerCase()),
+  };
+
+  const quotaColumn = {
+    title: t('计费类型'),
+    dataIndex: 'quota_type',
+    render: (text, record, index) => {
+      return renderQuotaType(parseInt(text), t);
+    },
+    sorter: (a, b) => a.quota_type - b.quota_type,
+  };
+
+  const descriptionColumn = {
+    title: t('描述'),
+    dataIndex: 'description',
+    render: (text) => renderDescription(text, 200),
+  };
+
+  const tagsColumn = {
+    title: t('标签'),
+    dataIndex: 'tags',
+    render: renderTags,
+  };
+
+  const vendorColumn = {
+    title: t('供应商'),
+    dataIndex: 'vendor_name',
+    render: (text, record) => renderVendor(text, record.vendor_icon, t),
+  };
+
+  const baseColumns = [modelNameColumn, vendorColumn, descriptionColumn, tagsColumn, quotaColumn];
+
+  const ratioColumn = {
+    title: () => (
+      <div className="flex items-center space-x-1">
+        <span>{t('倍率')}</span>
+        <Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
+          <IconHelpCircle
+            className="text-blue-500 cursor-pointer"
+            onClick={() => {
+              setModalImageUrl('/ratio.png');
+              setIsModalOpenurl(true);
+            }}
+          />
+        </Tooltip>
+      </div>
+    ),
+    dataIndex: 'model_ratio',
+    render: (text, record, index) => {
+      const completionRatio = parseFloat(record.completion_ratio.toFixed(3));
+      const content = (
+        <div className="space-y-1">
+          <div className="text-gray-700">
+            {t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
+          </div>
+          <div className="text-gray-700">
+            {t('补全倍率')}:
+            {record.quota_type === 0 ? completionRatio : t('无')}
+          </div>
+          <div className="text-gray-700">
+            {t('分组倍率')}:{groupRatio[selectedGroup]}
+          </div>
+        </div>
+      );
+      return content;
+    },
+  };
+
+  const priceColumn = {
+    title: t('模型价格'),
+    dataIndex: 'model_price',
+    render: (text, record, index) => {
+      const priceData = calculateModelPrice({
+        record,
+        selectedGroup,
+        groupRatio,
+        tokenUnit,
+        displayPrice,
+        currency
+      });
+
+      if (priceData.isPerToken) {
+        return (
+          <div className="space-y-1">
+            <div className="text-gray-700">
+              {t('提示')} {priceData.inputPrice} / 1{priceData.unitLabel} tokens
+            </div>
+            <div className="text-gray-700">
+              {t('补全')} {priceData.completionPrice} / 1{priceData.unitLabel} tokens
+            </div>
+          </div>
+        );
+      } else {
+        return (
+          <div className="text-gray-700">
+            {t('模型价格')}:{priceData.price}
+          </div>
+        );
+      }
+    },
+  };
+
+  const columns = [...baseColumns];
+  if (showRatio) {
+    columns.push(ratioColumn);
+  }
+  columns.push(priceColumn);
+  columns.push(endpointColumn);
+  return columns;
+}; 

+ 170 - 0
web/src/components/table/models/ModelsActions.jsx

@@ -0,0 +1,170 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState } from 'react';
+import MissingModelsModal from './modals/MissingModelsModal.jsx';
+import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx';
+import EditPrefillGroupModal from './modals/EditPrefillGroupModal.jsx';
+import { Button, Modal } from '@douyinfe/semi-ui';
+import { showSuccess, showError, copy } from '../../../helpers';
+import CompactModeToggle from '../../common/ui/CompactModeToggle';
+import SelectionNotification from './components/SelectionNotification.jsx';
+
+const ModelsActions = ({
+  selectedKeys,
+  setSelectedKeys,
+  setEditingModel,
+  setShowEdit,
+  batchDeleteModels,
+  compactMode,
+  setCompactMode,
+  t,
+}) => {
+  // Modal states
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+  const [showMissingModal, setShowMissingModal] = useState(false);
+  const [showGroupManagement, setShowGroupManagement] = useState(false);
+  const [showAddPrefill, setShowAddPrefill] = useState(false);
+  const [prefillInit, setPrefillInit] = useState({ id: undefined });
+
+  // Handle delete selected models with confirmation
+  const handleDeleteSelectedModels = () => {
+    setShowDeleteModal(true);
+  };
+
+  // Handle delete confirmation
+  const handleConfirmDelete = () => {
+    batchDeleteModels();
+    setShowDeleteModal(false);
+  };
+
+  // Handle clear selection
+  const handleClearSelected = () => {
+    setSelectedKeys([]);
+  };
+
+  // Handle add selected models to prefill group
+  const handleCopyNames = async () => {
+    const text = selectedKeys.map(m => m.model_name).join(',');
+    if (!text) return;
+    const ok = await copy(text);
+    if (ok) {
+      showSuccess(t('已复制模型名称'));
+    } else {
+      showError(t('复制失败'));
+    }
+  };
+
+  const handleAddToPrefill = () => {
+    // Prepare initial data
+    const items = selectedKeys.map((m) => m.model_name);
+    setPrefillInit({ id: undefined, type: 'model', items });
+    setShowAddPrefill(true);
+  };
+
+  return (
+    <>
+      <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
+        <Button
+          type="primary"
+          className="flex-1 md:flex-initial"
+          onClick={() => {
+            setEditingModel({
+              id: undefined,
+            });
+            setShowEdit(true);
+          }}
+          size="small"
+        >
+          {t('添加模型')}
+        </Button>
+
+        <Button
+          type="secondary"
+          className="flex-1 md:flex-initial"
+          size="small"
+          onClick={() => setShowMissingModal(true)}
+        >
+          {t('未配置模型')}
+        </Button>
+
+        <Button
+          type="secondary"
+          className="flex-1 md:flex-initial"
+          size="small"
+          onClick={() => setShowGroupManagement(true)}
+        >
+          {t('预填组管理')}
+        </Button>
+
+        <CompactModeToggle
+          compactMode={compactMode}
+          setCompactMode={setCompactMode}
+          t={t}
+        />
+      </div>
+
+      <SelectionNotification
+        selectedKeys={selectedKeys}
+        t={t}
+        onDelete={handleDeleteSelectedModels}
+        onAddPrefill={handleAddToPrefill}
+        onClear={handleClearSelected}
+        onCopy={handleCopyNames}
+      />
+
+      <Modal
+        title={t('批量删除模型')}
+        visible={showDeleteModal}
+        onCancel={() => setShowDeleteModal(false)}
+        onOk={handleConfirmDelete}
+        type="warning"
+      >
+        <div>
+          {t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
+        </div>
+      </Modal>
+
+      <MissingModelsModal
+        visible={showMissingModal}
+        onClose={() => setShowMissingModal(false)}
+        onConfigureModel={(name) => {
+          setEditingModel({ id: undefined, model_name: name });
+          setShowEdit(true);
+          setShowMissingModal(false);
+        }}
+        t={t}
+      />
+
+      <PrefillGroupManagement
+        visible={showGroupManagement}
+        onClose={() => setShowGroupManagement(false)}
+      />
+
+      <EditPrefillGroupModal
+        visible={showAddPrefill}
+        onClose={() => setShowAddPrefill(false)}
+        editingGroup={prefillInit}
+        onSuccess={() => setShowAddPrefill(false)}
+      />
+    </>
+  );
+};
+
+export default ModelsActions;

+ 303 - 0
web/src/components/table/models/ModelsColumnDefs.js

@@ -0,0 +1,303 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui';
+import {
+  timestamp2string,
+  getLobeHubIcon,
+  stringToColor
+} from '../../../helpers';
+import { renderLimitedItems, renderDescription } from '../../common/ui/RenderUtils';
+
+const { Text } = Typography;
+
+// Render timestamp
+function renderTimestamp(timestamp) {
+  return <>{timestamp2string(timestamp)}</>;
+}
+
+// Render vendor column with icon
+const renderVendorTag = (vendorId, vendorMap, t) => {
+  if (!vendorId || !vendorMap[vendorId]) return '-';
+  const v = vendorMap[vendorId];
+  return (
+    <Tag
+      color='white'
+      shape='circle'
+      prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
+    >
+      {v.name}
+    </Tag>
+  );
+};
+
+// Render groups (enable_groups)
+const renderGroups = (groups) => {
+  if (!groups || groups.length === 0) return '-';
+  return renderLimitedItems({
+    items: groups,
+    renderItem: (g, idx) => (
+      <Tag key={idx} size="small" shape='circle' color={stringToColor(g)}>
+        {g}
+      </Tag>
+    ),
+  });
+};
+
+// Render tags
+const renderTags = (text) => {
+  if (!text) return '-';
+  const tagsArr = text.split(',').filter(Boolean);
+  return renderLimitedItems({
+    items: tagsArr,
+    renderItem: (tag, idx) => (
+      <Tag key={idx} size="small" shape='circle' color={stringToColor(tag)}>
+        {tag}
+      </Tag>
+    ),
+  });
+};
+
+// Render endpoints (supports object map or legacy array)
+const renderEndpoints = (value) => {
+  try {
+    const parsed = typeof value === 'string' ? JSON.parse(value) : value;
+    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+      const keys = Object.keys(parsed || {});
+      if (keys.length === 0) return '-';
+      return renderLimitedItems({
+        items: keys,
+        renderItem: (key, idx) => (
+          <Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
+            {key}
+          </Tag>
+        ),
+        maxDisplay: 3,
+      });
+    }
+    if (Array.isArray(parsed)) {
+      if (parsed.length === 0) return '-';
+      return renderLimitedItems({
+        items: parsed,
+        renderItem: (ep, idx) => (
+          <Tag key={idx} color="white" size="small" shape='circle'>
+            {ep}
+          </Tag>
+        ),
+        maxDisplay: 3,
+      });
+    }
+    return value || '-';
+  } catch (_) {
+    return value || '-';
+  }
+};
+
+// Render quota type
+const renderQuotaType = (qt, t) => {
+  if (qt === 1) {
+    return (
+      <Tag color='teal' size='small' shape='circle'>
+        {t('按次计费')}
+      </Tag>
+    );
+  }
+  if (qt === 0) {
+    return (
+      <Tag color='violet' size='small' shape='circle'>
+        {t('按量计费')}
+      </Tag>
+    );
+  }
+  return qt ?? '-';
+};
+
+// Render bound channels
+const renderBoundChannels = (channels) => {
+  if (!channels || channels.length === 0) return '-';
+  return renderLimitedItems({
+    items: channels,
+    renderItem: (c, idx) => (
+      <Tag key={idx} color="white" size="small" shape='circle'>
+        {c.name}({c.type})
+      </Tag>
+    ),
+  });
+};
+
+// Render operations column
+const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => {
+  return (
+    <Space wrap>
+      {record.status === 1 ? (
+        <Button
+          type='danger'
+          size="small"
+          onClick={() => manageModel(record.id, 'disable', record)}
+        >
+          {t('禁用')}
+        </Button>
+      ) : (
+        <Button
+          size="small"
+          onClick={() => manageModel(record.id, 'enable', record)}
+        >
+          {t('启用')}
+        </Button>
+      )}
+
+      <Button
+        type='tertiary'
+        size="small"
+        onClick={() => {
+          setEditingModel(record);
+          setShowEdit(true);
+        }}
+      >
+        {t('编辑')}
+      </Button>
+
+      <Button
+        type='danger'
+        size="small"
+        onClick={() => {
+          Modal.confirm({
+            title: t('确定是否要删除此模型?'),
+            content: t('此修改将不可逆'),
+            onOk: () => {
+              (async () => {
+                await manageModel(record.id, 'delete', record);
+                await refresh();
+              })();
+            },
+          });
+        }}
+      >
+        {t('删除')}
+      </Button>
+    </Space>
+  );
+};
+
+// 名称匹配类型渲染
+const renderNameRule = (rule, t) => {
+  const map = {
+    0: { color: 'green', label: t('精确') },
+    1: { color: 'blue', label: t('前缀') },
+    2: { color: 'orange', label: t('包含') },
+    3: { color: 'purple', label: t('后缀') },
+  };
+  const cfg = map[rule];
+  if (!cfg) return '-';
+  return (
+    <Tag color={cfg.color} size="small" shape='circle'>
+      {cfg.label}
+    </Tag>
+  );
+};
+
+export const getModelsColumns = ({
+  t,
+  manageModel,
+  setEditingModel,
+  setShowEdit,
+  refresh,
+  vendorMap,
+}) => {
+  return [
+    {
+      title: t('模型名称'),
+      dataIndex: 'model_name',
+      render: (text) => (
+        <Text copyable onClick={(e) => e.stopPropagation()}>
+          {text}
+        </Text>
+      ),
+    },
+    {
+      title: t('匹配类型'),
+      dataIndex: 'name_rule',
+      render: (val) => renderNameRule(val, t),
+    },
+    {
+      title: t('描述'),
+      dataIndex: 'description',
+      render: (text) => renderDescription(text, 200),
+    },
+    {
+      title: t('供应商'),
+      dataIndex: 'vendor_id',
+      render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
+    },
+    {
+      title: t('标签'),
+      dataIndex: 'tags',
+      render: renderTags,
+    },
+    {
+      title: t('端点'),
+      dataIndex: 'endpoints',
+      render: renderEndpoints,
+    },
+    {
+      title: t('已绑定渠道'),
+      dataIndex: 'bound_channels',
+      render: renderBoundChannels,
+    },
+    {
+      title: t('可用分组'),
+      dataIndex: 'enable_groups',
+      render: renderGroups,
+    },
+    {
+      title: t('计费类型'),
+      dataIndex: 'quota_type',
+      render: (qt) => renderQuotaType(qt, t),
+    },
+    {
+      title: t('创建时间'),
+      dataIndex: 'created_time',
+      render: (text, record, index) => {
+        return <div>{renderTimestamp(text)}</div>;
+      },
+    },
+    {
+      title: t('更新时间'),
+      dataIndex: 'updated_time',
+      render: (text, record, index) => {
+        return <div>{renderTimestamp(text)}</div>;
+      },
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      fixed: 'right',
+      render: (text, record, index) => renderOperations(
+        text,
+        record,
+        setEditingModel,
+        setShowEdit,
+        manageModel,
+        refresh,
+        t
+      ),
+    },
+  ];
+};

+ 44 - 0
web/src/components/table/models/ModelsDescription.jsx

@@ -0,0 +1,44 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Typography } from '@douyinfe/semi-ui';
+import { Layers } from 'lucide-react';
+import CompactModeToggle from '../../common/ui/CompactModeToggle';
+
+const { Text } = Typography;
+
+const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
+  return (
+    <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
+      <div className="flex items-center text-green-500">
+        <Layers size={16} className="mr-2" />
+        <Text>{t('模型管理')}</Text>
+      </div>
+
+      <CompactModeToggle
+        compactMode={compactMode}
+        setCompactMode={setCompactMode}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default ModelsDescription;

+ 106 - 0
web/src/components/table/models/ModelsFilters.jsx

@@ -0,0 +1,106 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useRef } from 'react';
+import { Form, Button } from '@douyinfe/semi-ui';
+import { IconSearch } from '@douyinfe/semi-icons';
+
+const ModelsFilters = ({
+  formInitValues,
+  setFormApi,
+  searchModels,
+  loading,
+  searching,
+  t,
+}) => {
+  // Handle form reset and immediate search
+  const formApiRef = useRef(null);
+
+  const handleReset = () => {
+    if (!formApiRef.current) return;
+    formApiRef.current.reset();
+    setTimeout(() => {
+      searchModels();
+    }, 100);
+  };
+
+  return (
+    <Form
+      initValues={formInitValues}
+      getFormApi={(api) => {
+        setFormApi(api);
+        formApiRef.current = api;
+      }}
+      onSubmit={searchModels}
+      allowEmpty={true}
+      autoComplete="off"
+      layout="horizontal"
+      trigger="change"
+      stopValidateWithError={false}
+      className="w-full md:w-auto order-1 md:order-2"
+    >
+      <div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
+        <div className="relative w-full md:w-56">
+          <Form.Input
+            field="searchKeyword"
+            prefix={<IconSearch />}
+            placeholder={t('搜索模型名称')}
+            showClear
+            pure
+            size="small"
+          />
+        </div>
+
+        <div className="relative w-full md:w-56">
+          <Form.Input
+            field="searchVendor"
+            prefix={<IconSearch />}
+            placeholder={t('搜索供应商')}
+            showClear
+            pure
+            size="small"
+          />
+        </div>
+
+        <div className="flex gap-2 w-full md:w-auto">
+          <Button
+            type="tertiary"
+            htmlType="submit"
+            loading={loading || searching}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('查询')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            onClick={handleReset}
+            className="flex-1 md:flex-initial md:w-auto"
+            size="small"
+          >
+            {t('重置')}
+          </Button>
+        </div>
+      </div>
+    </Form>
+  );
+};
+
+export default ModelsFilters;

+ 104 - 0
web/src/components/table/models/ModelsTable.jsx

@@ -0,0 +1,104 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useMemo } from 'react';
+import { Empty } from '@douyinfe/semi-ui';
+import CardTable from '../../common/ui/CardTable.js';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { getModelsColumns } from './ModelsColumnDefs.js';
+
+const ModelsTable = (modelsData) => {
+  const {
+    models,
+    loading,
+    activePage,
+    pageSize,
+    modelCount,
+    compactMode,
+    handlePageChange,
+    handlePageSizeChange,
+    rowSelection,
+    handleRow,
+    manageModel,
+    setEditingModel,
+    setShowEdit,
+    refresh,
+    vendorMap,
+    t,
+  } = modelsData;
+
+  // Get all columns
+  const columns = useMemo(() => {
+    return getModelsColumns({
+      t,
+      manageModel,
+      setEditingModel,
+      setShowEdit,
+      refresh,
+      vendorMap,
+    });
+  }, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]);
+
+  // Handle compact mode by removing fixed positioning
+  const tableColumns = useMemo(() => {
+    return compactMode ? columns.map(col => {
+      if (col.dataIndex === 'operate') {
+        const { fixed, ...rest } = col;
+        return rest;
+      }
+      return col;
+    }) : columns;
+  }, [compactMode, columns]);
+
+  return (
+    <CardTable
+      columns={tableColumns}
+      dataSource={models}
+      scroll={compactMode ? undefined : { x: 'max-content' }}
+      pagination={{
+        currentPage: activePage,
+        pageSize: pageSize,
+        total: modelCount,
+        showSizeChanger: true,
+        pageSizeOptions: [10, 20, 50, 100],
+        onPageSizeChange: handlePageSizeChange,
+        onPageChange: handlePageChange,
+      }}
+      hidePagination={true}
+      loading={loading}
+      rowSelection={rowSelection}
+      onRow={handleRow}
+      empty={
+        <Empty
+          image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+          darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+          description={t('搜索无结果')}
+          style={{ padding: 30 }}
+        />
+      }
+      className="rounded-xl overflow-hidden"
+      size="middle"
+    />
+  );
+};
+
+export default ModelsTable;

+ 169 - 0
web/src/components/table/models/ModelsTabs.jsx

@@ -0,0 +1,169 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
+import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
+import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
+import { API } from '../../../helpers';
+
+const ModelsTabs = ({
+  activeVendorKey,
+  setActiveVendorKey,
+  vendorCounts,
+  vendors,
+  loadModels,
+  activePage,
+  pageSize,
+  setActivePage,
+  setShowAddVendor,
+  setShowEditVendor,
+  setEditingVendor,
+  loadVendors,
+  t
+}) => {
+  const handleTabChange = (key) => {
+    setActiveVendorKey(key);
+    setActivePage(1);
+    loadModels(1, pageSize, key);
+  };
+
+  const handleEditVendor = (vendor, e) => {
+    e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
+    setEditingVendor(vendor);
+    setShowEditVendor(true);
+  };
+
+  const handleDeleteVendor = async (vendor, e) => {
+    e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
+    try {
+      const res = await API.delete(`/api/vendors/${vendor.id}`);
+      if (res.data.success) {
+        showSuccess(t('供应商删除成功'));
+        // 如果删除的是当前选中的供应商,切换到"全部"
+        if (activeVendorKey === String(vendor.id)) {
+          setActiveVendorKey('all');
+          loadModels(1, pageSize, 'all');
+        } else {
+          loadModels(activePage, pageSize, activeVendorKey);
+        }
+        loadVendors(); // 重新加载供应商列表
+      } else {
+        showError(res.data.message || t('删除失败'));
+      }
+    } catch (error) {
+      showError(error.response?.data?.message || t('删除失败'));
+    }
+  };
+
+  return (
+    <Tabs
+      activeKey={activeVendorKey}
+      type="card"
+      collapsible
+      onChange={handleTabChange}
+      className="mb-2"
+      tabBarExtraContent={
+        <Button
+          type="primary"
+          size="small"
+          onClick={() => setShowAddVendor(true)}
+        >
+          {t('新增供应商')}
+        </Button>
+      }
+    >
+      <TabPane
+        itemKey="all"
+        tab={
+          <span className="flex items-center gap-2">
+            {t('全部')}
+            <Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle'>
+              {vendorCounts['all'] || 0}
+            </Tag>
+          </span>
+        }
+      />
+
+      {vendors.map((vendor) => {
+        const key = String(vendor.id);
+        const count = vendorCounts[vendor.id] || 0;
+        return (
+          <TabPane
+            key={key}
+            itemKey={key}
+            tab={
+              <span className="flex items-center gap-2">
+                {getLobeHubIcon(vendor.icon || 'Layers', 14)}
+                {vendor.name}
+                <Tag color={activeVendorKey === key ? 'red' : 'grey'} shape='circle'>
+                  {count}
+                </Tag>
+                <Dropdown
+                  trigger="click"
+                  position="bottomRight"
+                  render={
+                    <Dropdown.Menu>
+                      <Dropdown.Item
+                        icon={<IconEdit />}
+                        onClick={(e) => handleEditVendor(vendor, e)}
+                      >
+                        {t('编辑')}
+                      </Dropdown.Item>
+                      <Dropdown.Item
+                        type="danger"
+                        icon={<IconDelete />}
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          Modal.confirm({
+                            title: t('确认删除'),
+                            content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }),
+                            onOk: () => handleDeleteVendor(vendor, e),
+                            okText: t('删除'),
+                            cancelText: t('取消'),
+                            type: 'warning',
+                            okType: 'danger',
+                          });
+                        }}
+                      >
+                        {t('删除')}
+                      </Dropdown.Item>
+                    </Dropdown.Menu>
+                  }
+                  onClickOutSide={(e) => e.stopPropagation()}
+                >
+                  <Button
+                    size="small"
+                    type="tertiary"
+                    theme="outline"
+                    onClick={(e) => e.stopPropagation()}
+                  >
+                    {t('操作')}
+                  </Button>
+                </Dropdown>
+              </span>
+            }
+          />
+        );
+      })}
+    </Tabs>
+  );
+};
+
+export default ModelsTabs;

+ 106 - 0
web/src/components/table/models/components/SelectionNotification.jsx

@@ -0,0 +1,106 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect } from 'react';
+import { Notification, Button, Space, Typography } from '@douyinfe/semi-ui';
+
+// 固定通知 ID,保持同一个实例即可避免闪烁
+const NOTICE_ID = 'models-batch-actions';
+
+/**
+ * SelectionNotification 选择通知组件
+ * 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知
+ * 2. 当 selectedKeys 清空时关闭通知
+ */
+const SelectionNotification = ({ selectedKeys = [], t, onDelete, onAddPrefill, onClear, onCopy }) => {
+  // 根据选中数量决定显示/隐藏或更新通知
+  useEffect(() => {
+    const selectedCount = selectedKeys.length;
+
+    if (selectedCount > 0) {
+      const titleNode = (
+        <Space wrap>
+          <span>{t('批量操作')}</span>
+          <Typography.Text type="tertiary" size="small">{t('已选择 {{count}} 个模型', { count: selectedCount })}</Typography.Text>
+        </Space>
+      );
+
+      const content = (
+        <Space wrap>
+          <Button
+            size="small"
+            type="tertiary"
+            theme="solid"
+            onClick={onClear}
+          >
+            {t('取消全选')}
+          </Button>
+          <Button
+            size="small"
+            type="primary"
+            theme="solid"
+            onClick={onAddPrefill}
+          >
+            {t('加入预填组')}
+          </Button>
+          <Button
+            size="small"
+            type="secondary"
+            theme="solid"
+            onClick={onCopy}
+          >
+            {t('复制名称')}
+          </Button>
+          <Button
+            size="small"
+            type="danger"
+            theme="solid"
+            onClick={onDelete}
+          >
+            {t('删除所选')}
+          </Button>
+        </Space>
+      );
+
+      // 使用相同 id 更新通知(若已存在则就地更新,不存在则创建)
+      Notification.info({
+        id: NOTICE_ID,
+        title: titleNode,
+        content,
+        duration: 0, // 不自动关闭
+        position: 'bottom',
+        showClose: false,
+      });
+    } else {
+      // 取消全部勾选时关闭通知
+      Notification.close(NOTICE_ID);
+    }
+  }, [selectedKeys, t, onDelete, onAddPrefill, onClear, onCopy]);
+
+  // 卸载时确保关闭通知
+  useEffect(() => {
+    return () => {
+      Notification.close(NOTICE_ID);
+    };
+  }, []);
+
+  return null; // 该组件不渲染可见内容
+};
+
+export default SelectionNotification;

+ 142 - 0
web/src/components/table/models/index.jsx

@@ -0,0 +1,142 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import CardPro from '../../common/ui/CardPro';
+import ModelsTable from './ModelsTable.jsx';
+import ModelsActions from './ModelsActions.jsx';
+import ModelsFilters from './ModelsFilters.jsx';
+import ModelsTabs from './ModelsTabs.jsx';
+import EditModelModal from './modals/EditModelModal.jsx';
+import EditVendorModal from './modals/EditVendorModal.jsx';
+import { useModelsData } from '../../../hooks/models/useModelsData';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { createCardProPagination } from '../../../helpers/utils';
+
+const ModelsPage = () => {
+  const modelsData = useModelsData();
+  const isMobile = useIsMobile();
+
+  const {
+    // Edit state
+    showEdit,
+    editingModel,
+    closeEdit,
+    refresh,
+
+    // Actions state
+    selectedKeys,
+    setSelectedKeys,
+    setEditingModel,
+    setShowEdit,
+    batchDeleteModels,
+
+    // Filters state
+    formInitValues,
+    setFormApi,
+    searchModels,
+    loading,
+    searching,
+
+    // Description state
+    compactMode,
+    setCompactMode,
+
+    // Vendor state
+    showAddVendor,
+    setShowAddVendor,
+    showEditVendor,
+    setShowEditVendor,
+    editingVendor,
+    setEditingVendor,
+    loadVendors,
+
+    // Translation
+    t,
+  } = modelsData;
+
+  return (
+    <>
+      <EditModelModal
+        refresh={refresh}
+        editingModel={editingModel}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      />
+
+      <EditVendorModal
+        visible={showAddVendor || showEditVendor}
+        handleClose={() => {
+          setShowAddVendor(false);
+          setShowEditVendor(false);
+          setEditingVendor({ id: undefined });
+        }}
+        editingVendor={showEditVendor ? editingVendor : { id: undefined }}
+        refresh={() => {
+          loadVendors();
+          refresh();
+        }}
+      />
+
+      <CardPro
+        type="type3"
+        tabsArea={<ModelsTabs {...modelsData} />}
+        actionsArea={
+          <div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
+            <ModelsActions
+              selectedKeys={selectedKeys}
+              setSelectedKeys={setSelectedKeys}
+              setEditingModel={setEditingModel}
+              setShowEdit={setShowEdit}
+              batchDeleteModels={batchDeleteModels}
+              compactMode={compactMode}
+              setCompactMode={setCompactMode}
+              t={t}
+            />
+
+            <div className="w-full md:w-full lg:w-auto order-1 md:order-2">
+              <ModelsFilters
+                formInitValues={formInitValues}
+                setFormApi={setFormApi}
+                searchModels={searchModels}
+                loading={loading}
+                searching={searching}
+                t={t}
+              />
+            </div>
+          </div>
+        }
+        paginationArea={createCardProPagination({
+          currentPage: modelsData.activePage,
+          pageSize: modelsData.pageSize,
+          total: modelsData.modelCount,
+          onPageChange: modelsData.handlePageChange,
+          onPageSizeChange: modelsData.handlePageSizeChange,
+          isMobile: isMobile,
+          t: modelsData.t,
+        })}
+        t={modelsData.t}
+      >
+        <ModelsTable {...modelsData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default ModelsPage;

+ 441 - 0
web/src/components/table/models/modals/EditModelModal.jsx

@@ -0,0 +1,441 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import JSONEditor from '../../../common/ui/JSONEditor';
+import {
+  SideSheet,
+  Form,
+  Button,
+  Space,
+  Spin,
+  Typography,
+  Card,
+  Tag,
+  Avatar,
+  Col,
+  Row,
+} from '@douyinfe/semi-ui';
+import { Save, X, FileText } from 'lucide-react';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const { Text, Title } = Typography;
+
+// Example endpoint template for quick fill
+const ENDPOINT_TEMPLATE = {
+  openai: { path: '/v1/chat/completions', method: 'POST' },
+  anthropic: { path: '/v1/messages', method: 'POST' },
+  'image-generation': { path: '/v1/images/generations', method: 'POST' },
+};
+
+const nameRuleOptions = [
+  { label: '精确名称匹配', value: 0 },
+  { label: '前缀名称匹配', value: 1 },
+  { label: '包含名称匹配', value: 2 },
+  { label: '后缀名称匹配', value: 3 },
+];
+
+const EditModelModal = (props) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const isMobile = useIsMobile();
+  const formApiRef = useRef(null);
+  const isEdit = props.editingModel && props.editingModel.id !== undefined;
+  const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
+
+  // 供应商列表
+  const [vendors, setVendors] = useState([]);
+
+  // 预填组(标签、端点)
+  const [tagGroups, setTagGroups] = useState([]);
+  const [endpointGroups, setEndpointGroups] = useState([]);
+
+  // 获取供应商列表
+  const fetchVendors = async () => {
+    try {
+      const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
+      if (res.data.success) {
+        const items = res.data.data.items || res.data.data || [];
+        setVendors(Array.isArray(items) ? items : []);
+      }
+    } catch (error) {
+      // ignore
+    }
+  };
+
+  // 获取预填组(标签、端点)
+  const fetchPrefillGroups = async () => {
+    try {
+      const [tagRes, endpointRes] = await Promise.all([
+        API.get('/api/prefill_group?type=tag'),
+        API.get('/api/prefill_group?type=endpoint'),
+      ]);
+      if (tagRes?.data?.success) {
+        setTagGroups(tagRes.data.data || []);
+      }
+      if (endpointRes?.data?.success) {
+        setEndpointGroups(endpointRes.data.data || []);
+      }
+    } catch (error) {
+      // ignore
+    }
+  };
+
+  useEffect(() => {
+    if (props.visiable) {
+      fetchVendors();
+      fetchPrefillGroups();
+    }
+  }, [props.visiable]);
+
+  const getInitValues = () => ({
+    model_name: props.editingModel?.model_name || '',
+    description: '',
+    tags: [],
+    vendor_id: undefined,
+    vendor: '',
+    vendor_icon: '',
+    endpoints: '',
+    name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
+    status: true,
+  });
+
+  const handleCancel = () => {
+    props.handleClose();
+  };
+
+  const loadModel = async () => {
+    if (!isEdit || !props.editingModel.id) return;
+
+    setLoading(true);
+    try {
+      const res = await API.get(`/api/models/${props.editingModel.id}`);
+      const { success, message, data } = res.data;
+      if (success) {
+        // 处理tags
+        if (data.tags) {
+          data.tags = data.tags.split(',').filter(Boolean);
+        } else {
+          data.tags = [];
+        }
+        // endpoints 保持原始 JSON 字符串,若为空设为空串
+        if (!data.endpoints) {
+          data.endpoints = '';
+        }
+        // 处理status,将数字转为布尔值
+        data.status = data.status === 1;
+        if (formApiRef.current) {
+          formApiRef.current.setValues({ ...getInitValues(), ...data });
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(t('加载模型信息失败'));
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    if (formApiRef.current) {
+      if (!isEdit) {
+        formApiRef.current.setValues({
+          ...getInitValues(),
+          model_name: props.editingModel?.model_name || '',
+        });
+      }
+    }
+  }, [props.editingModel?.id, props.editingModel?.model_name]);
+
+  useEffect(() => {
+    if (props.visiable) {
+      if (isEdit) {
+        loadModel();
+      } else {
+        formApiRef.current?.setValues({
+          ...getInitValues(),
+          model_name: props.editingModel?.model_name || '',
+        });
+      }
+    } else {
+      formApiRef.current?.reset();
+    }
+  }, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
+
+  const submit = async (values) => {
+    setLoading(true);
+    try {
+      const submitData = {
+        ...values,
+        tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
+        endpoints: values.endpoints || '',
+        status: values.status ? 1 : 0,
+      };
+
+      if (isEdit) {
+        submitData.id = props.editingModel.id;
+        const res = await API.put('/api/models/', submitData);
+        const { success, message } = res.data;
+        if (success) {
+          showSuccess(t('模型更新成功!'));
+          props.refresh();
+          props.handleClose();
+        } else {
+          showError(t(message));
+        }
+      } else {
+        const res = await API.post('/api/models/', submitData);
+        const { success, message } = res.data;
+        if (success) {
+          showSuccess(t('模型创建成功!'));
+          props.refresh();
+          props.handleClose();
+        } else {
+          showError(t(message));
+        }
+      }
+    } catch (error) {
+      showError(error.response?.data?.message || t('操作失败'));
+    }
+    setLoading(false);
+    formApiRef.current?.setValues(getInitValues());
+  };
+
+  return (
+    <SideSheet
+      placement={placement}
+      title={
+        <Space>
+          {isEdit ? (
+            <Tag color='blue' shape='circle'>
+              {t('更新')}
+            </Tag>
+          ) : (
+            <Tag color='green' shape='circle'>
+              {t('新建')}
+            </Tag>
+          )}
+          <Title heading={4} className='m-0'>
+            {isEdit ? t('更新模型信息') : t('创建新的模型')}
+          </Title>
+        </Space>
+      }
+      bodyStyle={{ padding: '0' }}
+      visible={props.visiable}
+      width={isMobile ? '100%' : 600}
+      footer={
+        <div className='flex justify-end bg-white'>
+          <Space>
+            <Button
+              theme='solid'
+              className='!rounded-lg'
+              onClick={() => formApiRef.current?.submitForm()}
+              icon={<Save size={16} />}
+              loading={loading}
+            >
+              {t('提交')}
+            </Button>
+            <Button
+              theme='light'
+              className='!rounded-lg'
+              type='primary'
+              onClick={handleCancel}
+              icon={<X size={16} />}
+            >
+              {t('取消')}
+            </Button>
+          </Space>
+        </div>
+      }
+      closeIcon={null}
+      onCancel={() => handleCancel()}
+    >
+      <Spin spinning={loading}>
+        <Form
+          key={isEdit ? 'edit' : 'new'}
+          initValues={getInitValues()}
+          getFormApi={(api) => (formApiRef.current = api)}
+          onSubmit={submit}
+        >
+          {({ values }) => (
+            <div className='p-2'>
+              {/* 基本信息 */}
+              <Card className='!rounded-2xl shadow-sm border-0'>
+                <div className='flex items-center mb-2'>
+                  <Avatar size='small' color='green' className='mr-2 shadow-md'>
+                    <FileText size={16} />
+                  </Avatar>
+                  <div>
+                    <Text className='text-lg font-medium'>{t('基本信息')}</Text>
+                    <div className='text-xs text-gray-600'>{t('设置模型的基本信息')}</div>
+                  </div>
+                </div>
+                <Row gutter={12}>
+                  <Col span={24}>
+                    <Form.Input
+                      field='model_name'
+                      label={t('模型名称')}
+                      placeholder={t('请输入模型名称,如:gpt-4')}
+                      rules={[{ required: true, message: t('请输入模型名称') }]}
+                      showClear
+                    />
+                  </Col>
+
+                  <Col span={24}>
+                    <Form.Select
+                      field='name_rule'
+                      label={t('名称匹配类型')}
+                      placeholder={t('请选择名称匹配类型')}
+                      optionList={nameRuleOptions.map(o => ({ label: t(o.label), value: o.value }))}
+                      rules={[{ required: true, message: t('请选择名称匹配类型') }]}
+                      extraText={t('根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含')}
+                      style={{ width: '100%' }}
+                    />
+                  </Col>
+
+                  <Col span={24}>
+                    <Form.TextArea
+                      field='description'
+                      label={t('描述')}
+                      placeholder={t('请输入模型描述')}
+                      rows={3}
+                      showClear
+                    />
+                  </Col>
+                  <Col span={24}>
+                    <Form.TagInput
+                      field='tags'
+                      label={t('标签')}
+                      placeholder={t('输入标签或使用","分隔多个标签')}
+                      addOnBlur
+                      showClear
+                      onChange={(newTags) => {
+                        if (!formApiRef.current) return;
+                        const normalize = (tags) => {
+                          if (!Array.isArray(tags)) return [];
+                          return [...new Set(tags.flatMap(tag => tag.split(',').map(t => t.trim()).filter(Boolean)))];
+                        };
+                        const normalized = normalize(newTags);
+                        formApiRef.current.setValue('tags', normalized);
+                      }}
+                      style={{ width: '100%' }}
+                      {...(tagGroups.length > 0 && {
+                        extraText: (
+                          <Space wrap>
+                            {tagGroups.map(group => (
+                              <Button
+                                key={group.id}
+                                size='small'
+                                type='primary'
+                                onClick={() => {
+                                  if (formApiRef.current) {
+                                    const currentTags = formApiRef.current.getValue('tags') || [];
+                                    const newTags = [...currentTags, ...(group.items || [])];
+                                    const uniqueTags = [...new Set(newTags)];
+                                    formApiRef.current.setValue('tags', uniqueTags);
+                                  }
+                                }}
+                              >
+                                {group.name}
+                              </Button>
+                            ))}
+                          </Space>
+                        )
+                      })}
+                    />
+                  </Col>
+                  <Col span={24}>
+                    <Form.Select
+                      field='vendor_id'
+                      label={t('供应商')}
+                      placeholder={t('选择模型供应商')}
+                      optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
+                      filter
+                      showClear
+                      onChange={(value) => {
+                        const vendorInfo = vendors.find(v => v.id === value);
+                        if (vendorInfo && formApiRef.current) {
+                          formApiRef.current.setValue('vendor', vendorInfo.name);
+                        }
+                      }}
+                      style={{ width: '100%' }}
+                    />
+                  </Col>
+                  <Col span={24}>
+                    <JSONEditor
+                      field='endpoints'
+                      label={t('端点映射')}
+                      placeholder={'{\n  "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
+                      value={values.endpoints}
+                      onChange={(val) => formApiRef.current?.setValue('endpoints', val)}
+                      formApi={formApiRef.current}
+                      editorType='object'
+                      template={ENDPOINT_TEMPLATE}
+                      templateLabel={t('填入模板')}
+                      extraText={t('留空则使用默认端点;支持 {path, method}')}
+                      extraFooter={endpointGroups.length > 0 && (
+                        <Space wrap>
+                          {endpointGroups.map(group => (
+                            <Button
+                              key={group.id}
+                              size='small'
+                              type='primary'
+                              onClick={() => {
+                                try {
+                                  const current = formApiRef.current?.getValue('endpoints') || '';
+                                  let base = {};
+                                  if (current && current.trim()) base = JSON.parse(current);
+                                  const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
+                                  const merged = { ...base, ...groupObj };
+                                  formApiRef.current?.setValue('endpoints', JSON.stringify(merged, null, 2));
+                                } catch (e) {
+                                  try {
+                                    const groupObj = typeof group.items === 'string' ? JSON.parse(group.items || '{}') : (group.items || {});
+                                    formApiRef.current?.setValue('endpoints', JSON.stringify(groupObj, null, 2));
+                                  } catch { }
+                                }
+                              }}
+                            >
+                              {group.name}
+                            </Button>
+                          ))}
+                        </Space>
+                      )}
+                    />
+                  </Col>
+                  <Col span={24}>
+                    <Form.Switch
+                      field='status'
+                      label={t('状态')}
+                      size="large"
+                    />
+                  </Col>
+                </Row>
+              </Card>
+            </div>
+          )}
+        </Form>
+      </Spin>
+    </SideSheet>
+  );
+};
+
+export default EditModelModal;

+ 259 - 0
web/src/components/table/models/modals/EditPrefillGroupModal.jsx

@@ -0,0 +1,259 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useRef, useEffect } from 'react';
+import JSONEditor from '../../../common/ui/JSONEditor';
+import {
+  SideSheet,
+  Button,
+  Form,
+  Typography,
+  Space,
+  Tag,
+  Row,
+  Col,
+  Card,
+  Avatar,
+  Spin,
+} from '@douyinfe/semi-ui';
+import {
+  IconLayers,
+  IconSave,
+  IconClose,
+} from '@douyinfe/semi-icons';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const { Text, Title } = Typography;
+
+// Example endpoint template for quick fill
+const ENDPOINT_TEMPLATE = {
+  openai: { path: '/v1/chat/completions', method: 'POST' },
+  anthropic: { path: '/v1/messages', method: 'POST' },
+  'image-generation': { path: '/v1/images/generations', method: 'POST' },
+};
+
+const EditPrefillGroupModal = ({ visible, onClose, editingGroup, onSuccess }) => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+  const [loading, setLoading] = useState(false);
+  const formRef = useRef(null);
+  const isEdit = editingGroup && editingGroup.id !== undefined;
+
+  const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
+
+  // 当外部传入的编辑组类型变化时同步 selectedType
+  useEffect(() => {
+    setSelectedType(editingGroup?.type || 'tag');
+  }, [editingGroup?.type]);
+
+  const typeOptions = [
+    { label: t('模型组'), value: 'model' },
+    { label: t('标签组'), value: 'tag' },
+    { label: t('端点组'), value: 'endpoint' },
+  ];
+
+  // 提交表单
+  const handleSubmit = async (values) => {
+    setLoading(true);
+    try {
+      const submitData = {
+        ...values,
+      };
+      if (values.type === 'endpoint') {
+        submitData.items = values.items || '';
+      } else {
+        submitData.items = Array.isArray(values.items) ? values.items : [];
+      }
+
+      if (editingGroup.id) {
+        submitData.id = editingGroup.id;
+        const res = await API.put('/api/prefill_group', submitData);
+        if (res.data.success) {
+          showSuccess(t('更新成功'));
+          onSuccess();
+        } else {
+          showError(res.data.message || t('更新失败'));
+        }
+      } else {
+        const res = await API.post('/api/prefill_group', submitData);
+        if (res.data.success) {
+          showSuccess(t('创建成功'));
+          onSuccess();
+        } else {
+          showError(res.data.message || t('创建失败'));
+        }
+      }
+    } catch (error) {
+      showError(t('操作失败'));
+    }
+    setLoading(false);
+  };
+
+  return (
+    <SideSheet
+      placement="left"
+      title={
+        <Space>
+          {isEdit ? (
+            <Tag color='blue' shape='circle'>
+              {t('更新')}
+            </Tag>
+          ) : (
+            <Tag color='green' shape='circle'>
+              {t('新建')}
+            </Tag>
+          )}
+          <Title heading={4} className='m-0'>
+            {isEdit ? t('更新预填组') : t('创建新的预填组')}
+          </Title>
+        </Space>
+      }
+      visible={visible}
+      onCancel={onClose}
+      width={isMobile ? '100%' : 600}
+      bodyStyle={{ padding: '0' }}
+      footer={
+        <div className='flex justify-end bg-white'>
+          <Space>
+            <Button
+              theme='solid'
+              className='!rounded-lg'
+              onClick={() => formRef.current?.submitForm()}
+              icon={<IconSave />}
+              loading={loading}
+            >
+              {t('提交')}
+            </Button>
+            <Button
+              theme='light'
+              className='!rounded-lg'
+              type='primary'
+              onClick={onClose}
+              icon={<IconClose />}
+            >
+              {t('取消')}
+            </Button>
+          </Space>
+        </div>
+      }
+      closeIcon={null}
+    >
+      <Spin spinning={loading}>
+        <Form
+          getFormApi={(api) => (formRef.current = api)}
+          initValues={{
+            name: editingGroup?.name || '',
+            type: editingGroup?.type || 'tag',
+            description: editingGroup?.description || '',
+            items: (() => {
+              try {
+                if (editingGroup?.type === 'endpoint') {
+                  // 保持原始字符串
+                  return typeof editingGroup?.items === 'string'
+                    ? editingGroup.items
+                    : JSON.stringify(editingGroup.items || {}, null, 2);
+                }
+                return Array.isArray(editingGroup?.items)
+                  ? editingGroup.items
+                  : [];
+              } catch {
+                return editingGroup?.type === 'endpoint' ? '' : [];
+              }
+            })(),
+          }}
+          onSubmit={handleSubmit}
+        >
+          <div className='p-2'>
+            {/* 基本信息 */}
+            <Card className='!rounded-2xl shadow-sm border-0'>
+              <div className='flex items-center mb-2'>
+                <Avatar size='small' color='green' className='mr-2 shadow-md'>
+                  <IconLayers size={16} />
+                </Avatar>
+                <div>
+                  <Text className='text-lg font-medium'>{t('基本信息')}</Text>
+                  <div className='text-xs text-gray-600'>{t('设置预填组的基本信息')}</div>
+                </div>
+              </div>
+              <Row gutter={12}>
+                <Col span={24}>
+                  <Form.Input
+                    field="name"
+                    label={t('组名')}
+                    placeholder={t('请输入组名')}
+                    rules={[{ required: true, message: t('请输入组名') }]}
+                    showClear
+                  />
+                </Col>
+                <Col span={24}>
+                  <Form.Select
+                    field="type"
+                    label={t('类型')}
+                    placeholder={t('选择组类型')}
+                    optionList={typeOptions}
+                    rules={[{ required: true, message: t('请选择组类型') }]}
+                    style={{ width: '100%' }}
+                    onChange={(val) => setSelectedType(val)}
+                  />
+                </Col>
+                <Col span={24}>
+                  <Form.TextArea
+                    field="description"
+                    label={t('描述')}
+                    placeholder={t('请输入组描述')}
+                    rows={3}
+                    showClear
+                  />
+                </Col>
+                <Col span={24}>
+                  {selectedType === 'endpoint' ? (
+                    <JSONEditor
+                      field="items"
+                      label={t('端点映射')}
+                      value={formRef.current?.getValue('items') ?? (typeof editingGroup?.items === 'string' ? editingGroup.items : JSON.stringify(editingGroup.items || {}, null, 2))}
+                      onChange={(val) => formRef.current?.setValue('items', val)}
+                      editorType='object'
+                      placeholder={'{\n  "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'}
+                      template={ENDPOINT_TEMPLATE}
+                      templateLabel={t('填入模板')}
+                      extraText={t('键为端点类型,值为路径和方法对象')}
+                    />
+                  ) : (
+                    <Form.TagInput
+                      field="items"
+                      label={t('项目')}
+                      placeholder={t('输入项目名称,按回车添加')}
+                      addOnBlur
+                      showClear
+                      style={{ width: '100%' }}
+                    />
+                  )}
+                </Col>
+              </Row>
+            </Card>
+          </div>
+        </Form>
+      </Spin>
+    </SideSheet>
+  );
+};
+
+export default EditPrefillGroupModal;

+ 190 - 0
web/src/components/table/models/modals/EditVendorModal.jsx

@@ -0,0 +1,190 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useRef, useEffect } from 'react';
+import {
+  Modal,
+  Form,
+  Col,
+  Row,
+} from '@douyinfe/semi-ui';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { Typography } from '@douyinfe/semi-ui';
+import { IconLink } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const formApiRef = useRef(null);
+
+  const isMobile = useIsMobile();
+  const isEdit = editingVendor && editingVendor.id !== undefined;
+
+  const getInitValues = () => ({
+    name: '',
+    description: '',
+    icon: '',
+    status: true,
+  });
+
+  const handleCancel = () => {
+    handleClose();
+    formApiRef.current?.reset();
+  };
+
+  const loadVendor = async () => {
+    if (!isEdit || !editingVendor.id) return;
+
+    setLoading(true);
+    try {
+      const res = await API.get(`/api/vendors/${editingVendor.id}`);
+      const { success, message, data } = res.data;
+      if (success) {
+        // 将数字状态转为布尔值
+        data.status = data.status === 1;
+        if (formApiRef.current) {
+          formApiRef.current.setValues({ ...getInitValues(), ...data });
+        }
+      } else {
+        showError(message);
+      }
+    } catch (error) {
+      showError(t('加载供应商信息失败'));
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    if (visible) {
+      if (isEdit) {
+        loadVendor();
+      } else {
+        formApiRef.current?.setValues(getInitValues());
+      }
+    } else {
+      formApiRef.current?.reset();
+    }
+  }, [visible, editingVendor?.id]);
+
+  const submit = async (values) => {
+    setLoading(true);
+    try {
+      // 转换 status 为数字
+      const submitData = {
+        ...values,
+        status: values.status ? 1 : 0,
+      };
+
+      if (isEdit) {
+        submitData.id = editingVendor.id;
+        const res = await API.put('/api/vendors/', submitData);
+        const { success, message } = res.data;
+        if (success) {
+          showSuccess(t('供应商更新成功!'));
+          refresh();
+          handleClose();
+        } else {
+          showError(t(message));
+        }
+      } else {
+        const res = await API.post('/api/vendors/', submitData);
+        const { success, message } = res.data;
+        if (success) {
+          showSuccess(t('供应商创建成功!'));
+          refresh();
+          handleClose();
+        } else {
+          showError(t(message));
+        }
+      }
+    } catch (error) {
+      showError(error.response?.data?.message || t('操作失败'));
+    }
+    setLoading(false);
+  };
+
+  return (
+    <Modal
+      title={isEdit ? t('编辑供应商') : t('新增供应商')}
+      visible={visible}
+      onOk={() => formApiRef.current?.submitForm()}
+      onCancel={handleCancel}
+      confirmLoading={loading}
+      size={isMobile ? 'full-width' : 'small'}
+    >
+      <Form
+        initValues={getInitValues()}
+        getFormApi={(api) => (formApiRef.current = api)}
+        onSubmit={submit}
+      >
+        <Row gutter={12}>
+          <Col span={24}>
+            <Form.Input
+              field="name"
+              label={t('供应商名称')}
+              placeholder={t('请输入供应商名称,如:OpenAI')}
+              rules={[{ required: true, message: t('请输入供应商名称') }]}
+              showClear
+            />
+          </Col>
+          <Col span={24}>
+            <Form.TextArea
+              field="description"
+              label={t('描述')}
+              placeholder={t('请输入供应商描述')}
+              rows={3}
+              showClear
+            />
+          </Col>
+          <Col span={24}>
+            <Form.Input
+              field="icon"
+              label={t('供应商图标')}
+              placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')}
+              extraText={
+                <span>
+                  {t('图标使用@lobehub/icons库,查询所有可用图标 ')}
+                  <Typography.Text
+                    link={{ href: 'https://icons.lobehub.com/components/lobe-hub', target: '_blank' }}
+                    icon={<IconLink />}
+                    underline
+                  >
+                    {t('请点击我')}
+                  </Typography.Text>
+                </span>
+              }
+              showClear
+            />
+          </Col>
+          <Col span={24}>
+            <Form.Switch
+              field="status"
+              label={t('状态')}
+              initValue={true}
+            />
+          </Col>
+        </Row>
+      </Form>
+    </Modal>
+  );
+};
+
+export default EditVendorModal;

+ 179 - 0
web/src/components/table/models/modals/MissingModelsModal.jsx

@@ -0,0 +1,179 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useEffect, useState } from 'react';
+import { Modal, Table, Spin, Button, Typography, Empty, Input } from '@douyinfe/semi-ui';
+import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { API, showError } from '../../../../helpers';
+import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const MissingModelsModal = ({
+  visible,
+  onClose,
+  onConfigureModel,
+  t,
+}) => {
+  const [loading, setLoading] = useState(false);
+  const [missingModels, setMissingModels] = useState([]);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [currentPage, setCurrentPage] = useState(1);
+  const isMobile = useIsMobile();
+
+  const fetchMissing = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/models/missing');
+      if (res.data.success) {
+        setMissingModels(res.data.data || []);
+      } else {
+        showError(res.data.message);
+      }
+    } catch (_) {
+      showError(t('获取未配置模型失败'));
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    if (visible) {
+      fetchMissing();
+      setSearchKeyword('');
+      setCurrentPage(1);
+    } else {
+      setMissingModels([]);
+    }
+  }, [visible]);
+
+  // 过滤和分页逻辑
+  const filteredModels = missingModels.filter((model) =>
+    model.toLowerCase().includes(searchKeyword.toLowerCase())
+  );
+
+  const dataSource = (() => {
+    const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
+    const end = start + MODEL_TABLE_PAGE_SIZE;
+    return filteredModels.slice(start, end).map((model) => ({
+      model,
+      key: model,
+    }));
+  })();
+
+  const columns = [
+    {
+      title: t('模型名称'),
+      dataIndex: 'model',
+      render: (text) => (
+        <div className="flex items-center">
+          <Typography.Text strong>{text}</Typography.Text>
+        </div>
+      )
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      fixed: 'right',
+      width: 100,
+      render: (text, record) => (
+        <Button
+          type="primary"
+          size="small"
+          onClick={() => onConfigureModel(record.model)}
+        >
+          {t('配置')}
+        </Button>
+      )
+    }
+  ];
+
+  return (
+    <Modal
+      title={
+        <div className="flex flex-col gap-2 w-full">
+          <div className="flex items-center gap-2">
+            <Typography.Text strong className="!text-[var(--semi-color-text-0)] !text-base">
+              {t('未配置的模型列表')}
+            </Typography.Text>
+            <Typography.Text type="tertiary" size="small">
+              {t('共')} {missingModels.length} {t('个未配置模型')}
+            </Typography.Text>
+          </div>
+        </div>
+      }
+      visible={visible}
+      onCancel={onClose}
+      footer={null}
+      size={isMobile ? 'full-width' : 'medium'}
+      className="!rounded-lg"
+    >
+      <Spin spinning={loading}>
+        {missingModels.length === 0 && !loading ? (
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+            description={t('暂无缺失模型')}
+            style={{ padding: 30 }}
+          />
+        ) : (
+          <div className="missing-models-content">
+            {/* 搜索框 */}
+            <div className="flex items-center justify-end gap-2 w-full mb-4">
+              <Input
+                placeholder={t('搜索模型...')}
+                value={searchKeyword}
+                onChange={(v) => {
+                  setSearchKeyword(v);
+                  setCurrentPage(1);
+                }}
+                className="!w-full"
+                prefix={<IconSearch />}
+                showClear
+              />
+            </div>
+
+            {/* 表格 */}
+            {filteredModels.length > 0 ? (
+              <Table
+                columns={columns}
+                dataSource={dataSource}
+                pagination={{
+                  currentPage: currentPage,
+                  pageSize: MODEL_TABLE_PAGE_SIZE,
+                  total: filteredModels.length,
+                  showSizeChanger: false,
+                  onPageChange: (page) => setCurrentPage(page),
+                }}
+              />
+            ) : (
+              <Empty
+                image={<IllustrationNoResult style={{ width: 100, height: 100 }} />}
+                darkModeImage={<IllustrationNoResultDark style={{ width: 100, height: 100 }} />}
+                description={searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')}
+                style={{ padding: 20 }}
+              />
+            )}
+          </div>
+        )}
+      </Spin>
+    </Modal>
+  );
+};
+
+export default MissingModelsModal;

+ 285 - 0
web/src/components/table/models/modals/PrefillGroupManagement.jsx

@@ -0,0 +1,285 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useState, useEffect } from 'react';
+import {
+  SideSheet,
+  Button,
+  Typography,
+  Space,
+  Tag,
+  Popconfirm,
+  Card,
+  Avatar,
+  Spin,
+  Empty,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconLayers,
+} from '@douyinfe/semi-icons';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { API, showError, showSuccess, stringToColor } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+import CardTable from '../../../common/ui/CardTable';
+import EditPrefillGroupModal from './EditPrefillGroupModal';
+import { renderLimitedItems, renderDescription } from '../../../common/ui/RenderUtils';
+
+const { Text, Title } = Typography;
+
+const PrefillGroupManagement = ({ visible, onClose }) => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+  const [loading, setLoading] = useState(false);
+  const [groups, setGroups] = useState([]);
+  const [showEdit, setShowEdit] = useState(false);
+  const [editingGroup, setEditingGroup] = useState({ id: undefined });
+
+  const typeOptions = [
+    { label: t('模型组'), value: 'model' },
+    { label: t('标签组'), value: 'tag' },
+    { label: t('端点组'), value: 'endpoint' },
+  ];
+
+  // 加载组列表
+  const loadGroups = async () => {
+    setLoading(true);
+    try {
+      const res = await API.get('/api/prefill_group');
+      if (res.data.success) {
+        setGroups(res.data.data || []);
+      } else {
+        showError(res.data.message || t('获取组列表失败'));
+      }
+    } catch (error) {
+      showError(t('获取组列表失败'));
+    }
+    setLoading(false);
+  };
+
+  // 删除组
+  const deleteGroup = async (id) => {
+    try {
+      const res = await API.delete(`/api/prefill_group/${id}`);
+      if (res.data.success) {
+        showSuccess(t('删除成功'));
+        loadGroups();
+      } else {
+        showError(res.data.message || t('删除失败'));
+      }
+    } catch (error) {
+      showError(t('删除失败'));
+    }
+  };
+
+  // 编辑组
+  const handleEdit = (group = {}) => {
+    setEditingGroup(group);
+    setShowEdit(true);
+  };
+
+  // 关闭编辑
+  const closeEdit = () => {
+    setShowEdit(false);
+    setTimeout(() => {
+      setEditingGroup({ id: undefined });
+    }, 300);
+  };
+
+  // 编辑成功回调
+  const handleEditSuccess = () => {
+    closeEdit();
+    loadGroups();
+  };
+
+  // 表格列定义
+  const columns = [
+    {
+      title: t('组名'),
+      dataIndex: 'name',
+      key: 'name',
+      render: (text, record) => (
+        <Space>
+          <Text strong>{text}</Text>
+          <Tag color="white" shape="circle" size="small">
+            {typeOptions.find(opt => opt.value === record.type)?.label || record.type}
+          </Tag>
+        </Space>
+      ),
+    },
+    {
+      title: t('描述'),
+      dataIndex: 'description',
+      key: 'description',
+      render: (text) => renderDescription(text, 150),
+    },
+    {
+      title: t('项目内容'),
+      dataIndex: 'items',
+      key: 'items',
+      render: (items, record) => {
+        try {
+          if (record.type === 'endpoint') {
+            const obj = typeof items === 'string' ? JSON.parse(items || '{}') : (items || {});
+            const keys = Object.keys(obj);
+            if (keys.length === 0) return <Text type="tertiary">{t('暂无项目')}</Text>;
+            return renderLimitedItems({
+              items: keys,
+              renderItem: (key, idx) => (
+                <Tag key={idx} size="small" shape='circle' color={stringToColor(key)}>
+                  {key}
+                </Tag>
+              ),
+              maxDisplay: 3,
+            });
+          }
+          const itemsArray = typeof items === 'string' ? JSON.parse(items) : items;
+          if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
+            return <Text type="tertiary">{t('暂无项目')}</Text>;
+          }
+          return renderLimitedItems({
+            items: itemsArray,
+            renderItem: (item, idx) => (
+              <Tag key={idx} size="small" shape='circle' color={stringToColor(item)}>
+                {item}
+              </Tag>
+            ),
+            maxDisplay: 3,
+          });
+        } catch {
+          return <Text type="tertiary">{t('数据格式错误')}</Text>;
+        }
+      },
+    },
+    {
+      title: '',
+      key: 'action',
+      fixed: 'right',
+      width: 140,
+      render: (_, record) => (
+        <Space>
+          <Button
+            size="small"
+            onClick={() => handleEdit(record)}
+          >
+            {t('编辑')}
+          </Button>
+          <Popconfirm
+            title={t('确定删除此组?')}
+            onConfirm={() => deleteGroup(record.id)}
+          >
+            <Button
+              size="small"
+              type="danger"
+            >
+              {t('删除')}
+            </Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ];
+
+  useEffect(() => {
+    if (visible) {
+      loadGroups();
+    }
+  }, [visible]);
+
+  return (
+    <>
+      <SideSheet
+        placement="left"
+        title={
+          <Space>
+            <Tag color='blue' shape='circle'>
+              {t('管理')}
+            </Tag>
+            <Title heading={4} className='m-0'>
+              {t('预填组管理')}
+            </Title>
+          </Space>
+        }
+        visible={visible}
+        onCancel={onClose}
+        width={isMobile ? '100%' : 800}
+        bodyStyle={{ padding: '0' }}
+        closeIcon={null}
+      >
+        <Spin spinning={loading}>
+          <div className='p-2'>
+            <Card className='!rounded-2xl shadow-sm border-0'>
+              <div className='flex items-center mb-2'>
+                <Avatar size='small' color='blue' className='mr-2 shadow-md'>
+                  <IconLayers size={16} />
+                </Avatar>
+                <div>
+                  <Text className='text-lg font-medium'>{t('组列表')}</Text>
+                  <div className='text-xs text-gray-600'>{t('管理模型、标签、端点等预填组')}</div>
+                </div>
+              </div>
+              <div className="flex justify-end mb-4">
+                <Button
+                  type="primary"
+                  theme='solid'
+                  size="small"
+                  icon={<IconPlus />}
+                  onClick={() => handleEdit()}
+                >
+                  {t('新建组')}
+                </Button>
+              </div>
+              {groups.length > 0 ? (
+                <CardTable
+                  columns={columns}
+                  dataSource={groups}
+                  rowKey="id"
+                  hidePagination={true}
+                  size="small"
+                  scroll={{ x: 'max-content' }}
+                />
+              ) : (
+                <Empty
+                  image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+                  darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+                  description={t('暂无预填组')}
+                  style={{ padding: 30 }}
+                />
+              )}
+            </Card>
+          </div>
+        </Spin>
+      </SideSheet>
+
+      {/* 编辑组件 */}
+      <EditPrefillGroupModal
+        visible={showEdit}
+        onClose={closeEdit}
+        editingGroup={editingGroup}
+        onSuccess={handleEditSuccess}
+      />
+    </>
+  );
+};
+
+export default PrefillGroupManagement;

+ 3 - 20
web/src/components/table/usage-logs/UsageLogsActions.jsx

@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import React, { useState, useEffect, useRef } from 'react';
+import React from 'react';
 import { Tag, Space, Skeleton } from '@douyinfe/semi-ui';
 import { renderQuota } from '../../../helpers';
 import CompactModeToggle from '../../common/ui/CompactModeToggle';
+import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
 
 const LogsActions = ({
   stat,
@@ -30,27 +31,9 @@ const LogsActions = ({
   setCompactMode,
   t,
 }) => {
-  const [showSkeleton, setShowSkeleton] = useState(loadingStat);
+  const showSkeleton = useMinimumLoadingTime(loadingStat);
   const needSkeleton = !showStat || showSkeleton;
-  const loadingStartRef = useRef(Date.now());
 
-  useEffect(() => {
-    if (loadingStat) {
-      loadingStartRef.current = Date.now();
-      setShowSkeleton(true);
-    } else {
-      const elapsed = Date.now() - loadingStartRef.current;
-      const remaining = Math.max(0, 500 - elapsed);
-      if (remaining === 0) {
-        setShowSkeleton(false);
-      } else {
-        const timer = setTimeout(() => setShowSkeleton(false), remaining);
-        return () => clearTimeout(timer);
-      }
-    }
-  }, [loadingStat]);
-
-  // Skeleton placeholder layout (three tag-size blocks)
   const placeholder = (
     <Space>
       <Skeleton.Title style={{ width: 108, height: 21, borderRadius: 6 }} />

+ 58 - 32
web/src/helpers/render.js

@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import i18next from 'i18next';
-import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
+import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
 import { copy, showSuccess } from './utils';
 import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
 import { visit } from 'unist-util-visit';
+import * as LobeIcons from '@lobehub/icons';
 import {
   OpenAI,
   Claude,
@@ -69,28 +70,15 @@ import {
   User,
   Settings,
   CircleUser,
+  Package,
 } from 'lucide-react';
 
-// 侧边栏图标颜色映射
-export const sidebarIconColors = {
-  dashboard: '#10B981', // 绿色
-  terminal: '#10B981', // 绿色
-  message: '#06B6D4', // 青色
-  key: '#3B82F6', // 蓝色
-  chart: '#F59E0B', // 琥珀色
-  image: '#EC4899', // 粉色
-  check: '#F59E0B', // 琥珀色
-  credit: '#F97316', // 橙色
-  layers: '#EF4444', // 红色
-  gift: '#F43F5E', // 玫红色
-  user: '#10B981', // 绿色
-  settings: '#F97316', // 橙色
-};
-
 // 获取侧边栏Lucide图标组件
 export function getLucideIcon(key, selected = false) {
   const size = 16;
   const strokeWidth = 2;
+  const SELECTED_COLOR = 'var(--semi-color-primary)';
+  const iconColor = selected ? SELECTED_COLOR : 'currentColor';
   const commonProps = {
     size,
     strokeWidth,
@@ -103,70 +91,70 @@ export function getLucideIcon(key, selected = false) {
       return (
         <LayoutDashboard
           {...commonProps}
-          color={selected ? sidebarIconColors.dashboard : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'playground':
       return (
         <TerminalSquare
           {...commonProps}
-          color={selected ? sidebarIconColors.terminal : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'chat':
       return (
         <MessageSquare
           {...commonProps}
-          color={selected ? sidebarIconColors.message : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'token':
       return (
         <Key
           {...commonProps}
-          color={selected ? sidebarIconColors.key : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'log':
       return (
         <BarChart3
           {...commonProps}
-          color={selected ? sidebarIconColors.chart : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'midjourney':
       return (
         <ImageIcon
           {...commonProps}
-          color={selected ? sidebarIconColors.image : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'task':
       return (
         <CheckSquare
           {...commonProps}
-          color={selected ? sidebarIconColors.check : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'topup':
       return (
         <CreditCard
           {...commonProps}
-          color={selected ? sidebarIconColors.credit : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'channel':
       return (
         <Layers
           {...commonProps}
-          color={selected ? sidebarIconColors.layers : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'redemption':
       return (
         <Gift
           {...commonProps}
-          color={selected ? sidebarIconColors.gift : 'currentColor'}
+          color={iconColor}
         />
       );
     case 'user':
@@ -174,21 +162,28 @@ export function getLucideIcon(key, selected = false) {
       return (
         <User
           {...commonProps}
-          color={selected ? sidebarIconColors.user : 'currentColor'}
+          color={iconColor}
+        />
+      );
+    case 'models':
+      return (
+        <Package
+          {...commonProps}
+          color={iconColor}
         />
       );
     case 'setting':
       return (
         <Settings
           {...commonProps}
-          color={selected ? sidebarIconColors.settings : 'currentColor'}
+          color={iconColor}
         />
       );
     default:
       return (
         <CircleUser
           {...commonProps}
-          color={selected ? sidebarIconColors.user : 'currentColor'}
+          color={iconColor}
         />
       );
   }
@@ -422,6 +417,37 @@ export function getChannelIcon(channelType) {
   }
 }
 
+/**
+ * 根据图标名称动态获取 LobeHub 图标组件
+ * @param {string} iconName - 图标名称
+ * @param {number} size - 图标大小,默认为 14
+ * @returns {JSX.Element} - 对应的图标组件或 Avatar
+ */
+export function getLobeHubIcon(iconName, size = 14) {
+  if (typeof iconName === 'string') iconName = iconName.trim();
+  // 如果没有图标名称,返回 Avatar
+  if (!iconName) {
+    return <Avatar size="extra-extra-small">?</Avatar>;
+  }
+
+  let IconComponent;
+
+  if (iconName.includes('.')) {
+    const [base, variant] = iconName.split('.');
+    const BaseIcon = LobeIcons[base];
+    IconComponent = BaseIcon ? BaseIcon[variant] : undefined;
+  } else {
+    IconComponent = LobeIcons[iconName];
+  }
+
+  if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) {
+    return <IconComponent size={size} />;
+  }
+
+  const firstLetter = iconName.charAt(0).toUpperCase();
+  return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
+}
+
 // 颜色列表
 const colors = [
   'amber',
@@ -891,13 +917,13 @@ export function renderQuota(quota, digits = 2) {
   if (displayInCurrency) {
     const result = quota / quotaPerUnit;
     const fixedResult = result.toFixed(digits);
-    
+
     // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
     if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
       const minValue = Math.pow(10, -digits);
       return '$' + minValue.toFixed(digits);
     }
-    
+
     return '$' + fixedResult;
   }
   return renderNumber(quota);

+ 108 - 0
web/src/helpers/utils.js

@@ -572,6 +572,72 @@ export const selectFilter = (input, option) => {
   return valueText.includes(keyword) || labelText.includes(keyword);
 };
 
+// -------------------------------
+// 模型定价计算工具函数
+export const calculateModelPrice = ({
+  record,
+  selectedGroup,
+  groupRatio,
+  tokenUnit,
+  displayPrice,
+  currency,
+  precision = 4
+}) => {
+  if (record.quota_type === 0) {
+    // 按量计费
+    const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
+    const completionRatioPriceUSD =
+      record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
+
+    const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
+    const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
+
+    const rawDisplayInput = displayPrice(inputRatioPriceUSD);
+    const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
+
+    const numInput = parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
+    const numCompletion = parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
+
+    return {
+      inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
+      completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
+      unitLabel,
+      isPerToken: true
+    };
+  } else {
+    // 按次计费
+    const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup];
+    const displayVal = displayPrice(priceUSD);
+
+    return {
+      price: displayVal,
+      isPerToken: false
+    };
+  }
+};
+
+// 格式化价格信息(用于卡片视图)
+export const formatPriceInfo = (priceData, t) => {
+  if (priceData.isPerToken) {
+    return (
+      <>
+        <span style={{ color: 'var(--semi-color-text-1)' }}>
+          {t('提示')} {priceData.inputPrice}/{priceData.unitLabel}
+        </span>
+        <span style={{ color: 'var(--semi-color-text-1)' }}>
+          {t('补全')} {priceData.completionPrice}/{priceData.unitLabel}
+        </span>
+      </>
+    );
+  } else {
+    return (
+      <span style={{ color: 'var(--semi-color-text-1)' }}>
+        {t('模型价格')} {priceData.price}
+      </span>
+    );
+  }
+};
+
 // -------------------------------
 // CardPro 分页配置函数
 // 用于创建 CardPro 的 paginationArea 配置
@@ -620,3 +686,45 @@ export const createCardProPagination = ({
     </>
   );
 };
+
+// 模型定价筛选条件默认值
+const DEFAULT_PRICING_FILTERS = {
+  search: '',
+  showWithRecharge: false,
+  currency: 'USD',
+  showRatio: false,
+  viewMode: 'card',
+  tokenUnit: 'M',
+  filterGroup: 'all',
+  filterQuotaType: 'all',
+  filterEndpointType: 'all',
+  filterVendor: 'all',
+  currentPage: 1,
+};
+
+// 重置模型定价筛选条件
+export const resetPricingFilters = ({
+  handleChange,
+  setShowWithRecharge,
+  setCurrency,
+  setShowRatio,
+  setViewMode,
+  setFilterGroup,
+  setFilterQuotaType,
+  setFilterEndpointType,
+  setFilterVendor,
+  setCurrentPage,
+  setTokenUnit,
+}) => {
+  handleChange?.(DEFAULT_PRICING_FILTERS.search);
+  setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge);
+  setCurrency?.(DEFAULT_PRICING_FILTERS.currency);
+  setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio);
+  setViewMode?.(DEFAULT_PRICING_FILTERS.viewMode);
+  setTokenUnit?.(DEFAULT_PRICING_FILTERS.tokenUnit);
+  setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup);
+  setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
+  setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
+  setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
+  setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
+};

+ 50 - 0
web/src/hooks/common/useMinimumLoadingTime.js

@@ -0,0 +1,50 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useRef } from 'react';
+
+/**
+ * 自定义 Hook:确保骨架屏至少显示指定的时间
+ * @param {boolean} loading - 实际的加载状态
+ * @param {number} minimumTime - 最小显示时间(毫秒),默认 1000ms
+ * @returns {boolean} showSkeleton - 是否显示骨架屏的状态
+ */
+export const useMinimumLoadingTime = (loading, minimumTime = 1000) => {
+  const [showSkeleton, setShowSkeleton] = useState(loading);
+  const loadingStartRef = useRef(Date.now());
+
+  useEffect(() => {
+    if (loading) {
+      loadingStartRef.current = Date.now();
+      setShowSkeleton(true);
+    } else {
+      const elapsed = Date.now() - loadingStartRef.current;
+      const remaining = Math.max(0, minimumTime - elapsed);
+
+      if (remaining === 0) {
+        setShowSkeleton(false);
+      } else {
+        const timer = setTimeout(() => setShowSkeleton(false), remaining);
+        return () => clearTimeout(timer);
+      }
+    }
+  }, [loading, minimumTime]);
+
+  return showSkeleton;
+}; 

+ 4 - 7
web/src/hooks/dashboard/useDashboardData.js

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

+ 112 - 50
web/src/hooks/model-pricing/useModelPricingData.js

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

+ 131 - 0
web/src/hooks/model-pricing/usePricingFilterCounts.js

@@ -0,0 +1,131 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+/*
+  统一计算模型筛选后的各种集合与动态计数,供多个组件复用
+*/
+import { useMemo } from 'react';
+
+export const usePricingFilterCounts = ({
+  models = [],
+  filterGroup = 'all',
+  filterQuotaType = 'all',
+  filterEndpointType = 'all',
+  filterVendor = 'all',
+  searchValue = '',
+}) => {
+  // 所有模型(不再需要分类过滤)
+  const allModels = models;
+
+  // 针对计费类型按钮计数
+  const quotaTypeModels = useMemo(() => {
+    let result = allModels;
+    if (filterGroup !== 'all') {
+      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+    }
+    if (filterEndpointType !== 'all') {
+      result = result.filter(m =>
+        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
+      );
+    }
+    if (filterVendor !== 'all') {
+      if (filterVendor === 'unknown') {
+        result = result.filter(m => !m.vendor_name);
+      } else {
+        result = result.filter(m => m.vendor_name === filterVendor);
+      }
+    }
+    return result;
+  }, [allModels, filterGroup, filterEndpointType, filterVendor]);
+
+  // 针对端点类型按钮计数
+  const endpointTypeModels = useMemo(() => {
+    let result = allModels;
+    if (filterGroup !== 'all') {
+      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+    }
+    if (filterQuotaType !== 'all') {
+      result = result.filter(m => m.quota_type === filterQuotaType);
+    }
+    if (filterVendor !== 'all') {
+      if (filterVendor === 'unknown') {
+        result = result.filter(m => !m.vendor_name);
+      } else {
+        result = result.filter(m => m.vendor_name === filterVendor);
+      }
+    }
+    return result;
+  }, [allModels, filterGroup, filterQuotaType, filterVendor]);
+
+  // === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
+  const groupCountModels = useMemo(() => {
+    let result = allModels;
+
+    // 不应用 filterGroup 本身
+    if (filterQuotaType !== 'all') {
+      result = result.filter(m => m.quota_type === filterQuotaType);
+    }
+    if (filterEndpointType !== 'all') {
+      result = result.filter(m =>
+        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
+      );
+    }
+    if (filterVendor !== 'all') {
+      if (filterVendor === 'unknown') {
+        result = result.filter(m => !m.vendor_name);
+      } else {
+        result = result.filter(m => m.vendor_name === filterVendor);
+      }
+    }
+    if (searchValue && searchValue.length > 0) {
+      const term = searchValue.toLowerCase();
+      result = result.filter(m =>
+        m.model_name.toLowerCase().includes(term) ||
+        (m.description && m.description.toLowerCase().includes(term)) ||
+        (m.tags && m.tags.toLowerCase().includes(term)) ||
+        (m.vendor_name && m.vendor_name.toLowerCase().includes(term))
+      );
+    }
+    return result;
+  }, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
+
+  // 针对供应商按钮计数
+  const vendorModels = useMemo(() => {
+    let result = allModels;
+    if (filterGroup !== 'all') {
+      result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
+    }
+    if (filterQuotaType !== 'all') {
+      result = result.filter(m => m.quota_type === filterQuotaType);
+    }
+    if (filterEndpointType !== 'all') {
+      result = result.filter(m =>
+        m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
+      );
+    }
+    return result;
+  }, [allModels, filterGroup, filterQuotaType, filterEndpointType]);
+
+  return {
+    quotaTypeModels,
+    endpointTypeModels,
+    vendorModels,
+    groupCountModels,
+  };
+}; 

+ 384 - 0
web/src/hooks/models/useModelsData.js

@@ -0,0 +1,384 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../helpers';
+import { ITEMS_PER_PAGE } from '../../constants';
+import { useTableCompactMode } from '../common/useTableCompactMode';
+
+export const useModelsData = () => {
+  const { t } = useTranslation();
+  const [compactMode, setCompactMode] = useTableCompactMode('models');
+
+  // State management
+  const [models, setModels] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [searching, setSearching] = useState(false);
+  const [modelCount, setModelCount] = useState(0);
+
+  // Modal states
+  const [showEdit, setShowEdit] = useState(false);
+  const [editingModel, setEditingModel] = useState({
+    id: undefined,
+  });
+
+  // Row selection
+  const [selectedKeys, setSelectedKeys] = useState([]);
+  const rowSelection = {
+    getCheckboxProps: (record) => ({
+      name: record.model_name,
+    }),
+    selectedRowKeys: selectedKeys.map((model) => model.id),
+    onChange: (selectedRowKeys, selectedRows) => {
+      setSelectedKeys(selectedRows);
+    },
+  };
+
+  // Form initial values
+  const formInitValues = {
+    searchKeyword: '',
+    searchVendor: '',
+  };
+
+  // ---------- helpers ----------
+  // Safely extract array items from API payload
+  const extractItems = (payload) => {
+    const items = payload?.items || payload || [];
+    return Array.isArray(items) ? items : [];
+  };
+
+  // Form API reference
+  const [formApi, setFormApi] = useState(null);
+
+  // Get form values helper function
+  const getFormValues = () => formApi?.getValues() || formInitValues;
+
+  // Close edit modal
+  const closeEdit = () => {
+    setShowEdit(false);
+    setTimeout(() => {
+      setEditingModel({ id: undefined });
+    }, 500);
+  };
+
+  // Set model format with key field
+  const setModelFormat = (models) => {
+    for (let i = 0; i < models.length; i++) {
+      models[i].key = models[i].id;
+    }
+    setModels(models);
+  };
+
+  // Vendor list
+  const [vendors, setVendors] = useState([]);
+  const [vendorCounts, setVendorCounts] = useState({});
+  const [activeVendorKey, setActiveVendorKey] = useState('all');
+  const [showAddVendor, setShowAddVendor] = useState(false);
+  const [showEditVendor, setShowEditVendor] = useState(false);
+  const [editingVendor, setEditingVendor] = useState({ id: undefined });
+
+  const vendorMap = useMemo(() => {
+    const map = {};
+    vendors.forEach(v => {
+      map[v.id] = v;
+    });
+    return map;
+  }, [vendors]);
+
+  // Load vendor list
+  const loadVendors = async () => {
+    try {
+      const res = await API.get('/api/vendors/?page_size=1000');
+      if (res.data.success) {
+        const items = res.data.data.items || res.data.data || [];
+        setVendors(Array.isArray(items) ? items : []);
+      }
+    } catch (_) {
+      // ignore
+    }
+  };
+
+  // Load models data
+  const loadModels = async (page = 1, size = pageSize, vendorKey = activeVendorKey) => {
+    setLoading(true);
+    try {
+      let url = `/api/models/?p=${page}&page_size=${size}`;
+      if (vendorKey && vendorKey !== 'all') {
+        // Filter by vendor ID
+        url = `/api/models/search?vendor=${vendorKey}&p=${page}&page_size=${size}`;
+      }
+
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        const newPageData = extractItems(data);
+        setActivePage(data.page || page);
+        setModelCount(data.total || newPageData.length);
+        setModelFormat(newPageData);
+
+        if (data.vendor_counts) {
+          const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0);
+          setVendorCounts({ ...data.vendor_counts, all: sumAll });
+        }
+      } else {
+        showError(message);
+        setModels([]);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('获取模型列表失败'));
+      setModels([]);
+    }
+    setLoading(false);
+  };
+
+  // Refresh data
+  const refresh = async (page = activePage) => {
+    await loadModels(page, pageSize);
+  };
+
+  // Search models with keyword and vendor
+  const searchModels = async () => {
+    const { searchKeyword = '', searchVendor = '' } = getFormValues();
+
+    if (searchKeyword === '' && searchVendor === '') {
+      // If keyword is blank, load models instead
+      await loadModels(1, pageSize);
+      return;
+    }
+
+    setSearching(true);
+    try {
+      const res = await API.get(
+        `/api/models/search?keyword=${searchKeyword}&vendor=${searchVendor}&p=1&page_size=${pageSize}`,
+      );
+      const { success, message, data } = res.data;
+      if (success) {
+        const newPageData = extractItems(data);
+        setActivePage(data.page || 1);
+        setModelCount(data.total || newPageData.length);
+        setModelFormat(newPageData);
+        if (data.vendor_counts) {
+          const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0);
+          setVendorCounts({ ...data.vendor_counts, all: sumAll });
+        }
+      } else {
+        showError(message);
+        setModels([]);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('搜索模型失败'));
+      setModels([]);
+    }
+    setSearching(false);
+  };
+
+  // Manage model (enable/disable/delete)
+  const manageModel = async (id, action, record) => {
+    let res;
+    switch (action) {
+      case 'delete':
+        res = await API.delete(`/api/models/${id}`);
+        break;
+      case 'enable':
+        res = await API.put('/api/models/?status_only=true', { id, status: 1 });
+        break;
+      case 'disable':
+        res = await API.put('/api/models/?status_only=true', { id, status: 0 });
+        break;
+      default:
+        return;
+    }
+
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(t('操作成功完成!'));
+      if (action === 'delete') {
+        await refresh();
+      } else {
+        // Update local state for enable/disable
+        setModels(prevModels =>
+          prevModels.map(model =>
+            model.id === id ? { ...model, status: action === 'enable' ? 1 : 0 } : model
+          )
+        );
+      }
+    } else {
+      showError(message);
+    }
+  };
+
+
+  // Handle page change
+  const handlePageChange = (page) => {
+    setActivePage(page);
+    loadModels(page, pageSize, activeVendorKey);
+  };
+
+  // Reload models when activeVendorKey changes
+  useEffect(() => {
+    loadModels(1, pageSize, activeVendorKey);
+  }, [activeVendorKey]);
+
+  // Handle page size change
+  const handlePageSizeChange = async (size) => {
+    setPageSize(size);
+    setActivePage(1);
+    await loadModels(1, size, activeVendorKey);
+  };
+
+  // Handle row click and styling
+  const handleRow = (record, index) => {
+    const rowStyle = record.status !== 1 ? {
+      style: {
+        background: 'var(--semi-color-disabled-border)',
+      },
+    } : {};
+
+    return {
+      ...rowStyle,
+      onClick: (event) => {
+        // Don't trigger row selection when clicking on buttons
+        if (event.target.closest('button, .semi-button')) {
+          return;
+        }
+        const newSelectedKeys = selectedKeys.some(item => item.id === record.id)
+          ? selectedKeys.filter(item => item.id !== record.id)
+          : [...selectedKeys, record];
+        setSelectedKeys(newSelectedKeys);
+      },
+    };
+  };
+
+  // Batch delete models
+  const batchDeleteModels = async () => {
+    if (selectedKeys.length === 0) {
+      showError(t('请至少选择一个模型'));
+      return;
+    }
+
+    try {
+      const deletePromises = selectedKeys.map(model =>
+        API.delete(`/api/models/${model.id}`)
+      );
+
+      const results = await Promise.all(deletePromises);
+      let successCount = 0;
+
+      results.forEach((res, index) => {
+        if (res.data.success) {
+          successCount++;
+        } else {
+          showError(`删除模型 ${selectedKeys[index].model_name} 失败: ${res.data.message}`);
+        }
+      });
+
+      if (successCount > 0) {
+        showSuccess(t(`成功删除 ${successCount} 个模型`));
+        setSelectedKeys([]);
+        await refresh();
+      }
+    } catch (error) {
+      showError(t('批量删除失败'));
+    }
+  };
+
+  // Copy text helper
+  const copyText = async (text) => {
+    try {
+      await navigator.clipboard.writeText(text);
+      showSuccess(t('复制成功'));
+    } catch (error) {
+      console.error('Copy failed:', error);
+      showError(t('复制失败'));
+    }
+  };
+
+  // Initial load
+  useEffect(() => {
+    (async () => {
+      await loadVendors();
+    })();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return {
+    // Data state
+    models,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    modelCount,
+
+    // Selection state
+    selectedKeys,
+    rowSelection,
+    handleRow,
+    setSelectedKeys,
+
+    // Modal state
+    showEdit,
+    editingModel,
+    setEditingModel,
+    setShowEdit,
+    closeEdit,
+
+    // Form state
+    formInitValues,
+    setFormApi,
+
+    // Actions
+    loadModels,
+    searchModels,
+    refresh,
+    manageModel,
+    batchDeleteModels,
+    copyText,
+
+    // Pagination
+    handlePageChange,
+    handlePageSizeChange,
+
+    // UI state
+    compactMode,
+    setCompactMode,
+
+    // Vendor data
+    vendors,
+    vendorMap,
+    vendorCounts,
+    activeVendorKey,
+    setActiveVendorKey,
+    showAddVendor,
+    setShowAddVendor,
+    showEditVendor,
+    setShowEditVendor,
+    editingVendor,
+    setEditingVendor,
+    loadVendors,
+
+    // Translation
+    t,
+  };
+};

+ 2 - 2
web/src/i18n/locales/en.json

@@ -699,7 +699,6 @@
   "个": "indivual",
   "倍率是本站的计算方式,不同模型有着不同的倍率,并非官方价格的多少倍,请务必知晓。": "The magnification is the calculation method of this website. Different models have different magnifications, which are not multiples of the official price. Please be sure to know.",
   "所有各厂聊天模型请统一使用OpenAI方式请求,支持OpenAI官方库<br/>Claude()Claude官方格式请求": "Please use the OpenAI method to request all chat models from each factory, and support the OpenAI official library<br/>Claude()Claude official format request",
-  "复制选中模型": "Copy selected model",
   "分组说明": "Group description",
   "倍率是为了方便换算不同价格的模型": "The magnification is to facilitate the conversion of models with different prices.",
   "点击查看倍率说明": "Click to view the magnification description",
@@ -951,7 +950,7 @@
   "黑夜模式": "Dark mode",
   "管理员设置": "Admin",
   "待更新": "To be updated",
-  "定价": "Pricing",
+  "模型广场": "Pricing",
   "支付中..": "Paying",
   "查看图片": "View pictures",
   "并发限制": "Concurrency limit",
@@ -1196,6 +1195,7 @@
   "兑换码创建成功,是否下载兑换码?": "Redemption code created successfully. Do you want to download it?",
   "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "The redemption code will be downloaded as a text file, with the filename being the redemption code name.",
   "模型价格": "Model price",
+  "按K显示单位": "Display in K units",
   "可用分组": "Available groups",
   "您的默认分组为:{{group}},分组倍率为:{{ratio}}": "Your default group is: {{group}}, group ratio: {{ratio}}",
   "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "The cost of pay-as-you-go = Group ratio × Model ratio × (Prompt token number + Completion token number × Completion ratio) / 500000 (Unit: USD)",

+ 59 - 1
web/src/index.css

@@ -53,6 +53,7 @@ code {
 
 /* ==================== 导航和侧边栏样式 ==================== */
 /* 导航项样式 */
+.semi-tagInput,
 .semi-input-textarea-wrapper,
 .semi-navigation-sub-title,
 .semi-chat-inputBox-sendButton,
@@ -391,7 +392,8 @@ code {
   background: transparent;
 }
 
-/* 隐藏卡片内容区域的滚动条 */
+/* 隐藏内容区域滚动条 */
+.pricing-scroll-hide,
 .model-test-scroll,
 .card-content-scroll,
 .model-settings-scroll,
@@ -403,6 +405,7 @@ code {
   scrollbar-width: none;
 }
 
+.pricing-scroll-hide::-webkit-scrollbar,
 .model-test-scroll::-webkit-scrollbar,
 .card-content-scroll::-webkit-scrollbar,
 .model-settings-scroll::-webkit-scrollbar,
@@ -615,4 +618,59 @@ html:not(.dark) .blur-ball-teal {
     height: calc(100vh - 77px);
     max-height: calc(100vh - 77px);
   }
+}
+
+/* ==================== 模型定价页面布局 ==================== */
+.pricing-layout {
+  height: calc(100vh - 60px);
+  overflow: hidden;
+  margin-top: 60px;
+}
+
+.pricing-sidebar {
+  min-width: 460px;
+  max-width: 460px;
+  height: calc(100vh - 60px);
+  background-color: var(--semi-color-bg-0);
+  border-right: 1px solid var(--semi-color-border);
+  overflow: auto;
+}
+
+.pricing-content {
+  height: calc(100vh - 60px);
+  background-color: var(--semi-color-bg-0);
+  display: flex;
+  flex-direction: column;
+}
+
+.pricing-pagination-divider {
+  border-color: var(--semi-color-border);
+}
+
+.pricing-content-mobile {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+}
+
+.pricing-search-header {
+  padding: 16px 24px;
+  border-bottom: 1px solid var(--semi-color-border);
+  background-color: var(--semi-color-bg-0);
+  flex-shrink: 0;
+  position: sticky;
+  top: 0;
+  z-index: 5;
+}
+
+.pricing-view-container {
+  flex: 1;
+  overflow: auto;
+}
+
+.pricing-view-container-mobile {
+  flex: 1;
+  overflow: auto;
+  min-height: 0;
 }

+ 12 - 0
web/src/pages/Model/index.js

@@ -0,0 +1,12 @@
+import React from 'react';
+import ModelsTable from '../../components/table/models';
+
+const ModelPage = () => {
+  return (
+    <div className="mt-[60px] px-2">
+      <ModelsTable />
+    </div>
+  );
+};
+
+export default ModelPage;

+ 3 - 3
web/src/pages/Pricing/index.js

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