diff --git a/web/src/components/table/model-pricing/filter/PricingVendors.jsx b/web/src/components/table/model-pricing/filter/PricingVendors.jsx index 192d166..6d9906c 100644 --- a/web/src/components/table/model-pricing/filter/PricingVendors.jsx +++ b/web/src/components/table/model-pricing/filter/PricingVendors.jsx @@ -56,7 +56,7 @@ const PricingVendors = ({ }); return { - vendors: Array.from(vendors).sort(), + vendors: Array.from(vendors), vendorIcons, hasUnknownVendor, }; diff --git a/web/src/components/table/models/ModelsColumnDefs.jsx b/web/src/components/table/models/ModelsColumnDefs.jsx index 4520a1b..fc5afd4 100644 --- a/web/src/components/table/models/ModelsColumnDefs.jsx +++ b/web/src/components/table/models/ModelsColumnDefs.jsx @@ -26,6 +26,7 @@ import { Modal, Tooltip, } from '@douyinfe/semi-ui'; +import { IconMenu } from '@douyinfe/semi-icons'; import { timestamp2string, getLobeHubIcon, @@ -282,6 +283,16 @@ export const getModelsColumns = ({ vendorMap, }) => { return [ + { + key: 'drag', + width: 40, + fixed: 'left', + render: () => ( + + + + ), + }, { title: t('图标'), dataIndex: 'icon', diff --git a/web/src/components/table/models/ModelsTable.jsx b/web/src/components/table/models/ModelsTable.jsx index 643bff6..9515ac0 100644 --- a/web/src/components/table/models/ModelsTable.jsx +++ b/web/src/components/table/models/ModelsTable.jsx @@ -19,6 +19,9 @@ For commercial licensing, please contact support@quantumnous.com import React, { useMemo } from 'react'; import { Empty } from '@douyinfe/semi-ui'; +import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; +import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import CardTable from '../../common/ui/CardTable'; import { IllustrationNoResult, @@ -26,6 +29,21 @@ import { } from '@douyinfe/semi-illustrations'; import { getModelsColumns } from './ModelsColumnDefs'; +const SortableRow = (props) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props['data-row-key'], + }); + const style = { + transform: transform ? `translate3d(0, ${transform.y}px, 0)` : undefined, + transition, + opacity: isDragging ? 0.5 : 1, + ...(props.style || {}), + }; + return ( + + ); +}; + const ModelsTable = (modelsData) => { const { models, @@ -43,10 +61,14 @@ const ModelsTable = (modelsData) => { setShowEdit, refresh, vendorMap, + reorderModels, t, } = modelsData; - // Get all columns + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + const columns = useMemo(() => { return getModelsColumns({ t, @@ -58,7 +80,6 @@ const ModelsTable = (modelsData) => { }); }, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]); - // Handle compact mode by removing fixed positioning const tableColumns = useMemo(() => { return compactMode ? columns.map((col) => { @@ -71,37 +92,62 @@ const ModelsTable = (modelsData) => { : columns; }, [compactMode, columns]); + const handleDragEnd = (event) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = models.findIndex((m) => m.id === active.id); + const newIndex = models.findIndex((m) => m.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = [...models]; + const [moved] = reordered.splice(oldIndex, 1); + reordered.splice(newIndex, 0, moved); + reorderModels(reordered); + }; + return ( - } - darkModeImage={ - + + m.id)} + strategy={verticalListSortingStrategy} + > + } + darkModeImage={} + description={t('搜索无结果')} + style={{ padding: 30 }} + /> } - description={t('搜索无结果')} - style={{ padding: 30 }} + className='rounded-xl overflow-hidden' + size='middle' /> - } - className='rounded-xl overflow-hidden' - size='middle' - /> + + ); }; diff --git a/web/src/components/table/models/ModelsTabs.jsx b/web/src/components/table/models/ModelsTabs.jsx index 820d707..c0f5e14 100644 --- a/web/src/components/table/models/ModelsTabs.jsx +++ b/web/src/components/table/models/ModelsTabs.jsx @@ -18,11 +18,76 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui'; +import { Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui'; import { IconEdit, IconDelete } from '@douyinfe/semi-icons'; +import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { SortableContext, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { getLobeHubIcon, showError, showSuccess } from '../../../helpers'; import { API } from '../../../helpers'; +const SortableVendorItem = ({ vendor, activeVendorKey, vendorCounts, handleEditVendor, handleDeleteVendor, t }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: String(vendor.id) }); + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '4px 12px', + borderRadius: 6, + cursor: 'grab', + background: activeVendorKey === String(vendor.id) ? 'var(--semi-color-primary-light-default)' : 'var(--semi-color-bg-2)', + border: activeVendorKey === String(vendor.id) ? '1px solid var(--semi-color-primary)' : '1px solid var(--semi-color-border)', + userSelect: 'none', + }; + const count = vendorCounts[vendor.id] || 0; + return ( +
+ {getLobeHubIcon(vendor.icon || 'Layers', 14)} + {vendor.name} + + {count} + + + } onClick={(e) => handleEditVendor(vendor, e)}> + {t('编辑')} + + } + onClick={(e) => { + e.stopPropagation(); + Modal.confirm({ + title: t('确认删除'), + content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }), + onOk: () => handleDeleteVendor(vendor, e), + okText: t('删除'), + cancelText: t('取消'), + type: 'warning', + okType: 'danger', + }); + }} + > + {t('删除')} + + + } + onClickOutSide={(e) => e.stopPropagation()} + > + + +
+ ); +}; + const ModelsTabs = ({ activeVendorKey, setActiveVendorKey, @@ -36,8 +101,13 @@ const ModelsTabs = ({ setShowEditVendor, setEditingVendor, loadVendors, + reorderVendors, t, }) => { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) + ); + const handleTabChange = (key) => { setActiveVendorKey(key); setActivePage(1); @@ -45,25 +115,24 @@ const ModelsTabs = ({ }; const handleEditVendor = (vendor, e) => { - e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + e.stopPropagation(); setEditingVendor(vendor); setShowEditVendor(true); }; const handleDeleteVendor = async (vendor, e) => { - e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 + e.stopPropagation(); try { const res = await API.delete(`/api/vendors/${vendor.id}`); if (res.data.success) { showSuccess(t('供应商删除成功')); - // 如果删除的是当前选中的供应商,切换到"全部" if (activeVendorKey === String(vendor.id)) { setActiveVendorKey('all'); loadModels(1, pageSize, 'all'); } else { loadModels(activePage, pageSize, activeVendorKey); } - loadVendors(); // 重新加载供应商列表 + loadVendors(); } else { showError(res.data.message || t('删除失败')); } @@ -72,106 +141,62 @@ const ModelsTabs = ({ } }; + const handleDragEnd = (event) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = vendors.findIndex((v) => String(v.id) === active.id); + const newIndex = vendors.findIndex((v) => String(v.id) === over.id); + if (oldIndex === -1 || newIndex === -1) return; + const reordered = [...vendors]; + const [moved] = reordered.splice(oldIndex, 1); + reordered.splice(newIndex, 0, moved); + reorderVendors(reordered); + }; + return ( - setShowAddVendor(true)} - > - {t('新增供应商')} - - } - > - - {t('全部')} - - {vendorCounts['all'] || 0} - - - } - /> +
+ {/* "All" tab - not draggable */} +
handleTabChange('all')} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '4px 12px', + borderRadius: 6, + cursor: 'pointer', + background: activeVendorKey === 'all' ? 'var(--semi-color-primary-light-default)' : 'var(--semi-color-bg-2)', + border: activeVendorKey === 'all' ? '1px solid var(--semi-color-primary)' : '1px solid var(--semi-color-border)', + userSelect: 'none', + }} + > + {t('全部')} + + {vendorCounts['all'] || 0} + +
+ + {/* Draggable vendor tabs */} + + String(v.id))} strategy={horizontalListSortingStrategy}> + {vendors.map((vendor) => ( + + ))} + + - {vendors.map((vendor) => { - const key = String(vendor.id); - const count = vendorCounts[vendor.id] || 0; - return ( - - {getLobeHubIcon(vendor.icon || 'Layers', 14)} - {vendor.name} - - {count} - - - } - onClick={(e) => handleEditVendor(vendor, e)} - > - {t('编辑')} - - } - onClick={(e) => { - e.stopPropagation(); - Modal.confirm({ - title: t('确认删除'), - content: t( - '确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', - { name: vendor.name }, - ), - onOk: () => handleDeleteVendor(vendor, e), - okText: t('删除'), - cancelText: t('取消'), - type: 'warning', - okType: 'danger', - }); - }} - > - {t('删除')} - - - } - onClickOutSide={(e) => e.stopPropagation()} - > - - - - } - /> - ); - })} - + +
); }; diff --git a/web/src/hooks/models/useModelsData.jsx b/web/src/hooks/models/useModelsData.jsx index b1bd8b6..fbf73f7 100644 --- a/web/src/hooks/models/useModelsData.jsx +++ b/web/src/hooks/models/useModelsData.jsx @@ -119,6 +119,48 @@ export const useModelsData = () => { } }; + // Reorder vendors via drag-and-drop + const reorderVendors = async (reorderedVendors) => { + try { + const items = reorderedVendors.map((v, index) => ({ + id: v.id, + sort_order: index, + })); + const res = await API.put('/api/vendors/reorder', { items }); + if (res.data.success) { + setVendors(reorderedVendors); + showSuccess(t('供应商排序已更新')); + } else { + showError(res.data.message || t('排序更新失败')); + await loadVendors(); + } + } catch (error) { + showError(t('排序更新失败')); + await loadVendors(); + } + }; + + // Reorder models via drag-and-drop + const reorderModels = async (reorderedModels) => { + try { + const items = reorderedModels.map((m, index) => ({ + id: m.id, + sort_order: index, + })); + const res = await API.put('/api/models/reorder', { items }); + if (res.data.success) { + showSuccess(t('模型排序已更新')); + await refresh(); + } else { + showError(res.data.message || t('排序更新失败')); + await refresh(); + } + } catch (error) { + showError(t('排序更新失败')); + await refresh(); + } + }; + // Load models data const loadModels = async ( page = 1, @@ -483,6 +525,8 @@ export const useModelsData = () => { editingVendor, setEditingVendor, loadVendors, + reorderVendors, + reorderModels, // Translation t, diff --git a/web/src/pages/Home/HomePricingFilters.jsx b/web/src/pages/Home/HomePricingFilters.jsx index 4f4f76d..ed91d2f 100644 --- a/web/src/pages/Home/HomePricingFilters.jsx +++ b/web/src/pages/Home/HomePricingFilters.jsx @@ -90,7 +90,7 @@ const HomePricingFilters = ({ t }) => { hasUnknown = true; } }); - const vendors = Array.from(vendorSet).sort(); + const vendors = Array.from(vendorSet); const getVendorCount = () => { // 计算供应商数量(包含未知供应商) const count = vendorSet.size + (hasUnknown ? 1 : 0);