2026-02-25 08:48:31 +00:00
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
2026-02-27 07:57:22 +00:00
|
|
|
|
import { Button, Card, Form, Input, Tag, Select, InputNumber, Space, Popconfirm, Popover, Tooltip, TreeSelect } from "antd";
|
2026-02-25 08:48:31 +00:00
|
|
|
|
import {
|
2026-02-27 07:57:22 +00:00
|
|
|
|
PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined,
|
|
|
|
|
|
CaretRightOutlined, CaretDownOutlined
|
2026-02-25 08:48:31 +00:00
|
|
|
|
} from "@ant-design/icons";
|
|
|
|
|
|
import ListTable from "../components/ListTable/ListTable";
|
|
|
|
|
|
import DetailDrawer from "../components/DetailDrawer/DetailDrawer";
|
|
|
|
|
|
import PageHeader from "../components/PageHeader/PageHeader";
|
|
|
|
|
|
import Toast from "../components/Toast/Toast";
|
|
|
|
|
|
import { api } from "../api";
|
|
|
|
|
|
import { clearTokens } from "../auth";
|
|
|
|
|
|
import { ICON_LIST, getIcon } from "../utils/icons";
|
|
|
|
|
|
|
|
|
|
|
|
interface PermItem {
|
|
|
|
|
|
perm_id: number;
|
|
|
|
|
|
parent_id: number | null;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
code: string;
|
|
|
|
|
|
perm_type: string;
|
|
|
|
|
|
level: number;
|
|
|
|
|
|
path?: string | null;
|
|
|
|
|
|
icon?: string | null;
|
|
|
|
|
|
sort_order?: number;
|
|
|
|
|
|
status?: number;
|
|
|
|
|
|
children?: PermItem[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DrawerMode = "create" | "edit";
|
|
|
|
|
|
|
|
|
|
|
|
export default function PermissionTreePage() {
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const [perms, setPerms] = useState<PermItem[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
|
|
|
|
const [drawerMode, setDrawerMode] = useState<DrawerMode>("create");
|
|
|
|
|
|
const [editing, setEditing] = useState<PermItem | null>(null);
|
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
|
|
|
|
|
|
const [iconPopoverOpen, setIconPopoverOpen] = useState(false);
|
|
|
|
|
|
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const res = await api.listPermissions();
|
|
|
|
|
|
const tree = buildTree(res as any);
|
|
|
|
|
|
setPerms(tree);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
clearTokens();
|
|
|
|
|
|
navigate("/");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const openCreate = (parentId: number | null = null) => {
|
|
|
|
|
|
setDrawerMode("create");
|
|
|
|
|
|
setEditing(null);
|
|
|
|
|
|
form.resetFields();
|
|
|
|
|
|
setSelectedIcon(null);
|
|
|
|
|
|
form.setFieldsValue({ parent_id: parentId, level: parentId ? 2 : 1, perm_type: parentId ? 'menu' : 'menu', status: 1 });
|
|
|
|
|
|
setDrawerOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openEdit = (row: PermItem) => {
|
|
|
|
|
|
setDrawerMode("edit");
|
|
|
|
|
|
setEditing(row);
|
|
|
|
|
|
setSelectedIcon(row.icon || null);
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
parent_id: row.parent_id,
|
|
|
|
|
|
name: row.name,
|
|
|
|
|
|
code: row.code,
|
|
|
|
|
|
perm_type: row.perm_type,
|
|
|
|
|
|
level: row.level,
|
|
|
|
|
|
path: row.path || "",
|
|
|
|
|
|
icon: row.icon || "",
|
|
|
|
|
|
sort_order: row.sort_order || 0,
|
|
|
|
|
|
status: row.status || 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
setDrawerOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const notifyMenuRefresh = () => {
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('menu-refresh'));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submit = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const values = await form.validateFields();
|
|
|
|
|
|
if (drawerMode === "create") {
|
|
|
|
|
|
await api.createPermission(values);
|
|
|
|
|
|
Toast.success("已创建");
|
|
|
|
|
|
} else if (editing) {
|
|
|
|
|
|
await api.updatePermission(editing.perm_id, values);
|
|
|
|
|
|
Toast.success("已更新");
|
|
|
|
|
|
}
|
|
|
|
|
|
setDrawerOpen(false);
|
|
|
|
|
|
load();
|
|
|
|
|
|
notifyMenuRefresh();
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const remove = async (row: PermItem) => {
|
|
|
|
|
|
await api.deletePermission(row.perm_id);
|
|
|
|
|
|
Toast.success("已删除");
|
|
|
|
|
|
load();
|
|
|
|
|
|
notifyMenuRefresh();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleIconSelect = (iconName: string) => {
|
|
|
|
|
|
form.setFieldsValue({ icon: iconName });
|
|
|
|
|
|
setSelectedIcon(iconName);
|
|
|
|
|
|
setIconPopoverOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const iconContent = (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gridTemplateColumns: 'repeat(8, 1fr)',
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
width: 320,
|
|
|
|
|
|
maxHeight: 240,
|
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
padding: '8px'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{ICON_LIST.map((item) => (
|
|
|
|
|
|
<Tooltip key={item.name} title={item.name}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => handleIconSelect(item.name)}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
padding: '8px 0',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
background: selectedIcon === item.name ? '#f0f7ff' : 'transparent',
|
|
|
|
|
|
border: selectedIcon === item.name ? '1px solid #1890ff' : '1px solid transparent',
|
|
|
|
|
|
fontSize: '18px',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
transition: 'all 0.2s'
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
|
if (selectedIcon !== item.name) {
|
|
|
|
|
|
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
|
if (selectedIcon !== item.name) {
|
|
|
|
|
|
e.currentTarget.style.backgroundColor = 'transparent';
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.icon}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const columns = useMemo(() => [
|
|
|
|
|
|
{ title: "名称", dataIndex: "name", key: "name" },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "图标",
|
|
|
|
|
|
dataIndex: "icon",
|
|
|
|
|
|
key: "icon",
|
|
|
|
|
|
width: 80,
|
|
|
|
|
|
render: (v: string) => v ? <span style={{ fontSize: 18, color: '#1890ff' }}>{getIcon(v)}</span> : '-'
|
|
|
|
|
|
},
|
|
|
|
|
|
{ title: "编码", dataIndex: "code", key: "code" },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "类型",
|
|
|
|
|
|
dataIndex: "perm_type",
|
|
|
|
|
|
key: "perm_type",
|
|
|
|
|
|
render: (v: string) => <Tag color={v === 'menu' ? 'blue' : 'orange'}>{v === 'menu' ? '菜单' : '按钮'}</Tag>
|
|
|
|
|
|
},
|
|
|
|
|
|
{ title: "路径", dataIndex: "path", key: "path", render: (v: string | null) => v || "-" },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "操作",
|
|
|
|
|
|
key: "action",
|
|
|
|
|
|
width: 200,
|
|
|
|
|
|
render: (_: any, record: PermItem) => (
|
|
|
|
|
|
<Space size="middle">
|
|
|
|
|
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>编辑</Button>
|
|
|
|
|
|
{record.level < 3 && (
|
|
|
|
|
|
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openCreate(record.perm_id)}>子项</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Popconfirm title="确定删除吗?" onConfirm={() => remove(record)}>
|
|
|
|
|
|
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
|
|
|
|
</Popconfirm>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
], [perms]);
|
|
|
|
|
|
|
2026-02-27 07:57:22 +00:00
|
|
|
|
const treeData = useMemo(() => {
|
|
|
|
|
|
const loop = (data: PermItem[], disabled = false): any[] =>
|
|
|
|
|
|
data.map((item) => {
|
|
|
|
|
|
const isDisabled = disabled || editing?.perm_id === item.perm_id;
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: item.name,
|
|
|
|
|
|
value: item.perm_id,
|
|
|
|
|
|
key: item.perm_id,
|
|
|
|
|
|
disabled: isDisabled,
|
|
|
|
|
|
children: item.children ? loop(item.children, isDisabled) : [],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ title: '无 (顶级菜单)', value: null, key: 'root', children: [] },
|
|
|
|
|
|
...loop(perms)
|
|
|
|
|
|
];
|
|
|
|
|
|
}, [perms, editing]);
|
|
|
|
|
|
|
2026-02-25 08:48:31 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="page-wrapper" style={{ padding: 24 }}>
|
|
|
|
|
|
<PageHeader
|
|
|
|
|
|
title="功能菜单"
|
|
|
|
|
|
description="管理系统功能权限结构,包括菜单和按钮"
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreate()}>
|
|
|
|
|
|
新增权限
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="shadow-card" style={{ marginTop: 24, background: '#fff', padding: 24, borderRadius: 8 }}>
|
|
|
|
|
|
<ListTable<PermItem>
|
|
|
|
|
|
columns={columns}
|
|
|
|
|
|
dataSource={perms}
|
|
|
|
|
|
rowKey="perm_id"
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
pagination={false}
|
|
|
|
|
|
scroll={{ x: 1000 }}
|
2026-02-27 07:57:22 +00:00
|
|
|
|
expandable={{
|
|
|
|
|
|
expandIcon: ({ expanded, onExpand, record }) => {
|
|
|
|
|
|
if (record.children && record.children.length > 0) {
|
|
|
|
|
|
return expanded ? (
|
|
|
|
|
|
<CaretDownOutlined
|
|
|
|
|
|
onClick={e => onExpand(record, e)}
|
|
|
|
|
|
style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CaretRightOutlined
|
|
|
|
|
|
onClick={e => onExpand(record, e)}
|
|
|
|
|
|
style={{ marginRight: 8, cursor: 'pointer', color: '#666', fontSize: '12px' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return <span style={{ marginRight: 8, display: 'inline-block', width: 14 }} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-02-25 08:48:31 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<DetailDrawer
|
|
|
|
|
|
visible={drawerOpen}
|
|
|
|
|
|
onClose={() => setDrawerOpen(false)}
|
|
|
|
|
|
title={{ text: drawerMode === "create" ? "新增权限" : "编辑权限" }}
|
|
|
|
|
|
headerActions={[
|
|
|
|
|
|
{ key: "cancel", label: "取消", onClick: () => setDrawerOpen(false) },
|
|
|
|
|
|
{ key: "submit", label: "保存", type: "primary", onClick: submit },
|
|
|
|
|
|
]}
|
|
|
|
|
|
width={450}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form form={form} layout="vertical" style={{ padding: 24 }}>
|
2026-02-27 07:57:22 +00:00
|
|
|
|
<Form.Item label="上级菜单" name="parent_id">
|
|
|
|
|
|
<TreeSelect
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
|
|
|
|
|
treeData={treeData}
|
|
|
|
|
|
placeholder="请选择上级菜单"
|
|
|
|
|
|
treeDefaultExpandAll
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
/>
|
2026-02-25 08:48:31 +00:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="权限名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
|
|
|
|
|
<Input placeholder="例如: 用户管理" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="权限编码" name="code" rules={[{ required: true, message: '请输入编码' }]}>
|
|
|
|
|
|
<Input placeholder="例如: system:user" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="类型" name="perm_type" initialValue="menu">
|
|
|
|
|
|
<Select>
|
|
|
|
|
|
<Select.Option value="menu">菜单</Select.Option>
|
|
|
|
|
|
<Select.Option value="button">按钮</Select.Option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item label="图标" name="icon">
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
content={iconContent}
|
|
|
|
|
|
title="选择图标"
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
open={iconPopoverOpen}
|
|
|
|
|
|
onOpenChange={setIconPopoverOpen}
|
|
|
|
|
|
placement="bottom"
|
|
|
|
|
|
overlayStyle={{ zIndex: 2000 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="点击选择图标"
|
|
|
|
|
|
readOnly
|
|
|
|
|
|
prefix={selectedIcon ? getIcon(selectedIcon) : <AppstoreOutlined style={{ opacity: 0.3 }} />}
|
|
|
|
|
|
style={{ cursor: 'pointer' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{selectedIcon && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
right: 10,
|
|
|
|
|
|
top: '50%',
|
|
|
|
|
|
transform: 'translateY(-50%)',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
|
zIndex: 1
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleIconSelect('');
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item label="层级" name="level">
|
|
|
|
|
|
<InputNumber min={1} max={3} style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="访问路径" name="path">
|
|
|
|
|
|
<Input placeholder="例如: /system/users" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="排序" name="sort_order" initialValue={0}>
|
|
|
|
|
|
<InputNumber style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</DetailDrawer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildTree(items: any[]): PermItem[] {
|
|
|
|
|
|
const map = new Map<number, PermItem>();
|
|
|
|
|
|
const roots: PermItem[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const rawItems = items.map(it => ({...it, children: []}));
|
|
|
|
|
|
rawItems.forEach((it) => map.set(it.perm_id, it));
|
|
|
|
|
|
|
|
|
|
|
|
rawItems.forEach((it) => {
|
|
|
|
|
|
if (it.parent_id) {
|
|
|
|
|
|
const parent = map.get(it.parent_id);
|
|
|
|
|
|
if (parent) {
|
|
|
|
|
|
if (!parent.children) parent.children = [];
|
|
|
|
|
|
parent.children.push(it);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
roots.push(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
roots.push(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Sort children by sort_order
|
|
|
|
|
|
const sortFunc = (a: any, b: any) => (a.sort_order || 0) - (b.sort_order || 0);
|
|
|
|
|
|
rawItems.forEach(it => {
|
|
|
|
|
|
if (it.children && it.children.length > 0) {
|
|
|
|
|
|
it.children.sort(sortFunc);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
roots.sort(sortFunc);
|
|
|
|
|
|
|
|
|
|
|
|
return roots;
|
|
|
|
|
|
}
|