diff --git a/controller/model_meta.go b/controller/model_meta.go index ec996555..090ea3c1 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -25,9 +25,19 @@ func GetAllModelsMeta(c *gin.Context) { } var total int64 model.DB.Model(&model.Model{}).Count(&total) + + // 统计供应商计数(全部数据,不受分页影响) + vendorCounts, _ := model.GetVendorModelCounts() + pageInfo.SetTotal(int(total)) pageInfo.SetItems(modelsMeta) - common.ApiSuccess(c, pageInfo) + common.ApiSuccess(c, gin.H{ + "items": modelsMeta, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "vendor_counts": vendorCounts, + }) } // SearchModelsMeta 搜索模型列表 @@ -78,6 +88,14 @@ func CreateModelMeta(c *gin.Context) { common.ApiErrorMsg(c, "模型名称不能为空") return } + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } if err := m.Insert(); err != nil { common.ApiError(c, err) @@ -108,6 +126,15 @@ func UpdateModelMeta(c *gin.Context) { return } } else { + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + if err := m.Update(); err != nil { common.ApiError(c, err) return diff --git a/controller/prefill_group.go b/controller/prefill_group.go index e37082e6..4e29379b 100644 --- a/controller/prefill_group.go +++ b/controller/prefill_group.go @@ -31,6 +31,15 @@ func CreatePrefillGroup(c *gin.Context) { common.ApiErrorMsg(c, "组名称和类型不能为空") return } + // 创建前检查名称 + if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + if err := g.Insert(); err != nil { common.ApiError(c, err) return @@ -49,6 +58,15 @@ func UpdatePrefillGroup(c *gin.Context) { common.ApiErrorMsg(c, "缺少组 ID") return } + // 名称冲突检查 + if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + if err := g.Update(); err != nil { common.ApiError(c, err) return diff --git a/controller/vendor_meta.go b/controller/vendor_meta.go index 27e4294b..28664dd6 100644 --- a/controller/vendor_meta.go +++ b/controller/vendor_meta.go @@ -65,6 +65,15 @@ func CreateVendorMeta(c *gin.Context) { common.ApiErrorMsg(c, "供应商名称不能为空") return } + // 创建前先检查名称 + if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + if err := v.Insert(); err != nil { common.ApiError(c, err) return @@ -83,10 +92,11 @@ func UpdateVendorMeta(c *gin.Context) { 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 { + // 名称冲突检查 + if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { common.ApiErrorMsg(c, "供应商名称已存在") return } diff --git a/model/model_meta.go b/model/model_meta.go index f90d4831..5ccd80c5 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -60,6 +60,16 @@ func (mi *Model) Insert() error { return DB.Create(mi).Error } +// IsModelNameDuplicated 检查模型名称是否重复(排除自身 ID) +func IsModelNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新现有模型记录 func (mi *Model) Update() error { // 仅更新需要变更的字段,避免覆盖 CreatedTime @@ -84,6 +94,25 @@ func GetModelByName(name string) (*Model, error) { return &mi, nil } +// GetVendorModelCounts 统计每个供应商下模型数量(不受分页影响) +func GetVendorModelCounts() (map[int64]int64, error) { + var stats []struct { + VendorID int64 + Count int64 + } + if err := DB.Model(&Model{}). + Select("vendor_id as vendor_id, count(*) as count"). + Group("vendor_id"). + Scan(&stats).Error; err != nil { + return nil, err + } + m := make(map[int64]int64, len(stats)) + for _, s := range stats { + m[s.VendorID] = s.Count + } + return m, nil +} + // GetAllModels 分页获取所有模型元数据 func GetAllModels(offset int, limit int) ([]*Model, error) { var models []*Model diff --git a/model/prefill_group.go b/model/prefill_group.go index 6ebe3b04..51e3e7f1 100644 --- a/model/prefill_group.go +++ b/model/prefill_group.go @@ -33,6 +33,16 @@ func (g *PrefillGroup) Insert() error { return DB.Create(g).Error } +// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID) +func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新组 func (g *PrefillGroup) Update() error { g.UpdatedTime = common.GetTimestamp() diff --git a/model/vendor_meta.go b/model/vendor_meta.go index 76bda1f0..fd316156 100644 --- a/model/vendor_meta.go +++ b/model/vendor_meta.go @@ -31,6 +31,16 @@ func (v *Vendor) Insert() error { return DB.Create(v).Error } +// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID) +func IsVendorNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + // Update 更新供应商记录 func (v *Vendor) Update() error { v.UpdatedTime = common.GetTimestamp() diff --git a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx index d33d2766..1944a939 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelBasicInfo.jsx @@ -71,21 +71,18 @@ const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {

{getModelDescription()}

{getModelTags().length > 0 && ( -
- {t('模型标签')} - - {getModelTags().map((tag, index) => ( - - {tag.text} - - ))} - -
+ + {getModelTags().map((tag, index) => ( + + {tag.text} + + ))} + )}
diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 35b84e2e..83814b56 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -131,41 +131,42 @@ const PricingCardView = ({ // 渲染标签 const renderTags = (record) => { - const allTags = []; - - // 计费类型标签 + // 计费类型标签(左边) const billingType = record.quota_type === 1 ? 'teal' : 'violet'; const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); - allTags.push({ - key: "billing", - element: ( - - {billingText} - - ) - }); + const billingTag = ( + + {billingText} + + ); - // 自定义标签 + // 自定义标签(右边) + const customTags = []; if (record.tags) { const tagArr = record.tags.split(',').filter(Boolean); tagArr.forEach((tg, idx) => { - allTags.push({ - key: `custom-${idx}`, - element: ( - - {tg} - - ) - }); + customTags.push( + + {tg} + + ); }); } - // 使用 renderLimitedItems 渲染标签 - return renderLimitedItems({ - items: allTags, - renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }), - maxDisplay: 3 - }); + return ( +
+
+ {billingTag} +
+
+ {renderLimitedItems({ + items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })), + renderItem: (item, idx) => item.element, + maxDisplay: 3 + })} +
+
+ ); }; // 显示骨架屏 @@ -201,96 +202,101 @@ const PricingCardView = ({ openModelDetail && openModelDetail(model)} > - {/* 头部:图标 + 模型名称 + 操作按钮 */} -
-
- {getModelIcon(model)} -
-

- {model.model_name} -

-
- {renderPriceInfo(model)} +
+ {/* 头部:图标 + 模型名称 + 操作按钮 */} +
+
+ {getModelIcon(model)} +
+

+ {model.model_name} +

+
+ {renderPriceInfo(model)} +
+ +
+ {/* 复制按钮 */} +
-
- {/* 复制按钮 */} -
- - {/* 模型描述 */} -
-

- {getModelDescription(model)} -

-
- - {/* 标签区域 */} -
- {renderTags(model)} -
- - {/* 倍率信息(可选) */} - {showRatio && ( -
-
- {t('倍率信息')} - - { - e.stopPropagation(); - setModalImageUrl('/ratio.png'); - setIsModalOpenurl(true); - }} - /> - -
-
-
- {t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')} -
-
- {t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')} -
-
- {t('分组')}: {groupRatio[selectedGroup]} -
-
-
- )} ); })} diff --git a/web/src/components/table/models/ModelsActions.jsx b/web/src/components/table/models/ModelsActions.jsx index cb91ed29..9eacab69 100644 --- a/web/src/components/table/models/ModelsActions.jsx +++ b/web/src/components/table/models/ModelsActions.jsx @@ -23,6 +23,7 @@ import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx'; import { Button, Space, Modal } from '@douyinfe/semi-ui'; import CompactModeToggle from '../../common/ui/CompactModeToggle'; import { showError } from '../../../helpers'; +import SelectionNotification from './components/SelectionNotification.jsx'; const ModelsActions = ({ selectedKeys, @@ -70,14 +71,6 @@ const ModelsActions = ({ {t('添加模型')} -
+ + . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect } from 'react'; +import { Notification, Button, Space } from '@douyinfe/semi-ui'; + +// 固定通知 ID,保持同一个实例即可避免闪烁 +const NOTICE_ID = 'models-batch-actions'; + +/** + * SelectionNotification 选择通知组件 + * 1. 当 selectedKeys.length > 0 时,使用固定 id 创建/更新通知 + * 2. 当 selectedKeys 清空时关闭通知 + */ +const SelectionNotification = ({ selectedKeys = [], t, onDelete }) => { + // 根据选中数量决定显示/隐藏或更新通知 + useEffect(() => { + const selectedCount = selectedKeys.length; + + if (selectedCount > 0) { + const content = ( + + {t('已选择 {{count}} 个模型', { count: selectedCount })} + + + ); + + // 使用相同 id 更新通知(若已存在则就地更新,不存在则创建) + Notification.info({ + id: NOTICE_ID, + title: t('批量操作'), + content, + duration: 0, // 不自动关闭 + position: 'bottom', + showClose: false, + }); + } else { + // 取消全部勾选时关闭通知 + Notification.close(NOTICE_ID); + } + }, [selectedKeys, t, onDelete]); + + // 卸载时确保关闭通知 + useEffect(() => { + return () => { + Notification.close(NOTICE_ID); + }; + }, []); + + return null; // 该组件不渲染可见内容 +}; + +export default SelectionNotification; diff --git a/web/src/components/table/models/modals/EditModelModal.jsx b/web/src/components/table/models/modals/EditModelModal.jsx index 33b2f979..5cc6124d 100644 --- a/web/src/components/table/models/modals/EditModelModal.jsx +++ b/web/src/components/table/models/modals/EditModelModal.jsx @@ -32,10 +32,12 @@ import { Row, } from '@douyinfe/semi-ui'; import { - IconSave, - IconClose, - IconLayers, -} from '@douyinfe/semi-icons'; + Save, + X, + FileText, + Building, + Settings, +} from 'lucide-react'; import { API, showError, showSuccess } from '../../../../helpers'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; @@ -258,7 +260,7 @@ const EditModelModal = (props) => { theme='solid' className='!rounded-lg' onClick={() => formApiRef.current?.submitForm()} - icon={} + icon={} loading={loading} > {t('提交')} @@ -268,7 +270,7 @@ const EditModelModal = (props) => { className='!rounded-lg' type='primary' onClick={handleCancel} - icon={} + icon={} > {t('取消')} @@ -291,7 +293,7 @@ const EditModelModal = (props) => {
- +
{t('基本信息')} @@ -373,7 +375,7 @@ const EditModelModal = (props) => {
- +
{t('供应商信息')} @@ -405,7 +407,7 @@ const EditModelModal = (props) => {
- +
{t('功能配置')} diff --git a/web/src/hooks/models/useModelsData.js b/web/src/hooks/models/useModelsData.js index 8c17f78d..0195858d 100644 --- a/web/src/hooks/models/useModelsData.js +++ b/web/src/hooks/models/useModelsData.js @@ -135,9 +135,9 @@ export const useModelsData = () => { setModelCount(data.total || newPageData.length); setModelFormat(newPageData); - // Refresh vendor counts only when viewing 'all' to preserve other counts - if (vendorKey === 'all') { - updateVendorCounts(newPageData); + if (data.vendor_counts) { + const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0); + setVendorCounts({ ...data.vendor_counts, all: sumAll }); } } else { showError(message); @@ -151,27 +151,9 @@ export const useModelsData = () => { setLoading(false); }; - // Fetch vendor counts separately to keep tab numbers accurate - const refreshVendorCounts = async () => { - try { - // Load all models (large page_size) to compute counts for every vendor - const res = await API.get('/api/models/?p=1&page_size=100000'); - if (res.data.success) { - const newItems = extractItems(res.data.data); - updateVendorCounts(newItems); - } - } catch (_) { - // ignore count refresh errors - } - }; - // Refresh data const refresh = async (page = activePage) => { await loadModels(page, pageSize); - // When not viewing 'all', tab counts need a separate refresh - if (activeVendorKey !== 'all') { - await refreshVendorCounts(); - } }; // Search models with keyword and vendor @@ -195,6 +177,10 @@ export const useModelsData = () => { setActivePage(data.page || 1); setModelCount(data.total || newPageData.length); setModelFormat(newPageData); + if (data.vendor_counts) { + const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0); + setVendorCounts({ ...data.vendor_counts, all: sumAll }); + } } else { showError(message); setModels([]); @@ -242,16 +228,6 @@ export const useModelsData = () => { } }; - // Update vendor counts - 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) => { @@ -335,7 +311,6 @@ export const useModelsData = () => { useEffect(() => { (async () => { await loadVendors(); - await loadModels(); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []);