回退 @dnd-kit 拖拽实现,改为在编辑弹窗中直接设置 sort_order 数值: - 模型编辑弹窗新增「排序」InputNumber 字段 - 供应商编辑弹窗新增「排序」InputNumber 字段 - 模型表格新增 sort_order 显示列 - 移除 @dnd-kit 依赖及所有 DnD 相关代码 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>master
| @@ -4,10 +4,6 @@ | |||
| "private": true, | |||
| "type": "module", | |||
| "dependencies": { | |||
| "@dnd-kit/core": "^6.3.1", | |||
| "@dnd-kit/modifiers": "^9.0.0", | |||
| "@dnd-kit/sortable": "^10.0.0", | |||
| "@dnd-kit/utilities": "^3.2.2", | |||
| "@douyinfe/semi-icons": "^2.63.1", | |||
| "@douyinfe/semi-ui": "^2.69.1", | |||
| "@lobehub/icons": "^5.0.1", | |||
| @@ -56,7 +56,7 @@ const PricingVendors = ({ | |||
| }); | |||
| return { | |||
| vendors: Array.from(vendors), | |||
| vendors: Array.from(vendors).sort(), | |||
| vendorIcons, | |||
| hasUnknownVendor, | |||
| }; | |||
| @@ -26,7 +26,6 @@ import { | |||
| Modal, | |||
| Tooltip, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { IconMenu } from '@douyinfe/semi-icons'; | |||
| import { | |||
| timestamp2string, | |||
| getLobeHubIcon, | |||
| @@ -284,14 +283,9 @@ export const getModelsColumns = ({ | |||
| }) => { | |||
| return [ | |||
| { | |||
| key: 'drag', | |||
| width: 40, | |||
| fixed: 'left', | |||
| render: () => ( | |||
| <span style={{ cursor: 'grab', color: 'var(--semi-color-text-3)' }}> | |||
| <IconMenu /> | |||
| </span> | |||
| ), | |||
| title: t('排序'), | |||
| dataIndex: 'sort_order', | |||
| width: 60, | |||
| }, | |||
| { | |||
| title: t('图标'), | |||
| @@ -19,9 +19,6 @@ 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, | |||
| @@ -29,21 +26,6 @@ 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 ( | |||
| <tr ref={setNodeRef} style={style} {...attributes} {...listeners} {...props} /> | |||
| ); | |||
| }; | |||
| const ModelsTable = (modelsData) => { | |||
| const { | |||
| models, | |||
| @@ -61,14 +43,10 @@ const ModelsTable = (modelsData) => { | |||
| setShowEdit, | |||
| refresh, | |||
| vendorMap, | |||
| reorderModels, | |||
| t, | |||
| } = modelsData; | |||
| const sensors = useSensors( | |||
| useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) | |||
| ); | |||
| // Get all columns | |||
| const columns = useMemo(() => { | |||
| return getModelsColumns({ | |||
| t, | |||
| @@ -80,6 +58,7 @@ const ModelsTable = (modelsData) => { | |||
| }); | |||
| }, [t, manageModel, setEditingModel, setShowEdit, refresh, vendorMap]); | |||
| // Handle compact mode by removing fixed positioning | |||
| const tableColumns = useMemo(() => { | |||
| return compactMode | |||
| ? columns.map((col) => { | |||
| @@ -92,62 +71,37 @@ 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 ( | |||
| <DndContext | |||
| sensors={sensors} | |||
| collisionDetection={closestCenter} | |||
| modifiers={[restrictToVerticalAxis]} | |||
| onDragEnd={handleDragEnd} | |||
| > | |||
| <SortableContext | |||
| items={models.map((m) => m.id)} | |||
| strategy={verticalListSortingStrategy} | |||
| > | |||
| <CardTable | |||
| columns={tableColumns} | |||
| dataSource={models} | |||
| scroll={compactMode ? undefined : { x: 'max-content' }} | |||
| pagination={{ | |||
| currentPage: activePage, | |||
| pageSize: pageSize, | |||
| total: modelCount, | |||
| showSizeChanger: true, | |||
| pageSizeOptions: [10, 20, 50, 100], | |||
| onPageSizeChange: handlePageSizeChange, | |||
| onPageChange: handlePageChange, | |||
| }} | |||
| hidePagination={true} | |||
| loading={loading} | |||
| rowSelection={rowSelection} | |||
| onRow={handleRow} | |||
| components={{ | |||
| body: { row: SortableRow }, | |||
| }} | |||
| empty={ | |||
| <Empty | |||
| image={<IllustrationNoResult style={{ width: 150, height: 150 }} />} | |||
| darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />} | |||
| description={t('搜索无结果')} | |||
| style={{ padding: 30 }} | |||
| /> | |||
| <CardTable | |||
| columns={tableColumns} | |||
| dataSource={models} | |||
| scroll={compactMode ? undefined : { x: 'max-content' }} | |||
| pagination={{ | |||
| currentPage: activePage, | |||
| pageSize: pageSize, | |||
| total: modelCount, | |||
| showSizeChanger: true, | |||
| pageSizeOptions: [10, 20, 50, 100], | |||
| onPageSizeChange: handlePageSizeChange, | |||
| onPageChange: handlePageChange, | |||
| }} | |||
| hidePagination={true} | |||
| loading={loading} | |||
| rowSelection={rowSelection} | |||
| onRow={handleRow} | |||
| empty={ | |||
| <Empty | |||
| image={<IllustrationNoResult style={{ width: 150, height: 150 }} />} | |||
| darkModeImage={ | |||
| <IllustrationNoResultDark style={{ width: 150, height: 150 }} /> | |||
| } | |||
| className='rounded-xl overflow-hidden' | |||
| size='middle' | |||
| description={t('搜索无结果')} | |||
| style={{ padding: 30 }} | |||
| /> | |||
| </SortableContext> | |||
| </DndContext> | |||
| } | |||
| className='rounded-xl overflow-hidden' | |||
| size='middle' | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -18,76 +18,11 @@ For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React from 'react'; | |||
| import { Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui'; | |||
| import { Tabs, TabPane, 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 ( | |||
| <div ref={setNodeRef} style={style} {...attributes} {...listeners}> | |||
| {getLobeHubIcon(vendor.icon || 'Layers', 14)} | |||
| {vendor.name} | |||
| <Tag color={activeVendorKey === String(vendor.id) ? 'red' : 'grey'} shape='circle' size='small'> | |||
| {count} | |||
| </Tag> | |||
| <Dropdown | |||
| trigger='click' | |||
| position='bottomRight' | |||
| render={ | |||
| <Dropdown.Menu> | |||
| <Dropdown.Item icon={<IconEdit />} onClick={(e) => handleEditVendor(vendor, e)}> | |||
| {t('编辑')} | |||
| </Dropdown.Item> | |||
| <Dropdown.Item | |||
| type='danger' | |||
| icon={<IconDelete />} | |||
| 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('删除')} | |||
| </Dropdown.Item> | |||
| </Dropdown.Menu> | |||
| } | |||
| onClickOutSide={(e) => e.stopPropagation()} | |||
| > | |||
| <Button size='small' type='tertiary' theme='outline' onClick={(e) => e.stopPropagation()} style={{ marginLeft: 4 }}> | |||
| {t('操作')} | |||
| </Button> | |||
| </Dropdown> | |||
| </div> | |||
| ); | |||
| }; | |||
| const ModelsTabs = ({ | |||
| activeVendorKey, | |||
| setActiveVendorKey, | |||
| @@ -101,13 +36,8 @@ const ModelsTabs = ({ | |||
| setShowEditVendor, | |||
| setEditingVendor, | |||
| loadVendors, | |||
| reorderVendors, | |||
| t, | |||
| }) => { | |||
| const sensors = useSensors( | |||
| useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) | |||
| ); | |||
| const handleTabChange = (key) => { | |||
| setActiveVendorKey(key); | |||
| setActivePage(1); | |||
| @@ -115,24 +45,25 @@ const ModelsTabs = ({ | |||
| }; | |||
| const handleEditVendor = (vendor, e) => { | |||
| e.stopPropagation(); | |||
| e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 | |||
| setEditingVendor(vendor); | |||
| setShowEditVendor(true); | |||
| }; | |||
| const handleDeleteVendor = async (vendor, e) => { | |||
| e.stopPropagation(); | |||
| e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换 | |||
| 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('删除失败')); | |||
| } | |||
| @@ -141,62 +72,106 @@ 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 ( | |||
| <div className='flex items-center gap-2 flex-wrap mb-2'> | |||
| {/* "All" tab - not draggable */} | |||
| <div | |||
| onClick={() => 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('全部')} | |||
| <Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle' size='small'> | |||
| {vendorCounts['all'] || 0} | |||
| </Tag> | |||
| </div> | |||
| {/* Draggable vendor tabs */} | |||
| <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> | |||
| <SortableContext items={vendors.map((v) => String(v.id))} strategy={horizontalListSortingStrategy}> | |||
| {vendors.map((vendor) => ( | |||
| <SortableVendorItem | |||
| key={String(vendor.id)} | |||
| vendor={vendor} | |||
| activeVendorKey={activeVendorKey} | |||
| vendorCounts={vendorCounts} | |||
| handleEditVendor={handleEditVendor} | |||
| handleDeleteVendor={handleDeleteVendor} | |||
| t={t} | |||
| /> | |||
| ))} | |||
| </SortableContext> | |||
| </DndContext> | |||
| <Tabs | |||
| activeKey={activeVendorKey} | |||
| type='card' | |||
| collapsible | |||
| onChange={handleTabChange} | |||
| className='mb-2' | |||
| tabBarExtraContent={ | |||
| <Button | |||
| type='primary' | |||
| size='small' | |||
| onClick={() => setShowAddVendor(true)} | |||
| > | |||
| {t('新增供应商')} | |||
| </Button> | |||
| } | |||
| > | |||
| <TabPane | |||
| itemKey='all' | |||
| tab={ | |||
| <span className='flex items-center gap-2'> | |||
| {t('全部')} | |||
| <Tag | |||
| color={activeVendorKey === 'all' ? 'red' : 'grey'} | |||
| shape='circle' | |||
| > | |||
| {vendorCounts['all'] || 0} | |||
| </Tag> | |||
| </span> | |||
| } | |||
| /> | |||
| <Button type='primary' size='small' onClick={() => setShowAddVendor(true)}> | |||
| {t('新增供应商')} | |||
| </Button> | |||
| </div> | |||
| {vendors.map((vendor) => { | |||
| const key = String(vendor.id); | |||
| const count = vendorCounts[vendor.id] || 0; | |||
| return ( | |||
| <TabPane | |||
| key={key} | |||
| itemKey={key} | |||
| tab={ | |||
| <span className='flex items-center gap-2'> | |||
| {getLobeHubIcon(vendor.icon || 'Layers', 14)} | |||
| {vendor.name} | |||
| <Tag | |||
| color={activeVendorKey === key ? 'red' : 'grey'} | |||
| shape='circle' | |||
| > | |||
| {count} | |||
| </Tag> | |||
| <Dropdown | |||
| trigger='click' | |||
| position='bottomRight' | |||
| render={ | |||
| <Dropdown.Menu> | |||
| <Dropdown.Item | |||
| icon={<IconEdit />} | |||
| onClick={(e) => handleEditVendor(vendor, e)} | |||
| > | |||
| {t('编辑')} | |||
| </Dropdown.Item> | |||
| <Dropdown.Item | |||
| type='danger' | |||
| icon={<IconDelete />} | |||
| 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('删除')} | |||
| </Dropdown.Item> | |||
| </Dropdown.Menu> | |||
| } | |||
| onClickOutSide={(e) => e.stopPropagation()} | |||
| > | |||
| <Button | |||
| size='small' | |||
| type='tertiary' | |||
| theme='outline' | |||
| onClick={(e) => e.stopPropagation()} | |||
| > | |||
| {t('操作')} | |||
| </Button> | |||
| </Dropdown> | |||
| </span> | |||
| } | |||
| /> | |||
| ); | |||
| })} | |||
| </Tabs> | |||
| ); | |||
| }; | |||
| @@ -126,6 +126,7 @@ const EditModelModal = (props) => { | |||
| name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配 | |||
| status: true, | |||
| sync_official: true, | |||
| sort_order: 0, | |||
| }); | |||
| const handleCancel = () => { | |||
| @@ -332,6 +333,15 @@ const EditModelModal = (props) => { | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.InputNumber | |||
| field='sort_order' | |||
| label={t('排序')} | |||
| precision={0} | |||
| defaultValue={0} | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.Select | |||
| field='type' | |||
| @@ -38,6 +38,7 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { | |||
| description: '', | |||
| icon: '', | |||
| status: true, | |||
| sort_order: 0, | |||
| }); | |||
| const handleCancel = () => { | |||
| @@ -140,6 +141,14 @@ const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => { | |||
| showClear | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.InputNumber | |||
| field='sort_order' | |||
| label={t('排序')} | |||
| precision={0} | |||
| defaultValue={0} | |||
| /> | |||
| </Col> | |||
| <Col span={24}> | |||
| <Form.TextArea | |||
| field='description' | |||
| @@ -119,48 +119,6 @@ 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, | |||
| @@ -525,8 +483,6 @@ export const useModelsData = () => { | |||
| editingVendor, | |||
| setEditingVendor, | |||
| loadVendors, | |||
| reorderVendors, | |||
| reorderModels, | |||
| // Translation | |||
| t, | |||
| @@ -90,7 +90,7 @@ const HomePricingFilters = ({ t }) => { | |||
| hasUnknown = true; | |||
| } | |||
| }); | |||
| const vendors = Array.from(vendorSet); | |||
| const vendors = Array.from(vendorSet).sort(); | |||
| const getVendorCount = () => { | |||
| // 计算供应商数量(包含未知供应商) | |||
| const count = vendorSet.size + (hasUnknown ? 1 : 0); | |||