381 lines
12 KiB
TypeScript
381 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { Button, Card, Form, Input, Tag, Select, InputNumber, Space, Popconfirm, Popover, Tooltip, TreeSelect } from "antd";
|
||
import {
|
||
PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined,
|
||
CaretRightOutlined, CaretDownOutlined
|
||
} 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]);
|
||
|
||
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]);
|
||
|
||
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 }}
|
||
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 }} />;
|
||
}
|
||
}}
|
||
/>
|
||
</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 }}>
|
||
<Form.Item label="上级菜单" name="parent_id">
|
||
<TreeSelect
|
||
style={{ width: '100%' }}
|
||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||
treeData={treeData}
|
||
placeholder="请选择上级菜单"
|
||
treeDefaultExpandAll
|
||
allowClear
|
||
/>
|
||
</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;
|
||
} |