Implement the complete erp-config crate with: - Data dictionaries (CRUD + items management) - Dynamic menus (tree structure with role filtering) - System settings (hierarchical: platform > tenant > org > user) - Numbering rules (concurrency-safe via PostgreSQL advisory_lock) - Theme and language configuration (via settings store) - 6 database migrations (dictionaries, menus, settings, numbering_rules) - Frontend Settings page with 5 tabs (dictionary, menu, numbering, settings, theme) Refactor: move RBAC functions (require_permission) from erp-auth to erp-core to avoid cross-module dependencies. Add 20 new seed permissions for config module operations.
322 lines
8.1 KiB
TypeScript
322 lines
8.1 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
Table,
|
|
Button,
|
|
Space,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Select,
|
|
Switch,
|
|
TreeSelect,
|
|
Popconfirm,
|
|
message,
|
|
Typography,
|
|
Tag,
|
|
} from 'antd';
|
|
import { PlusOutlined } from '@ant-design/icons';
|
|
import client from '../../api/client';
|
|
|
|
// --- Types ---
|
|
|
|
interface MenuItem {
|
|
id: string;
|
|
parent_id?: string | null;
|
|
title: string;
|
|
path?: string;
|
|
icon?: string;
|
|
menu_type: 'directory' | 'menu' | 'button';
|
|
sort_order: number;
|
|
visible: boolean;
|
|
permission?: string;
|
|
children?: MenuItem[];
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
/** Convert flat menu list to tree structure for Table children prop */
|
|
function buildMenuTree(items: MenuItem[]): MenuItem[] {
|
|
const map = new Map<string, MenuItem>();
|
|
const roots: MenuItem[] = [];
|
|
|
|
const withChildren = items.map((item) => ({
|
|
...item,
|
|
children: [] as MenuItem[],
|
|
}));
|
|
|
|
withChildren.forEach((item) => map.set(item.id, item));
|
|
|
|
withChildren.forEach((item) => {
|
|
if (item.parent_id && map.has(item.parent_id)) {
|
|
map.get(item.parent_id)!.children!.push(item);
|
|
} else {
|
|
roots.push(item);
|
|
}
|
|
});
|
|
|
|
return roots;
|
|
}
|
|
|
|
/** Convert menu tree to TreeSelect data nodes */
|
|
function toTreeSelectData(
|
|
items: MenuItem[],
|
|
): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> {
|
|
return items.map((item) => ({
|
|
title: item.title,
|
|
value: item.id,
|
|
children:
|
|
item.children && item.children.length > 0
|
|
? toTreeSelectData(item.children)
|
|
: undefined,
|
|
}));
|
|
}
|
|
|
|
const menuTypeLabels: Record<string, { text: string; color: string }> = {
|
|
directory: { text: '目录', color: 'blue' },
|
|
menu: { text: '菜单', color: 'green' },
|
|
button: { text: '按钮', color: 'orange' },
|
|
};
|
|
|
|
// --- Component ---
|
|
|
|
export default function MenuConfig() {
|
|
const [menus, setMenus] = useState<MenuItem[]>([]);
|
|
const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editMenu, setEditMenu] = useState<MenuItem | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
const fetchMenus = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { data: resp } = await client.get('/config/menus');
|
|
const list: MenuItem[] = resp.data ?? resp;
|
|
setMenus(list);
|
|
setMenuTree(buildMenuTree(list));
|
|
} catch {
|
|
message.error('加载菜单失败');
|
|
}
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchMenus();
|
|
}, [fetchMenus]);
|
|
|
|
const handleSubmit = async (values: {
|
|
parent_id?: string;
|
|
title: string;
|
|
path?: string;
|
|
icon?: string;
|
|
menu_type: 'directory' | 'menu' | 'button';
|
|
sort_order: number;
|
|
visible: boolean;
|
|
permission?: string;
|
|
}) => {
|
|
try {
|
|
if (editMenu) {
|
|
await client.put(`/config/menus/${editMenu.id}`, values);
|
|
message.success('菜单更新成功');
|
|
} else {
|
|
await client.post('/config/menus', values);
|
|
message.success('菜单创建成功');
|
|
}
|
|
closeModal();
|
|
fetchMenus();
|
|
} catch (err: unknown) {
|
|
const errorMsg =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
?.message || '操作失败';
|
|
message.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await client.delete(`/config/menus/${id}`);
|
|
message.success('菜单已删除');
|
|
fetchMenus();
|
|
} catch {
|
|
message.error('删除失败');
|
|
}
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setEditMenu(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
menu_type: 'menu',
|
|
sort_order: 0,
|
|
visible: true,
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = (menu: MenuItem) => {
|
|
setEditMenu(menu);
|
|
form.setFieldsValue({
|
|
parent_id: menu.parent_id || undefined,
|
|
title: menu.title,
|
|
path: menu.path,
|
|
icon: menu.icon,
|
|
menu_type: menu.menu_type,
|
|
sort_order: menu.sort_order,
|
|
visible: menu.visible,
|
|
permission: menu.permission,
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setModalOpen(false);
|
|
setEditMenu(null);
|
|
form.resetFields();
|
|
};
|
|
|
|
const columns = [
|
|
{ title: '标题', dataIndex: 'title', key: 'title', width: 200 },
|
|
{
|
|
title: '路径',
|
|
dataIndex: 'path',
|
|
key: 'path',
|
|
ellipsis: true,
|
|
render: (v?: string) => v || '-',
|
|
},
|
|
{
|
|
title: '图标',
|
|
dataIndex: 'icon',
|
|
key: 'icon',
|
|
width: 100,
|
|
render: (v?: string) => v || '-',
|
|
},
|
|
{
|
|
title: '类型',
|
|
dataIndex: 'menu_type',
|
|
key: 'menu_type',
|
|
width: 90,
|
|
render: (v: string) => {
|
|
const info = menuTypeLabels[v] ?? { text: v, color: 'default' };
|
|
return <Tag color={info.color}>{info.text}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: '排序',
|
|
dataIndex: 'sort_order',
|
|
key: 'sort_order',
|
|
width: 80,
|
|
},
|
|
{
|
|
title: '可见',
|
|
dataIndex: 'visible',
|
|
key: 'visible',
|
|
width: 80,
|
|
render: (v: boolean) =>
|
|
v ? <Tag color="green">是</Tag> : <Tag color="default">否</Tag>,
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'actions',
|
|
width: 150,
|
|
render: (_: unknown, record: MenuItem) => (
|
|
<Space>
|
|
<Button size="small" onClick={() => openEdit(record)}>
|
|
编辑
|
|
</Button>
|
|
<Popconfirm
|
|
title="确定删除此菜单?"
|
|
onConfirm={() => handleDelete(record.id)}
|
|
>
|
|
<Button size="small" danger>
|
|
删除
|
|
</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
菜单配置
|
|
</Typography.Title>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
|
添加菜单
|
|
</Button>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={menuTree}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={false}
|
|
indentSize={20}
|
|
/>
|
|
|
|
<Modal
|
|
title={editMenu ? '编辑菜单' : '添加菜单'}
|
|
open={modalOpen}
|
|
onCancel={closeModal}
|
|
onOk={() => form.submit()}
|
|
width={560}
|
|
>
|
|
<Form form={form} onFinish={handleSubmit} layout="vertical">
|
|
<Form.Item name="parent_id" label="上级菜单">
|
|
<TreeSelect
|
|
treeData={toTreeSelectData(menuTree)}
|
|
placeholder="无(顶级菜单)"
|
|
allowClear
|
|
treeDefaultExpandAll
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="title"
|
|
label="标题"
|
|
rules={[{ required: true, message: '请输入菜单标题' }]}
|
|
>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="path" label="路径">
|
|
<Input placeholder="/example/path" />
|
|
</Form.Item>
|
|
<Form.Item name="icon" label="图标">
|
|
<Input placeholder="图标名称,如 HomeOutlined" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="menu_type"
|
|
label="类型"
|
|
rules={[{ required: true, message: '请选择菜单类型' }]}
|
|
>
|
|
<Select
|
|
options={[
|
|
{ label: '目录', value: 'directory' },
|
|
{ label: '菜单', value: 'menu' },
|
|
{ label: '按钮', value: 'button' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item name="permission" label="权限标识">
|
|
<Input placeholder="如 system:user:list" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|