feat(system): 新增菜单管理功能

- 添加菜单管理相关的 API 接口
- 实现菜单列表、新增菜单、编辑菜单、删除菜单等功能
- 添加菜单管理的国际化支持
- 新增菜单管理的样式文件
main
Tuzki 5 months ago
parent e1932a717b
commit 1890f9028d
  1. 32
      web/client/api/system/index.ts
  2. 4
      web/locales/en/common.ts
  3. 4
      web/locales/zh/common.ts
  4. 34
      web/pages/system/menu/index.module.scss
  5. 423
      web/pages/system/menu/index.tsx
  6. 4
      web/pages/system/user/index.tsx
  7. 17
      web/types/prompt.ts

@ -112,4 +112,34 @@ export const getUserRole = (id: number) => {
//用户管理-保存用户角色绑定关系
export const addUserAndRoles = (prpos:any) => {
return POST<any,[]>('/api/role/save_user_role', prpos);
};
};
//菜单管理-菜单列表-树
export const getMenuList = (params:any) => {
return GET<any, any>(`/api/menu/tree`,params);
};
//菜单管理-新增菜单
export const addMenu = (prpos:any) => {
return POST<any,[]>('/api/menu/create', prpos);
};
//菜单管理-修改菜单
export const updateMenu = (prpos:any) => {
return PUT<any,[]>('/api/menu/update', prpos);
};
//菜单管理-删除菜单
export const deleteMenu = (id: number) => {
return DELETE<any, any>(`/api/menu/delete/`+id);
};
//菜单管理-修改菜单状态
export const updateMenuStatus = (prpos:any) => {
return PUT<any,[]>('/api/menu/update/'+prpos.id+'/'+ prpos.status);
};
//菜单管理-获取当前登录用户的菜单
export const getUserMenu = () => {
return GET<any, any>(`/api/menu/user_menu_auth_tree`);
};
//菜单管理-获取菜单详情
export const getMenuDetail = (id: number) => {
return GET<any, any>(`/api/menu/menu_detail/`+id);
};

@ -376,4 +376,8 @@ export const CommonEn = {
user_name:'userName',
user_nickname:'nickName',
user_phone:'phone',
menu_nmae:'menuName',
rights_markup:'rightsMarkup',
component_path:'componentPath',
component_name:'componentName',
} as const;

@ -381,4 +381,8 @@ export const CommonZh: Resources['translation'] = {
user_name:'用户名称',
user_nickname:'用户昵称',
user_phone:'手机号码',
menu_nmae:'菜单名称',
rights_markup:'权限标识',
component_path:'组件路径',
component_name:'组件名称',
} as const;

@ -0,0 +1,34 @@
.color-red {
color: red
}
.text-center {
text-align: center
}
.p-10 {
padding: 10px
}
.construc-container table{
display: table;;
}
.search-bar-box{
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
.search-bar-left,.search-bar-right{
display: flex;
align-items: center;
}
.search-bar-left{
width: 60%;
}
.search-bar-right{
width: 40%;
}
}

@ -0,0 +1,423 @@
import { Space, Switch, Input, Table, Form, Button, Select, Popconfirm, App, Modal, InputNumber, TreeSelect } from 'antd';
import { apiInterceptors, getMenuList, addMenu, updateMenu, deleteMenu, updateMenuStatus, getMenuDetail } from '@/client/api';
import useUser from '@/hooks/use-user';
import SystemConstructConstruct from '@/new-components/layout/SystemConstruct';
import styles from './index.module.scss';
import type { ColumnsType } from 'antd/es/table';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu } from '@/types/prompt';
import { useRequest } from 'ahooks';
import { TFunction } from 'i18next';
const { Option } = Select;
const Prompt = () => {
const { message } = App.useApp();
const { t } = useTranslation();
const [menuList, setMenuList] = useState<Menu[]>([]);
const [searchForm] = Form.useForm();
const [menuModalVisible, setMenuModalVisible] = useState(false);
const [menuModalType, setMenuModalType] = useState<'add' | 'edit'>('add');
const [menuEditId, setMenuEditId] = useState<number | null>(null);
const [menuForm] = Form.useForm();
useEffect(() => {
getMenus();
}, []);
const processMenuData = (data: Menu[]): Menu[] => {
return data.map(item => ({
...item,
children: item.children?.length ? processMenuData(item.children) : undefined,
}));
};
const { run: getMenus, loading: loadingMenus } = useRequest(
async (params?: { name?: string; status?: string }) => {
const [_, data] = await apiInterceptors(getMenuList(params));
return data;
},
{
manual: true,
onSuccess: (data: any) => {
if (Array.isArray(data)) {
const processedData = processMenuData(data);
setMenuList(processedData);
}
},
}
);
const handleAddMenu = () => {
setMenuModalType('add');
setMenuModalVisible(true);
menuForm.resetFields();
};
const handleEditMenu = async (record: Menu) => {
setMenuModalType('edit');
setMenuEditId(record.id);
setMenuModalVisible(true);
try {
const [err, data] = await apiInterceptors(getMenuDetail(record.id));
if (!err && data) {
menuForm.setFieldsValue({
...data,
status: data.status === 0,
visible: data.visible === 1,
});
} else {
message.error('获取菜单详情失败');
}
} catch (e) {
message.error('获取菜单详情失败');
}
};
const buildMenuTreeOptions = (list: Menu[]): any[] => {
// 先递归构建原始菜单树
const menuOptions = list.map(item => ({
title: item.name,
value: item.id,
children: item.children ? buildMenuTreeOptionsRecursive(item.children) : [],
}));
// 最外层包裹一个“根目录”
return [
{
title: '根目录',
value: 0,
children: menuOptions,
},
];
};
// 仅用于递归构建子节点,不添加“根目录”
const buildMenuTreeOptionsRecursive = (list: Menu[]): any[] => {
return list.map(item => ({
title: item.name,
value: item.id,
children: item.children ? buildMenuTreeOptionsRecursive(item.children) : [],
}));
};
const handleDeleteMenu = async (record: Menu) => {
if (record.children && record.children.length > 0) {
message.warning('该菜单包含子菜单,不能直接删除');
return;
}
try {
const [_, res] = await apiInterceptors(deleteMenu(record.id));
if (res) {
message.success('删除成功');
await getMenus(); // 刷新列表
} else {
message.error(res?.err_msg || '删除失败');
}
} catch (e) {
message.error('请求失败,请重试');
}
};
const getMenuColumns = (t: TFunction): ColumnsType<Menu> => [
{
title: t('menu_nmae'),
dataIndex: 'name',
key: 'name',
width: '20%',
ellipsis: true,
},
{
title: t('component_path'),
dataIndex: 'path',
key: 'path',
width: '25%',
ellipsis: true,
},
{
title: t('component_name'),
dataIndex: 'component_name',
key: 'component_name',
width: '25%',
ellipsis: true,
},
{
title: t('rights_markup'),
dataIndex: 'permission',
key: 'permission',
width: '15%',
},
{
title: t('sort'),
dataIndex: 'sort',
key: 'sort',
width: '8%',
align: 'center',
},
{
title: t('Status'),
dataIndex: 'status',
key: 'status',
width: '10%',
render: (status: number, record: Menu) => (
<Switch
checked={status === 0}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={async (checked) => {
try {
const [_, res] = await apiInterceptors(
updateMenuStatus({ id: record.id, status: checked ? 0 : 1 })
);
if (res) {
message.success('状态更新成功');
// 刷新列表
getMenus();
} else {
message.error('状态更新失败');
}
} catch (e) {
message.error('请求失败,请重试');
}
}}
/>
),
},
{
title: t('Operation'),
dataIndex: 'operate',
key: 'operate',
width: '15%',
render: (_, record) => (
<Space>
<Button type="link" size="small" onClick={() => handleEditMenu(record)}>
</Button>
<Popconfirm title="确认删除吗?" onConfirm={async () => await handleDeleteMenu(record)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
const handleSearch = async () => {
try {
const values = await searchForm.validateFields();
// 这里可以发请求给后端带参数 /api/menu/tree?name=xxx&status=xxx
await getMenus(values);
} catch (err) {
console.error('表单验证失败:', err);
}
};
const handleReset = async () => {
searchForm.resetFields(); // 清空表单
await getMenus(); // 重新获取全部数据
};
return (
<SystemConstructConstruct>
<div className={`px-6 py-2 ${styles['construc-container']} h-[90vh] overflow-y-auto`}>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
className="mb-4"
>
<Form.Item label="菜单名称" name="name">
<Input placeholder="请输入菜单名称" />
</Form.Item>
<Form.Item label="状态" name="status">
<Select style={{ width: 120 }} placeholder="请选择状态">
<Option value=""></Option>
<Option value="0"></Option>
<Option value="1"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={handleReset}></Button>
<Button type="primary" onClick={handleAddMenu}>
</Button>
</Space>
</Form.Item>
</Form>
<Modal
title={menuModalType === 'add' ? '新增菜单' : '编辑菜单'}
open={menuModalVisible}
onCancel={() => {
setMenuModalVisible(false);
menuForm.resetFields();
}}
footer={null}
>
<Form
form={menuForm}
labelCol={{ span: 6 }}
initialValues={{
visible: true // 确保新增时默认开启
}}
wrapperCol={{ span: 16 }}
onFinish={async (values) => {
const params = {
...values,
status: 0,
visible: values.visible ? 1 : 0,
};
try {
let apiCall;
if (menuModalType === 'edit' && menuEditId) {
apiCall = updateMenu({ ...params, id: menuEditId });
} else {
apiCall = addMenu(params);
}
const [_, res] = await apiInterceptors(apiCall);
if (res) {
message.success(menuModalType === 'edit' ? '编辑成功' : '新增成功');
setMenuModalVisible(false);
menuForm.resetFields();
await getMenus(); // 刷新列表
} else {
message.error(res?.err_msg || '操作失败');
}
} catch (e) {
message.error('请求失败,请重试');
}
}}
>
<Form.Item label="菜单名称" name="name" rules={[{ required: true }]}>
<Input placeholder="请输入菜单名称" />
</Form.Item>
<Form.Item label="权限标识" name="permission">
<Input placeholder="请输入权限标识" />
</Form.Item>
<Form.Item label="菜单类型" name="type" rules={[{ required: true }]}>
<Select placeholder="请选择菜单类型">
<Option value={1}></Option>
<Option value={2}></Option>
<Option value={3}></Option>
</Select>
</Form.Item>
<Form.Item label="排序" name="sort" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="父级菜单" name="parent_id" rules={[{ required: true }]}>
<TreeSelect
treeData={buildMenuTreeOptions(menuList)}
placeholder="请选择上级菜单"
treeDefaultExpandAll
/>
</Form.Item>
<Form.Item
label="路由地址"
name="path"
rules={[
{ required: true, message: '请输入路由地址' },
{
validator(_, value) {
const trimmed = value?.trim();
if (!trimmed) {
return Promise.reject(new Error('请输入路由地址'));
}
if (trimmed.startsWith('/')) {
return Promise.resolve();
}
return Promise.reject(new Error('路由地址必须以 / 开头'));
},
},
]}
normalize={(value) => value?.trim()} // 自动去除前后空格
>
<Input placeholder="请输入组件路径" />
</Form.Item>
<Form.Item
label="组件路径"
name="component"
rules={[
{ required: true, message: '请输入组件路径' },
{
validator(_, value) {
const trimmed = value?.trim();
if (!trimmed) {
return Promise.reject(new Error('请输入组件路径'));
}
if (trimmed.startsWith('/')) {
return Promise.resolve();
}
return Promise.reject(new Error('组件路径必须以 / 开头'));
},
},
]}
normalize={(value) => value?.trim()} // 自动去除前后空格
>
<Input placeholder="请输入组件路径" />
</Form.Item>
<Form.Item label="组件名称" name="component_name" rules={[{ required: true }]}>
<Input placeholder="请输入组件名称" />
</Form.Item>
{/* <Form.Item label="" name="status" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" defaultChecked />
</Form.Item> */}
<Form.Item
label="是否可见"
name="visible"
valuePropName="checked"
getValueFromEvent={(e) => (e ? 1 : 0)}
initialValue={true}
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Space>
<Button onClick={() => setMenuModalVisible(false)}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Table
columns={getMenuColumns(t)}
dataSource={menuList}
rowKey="id"
loading={loadingMenus} // ✅ 显示加载状态
childrenColumnName="children"
defaultExpandAllRows
pagination={false}
/>
</div>
</SystemConstructConstruct>
);
};
export default Prompt;

@ -110,7 +110,7 @@ const Prompt = () => {
// 加载所有可用角色
setLoadingRoles(true);
const [err, roleData] = await apiInterceptors(getRoleListAuth());
debugger;
if (!err && roleData) {
setRoleList(roleData || []);
}
@ -239,7 +239,7 @@ const Prompt = () => {
setCurrentEditId(record.id);
try {
const [_, data] = await apiInterceptors(getUserDetail(record.id));
debugger;
if (data) {
addForm.setFieldsValue({
...data,

@ -134,4 +134,21 @@ export interface IOrganization {
name: string;
parent_id: number;
children?: IOrganization[];
}
// 在 @/types/system.ts 中添加或检查是否已有
export interface Menu {
id: number;
name: string;
parent_id: number;
path: string;
icon: string;
component: string;
component_name: string;
type: number;
visible: boolean;
status: number;
permission: string;
sort: number;
children?: Menu[];
}
Loading…
Cancel
Save