🚀 refactor: migrate vendor-count aggregation to model layer & align frontend logic
Summary • Backend – Moved duplicate-name validation and total vendor-count aggregation from controllers (`controller/model_meta.go`, `controller/vendor_meta.go`, `controller/prefill_group.go`) to model layer (`model/model_meta.go`, `model/vendor_meta.go`, `model/prefill_group.go`). – Added `GetVendorModelCounts()` and `Is*NameDuplicated()` helpers; controllers now call these instead of duplicating queries. – API response for `/api/models` now returns `vendor_counts` with per-vendor totals across all pages, plus `all` summary. – Removed redundant checks and unused imports, eliminating `go vet` warnings. • Frontend – `useModelsData.js` updated to consume backend-supplied `vendor_counts`, calculate the `all` total once, and drop legacy client-side counting logic. – Simplified initial data flow: first render now triggers only one models request. – Deleted obsolete `updateVendorCounts` helper and related comments. – Ensured search flow also sets `vendorCounts`, keeping tab badges accurate. Why This refactor enforces single-responsibility (aggregation in model layer), delivers consistent totals irrespective of pagination, and removes redundant client queries, leading to cleaner code and better performance.
This commit is contained in:
@@ -25,9 +25,19 @@ func GetAllModelsMeta(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
model.DB.Model(&model.Model{}).Count(&total)
|
model.DB.Model(&model.Model{}).Count(&total)
|
||||||
|
|
||||||
|
// 统计供应商计数(全部数据,不受分页影响)
|
||||||
|
vendorCounts, _ := model.GetVendorModelCounts()
|
||||||
|
|
||||||
pageInfo.SetTotal(int(total))
|
pageInfo.SetTotal(int(total))
|
||||||
pageInfo.SetItems(modelsMeta)
|
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 搜索模型列表
|
// SearchModelsMeta 搜索模型列表
|
||||||
@@ -78,6 +88,14 @@ func CreateModelMeta(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "模型名称不能为空")
|
common.ApiErrorMsg(c, "模型名称不能为空")
|
||||||
return
|
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 {
|
if err := m.Insert(); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
@@ -108,6 +126,15 @@ func UpdateModelMeta(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if err := m.Update(); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ func CreatePrefillGroup(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
common.ApiErrorMsg(c, "组名称和类型不能为空")
|
||||||
return
|
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 {
|
if err := g.Insert(); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
@@ -49,6 +58,15 @@ func UpdatePrefillGroup(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "缺少组 ID")
|
common.ApiErrorMsg(c, "缺少组 ID")
|
||||||
return
|
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 {
|
if err := g.Update(); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ func CreateVendorMeta(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "供应商名称不能为空")
|
common.ApiErrorMsg(c, "供应商名称不能为空")
|
||||||
return
|
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 {
|
if err := v.Insert(); err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
@@ -83,10 +92,11 @@ func UpdateVendorMeta(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "缺少供应商 ID")
|
common.ApiErrorMsg(c, "缺少供应商 ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 检查名称冲突
|
// 名称冲突检查
|
||||||
var dup int64
|
if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
|
||||||
_ = model.DB.Model(&model.Vendor{}).Where("name = ? AND id <> ?", v.Name, v.Id).Count(&dup).Error
|
common.ApiError(c, err)
|
||||||
if dup > 0 {
|
return
|
||||||
|
} else if dup {
|
||||||
common.ApiErrorMsg(c, "供应商名称已存在")
|
common.ApiErrorMsg(c, "供应商名称已存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ func (mi *Model) Insert() error {
|
|||||||
return DB.Create(mi).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 更新现有模型记录
|
// Update 更新现有模型记录
|
||||||
func (mi *Model) Update() error {
|
func (mi *Model) Update() error {
|
||||||
// 仅更新需要变更的字段,避免覆盖 CreatedTime
|
// 仅更新需要变更的字段,避免覆盖 CreatedTime
|
||||||
@@ -84,6 +94,25 @@ func GetModelByName(name string) (*Model, error) {
|
|||||||
return &mi, nil
|
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 分页获取所有模型元数据
|
// GetAllModels 分页获取所有模型元数据
|
||||||
func GetAllModels(offset int, limit int) ([]*Model, error) {
|
func GetAllModels(offset int, limit int) ([]*Model, error) {
|
||||||
var models []*Model
|
var models []*Model
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ func (g *PrefillGroup) Insert() error {
|
|||||||
return DB.Create(g).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 更新组
|
// Update 更新组
|
||||||
func (g *PrefillGroup) Update() error {
|
func (g *PrefillGroup) Update() error {
|
||||||
g.UpdatedTime = common.GetTimestamp()
|
g.UpdatedTime = common.GetTimestamp()
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ func (v *Vendor) Insert() error {
|
|||||||
return DB.Create(v).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 更新供应商记录
|
// Update 更新供应商记录
|
||||||
func (v *Vendor) Update() error {
|
func (v *Vendor) Update() error {
|
||||||
v.UpdatedTime = common.GetTimestamp()
|
v.UpdatedTime = common.GetTimestamp()
|
||||||
|
|||||||
@@ -71,21 +71,18 @@ const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
|
|||||||
<div className="text-gray-600">
|
<div className="text-gray-600">
|
||||||
<p className="mb-4">{getModelDescription()}</p>
|
<p className="mb-4">{getModelDescription()}</p>
|
||||||
{getModelTags().length > 0 && (
|
{getModelTags().length > 0 && (
|
||||||
<div>
|
<Space wrap>
|
||||||
<Text className="text-sm font-medium text-gray-700 mb-2 block">{t('模型标签')}</Text>
|
{getModelTags().map((tag, index) => (
|
||||||
<Space wrap>
|
<Tag
|
||||||
{getModelTags().map((tag, index) => (
|
key={index}
|
||||||
<Tag
|
color={tag.color}
|
||||||
key={index}
|
shape="circle"
|
||||||
color={tag.color}
|
size="small"
|
||||||
shape="circle"
|
>
|
||||||
size="small"
|
{tag.text}
|
||||||
>
|
</Tag>
|
||||||
{tag.text}
|
))}
|
||||||
</Tag>
|
</Space>
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -131,41 +131,42 @@ const PricingCardView = ({
|
|||||||
|
|
||||||
// 渲染标签
|
// 渲染标签
|
||||||
const renderTags = (record) => {
|
const renderTags = (record) => {
|
||||||
const allTags = [];
|
// 计费类型标签(左边)
|
||||||
|
|
||||||
// 计费类型标签
|
|
||||||
const billingType = record.quota_type === 1 ? 'teal' : 'violet';
|
const billingType = record.quota_type === 1 ? 'teal' : 'violet';
|
||||||
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
|
const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费');
|
||||||
allTags.push({
|
const billingTag = (
|
||||||
key: "billing",
|
<Tag key="billing" shape='circle' color={billingType} size='small'>
|
||||||
element: (
|
{billingText}
|
||||||
<Tag shape='circle' color={billingType} size='small'>
|
</Tag>
|
||||||
{billingText}
|
);
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自定义标签
|
// 自定义标签(右边)
|
||||||
|
const customTags = [];
|
||||||
if (record.tags) {
|
if (record.tags) {
|
||||||
const tagArr = record.tags.split(',').filter(Boolean);
|
const tagArr = record.tags.split(',').filter(Boolean);
|
||||||
tagArr.forEach((tg, idx) => {
|
tagArr.forEach((tg, idx) => {
|
||||||
allTags.push({
|
customTags.push(
|
||||||
key: `custom-${idx}`,
|
<Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
|
||||||
element: (
|
{tg}
|
||||||
<Tag shape='circle' color={stringToColor(tg)} size='small'>
|
</Tag>
|
||||||
{tg}
|
);
|
||||||
</Tag>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 renderLimitedItems 渲染标签
|
return (
|
||||||
return renderLimitedItems({
|
<div className="flex items-center justify-between">
|
||||||
items: allTags,
|
<div className="flex items-center gap-2">
|
||||||
renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }),
|
{billingTag}
|
||||||
maxDisplay: 3
|
</div>
|
||||||
});
|
<div className="flex items-center gap-1">
|
||||||
|
{renderLimitedItems({
|
||||||
|
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
|
||||||
|
renderItem: (item, idx) => item.element,
|
||||||
|
maxDisplay: 3
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 显示骨架屏
|
// 显示骨架屏
|
||||||
@@ -201,96 +202,101 @@ const PricingCardView = ({
|
|||||||
<Card
|
<Card
|
||||||
key={modelKey || index}
|
key={modelKey || index}
|
||||||
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
|
className={`!rounded-2xl transition-all duration-200 hover:shadow-lg border cursor-pointer ${isSelected ? CARD_STYLES.selected : CARD_STYLES.default}`}
|
||||||
bodyStyle={{ padding: '24px' }}
|
bodyStyle={{ height: '100%' }}
|
||||||
onClick={() => openModelDetail && openModelDetail(model)}
|
onClick={() => openModelDetail && openModelDetail(model)}
|
||||||
>
|
>
|
||||||
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-start justify-between mb-3">
|
{/* 头部:图标 + 模型名称 + 操作按钮 */}
|
||||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
<div className="flex items-start justify-between mb-3">
|
||||||
{getModelIcon(model)}
|
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
{getModelIcon(model)}
|
||||||
<h3 className="text-lg font-bold text-gray-900 truncate">
|
<div className="flex-1 min-w-0">
|
||||||
{model.model_name}
|
<h3 className="text-lg font-bold text-gray-900 truncate">
|
||||||
</h3>
|
{model.model_name}
|
||||||
<div className="flex items-center gap-3 text-xs mt-1">
|
</h3>
|
||||||
{renderPriceInfo(model)}
|
<div className="flex items-center gap-3 text-xs mt-1">
|
||||||
|
{renderPriceInfo(model)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 ml-3">
|
||||||
|
{/* 复制按钮 */}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="tertiary"
|
||||||
|
icon={<IconCopy />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyText(model.model_name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 选择框 */}
|
||||||
|
{rowSelection && (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCheckboxChange(model, e.target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 ml-3">
|
{/* 模型描述 - 占据剩余空间 */}
|
||||||
{/* 复制按钮 */}
|
<div className="flex-1 mb-4">
|
||||||
<Button
|
<p
|
||||||
size="small"
|
className="text-xs line-clamp-2 leading-relaxed"
|
||||||
type="tertiary"
|
style={{ color: 'var(--semi-color-text-2)' }}
|
||||||
icon={<IconCopy />}
|
>
|
||||||
onClick={(e) => {
|
{getModelDescription(model)}
|
||||||
e.stopPropagation();
|
</p>
|
||||||
copyText(model.model_name);
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 选择框 */}
|
{/* 底部区域 */}
|
||||||
{rowSelection && (
|
<div className="mt-auto">
|
||||||
<Checkbox
|
{/* 标签区域 */}
|
||||||
checked={isSelected}
|
<div className="mb-3">
|
||||||
onChange={(e) => {
|
{renderTags(model)}
|
||||||
e.stopPropagation();
|
</div>
|
||||||
handleCheckboxChange(model, e.target.checked);
|
|
||||||
}}
|
{/* 倍率信息(可选) */}
|
||||||
/>
|
{showRatio && (
|
||||||
|
<div
|
||||||
|
className="pt-3 border-t border-dashed"
|
||||||
|
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1 mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
|
||||||
|
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||||
|
<IconHelpCircle
|
||||||
|
className="text-blue-500 cursor-pointer"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setModalImageUrl('/ratio.png');
|
||||||
|
setIsModalOpenurl(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
|
||||||
|
<div>
|
||||||
|
{t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t('分组')}: {groupRatio[selectedGroup]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 模型描述 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<p
|
|
||||||
className="text-xs line-clamp-2 leading-relaxed"
|
|
||||||
style={{ color: 'var(--semi-color-text-2)' }}
|
|
||||||
>
|
|
||||||
{getModelDescription(model)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标签区域 */}
|
|
||||||
<div>
|
|
||||||
{renderTags(model)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 倍率信息(可选) */}
|
|
||||||
{showRatio && (
|
|
||||||
<div
|
|
||||||
className="mt-4 pt-3 border-t border-dashed"
|
|
||||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-1 mb-2">
|
|
||||||
<span className="text-xs font-medium text-gray-700">{t('倍率信息')}</span>
|
|
||||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
|
||||||
<IconHelpCircle
|
|
||||||
className="text-blue-500 cursor-pointer"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setModalImageUrl('/ratio.png');
|
|
||||||
setIsModalOpenurl(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600">
|
|
||||||
<div>
|
|
||||||
{t('模型')}: {model.quota_type === 0 ? model.model_ratio : t('无')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{t('分组')}: {groupRatio[selectedGroup]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import PrefillGroupManagement from './modals/PrefillGroupManagement.jsx';
|
|||||||
import { Button, Space, Modal } from '@douyinfe/semi-ui';
|
import { Button, Space, Modal } from '@douyinfe/semi-ui';
|
||||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||||
import { showError } from '../../../helpers';
|
import { showError } from '../../../helpers';
|
||||||
|
import SelectionNotification from './components/SelectionNotification.jsx';
|
||||||
|
|
||||||
const ModelsActions = ({
|
const ModelsActions = ({
|
||||||
selectedKeys,
|
selectedKeys,
|
||||||
@@ -70,14 +71,6 @@ const ModelsActions = ({
|
|||||||
{t('添加模型')}
|
{t('添加模型')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
type='danger'
|
|
||||||
className="flex-1 md:flex-initial"
|
|
||||||
onClick={handleDeleteSelectedModels}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{t('删除所选模型')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@@ -104,6 +97,12 @@ const ModelsActions = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SelectionNotification
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
t={t}
|
||||||
|
onDelete={handleDeleteSelectedModels}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={t('批量删除模型')}
|
title={t('批量删除模型')}
|
||||||
visible={showDeleteModal}
|
visible={showDeleteModal}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
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, { 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 = (
|
||||||
|
<Space>
|
||||||
|
<span>{t('已选择 {{count}} 个模型', { count: selectedCount })}</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
{t('删除所选模型')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用相同 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;
|
||||||
@@ -32,10 +32,12 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconSave,
|
Save,
|
||||||
IconClose,
|
X,
|
||||||
IconLayers,
|
FileText,
|
||||||
} from '@douyinfe/semi-icons';
|
Building,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
import { API, showError, showSuccess } from '../../../../helpers';
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||||
@@ -258,7 +260,7 @@ const EditModelModal = (props) => {
|
|||||||
theme='solid'
|
theme='solid'
|
||||||
className='!rounded-lg'
|
className='!rounded-lg'
|
||||||
onClick={() => formApiRef.current?.submitForm()}
|
onClick={() => formApiRef.current?.submitForm()}
|
||||||
icon={<IconSave />}
|
icon={<Save size={16} />}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
{t('提交')}
|
{t('提交')}
|
||||||
@@ -268,7 +270,7 @@ const EditModelModal = (props) => {
|
|||||||
className='!rounded-lg'
|
className='!rounded-lg'
|
||||||
type='primary'
|
type='primary'
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
icon={<IconClose />}
|
icon={<X size={16} />}
|
||||||
>
|
>
|
||||||
{t('取消')}
|
{t('取消')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -291,7 +293,7 @@ const EditModelModal = (props) => {
|
|||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
<div className='flex items-center mb-2'>
|
<div className='flex items-center mb-2'>
|
||||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||||
<IconLayers size={16} />
|
<FileText size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||||
@@ -373,7 +375,7 @@ const EditModelModal = (props) => {
|
|||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
<div className='flex items-center mb-2'>
|
<div className='flex items-center mb-2'>
|
||||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||||
<IconLayers size={16} />
|
<Building size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text className='text-lg font-medium'>{t('供应商信息')}</Text>
|
<Text className='text-lg font-medium'>{t('供应商信息')}</Text>
|
||||||
@@ -405,7 +407,7 @@ const EditModelModal = (props) => {
|
|||||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
<div className='flex items-center mb-2'>
|
<div className='flex items-center mb-2'>
|
||||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||||
<IconLayers size={16} />
|
<Settings size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<Text className='text-lg font-medium'>{t('功能配置')}</Text>
|
<Text className='text-lg font-medium'>{t('功能配置')}</Text>
|
||||||
|
|||||||
@@ -135,9 +135,9 @@ export const useModelsData = () => {
|
|||||||
setModelCount(data.total || newPageData.length);
|
setModelCount(data.total || newPageData.length);
|
||||||
setModelFormat(newPageData);
|
setModelFormat(newPageData);
|
||||||
|
|
||||||
// Refresh vendor counts only when viewing 'all' to preserve other counts
|
if (data.vendor_counts) {
|
||||||
if (vendorKey === 'all') {
|
const sumAll = Object.values(data.vendor_counts).reduce((acc, v) => acc + v, 0);
|
||||||
updateVendorCounts(newPageData);
|
setVendorCounts({ ...data.vendor_counts, all: sumAll });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
@@ -151,27 +151,9 @@ export const useModelsData = () => {
|
|||||||
setLoading(false);
|
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
|
// Refresh data
|
||||||
const refresh = async (page = activePage) => {
|
const refresh = async (page = activePage) => {
|
||||||
await loadModels(page, pageSize);
|
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
|
// Search models with keyword and vendor
|
||||||
@@ -195,6 +177,10 @@ export const useModelsData = () => {
|
|||||||
setActivePage(data.page || 1);
|
setActivePage(data.page || 1);
|
||||||
setModelCount(data.total || newPageData.length);
|
setModelCount(data.total || newPageData.length);
|
||||||
setModelFormat(newPageData);
|
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 {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
setModels([]);
|
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
|
// Handle page change
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
@@ -335,7 +311,6 @@ export const useModelsData = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
await loadVendors();
|
await loadVendors();
|
||||||
await loadModels();
|
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user