🚀 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{},
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Model{},
|
||||
&Vendor{},
|
||||
&Setup{},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -276,6 +278,8 @@ func migrateDBFast() error {
|
||||
{&TopUp{}, "TopUp"},
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Model{}, "Model"},
|
||||
{&Vendor{}, "Vendor"},
|
||||
{&Setup{}, "Setup"},
|
||||
}
|
||||
// 动态计算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("/", 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 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={
|
||||
|
||||
@@ -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',
|
||||
|
||||
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 { 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,
|
||||
@@ -85,6 +86,7 @@ export const sidebarIconColors = {
|
||||
gift: '#F43F5E', // 玫红色
|
||||
user: '#10B981', // 绿色
|
||||
settings: '#F97316', // 橙色
|
||||
models: '#10B981', // 绿色
|
||||
};
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
@@ -177,6 +179,13 @@ export function getLucideIcon(key, selected = false) {
|
||||
color={selected ? sidebarIconColors.user : 'currentColor'}
|
||||
/>
|
||||
);
|
||||
case 'models':
|
||||
return (
|
||||
<Layers
|
||||
{...commonProps}
|
||||
color={selected ? sidebarIconColors.models : 'currentColor'}
|
||||
/>
|
||||
);
|
||||
case 'setting':
|
||||
return (
|
||||
<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 = [
|
||||
'amber',
|
||||
|
||||
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-navigation-sub-title,
|
||||
.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