feat(config): add system configuration module (Phase 3)
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.
This commit is contained in:
321
apps/web/src/pages/settings/MenuConfig.tsx
Normal file
321
apps/web/src/pages/settings/MenuConfig.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user