nex_basse/frontend/src/pages/PermissionTree.tsx

381 lines
12 KiB
TypeScript
Raw Normal View History

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;
}