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

🚀 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.
t0ng7u 7 месяцев назад
Родитель
Сommit
af59b61f8a

+ 143 - 0
controller/model_meta.go

@@ -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 - 0
controller/vendor_meta.go

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

+ 4 - 0
model/main.go

@@ -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 - 0
model/model_meta.go

@@ -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 - 0
model/vendor_meta.go

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

+ 22 - 0
router/api-router.go

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

+ 9 - 0
web/src/App.js

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

+ 7 - 0
web/src/components/layout/SiderBar.js

@@ -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 - 0
web/src/components/table/models/ModelsActions.jsx

@@ -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 - 0
web/src/components/table/models/ModelsColumnDefs.js

@@ -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 - 0
web/src/components/table/models/ModelsDescription.jsx

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

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

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

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

@@ -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 - 0
web/src/components/table/models/ModelsTabs.jsx

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

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

@@ -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 - 0
web/src/components/table/models/modals/EditModelModal.jsx

@@ -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 - 0
web/src/components/table/models/modals/EditVendorModal.jsx

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

+ 43 - 3
web/src/helpers/render.js

@@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import i18next from 'i18next';
-import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
+import { Modal, Tag, Typography, Avatar } from '@douyinfe/semi-ui';
 import { copy, showSuccess } from './utils';
 import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile.js';
 import { visit } from 'unist-util-visit';
+import * as LobeIcons from '@lobehub/icons';
 import {
   OpenAI,
   Claude,
@@ -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',
@@ -891,13 +931,13 @@ export function renderQuota(quota, digits = 2) {
   if (displayInCurrency) {
     const result = quota / quotaPerUnit;
     const fixedResult = result.toFixed(digits);
-    
+
     // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
     if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
       const minValue = Math.pow(10, -digits);
       return '$' + minValue.toFixed(digits);
     }
-    
+
     return '$' + fixedResult;
   }
   return renderNumber(quota);

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

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

+ 1 - 0
web/src/index.css

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

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

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