diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fd8ca7..509177c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0d3cedb..252909d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/pages/access/permissions/index.tsx b/frontend/src/pages/access/permissions/index.tsx index c2ef062..e4329b7 100644 --- a/frontend/src/pages/access/permissions/index.tsx +++ b/frontend/src/pages/access/permissions/index.tsx @@ -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 = { setting: "SettingOutlined" }; +interface RowProps extends React.HTMLAttributes { + 'data-row-key': string; +} + +const DragContext = React.createContext({}); + +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 ( + + + {children} + + + ); +}; + 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(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 ( + + ); + }, + }, { title: t("permissions.permName"), dataIndex: "name", @@ -314,7 +432,29 @@ export default function Permissions() { - record.permType !== "button" && !!record.children?.length }} /> + + +
record.permType !== "button" && !!record.children?.length, expandIconColumnIndex: 1 }} + components={{ + body: { + row: DraggableRow, + }, + }} + /> + +