🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements
Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
This commit is contained in:
143
controller/model_meta.go
Normal file
143
controller/model_meta.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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)
|
||||||
|
pageInfo.SetTotal(int(total))
|
||||||
|
pageInfo.SetItems(modelsMeta)
|
||||||
|
common.ApiSuccess(c, pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err := m.Insert(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 err := m.Update(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:填充 Endpoints 和 BoundChannels
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
114
controller/vendor_meta.go
Normal file
114
controller/vendor_meta.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
|
// 检查名称冲突
|
||||||
|
var dup int64
|
||||||
|
_ = model.DB.Model(&model.Vendor{}).Where("name = ? AND id <> ?", v.Name, v.Id).Count(&dup).Error
|
||||||
|
if dup > 0 {
|
||||||
|
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.Update(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteVendorMeta 删除供应商
|
||||||
|
func DeleteVendorMeta(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
@@ -250,6 +250,8 @@ func migrateDB() error {
|
|||||||
&TopUp{},
|
&TopUp{},
|
||||||
&QuotaData{},
|
&QuotaData{},
|
||||||
&Task{},
|
&Task{},
|
||||||
|
&Model{},
|
||||||
|
&Vendor{},
|
||||||
&Setup{},
|
&Setup{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -276,6 +278,8 @@ func migrateDBFast() error {
|
|||||||
{&TopUp{}, "TopUp"},
|
{&TopUp{}, "TopUp"},
|
||||||
{&QuotaData{}, "QuotaData"},
|
{&QuotaData{}, "QuotaData"},
|
||||||
{&Task{}, "Task"},
|
{&Task{}, "Task"},
|
||||||
|
{&Model{}, "Model"},
|
||||||
|
{&Vendor{}, "Vendor"},
|
||||||
{&Setup{}, "Setup"},
|
{&Setup{}, "Setup"},
|
||||||
}
|
}
|
||||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||||
|
|||||||
108
model/model_meta.go
Normal file
108
model/model_meta.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"one-api/common"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model 用于存储模型的元数据,例如描述、标签等
|
||||||
|
// ModelName 字段具有唯一性约束,确保每个模型只会出现一次
|
||||||
|
// Tags 字段使用逗号分隔的字符串保存标签集合,后期可根据需要扩展为 JSON 类型
|
||||||
|
// Status: 1 表示启用,0 表示禁用,保留以便后续功能扩展
|
||||||
|
// CreatedTime 和 UpdatedTime 使用 Unix 时间戳(秒)保存方便跨数据库移植
|
||||||
|
// DeletedAt 采用 GORM 的软删除特性,便于后续数据恢复
|
||||||
|
//
|
||||||
|
// 该表设计遵循第三范式(3NF):
|
||||||
|
// 1. 每一列都与主键(Id 或 ModelName)直接相关
|
||||||
|
// 2. 不存在部分依赖(ModelName 是唯一键)
|
||||||
|
// 3. 不存在传递依赖(描述、标签等都依赖于 ModelName,而非依赖于其他非主键列)
|
||||||
|
// 这样既保证了数据一致性,也方便后期扩展
|
||||||
|
|
||||||
|
type BoundChannel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
ModelName string `json:"model_name" gorm:"uniqueIndex;size:128;not 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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert 创建新的模型元数据记录
|
||||||
|
func (mi *Model) Insert() error {
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
mi.CreatedTime = now
|
||||||
|
mi.UpdatedTime = now
|
||||||
|
return DB.Create(mi).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新现有模型记录
|
||||||
|
func (mi *Model) Update() error {
|
||||||
|
mi.UpdatedTime = common.GetTimestamp()
|
||||||
|
return DB.Save(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
||||||
|
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("id DESC").Find(&models).Error
|
||||||
|
return models, total, err
|
||||||
|
}
|
||||||
78
model/vendor_meta.go
Normal file
78
model/vendor_meta.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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:"uniqueIndex;size:128;not 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -175,5 +175,27 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
||||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vendorRoute := apiRouter.Group("/vendors")
|
||||||
|
vendorRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
vendorRoute.GET("/", controller.GetAllVendors)
|
||||||
|
vendorRoute.GET("/search", controller.SearchVendors)
|
||||||
|
vendorRoute.GET("/:id", controller.GetVendorMeta)
|
||||||
|
vendorRoute.POST("/", controller.CreateVendorMeta)
|
||||||
|
vendorRoute.PUT("/", controller.UpdateVendorMeta)
|
||||||
|
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsRoute := apiRouter.Group("/models")
|
||||||
|
modelsRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
modelsRoute.GET("/", controller.GetAllModelsMeta)
|
||||||
|
modelsRoute.GET("/search", controller.SearchModelsMeta)
|
||||||
|
modelsRoute.GET("/:id", controller.GetModelMeta)
|
||||||
|
modelsRoute.POST("/", controller.CreateModelMeta)
|
||||||
|
modelsRoute.PUT("/", controller.UpdateModelMeta)
|
||||||
|
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import Chat2Link from './pages/Chat2Link';
|
|||||||
import Midjourney from './pages/Midjourney';
|
import Midjourney from './pages/Midjourney';
|
||||||
import Pricing from './pages/Pricing/index.js';
|
import Pricing from './pages/Pricing/index.js';
|
||||||
import Task from './pages/Task/index.js';
|
import Task from './pages/Task/index.js';
|
||||||
|
import ModelPage from './pages/Model/index.js';
|
||||||
import Playground from './pages/Playground/index.js';
|
import Playground from './pages/Playground/index.js';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||||
@@ -71,6 +72,14 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/models'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<ModelPage />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/console/channel'
|
path='/console/channel'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const routerMap = {
|
|||||||
detail: '/console',
|
detail: '/console',
|
||||||
pricing: '/pricing',
|
pricing: '/pricing',
|
||||||
task: '/console/task',
|
task: '/console/task',
|
||||||
|
models: '/console/models',
|
||||||
playground: '/console/playground',
|
playground: '/console/playground',
|
||||||
personal: '/console/personal',
|
personal: '/console/personal',
|
||||||
};
|
};
|
||||||
@@ -127,6 +128,12 @@ const SiderBar = ({ onNavigate = () => { } }) => {
|
|||||||
|
|
||||||
const adminItems = useMemo(
|
const adminItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
text: t('模型管理'),
|
||||||
|
itemKey: 'models',
|
||||||
|
to: '/console/models',
|
||||||
|
className: isAdmin() ? '' : 'tableHiddle',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: t('渠道管理'),
|
text: t('渠道管理'),
|
||||||
itemKey: 'channel',
|
itemKey: 'channel',
|
||||||
|
|||||||
100
web/src/components/table/models/ModelsActions.jsx
Normal file
100
web/src/components/table/models/ModelsActions.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button, Space, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||||
|
import { showError } from '../../../helpers';
|
||||||
|
|
||||||
|
const ModelsActions = ({
|
||||||
|
selectedKeys,
|
||||||
|
setEditingModel,
|
||||||
|
setShowEdit,
|
||||||
|
batchDeleteModels,
|
||||||
|
compactMode,
|
||||||
|
setCompactMode,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
// Modal states
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
|
// Handle delete selected models with confirmation
|
||||||
|
const handleDeleteSelectedModels = () => {
|
||||||
|
if (selectedKeys.length === 0) {
|
||||||
|
showError(t('请至少选择一个模型!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete confirmation
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
batchDeleteModels();
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
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='danger'
|
||||||
|
className="w-full md:w-auto"
|
||||||
|
onClick={handleDeleteSelectedModels}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('删除所选模型')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CompactModeToggle
|
||||||
|
compactMode={compactMode}
|
||||||
|
setCompactMode={setCompactMode}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('批量删除模型')}
|
||||||
|
visible={showDeleteModal}
|
||||||
|
onCancel={() => setShowDeleteModal(false)}
|
||||||
|
onOk={handleConfirmDelete}
|
||||||
|
type="warning"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsActions;
|
||||||
259
web/src/components/table/models/ModelsColumnDefs.js
Normal file
259
web/src/components/table/models/ModelsColumnDefs.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Modal,
|
||||||
|
Popover
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
timestamp2string,
|
||||||
|
getLobeHubIcon,
|
||||||
|
stringToColor
|
||||||
|
} from '../../../helpers';
|
||||||
|
|
||||||
|
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 description with ellipsis
|
||||||
|
const renderDescription = (text) => {
|
||||||
|
return (
|
||||||
|
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 200 }}>
|
||||||
|
{text || '-'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render tags
|
||||||
|
const renderTags = (text) => {
|
||||||
|
if (!text) return '-';
|
||||||
|
const tagsArr = text.split(',').filter(Boolean);
|
||||||
|
const maxDisplayTags = 3;
|
||||||
|
const displayTags = tagsArr.slice(0, maxDisplayTags);
|
||||||
|
const remainingTags = tagsArr.slice(maxDisplayTags);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space spacing={1} wrap>
|
||||||
|
{displayTags.map((tag, index) => (
|
||||||
|
<Tag key={index} size="small" shape='circle' color={stringToColor(tag)}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{remainingTags.length > 0 && (
|
||||||
|
<Popover
|
||||||
|
content={
|
||||||
|
<div className='p-2'>
|
||||||
|
<Space spacing={1} wrap>
|
||||||
|
{remainingTags.map((tag, index) => (
|
||||||
|
<Tag key={index} size="small" shape='circle' color={stringToColor(tag)}>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Tag size="small" shape='circle' color="grey">
|
||||||
|
+{remainingTags.length}
|
||||||
|
</Tag>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render endpoints
|
||||||
|
const renderEndpoints = (text) => {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(text);
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
return (
|
||||||
|
<Space spacing={1} wrap>
|
||||||
|
{arr.map((ep) => (
|
||||||
|
<Tag key={ep} color="blue" size="small" shape='circle'>
|
||||||
|
{ep}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) { }
|
||||||
|
return text || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render bound channels
|
||||||
|
const renderBoundChannels = (channels) => {
|
||||||
|
if (!channels || channels.length === 0) return '-';
|
||||||
|
return (
|
||||||
|
<Space spacing={1} wrap>
|
||||||
|
{channels.map((c, idx) => (
|
||||||
|
<Tag key={idx} color="purple" size="small" shape='circle'>
|
||||||
|
{c.name}({c.type})
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModelsColumns = ({
|
||||||
|
t,
|
||||||
|
manageModel,
|
||||||
|
setEditingModel,
|
||||||
|
setShowEdit,
|
||||||
|
refresh,
|
||||||
|
vendorMap,
|
||||||
|
}) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('模型名称'),
|
||||||
|
dataIndex: 'model_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('描述'),
|
||||||
|
dataIndex: 'description',
|
||||||
|
render: renderDescription,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 'created_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return <div>{renderTimestamp(text)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('更新时间'),
|
||||||
|
dataIndex: 'updated_time',
|
||||||
|
render: (text, record, index) => {
|
||||||
|
return <div>{renderTimestamp(text)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'operate',
|
||||||
|
fixed: 'right',
|
||||||
|
render: (text, record, index) => renderOperations(
|
||||||
|
text,
|
||||||
|
record,
|
||||||
|
setEditingModel,
|
||||||
|
setShowEdit,
|
||||||
|
manageModel,
|
||||||
|
refresh,
|
||||||
|
t
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
import { Layers } from 'lucide-react';
|
||||||
|
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||||
|
<div className="flex items-center text-green-500">
|
||||||
|
<Layers size={16} className="mr-2" />
|
||||||
|
<Text>{t('模型管理')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompactModeToggle
|
||||||
|
compactMode={compactMode}
|
||||||
|
setCompactMode={setCompactMode}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsDescription;
|
||||||
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Form, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
const ModelsFilters = ({
|
||||||
|
formInitValues,
|
||||||
|
setFormApi,
|
||||||
|
searchModels,
|
||||||
|
loading,
|
||||||
|
searching,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
// Handle form reset and immediate search
|
||||||
|
const formApiRef = useRef(null);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (!formApiRef.current) return;
|
||||||
|
formApiRef.current.reset();
|
||||||
|
setTimeout(() => {
|
||||||
|
searchModels();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
initValues={formInitValues}
|
||||||
|
getFormApi={(api) => {
|
||||||
|
setFormApi(api);
|
||||||
|
formApiRef.current = api;
|
||||||
|
}}
|
||||||
|
onSubmit={searchModels}
|
||||||
|
allowEmpty={true}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="horizontal"
|
||||||
|
trigger="change"
|
||||||
|
stopValidateWithError={false}
|
||||||
|
className="w-full md:w-auto order-1 md:order-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
|
||||||
|
<div className="relative w-full md:w-56">
|
||||||
|
<Form.Input
|
||||||
|
field="searchKeyword"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('搜索模型名称')}
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full md:w-56">
|
||||||
|
<Form.Input
|
||||||
|
field="searchVendor"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder={t('搜索供应商')}
|
||||||
|
showClear
|
||||||
|
pure
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
type="tertiary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading || searching}
|
||||||
|
className="flex-1 md:flex-initial md:w-auto"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('查询')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex-1 md:flex-initial md:w-auto"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('重置')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsFilters;
|
||||||
110
web/src/components/table/models/ModelsTable.jsx
Normal file
110
web/src/components/table/models/ModelsTable.jsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Handle compact mode by removing fixed positioning
|
||||||
|
const tableColumns = useMemo(() => {
|
||||||
|
return compactMode ? columns.map(col => {
|
||||||
|
if (col.dataIndex === 'operate') {
|
||||||
|
const { fixed, ...rest } = col;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
}) : columns;
|
||||||
|
}, [compactMode, columns]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardTable
|
||||||
|
columns={tableColumns}
|
||||||
|
dataSource={models}
|
||||||
|
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||||
|
pagination={{
|
||||||
|
currentPage: activePage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
total: modelCount,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
onPageSizeChange: handlePageSizeChange,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
hidePagination={true}
|
||||||
|
loading={loading}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRow={handleRow}
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||||
|
description={t('搜索无结果')}
|
||||||
|
style={{ padding: 30 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsTable;
|
||||||
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||||
|
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
|
||||||
|
import { API } from '../../../helpers';
|
||||||
|
|
||||||
|
const ModelsTabs = ({
|
||||||
|
activeVendorKey,
|
||||||
|
setActiveVendorKey,
|
||||||
|
vendorCounts,
|
||||||
|
vendors,
|
||||||
|
loadModels,
|
||||||
|
activePage,
|
||||||
|
pageSize,
|
||||||
|
setActivePage,
|
||||||
|
setShowAddVendor,
|
||||||
|
setShowEditVendor,
|
||||||
|
setEditingVendor,
|
||||||
|
loadVendors,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
const handleTabChange = (key) => {
|
||||||
|
setActiveVendorKey(key);
|
||||||
|
setActivePage(1);
|
||||||
|
loadModels(1, pageSize, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditVendor = (vendor, e) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||||
|
setEditingVendor(vendor);
|
||||||
|
setShowEditVendor(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteVendor = async (vendor, e) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/vendors/${vendor.id}`);
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess(t('供应商删除成功'));
|
||||||
|
// 如果删除的是当前选中的供应商,切换到"全部"
|
||||||
|
if (activeVendorKey === String(vendor.id)) {
|
||||||
|
setActiveVendorKey('all');
|
||||||
|
loadModels(1, pageSize, 'all');
|
||||||
|
} else {
|
||||||
|
loadModels(activePage, pageSize, activeVendorKey);
|
||||||
|
}
|
||||||
|
loadVendors(); // 重新加载供应商列表
|
||||||
|
} else {
|
||||||
|
showError(res.data.message || t('删除失败'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.response?.data?.message || t('删除失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeVendorKey}
|
||||||
|
type="card"
|
||||||
|
collapsible
|
||||||
|
onChange={handleTabChange}
|
||||||
|
className="mb-2"
|
||||||
|
tabBarExtraContent={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowAddVendor(true)}
|
||||||
|
>
|
||||||
|
{t('新增供应商')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabPane
|
||||||
|
itemKey="all"
|
||||||
|
tab={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{t('全部')}
|
||||||
|
<Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle'>
|
||||||
|
{vendorCounts['all'] || 0}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{vendors.map((vendor) => {
|
||||||
|
const key = String(vendor.id);
|
||||||
|
const count = vendorCounts[vendor.id] || 0;
|
||||||
|
return (
|
||||||
|
<TabPane
|
||||||
|
key={key}
|
||||||
|
itemKey={key}
|
||||||
|
tab={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
|
||||||
|
{vendor.name}
|
||||||
|
<Tag color={activeVendorKey === key ? 'red' : 'grey'} shape='circle'>
|
||||||
|
{count}
|
||||||
|
</Tag>
|
||||||
|
<Dropdown
|
||||||
|
trigger="click"
|
||||||
|
position="bottomRight"
|
||||||
|
render={
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={(e) => handleEditVendor(vendor, e)}
|
||||||
|
>
|
||||||
|
{t('编辑')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('确认删除'),
|
||||||
|
content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }),
|
||||||
|
onOk: () => handleDeleteVendor(vendor, e),
|
||||||
|
okText: t('删除'),
|
||||||
|
cancelText: t('取消'),
|
||||||
|
type: 'warning',
|
||||||
|
okType: 'danger',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('删除')}
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
}
|
||||||
|
onClickOutSide={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="tertiary"
|
||||||
|
theme="outline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{t('操作')}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelsTabs;
|
||||||
140
web/src/components/table/models/index.jsx
Normal file
140
web/src/components/table/models/index.jsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
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}
|
||||||
|
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;
|
||||||
368
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
368
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
/*
|
||||||
|
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 {
|
||||||
|
SideSheet,
|
||||||
|
Form,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Tag,
|
||||||
|
Avatar,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconSave,
|
||||||
|
IconClose,
|
||||||
|
IconLayers,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||||
|
|
||||||
|
const endpointOptions = [
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
{ label: 'Gemini', value: 'gemini' },
|
||||||
|
{ label: 'Image Generation', value: 'image-generation' },
|
||||||
|
{ label: 'Jina Rerank', value: 'jina-rerank' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVendors();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const getInitValues = () => ({
|
||||||
|
model_name: '',
|
||||||
|
description: '',
|
||||||
|
tags: [],
|
||||||
|
vendor_id: undefined,
|
||||||
|
vendor: '',
|
||||||
|
vendor_icon: '',
|
||||||
|
endpoints: [],
|
||||||
|
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
|
||||||
|
if (data.endpoints) {
|
||||||
|
try {
|
||||||
|
data.endpoints = JSON.parse(data.endpoints);
|
||||||
|
} catch (e) {
|
||||||
|
data.endpoints = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props.editingModel?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.visiable) {
|
||||||
|
if (isEdit) {
|
||||||
|
loadModel();
|
||||||
|
} else {
|
||||||
|
formApiRef.current?.setValues(getInitValues());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formApiRef.current?.reset();
|
||||||
|
}
|
||||||
|
}, [props.visiable, props.editingModel?.id]);
|
||||||
|
|
||||||
|
const submit = async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
...values,
|
||||||
|
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||||
|
endpoints: JSON.stringify(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={<IconSave />}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t('提交')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme='light'
|
||||||
|
className='!rounded-lg'
|
||||||
|
type='primary'
|
||||||
|
onClick={handleCancel}
|
||||||
|
icon={<IconClose />}
|
||||||
|
>
|
||||||
|
{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'>
|
||||||
|
<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='model_name'
|
||||||
|
label={t('模型名称')}
|
||||||
|
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||||
|
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||||
|
disabled={isEdit}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 供应商信息 */}
|
||||||
|
<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>
|
||||||
|
<Row gutter={12}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Select
|
||||||
|
field='vendor_id'
|
||||||
|
label={t('供应商')}
|
||||||
|
placeholder={t('选择模型供应商')}
|
||||||
|
optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
|
||||||
|
filter
|
||||||
|
showClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(value) => {
|
||||||
|
const vendorInfo = vendors.find(v => v.id === value);
|
||||||
|
if (vendorInfo && formApiRef.current) {
|
||||||
|
formApiRef.current.setValue('vendor', vendorInfo.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 功能配置 */}
|
||||||
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
|
<div className='flex items-center mb-2'>
|
||||||
|
<Avatar size='small' color='purple' 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.Select
|
||||||
|
field='endpoints'
|
||||||
|
label={t('支持端点')}
|
||||||
|
placeholder={t('选择模型支持的端点类型')}
|
||||||
|
optionList={endpointOptions}
|
||||||
|
multiple
|
||||||
|
showClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Switch
|
||||||
|
field='status'
|
||||||
|
label={t('状态')}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditModelModal;
|
||||||
177
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
177
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
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 { 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')}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={24}>
|
||||||
|
<Form.Switch
|
||||||
|
field="status"
|
||||||
|
label={t('状态')}
|
||||||
|
size="large"
|
||||||
|
initValue={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditVendorModal;
|
||||||
@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
|
import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
|
||||||
import { copy, showSuccess } from './utils';
|
import { copy, showSuccess } from './utils';
|
||||||
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
|
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
|
import * as LobeIcons from '@lobehub/icons';
|
||||||
import {
|
import {
|
||||||
OpenAI,
|
OpenAI,
|
||||||
Claude,
|
Claude,
|
||||||
@@ -85,6 +86,7 @@ export const sidebarIconColors = {
|
|||||||
gift: '#F43F5E', // 玫红色
|
gift: '#F43F5E', // 玫红色
|
||||||
user: '#10B981', // 绿色
|
user: '#10B981', // 绿色
|
||||||
settings: '#F97316', // 橙色
|
settings: '#F97316', // 橙色
|
||||||
|
models: '#10B981', // 绿色
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取侧边栏Lucide图标组件
|
// 获取侧边栏Lucide图标组件
|
||||||
@@ -177,6 +179,13 @@ export function getLucideIcon(key, selected = false) {
|
|||||||
color={selected ? sidebarIconColors.user : 'currentColor'}
|
color={selected ? sidebarIconColors.user : 'currentColor'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'models':
|
||||||
|
return (
|
||||||
|
<Layers
|
||||||
|
{...commonProps}
|
||||||
|
color={selected ? sidebarIconColors.models : 'currentColor'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return (
|
return (
|
||||||
<Settings
|
<Settings
|
||||||
@@ -422,6 +431,37 @@ export function getChannelIcon(channelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据图标名称动态获取 LobeHub 图标组件
|
||||||
|
* @param {string} iconName - 图标名称
|
||||||
|
* @param {number} size - 图标大小,默认为 14
|
||||||
|
* @returns {JSX.Element} - 对应的图标组件或 Avatar
|
||||||
|
*/
|
||||||
|
export function getLobeHubIcon(iconName, size = 14) {
|
||||||
|
if (typeof iconName === 'string') iconName = iconName.trim();
|
||||||
|
// 如果没有图标名称,返回 Avatar
|
||||||
|
if (!iconName) {
|
||||||
|
return <Avatar size="extra-extra-small">?</Avatar>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let IconComponent;
|
||||||
|
|
||||||
|
if (iconName.includes('.')) {
|
||||||
|
const [base, variant] = iconName.split('.');
|
||||||
|
const BaseIcon = LobeIcons[base];
|
||||||
|
IconComponent = BaseIcon ? BaseIcon[variant] : undefined;
|
||||||
|
} else {
|
||||||
|
IconComponent = LobeIcons[iconName];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IconComponent && (typeof IconComponent === 'function' || typeof IconComponent === 'object')) {
|
||||||
|
return <IconComponent size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstLetter = iconName.charAt(0).toUpperCase();
|
||||||
|
return <Avatar size="extra-extra-small">{firstLetter}</Avatar>;
|
||||||
|
}
|
||||||
|
|
||||||
// 颜色列表
|
// 颜色列表
|
||||||
const colors = [
|
const colors = [
|
||||||
'amber',
|
'amber',
|
||||||
@@ -891,13 +931,13 @@ export function renderQuota(quota, digits = 2) {
|
|||||||
if (displayInCurrency) {
|
if (displayInCurrency) {
|
||||||
const result = quota / quotaPerUnit;
|
const result = quota / quotaPerUnit;
|
||||||
const fixedResult = result.toFixed(digits);
|
const fixedResult = result.toFixed(digits);
|
||||||
|
|
||||||
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
// 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
|
||||||
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
|
||||||
const minValue = Math.pow(10, -digits);
|
const minValue = Math.pow(10, -digits);
|
||||||
return '$' + minValue.toFixed(digits);
|
return '$' + minValue.toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '$' + fixedResult;
|
return '$' + fixedResult;
|
||||||
}
|
}
|
||||||
return renderNumber(quota);
|
return renderNumber(quota);
|
||||||
|
|||||||
378
web/src/hooks/models/useModelsData.js
Normal file
378
web/src/hooks/models/useModelsData.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/*
|
||||||
|
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: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form API reference
|
||||||
|
const [formApi, setFormApi] = useState(null);
|
||||||
|
|
||||||
|
// Get form values helper function
|
||||||
|
const getFormValues = () => {
|
||||||
|
const formValues = formApi ? formApi.getValues() : {};
|
||||||
|
return {
|
||||||
|
searchKeyword: formValues.searchKeyword || '',
|
||||||
|
searchVendor: formValues.searchVendor || '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取供应商列表
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 加载供应商列表
|
||||||
|
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') {
|
||||||
|
// 按供应商筛选,通过vendor搜索接口
|
||||||
|
const vendor = vendors.find(v => String(v.id) === vendorKey);
|
||||||
|
if (vendor) {
|
||||||
|
url = `/api/models/search?vendor=${vendor.name}&p=${page}&page_size=${size}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await API.get(url);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
const items = data.items || data || [];
|
||||||
|
const newPageData = Array.isArray(items) ? items : [];
|
||||||
|
setActivePage(data.page || page);
|
||||||
|
setModelCount(data.total || newPageData.length);
|
||||||
|
setModelFormat(newPageData);
|
||||||
|
|
||||||
|
// 更新供应商统计
|
||||||
|
updateVendorCounts(newPageData);
|
||||||
|
} 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 formValues = getFormValues();
|
||||||
|
const { searchKeyword, searchVendor } = formValues;
|
||||||
|
|
||||||
|
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 items = data.items || data || [];
|
||||||
|
const newPageData = Array.isArray(items) ? items : [];
|
||||||
|
setActivePage(data.page || 1);
|
||||||
|
setModelCount(data.total || newPageData.length);
|
||||||
|
setModelFormat(newPageData);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新供应商统计
|
||||||
|
const updateVendorCounts = (models) => {
|
||||||
|
const counts = { all: models.length };
|
||||||
|
models.forEach(model => {
|
||||||
|
if (model.vendor_id) {
|
||||||
|
counts[model.vendor_id] = (counts[model.vendor_id] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setVendorCounts(counts);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setActivePage(page);
|
||||||
|
loadModels(page, pageSize, activeVendorKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page size change
|
||||||
|
const handlePageSizeChange = async (size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setActivePage(1);
|
||||||
|
await loadModels(1, size, activeVendorKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle row click
|
||||||
|
const handleRow = (record, index) => {
|
||||||
|
return {
|
||||||
|
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(() => {
|
||||||
|
loadVendors();
|
||||||
|
loadModels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data state
|
||||||
|
models,
|
||||||
|
loading,
|
||||||
|
searching,
|
||||||
|
activePage,
|
||||||
|
pageSize,
|
||||||
|
modelCount,
|
||||||
|
|
||||||
|
// Selection state
|
||||||
|
selectedKeys,
|
||||||
|
rowSelection,
|
||||||
|
handleRow,
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -53,6 +53,7 @@ code {
|
|||||||
|
|
||||||
/* ==================== 导航和侧边栏样式 ==================== */
|
/* ==================== 导航和侧边栏样式 ==================== */
|
||||||
/* 导航项样式 */
|
/* 导航项样式 */
|
||||||
|
.semi-tagInput,
|
||||||
.semi-input-textarea-wrapper,
|
.semi-input-textarea-wrapper,
|
||||||
.semi-navigation-sub-title,
|
.semi-navigation-sub-title,
|
||||||
.semi-chat-inputBox-sendButton,
|
.semi-chat-inputBox-sendButton,
|
||||||
|
|||||||
12
web/src/pages/Model/index.js
Normal file
12
web/src/pages/Model/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ModelsTable from '../../components/table/models';
|
||||||
|
|
||||||
|
const ModelPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="mt-[60px] px-2">
|
||||||
|
<ModelsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelPage;
|
||||||
Reference in New Issue
Block a user