nex_basse/frontend/src/pages/PermissionTree.tsx

381 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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