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)}
+
+
+
+ {/* 复制按钮 */}
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ copyText(model.model_name);
+ }}
+ />
+
+ {/* 选择框 */}
+ {rowSelection && (
+ {
+ e.stopPropagation();
+ handleCheckboxChange(model, e.target.checked);
+ }}
+ />
+ )}
+
-
- {/* 复制按钮 */}
-
}
- onClick={(e) => {
- e.stopPropagation();
- copyText(model.model_name);
- }}
- />
+ {/* 模型描述 - 占据剩余空间 */}
+
+
+ {getModelDescription(model)}
+
+
- {/* 选择框 */}
- {rowSelection && (
-
{
- e.stopPropagation();
- handleCheckboxChange(model, e.target.checked);
- }}
- />
+ {/* 底部区域 */}
+
+ {/* 标签区域 */}
+
+ {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]}
+
+
+
)}
-
- {/* 模型描述 */}
-
-
- {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
}, []);