Ver código fonte

refactor(frontend): 移除拖拽排序,改为 sort_order 数值输入

回退 @dnd-kit 拖拽实现,改为在编辑弹窗中直接设置 sort_order 数值:
- 模型编辑弹窗新增「排序」InputNumber 字段
- 供应商编辑弹窗新增「排序」InputNumber 字段
- 模型表格新增 sort_order 显示列
- 移除 @dnd-kit 依赖及所有 DnD 相关代码

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
master
fengsilin 2 semanas atrás
pai
commit
518f1ab87f
9 arquivos alterados com 157 adições e 263 exclusões
  1. +0
    -4
      web/package.json
  2. +1
    -1
      web/src/components/table/model-pricing/filter/PricingVendors.jsx
  3. +3
    -9
      web/src/components/table/models/ModelsColumnDefs.jsx
  4. +30
    -76
      web/src/components/table/models/ModelsTable.jsx
  5. +103
    -128
      web/src/components/table/models/ModelsTabs.jsx
  6. +10
    -0
      web/src/components/table/models/modals/EditModelModal.jsx
  7. +9
    -0
      web/src/components/table/models/modals/EditVendorModal.jsx
  8. +0
    -44
      web/src/hooks/models/useModelsData.jsx
  9. +1
    -1
      web/src/pages/Home/HomePricingFilters.jsx

+ 0
- 4
web/package.json Ver arquivo

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


+ 1
- 1
web/src/components/table/model-pricing/filter/PricingVendors.jsx Ver arquivo

@@ -56,7 +56,7 @@ const PricingVendors = ({
});

return {
vendors: Array.from(vendors),
vendors: Array.from(vendors).sort(),
vendorIcons,
hasUnknownVendor,
};


+ 3
- 9
web/src/components/table/models/ModelsColumnDefs.jsx Ver arquivo

@@ -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('图标'),


+ 30
- 76
web/src/components/table/models/ModelsTable.jsx Ver arquivo

@@ -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'
/>
);
};



+ 103
- 128
web/src/components/table/models/ModelsTabs.jsx Ver arquivo

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



+ 10
- 0
web/src/components/table/models/modals/EditModelModal.jsx Ver arquivo

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


+ 9
- 0
web/src/components/table/models/modals/EditVendorModal.jsx Ver arquivo

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


+ 0
- 44
web/src/hooks/models/useModelsData.jsx Ver arquivo

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


+ 1
- 1
web/src/pages/Home/HomePricingFilters.jsx Ver arquivo

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


Carregando…
Cancelar
Salvar