🚀 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:
t0ng7u
2025-08-06 01:40:08 +08:00
parent d61a862fa2
commit 7c814a5fd9
12 changed files with 334 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,21 +71,18 @@ const ModelBasicInfo = ({ modelData, vendorsMap = {}, t }) => {
<div className="text-gray-600">
<p className="mb-4">{getModelDescription()}</p>
{getModelTags().length > 0 && (
<div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">{t('模型标签')}</Text>
<Space wrap>
{getModelTags().map((tag, index) => (
<Tag
key={index}
color={tag.color}
shape="circle"
size="small"
>
{tag.text}
</Tag>
))}
</Space>
</div>
<Space wrap>
{getModelTags().map((tag, index) => (
<Tag
key={index}
color={tag.color}
shape="circle"
size="small"
>
{tag.text}
</Tag>
))}
</Space>
)}
</div>
</Card>

View File

@@ -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: (
<Tag shape='circle' color={billingType} size='small'>
{billingText}
</Tag>
)
});
const billingTag = (
<Tag key="billing" shape='circle' color={billingType} size='small'>
{billingText}
</Tag>
);
// 自定义标签
// 自定义标签(右边)
const customTags = [];
if (record.tags) {
const tagArr = record.tags.split(',').filter(Boolean);
tagArr.forEach((tg, idx) => {
allTags.push({
key: `custom-${idx}`,
element: (
<Tag shape='circle' color={stringToColor(tg)} size='small'>
{tg}
</Tag>
)
});
customTags.push(
<Tag key={`custom-${idx}`} shape='circle' color={stringToColor(tg)} size='small'>
{tg}
</Tag>
);
});
}
// 使用 renderLimitedItems 渲染标签
return renderLimitedItems({
items: allTags,
renderItem: (item, idx) => React.cloneElement(item.element, { key: item.key }),
maxDisplay: 3
});
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{billingTag}
</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
key={modelKey || index}
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)}
>
{/* 头部:图标 + 模型名称 + 操作按钮 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-start space-x-3 flex-1 min-w-0">
{getModelIcon(model)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 truncate">
{model.model_name}
</h3>
<div className="flex items-center gap-3 text-xs mt-1">
{renderPriceInfo(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">
{getModelIcon(model)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 truncate">
{model.model_name}
</h3>
<div className="flex items-center gap-3 text-xs mt-1">
{renderPriceInfo(model)}
</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 className="flex items-center space-x-2 ml-3">
{/* 复制按钮 */}
<Button
size="small"
type="tertiary"
icon={<IconCopy />}
onClick={(e) => {
e.stopPropagation();
copyText(model.model_name);
}}
/>
{/* 模型描述 - 占据剩余空间 */}
<div className="flex-1 mb-4">
<p
className="text-xs line-clamp-2 leading-relaxed"
style={{ color: 'var(--semi-color-text-2)' }}
>
{getModelDescription(model)}
</p>
</div>
{/* 选择框 */}
{rowSelection && (
<Checkbox
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handleCheckboxChange(model, e.target.checked);
}}
/>
{/* 底部区域 */}
<div className="mt-auto">
{/* 标签区域 */}
<div className="mb-3">
{renderTags(model)}
</div>
{/* 倍率信息(可选) */}
{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 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>
);
})}

View File

@@ -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('添加模型')}
</Button>
<Button
type='danger'
className="flex-1 md:flex-initial"
onClick={handleDeleteSelectedModels}
size="small"
>
{t('删除所选模型')}
</Button>
<Button
type="secondary"
@@ -104,6 +97,12 @@ const ModelsActions = ({
/>
</div>
<SelectionNotification
selectedKeys={selectedKeys}
t={t}
onDelete={handleDeleteSelectedModels}
/>
<Modal
title={t('批量删除模型')}
visible={showDeleteModal}

View File

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

View File

@@ -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={<IconSave />}
icon={<Save size={16} />}
loading={loading}
>
{t('提交')}
@@ -268,7 +270,7 @@ const EditModelModal = (props) => {
className='!rounded-lg'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
icon={<X size={16} />}
>
{t('取消')}
</Button>
@@ -291,7 +293,7 @@ const EditModelModal = (props) => {
<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} />
<FileText size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
@@ -373,7 +375,7 @@ const EditModelModal = (props) => {
<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} />
<Building size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('供应商信息')}</Text>
@@ -405,7 +407,7 @@ const EditModelModal = (props) => {
<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} />
<Settings size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('功能配置')}</Text>

View File

@@ -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
}, []);