🚀 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:
t0ng7u
2025-07-31 22:28:09 +08:00
parent 82bf149ade
commit af59b61f8a
21 changed files with 2392 additions and 3 deletions

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

View File

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

View File

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

View File

@@ -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={

View File

@@ -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',

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

@@ -0,0 +1,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;

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

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

View File

@@ -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',

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

View File

@@ -53,6 +53,7 @@ code {
/* ==================== 导航和侧边栏样式 ==================== */
/* 导航项样式 */
.semi-tagInput,
.semi-input-textarea-wrapper,
.semi-navigation-sub-title,
.semi-chat-inputBox-sendButton,

View File

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