新增拖拉排序

dev_na
alanpaine 2026-04-15 13:49:50 +08:00
parent 5972182519
commit 658b7e6b59
3 changed files with 223 additions and 2 deletions

View File

@ -9,6 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"antd": "^5.13.2",
"axios": "^1.6.7",
"classnames": "^2.5.1",
@ -436,6 +440,73 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
@ -4575,6 +4646,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",

View File

@ -10,6 +10,10 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"antd": "^5.13.2",
"axios": "^1.6.7",
"classnames": "^2.5.1",

View File

@ -2,7 +2,11 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
import * as AntIcons from "@ant-design/icons";
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { DndContext, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, HolderOutlined } from "@ant-design/icons";
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
@ -26,6 +30,42 @@ const legacyIconAliases: Record<string, string> = {
setting: "SettingOutlined"
};
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
'data-row-key': string;
}
const DragContext = React.createContext<any>({});
const DraggableRow = ({ children, ...props }: RowProps) => {
const id = props['data-row-key']?.toString();
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: id || 'empty-id',
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999, background: '#fafafa', boxShadow: '0 5px 15px rgba(0,0,0,0.15)' } : {}),
};
return (
<DragContext.Provider value={{ setActivatorNodeRef, listeners }}>
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{children}
</tr>
</DragContext.Provider>
);
};
const menuIconOptions = Object.keys(AntIcons)
.filter((key) => /(?:Outlined|Filled|TwoTone)$/.test(key))
.sort((left, right) => left.localeCompare(right));
@ -82,6 +122,14 @@ export default function Permissions() {
const iconGridRef = useRef<HTMLDivElement | null>(null);
const [form] = Form.useForm();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 1,
},
})
);
const load = async () => {
setLoading(true);
try {
@ -106,6 +154,21 @@ export default function Permissions() {
}, [data, query]);
const treeData = useMemo(() => buildTree(filtered), [filtered]);
const flatVisualKeys = useMemo(() => {
const keys: string[] = [];
const traverse = (nodes: TreePermission[]) => {
nodes.forEach(node => {
keys.push(node.permId.toString());
if (node.children && node.children.length > 0) {
traverse(node.children);
}
});
};
traverse(treeData);
return keys;
}, [treeData]);
const currentPermType = Form.useWatch("permType", form);
const selectedIcon = Form.useWatch("icon", form);
@ -146,6 +209,46 @@ export default function Permissions() {
setIconSearchKeyword("");
};
const onDragEnd = async ({ active, over }: DragEndEvent) => {
if (active.id !== over?.id) {
const activeId = Number(active.id);
const overId = Number(over?.id);
const activeNode = data.find(n => n.permId === activeId);
const overNode = data.find(n => n.permId === overId);
if (!activeNode || !overNode) return;
if (activeNode.parentId !== overNode.parentId) {
message.warning("只能在同级节点之间排序");
return;
}
const siblings = data
.filter(n => n.parentId === activeNode.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
const activeIndex = siblings.findIndex(n => n.permId === activeId);
const overIndex = siblings.findIndex(n => n.permId === overId);
const newSiblings = [...siblings];
const [removed] = newSiblings.splice(activeIndex, 1);
newSiblings.splice(overIndex, 0, removed);
setLoading(true);
try {
const updates = newSiblings.map((node, index) => {
if (node.sortOrder !== index) {
return updatePermission(node.permId, { sortOrder: index });
}
return Promise.resolve();
});
await Promise.all(updates);
message.success(t("common.success"));
load();
} catch (e) {
setLoading(false);
}
}
};
const openCreate = () => {
setEditing(null);
form.resetFields();
@ -223,6 +326,21 @@ export default function Permissions() {
};
const columns = [
{
key: 'sortHandle',
width: 40,
align: 'center' as const,
render: () => {
const { setActivatorNodeRef, listeners } = React.useContext(DragContext);
return (
<HolderOutlined
ref={setActivatorNodeRef}
style={{ touchAction: 'none', cursor: 'move', color: '#999' }}
{...listeners}
/>
);
},
},
{
title: t("permissions.permName"),
dataIndex: "name",
@ -314,7 +432,29 @@ export default function Permissions() {
</Card>
<Card className="app-page__content-card permissions-content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Table className="permissions-table-full" rowKey="permId" loading={loading} dataSource={treeData} columns={columns} pagination={false} size="middle" scroll={{ x: 'max-content', y: '100%' }} expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length }} />
<DndContext sensors={sensors} modifiers={[restrictToVerticalAxis]} onDragEnd={onDragEnd}>
<SortableContext
items={flatVisualKeys}
strategy={verticalListSortingStrategy}
>
<Table
className="permissions-table-full"
rowKey="permId"
loading={loading}
dataSource={treeData}
columns={columns}
pagination={false}
size="middle"
scroll={{ x: 'max-content', y: '100%' }}
expandable={{ defaultExpandAllRows: false, rowExpandable: (record) => record.permType !== "button" && !!record.children?.length, expandIconColumnIndex: 1 }}
components={{
body: {
row: DraggableRow,
},
}}
/>
</SortableContext>
</DndContext>
</Card>
<Drawer title={<Space><ClusterOutlined aria-hidden="true" /><span>{editing ? t("permissions.drawerTitleEdit") : t("permissions.drawerTitleCreate")}</span></Space>} open={open} onClose={() => setOpen(false)} width={520} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submit}>{t("common.save")}</Button></div>}>