Ver código fonte

feat(frontend): Vendor Tab 和 Model 表格支持拖拽排序,移除定价页字母排序

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
master
fengsilin 2 semanas atrás
pai
commit
65880fc69b
6 arquivos alterados com 261 adições e 135 exclusões
  1. +1
    -1
      web/src/components/table/model-pricing/filter/PricingVendors.jsx
  2. +11
    -0
      web/src/components/table/models/ModelsColumnDefs.jsx
  3. +76
    -30
      web/src/components/table/models/ModelsTable.jsx
  4. +128
    -103
      web/src/components/table/models/ModelsTabs.jsx
  5. +44
    -0
      web/src/hooks/models/useModelsData.jsx
  6. +1
    -1
      web/src/pages/Home/HomePricingFilters.jsx

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

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

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


+ 11
- 0
web/src/components/table/models/ModelsColumnDefs.jsx Ver arquivo

@@ -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: () => (
<span style={{ cursor: 'grab', color: 'var(--semi-color-text-3)' }}>
<IconMenu />
</span>
),
},
{
title: t('图标'),
dataIndex: 'icon',


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

@@ -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 (
<tr ref={setNodeRef} style={style} {...attributes} {...listeners} {...props} />
);
};

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 (
<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 }} />
<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 }}
/>
}
description={t('搜索无结果')}
style={{ padding: 30 }}
className='rounded-xl overflow-hidden'
size='middle'
/>
}
className='rounded-xl overflow-hidden'
size='middle'
/>
</SortableContext>
</DndContext>
);
};



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

@@ -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 (
<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,
@@ -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 (
<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>
}
/>
<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>

{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>
<Button type='primary' size='small' onClick={() => setShowAddVendor(true)}>
{t('新增供应商')}
</Button>
</div>
);
};



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

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


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

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


Carregando…
Cancelar
Salvar