🚀 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.
This commit is contained in:
378
web/src/hooks/models/useModelsData.js
Normal file
378
web/src/hooks/models/useModelsData.js
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user