功能24:菜单管理
功能23:从后端获取路由/菜单数据
功能22:用户管理
功能21:使用axios发送请求
功能20:使用分页插件
功能19:集成MyBatis-Plus
功能18:创建后端工程
功能17:菜单管理
功能16:角色管理
功能15:用户管理
功能14:使用本地SVG图标库
功能13:侧边栏加入Logo
功能12:折叠/展开侧边栏
功能11:实现面包屑功能
功能10:添加首页菜单项
功能9:退出登录功能
功能8:页面权限控制
功能7:路由全局前置守卫
功能6:动态添加路由记录
功能5:侧边栏菜单动态显示
功能4:首页使用Layout布局
功能3:点击登录按钮实现页面跳转
功能2:静态登录界面
功能1:创建前端项目
前言
实现菜单管理的前端+后端增改查功能,暂时不实现删除。
一.操作步骤
1.SysMenuController
src/main/java/com/ruoyi/web/controller/system/SysMenuController.java
@RestController
@RequestMapping("/system/menu")
public class SysMenuController {@Autowiredprivate ISysMenuService menuService;@GetMapping("/list")public AjaxResult list() {List<SysMenu> menus = menuService.list();return AjaxResult.success(menus);}@GetMapping(value = "/{menuId}")public AjaxResult getInfo(@PathVariable Long menuId) {return AjaxResult.success(menuService.getById(menuId));}@PostMappingpublic AjaxResult add(@RequestBody SysMenu menu) {return toAjax(menuService.save(menu));}@PutMappingpublic AjaxResult edit(@RequestBody SysMenu menu) {return toAjax(menuService.updateById(menu));}protected AjaxResult toAjax(boolean status) {return status ? AjaxResult.success() : AjaxResult.error();}
}
前端
2.API
src\api\system\menu.js
import request from '@/utils/request'// 查询菜单列表
export function listMenu(query) {return request({url: '/system/menu/list',method: 'get',params: query})
}// 查询菜单详细
export function getMenu(menuId) {return request({url: '/system/menu/' + menuId,method: 'get'})
}// 查询菜单下拉树结构
export function treeselect() {}// 根据角色ID查询菜单下拉树结构
export function roleMenuTreeselect(roleId) {}// 新增菜单
export function addMenu(data) {return request({url: '/system/menu',method: 'post',data: data})
}// 修改菜单
export function updateMenu(data) {return request({url: '/system/menu',method: 'put',data: data})
}// 删除菜单
export function delMenu(menuId) {}
3.修改模板
src\views\system\menu\index.vue
<template><div class="app-container"><el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch"><el-form-item label="菜单名称" prop="menuName"><el-input v-model="queryParams.menuName" placeholder="请输入菜单名称" clearable style="width: 200px"@keyup.enter="handleQuery" /></el-form-item><el-form-item label="状态" prop="status"><el-select v-model="queryParams.status" placeholder="菜单状态" clearable style="width: 200px"><el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" /></el-select></el-form-item><el-form-item><el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button><el-button icon="Refresh" @click="resetQuery">重置</el-button></el-form-item></el-form><el-row :gutter="10" class="mb8"><el-col :span="1.5"><el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['system:menu:add']">新增</el-button></el-col><el-col :span="1.5"><el-button type="info" plain icon="Sort" @click="toggleExpandAll">展开/折叠</el-button></el-col><right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar></el-row><el-table v-if="refreshTable" v-loading="loading" :data="menuList" row-key="menuId":default-expand-all="isExpandAll" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"><el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column><el-table-column prop="icon" label="图标" align="center" width="100"><template #default="scope"><svg-icon :icon-class="scope.row.icon" /></template></el-table-column><el-table-column prop="orderNum" label="排序" width="60"></el-table-column><el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true"></el-table-column><el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true"></el-table-column><el-table-column prop="status" label="状态" width="80"><template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template></el-table-column><el-table-column label="创建时间" align="center" width="160" prop="createTime"><template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template></el-table-column><el-table-column label="操作" align="center" width="210" class-name="small-padding fixed-width"><template #default="scope"><el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)"v-hasPermi="['system:menu:edit']">修改</el-button><el-button link type="primary" icon="Plus" @click="handleAdd(scope.row)"v-hasPermi="['system:menu:add']">新增</el-button><el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)"v-hasPermi="['system:menu:remove']">删除</el-button></template></el-table-column></el-table><!-- 添加或修改菜单对话框 --><el-dialog :title="title" v-model="open" width="680px" append-to-body><el-form ref="menuRef" :model="form" :rules="rules" label-width="100px"><el-row><el-col :span="24"><el-form-item label="上级菜单"><el-tree-select v-model="form.parentId" :data="menuOptions":props="{ value: 'menuId', label: 'menuName', children: 'children' }" value-key="menuId"placeholder="选择上级菜单" check-strictly /></el-form-item></el-col><el-col :span="24"><el-form-item label="菜单类型" prop="menuType"><el-radio-group v-model="form.menuType"><el-radio value="M">目录</el-radio><el-radio value="C">菜单</el-radio><el-radio value="F">按钮</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12" v-if="form.menuType != 'F'"><el-form-item label="菜单图标" prop="icon"><el-popover placement="bottom-start" :width="540" trigger="click"><template #reference><el-input v-model="form.icon" placeholder="点击选择图标" @blur="showSelectIcon" readonly><template #prefix><svg-icon v-if="form.icon" :icon-class="form.icon" class="el-input__icon"style="height: 32px;width: 16px;" /><el-icon v-else style="height: 32px;width: 16px;"><search /></el-icon></template></el-input></template><icon-select ref="iconSelectRef" @selected="selected" :active-icon="form.icon" /></el-popover></el-form-item></el-col><el-col :span="12"><el-form-item label="显示排序" prop="orderNum"><el-input-number v-model="form.orderNum" controls-position="right" :min="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="菜单名称" prop="menuName"><el-input v-model="form.menuName" placeholder="请输入菜单名称" /></el-form-item></el-col><el-col :span="12" v-if="form.menuType == 'C'"><el-form-item prop="routeName"><template #label><span><el-tooltipcontent="默认不填则和路由地址相同:如地址为:`user`,则名称为`User`(注意:因为router会删除名称相同路由,为避免名字的冲突,特殊情况下请自定义,保证唯一性)"placement="top"><el-icon><question-filled /></el-icon></el-tooltip>路由名称</span></template><el-input v-model="form.routeName" placeholder="请输入路由名称" /></el-form-item></el-col><el-col :span="12" v-if="form.menuType != 'F'"><el-form-item><template #label><span><el-tooltip content="选择是外链则路由地址需要以`http(s)://`开头" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>是否外链</span></template><el-radio-group v-model="form.isFrame"><el-radio value="0">是</el-radio><el-radio value="1">否</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12" v-if="form.menuType != 'F'"><el-form-item prop="path"><template #label><span><el-tooltip content="访问的路由地址,如:`user`,如外网地址需内链访问则以`http(s)://`开头" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>路由地址</span></template><el-input v-model="form.path" placeholder="请输入路由地址" /></el-form-item></el-col><el-col :span="12" v-if="form.menuType == 'C'"><el-form-item prop="component"><template #label><span><el-tooltip content="访问的组件路径,如:`system/user/index`,默认在`views`目录下" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>组件路径</span></template><el-input v-model="form.component" placeholder="请输入组件路径" /></el-form-item></el-col><el-col :span="12" v-if="form.menuType != 'M'"><el-form-item><el-input v-model="form.perms" placeholder="请输入权限标识" maxlength="100" /><template #label><span><el-tooltip content="控制器中定义的权限字符,如:@PreAuthorize(`@ss.hasPermi('system:user:list')`)" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>权限字符</span></template></el-form-item></el-col><el-col :span="12" v-if="form.menuType == 'C'"><el-form-item><el-input v-model="form.query" placeholder="请输入路由参数" maxlength="255" /><template #label><span><el-tooltip content='访问路由的默认传递参数,如:`{"id": 1, "name": "ry"}`' placement="top"><el-icon><question-filled /></el-icon></el-tooltip>路由参数</span></template></el-form-item></el-col><el-col :span="12" v-if="form.menuType == 'C'"><el-form-item><template #label><span><el-tooltip content="选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>是否缓存</span></template><el-radio-group v-model="form.isCache"><el-radio value="0">缓存</el-radio><el-radio value="1">不缓存</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12" v-if="form.menuType != 'F'"><el-form-item><template #label><span><el-tooltip content="选择隐藏则路由将不会出现在侧边栏,但仍然可以访问" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>显示状态</span></template><el-radio-group v-model="form.visible"><el-radio v-for="dict in sys_show_hide" :key="dict.value" :value="dict.value">{{ dict.label}}</el-radio></el-radio-group></el-form-item></el-col><el-col :span="12"><el-form-item><template #label><span><el-tooltip content="选择停用则路由将不会出现在侧边栏,也不能被访问" placement="top"><el-icon><question-filled /></el-icon></el-tooltip>菜单状态</span></template><el-radio-group v-model="form.status"><el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label}}</el-radio></el-radio-group></el-form-item></el-col></el-row></el-form><template #footer><div class="dialog-footer"><el-button type="primary" @click="submitForm">确 定</el-button><el-button @click="cancel">取 消</el-button></div></template></el-dialog></div>
</template><script setup name="Menu">
import { addMenu, delMenu, getMenu, listMenu, updateMenu } from "@/api/system/menu";
// import SvgIcon from "@/components/SvgIcon";
import IconSelect from "@/components/IconSelect/index.vue";
import { ref, getCurrentInstance, reactive, toRefs } from "vue";
import { parseTime, handleTree } from '@/utils/ruoyi'
import { ElMessage, ElMessageBox } from 'element-plus'const { proxy } = getCurrentInstance();
// const { sys_show_hide, sys_normal_disable } = proxy.useDict("sys_show_hide", "sys_normal_disable");const sys_normal_disable = [{ "value": "0", "label": "正常" }, { "value": "1", "label": "停用" }]
const sys_show_hide = [{ "value": "0", "label": "显示" }, { "value": "1", "label": "隐藏" }]const menuList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const title = ref("");
const menuOptions = ref([]);
const isExpandAll = ref(false);
const refreshTable = ref(true);
const iconSelectRef = ref(null);const data = reactive({form: {},queryParams: {menuName: undefined,visible: undefined},rules: {menuName: [{ required: true, message: "菜单名称不能为空", trigger: "blur" }],orderNum: [{ required: true, message: "菜单顺序不能为空", trigger: "blur" }],path: [{ required: true, message: "路由地址不能为空", trigger: "blur" }]},
});const { queryParams, form, rules } = toRefs(data);/** 查询菜单列表 */
function getList() {loading.value = true;listMenu(queryParams.value).then(response => {menuList.value = handleTree(response.data, "menuId");loading.value = false;});
}/** 查询菜单下拉树结构 */
function getTreeselect() {menuOptions.value = [];listMenu().then(response => {const menu = { menuId: 0, menuName: "主类目", children: [] };menu.children = handleTree(response.data, "menuId");menuOptions.value.push(menu);});
}/** 取消按钮 */
function cancel() {open.value = false;reset();
}/** 表单重置 */
function reset() {form.value = {menuId: undefined,parentId: 0,menuName: undefined,icon: undefined,menuType: "M",orderNum: undefined,isFrame: "1",isCache: "0",visible: "0",status: "0"};
}/** 展示下拉图标 */
function showSelectIcon() {iconSelectRef.value.reset();
}/** 选择图标 */
function selected(name) {form.value.icon = name;
}/** 搜索按钮操作 */
function handleQuery() {getList();
}/** 重置按钮操作 */
function resetQuery() {proxy.resetForm("queryRef");handleQuery();
}/** 新增按钮操作 */
function handleAdd(row) {reset();getTreeselect();if (row != null && row.menuId) {form.value.parentId = row.menuId;} else {form.value.parentId = 0;}open.value = true;title.value = "添加菜单";
}/** 展开/折叠操作 */
function toggleExpandAll() {refreshTable.value = false;isExpandAll.value = !isExpandAll.value;nextTick(() => {refreshTable.value = true;});
}/** 修改按钮操作 */
async function handleUpdate(row) {reset();getTreeselect();getMenu(row.menuId).then(response => {form.value = response.data;open.value = true;title.value = "修改菜单";});
}/** 提交按钮 */
function submitForm() {proxy.$refs["menuRef"].validate(valid => {if (valid) {if (form.value.menuId != undefined) {updateMenu(form.value).then(response => {ElMessage.success("修改成功")open.value = false;getList();});} else {addMenu(form.value).then(response => {ElMessage.success("新增成功")open.value = false;getList();});}}});
}/** 删除按钮操作 */
function handleDelete(row) {proxy.$modal.confirm('是否确认删除名称为"' + row.menuName + '"的数据项?').then(function () {return delMenu(row.menuId);}).then(() => {getList();proxy.$modal.msgSuccess("删除成功");}).catch(() => { });
}getList();
</script>
4.优化BUG
在开发过程中,随着功能的添加,之前实现的代码会产生一些bug,随功能一起优化代码。
src\components\IconSelect\index.vue
<template><div class="icon-body"><el-inputv-model="iconName"class="icon-search"clearableplaceholder="请输入图标名称"@clear="filterIcons"@input="filterIcons"><template #suffix><i class="el-icon-search el-input__icon" /></template></el-input><div class="icon-list"><div class="list-container"><div v-for="(item, index) in iconList" class="icon-item-wrapper" :key="index" @click="selectedIcon(item)"><div :class="['icon-item', { active: activeIcon === item }]"><svg-icon :icon-class="item" class-name="icon" style="height: 25px;width: 16px;"/><span>{{ item }}</span></div></div></div></div></div>
</template><script setup>
import icons from './requireIcons'
import { ref } from 'vue';const props = defineProps({activeIcon: {type: String}
});const iconName = ref('');
const iconList = ref(icons);
const emit = defineEmits(['selected']);function filterIcons() {iconList.value = iconsif (iconName.value) {iconList.value = icons.filter(item => item.indexOf(iconName.value) !== -1)}
}function selectedIcon(name) {emit('selected', name)document.body.click()
}function reset() {iconName.value = ''iconList.value = icons
}defineExpose({reset
})
</script><style lang='scss' scoped>.icon-body {width: 100%;padding: 10px;.icon-search {position: relative;margin-bottom: 5px;}.icon-list {height: 200px;overflow: auto;.list-container {display: flex;flex-wrap: wrap;.icon-item-wrapper {width: calc(100% / 3);height: 25px;line-height: 25px;cursor: pointer;display: flex;.icon-item {display: flex;max-width: 100%;height: 100%;padding: 0 5px;&:hover {background: #ececec;border-radius: 5px;}.icon {flex-shrink: 0;}span {display: inline-block;vertical-align: -0.15em;fill: currentColor;padding-left: 2px;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}}.icon-item.active {background: #ececec;border-radius: 5px;}}}}}
</style>
src\layout\components\MenuItem.vue
<template><div v-if="!item.hidden"><template v-if="hasChildren"><el-sub-menu :index="item.path"><template #title><svg-icon :icon-class="item.meta && item.meta.icon"/><span>{{ item.meta?.title }}</span></template><template v-for="child in item.children" :key="child.path"><MenuItem :item="child" :level="level + 1" :base-path="resolvePath(item.path)" /></template></el-sub-menu></template><template v-else><el-menu-item :index="resolvePath(item.path)"><svg-icon :icon-class="item.meta && item.meta.icon" /><template #title><span>{{ item.meta?.title }}</span></template></el-menu-item></template></div>
</template><script setup>
import { computed } from 'vue';const props = defineProps({item: {type: Object,required: true},level: {type: Number,default: 0},basePath: {type: String,default: ''}
});const hasChildren = computed(() => {if (props.level >= 2) {if (props.item.children) {console.error('菜单层级超过限制,最多允许两层子菜单');return false;}return false;}if (props.item.path === '/') {return false}return props.item.children && props.item.children.length > 0;
});const resolvePath = (routePath) => {if (routePath === '/') {return '/index'}if (props.basePath.length === 0) {return routePath}return getNormalPath(props.basePath + '/' + routePath)
}/*** 路径标准化处理器* 功能: 规范化URL/文件路径格式,确保路径符合统一格式标准* 核心处理逻辑:* 1. 处理空值及无效路径* 2. 转换双斜杠为单斜杠* 3. 去除路径末尾的斜杠* * @param {string} p - 原始路径字符串* @returns {string} 标准化后的路径*/
const getNormalPath = (p) => {// 空值安全处理:当传入空值/undefined字符串时直接返回原值if (p.length === 0 || !p || p == 'undefined') {return p};// 双斜杠转换:替换路径中的双斜杠为单斜杠let res = p.replace('/\/+/g', '/')// 末尾斜杠清理:当标准化后路径以斜杠结尾时移除末尾斜杠if (res[res.length - 1] === '/') {return res.slice(0, res.length - 1)}return res;
}
</script>
二.功能验证
运行项目,浏览器访问http://localhost:5173/system/menu