Files
erp/apps/web/src/pages/settings/MenuConfig.tsx
iven 14f431efff feat: systematic functional audit — fix 18 issues across Phase A/B
Phase A (P1 production blockers):
- A1: Apply IP rate limiting to public routes (login/refresh)
- A2: Publish domain events for workflow instance state transitions
  (completed/suspended/resumed/terminated) via outbox pattern
- A3: Replace hardcoded nil UUID default tenant with dynamic DB lookup
- A4: Add GET /api/v1/audit-logs query endpoint with pagination
- A5: Enhance CORS wildcard warning for production environments

Phase B (P2 functional gaps):
- B1: Remove dead erp-common crate (zero references in codebase)
- B2: Refactor 5 settings pages to use typed API modules instead of
  direct client calls; create api/themes.ts; delete dead errors.ts
- B3: Add resume/suspend buttons to InstanceMonitor page
- B4: Remove unused EventHandler trait from erp-core
- B5: Handle task.completed events in message module (send notifications)
- B6: Wire TimeoutChecker as 60s background task
- B7: Auto-skip ServiceTask nodes instead of crashing the process
- B8: Remove empty register_routes() from ErpModule trait and modules
2026-04-12 15:22:28 +08:00

322 lines
8.0 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 {
getMenus,
createMenu,
updateMenu,
deleteMenu,
type MenuInfo,
type MenuItemReq,
} from '../../api/menus';
// --- Types ---
type MenuItem = MenuInfo;
// --- Helpers ---
/** Convert nested menu tree back to flat list */
function flattenMenuTree(tree: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
const walk = (items: MenuItem[]) => {
for (const item of items) {
const { children, ...rest } = item;
result.push(rest as MenuItem);
if (children?.length) walk(children);
}
};
walk(tree);
return result;
}
/** 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 tree = await getMenus();
setMenus(flattenMenuTree(tree));
setMenuTree(tree);
} catch {
message.error('加载菜单失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchMenus();
}, [fetchMenus]);
const handleSubmit = async (values: MenuItemReq) => {
try {
if (editMenu) {
await updateMenu(editMenu.id, values);
message.success('菜单更新成功');
} else {
await createMenu(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 deleteMenu(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>
);
}