|
@@ -0,0 +1,340 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="menu-manage admin-page">
|
|
|
|
|
+ <el-card>
|
|
|
|
|
+ <div slot="header" class="admin-page-header">
|
|
|
|
|
+ <span class="admin-page-title">菜单管理</span>
|
|
|
|
|
+ <div class="flex items-center gap-sm">
|
|
|
|
|
+ <el-button type="primary" size="small" @click="handleRoleAuth">角色授权</el-button>
|
|
|
|
|
+ <el-button type="primary" size="small" @click="handleCreate(0)">新增顶级菜单</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="table-scroll-wrap">
|
|
|
|
|
+ <el-table
|
|
|
|
|
+ :data="menuTree"
|
|
|
|
|
+ row-key="id"
|
|
|
|
|
+ :tree-props="{children: 'children'}"
|
|
|
|
|
+ default-expand-all
|
|
|
|
|
+ stripe
|
|
|
|
|
+ :max-height="tableHeight"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-table-column prop="title" label="菜单名称">
|
|
|
|
|
+ <template slot-scope="scope">
|
|
|
|
|
+ <i v-if="scope.row.icon" :class="scope.row.icon" style="margin-right: 8px"></i>
|
|
|
|
|
+ <span>{{ scope.row.title || scope.row.label }}</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="icon" label="图标" width="120"></el-table-column>
|
|
|
|
|
+ <el-table-column prop="path" label="路由路径" width="180"></el-table-column>
|
|
|
|
|
+ <el-table-column prop="perm" label="权限标识" width="150">
|
|
|
|
|
+ <template slot-scope="scope">
|
|
|
|
|
+ <el-tag size="mini" type="info">{{ scope.row.perm || '-' }}</el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column prop="sort" label="排序" width="80"></el-table-column>
|
|
|
|
|
+ <el-table-column prop="visible" label="显示" width="80">
|
|
|
|
|
+ <template slot-scope="scope">
|
|
|
|
|
+ <el-switch v-model="scope.row.visible" size="mini" @change="handleToggleVisible(scope.row)"></el-switch>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="操作" width="280" fixed="right">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <el-button size="mini" @click="handleEdit(row)">编辑</el-button>
|
|
|
|
|
+ <el-button size="mini" @click="handleMove(row, 'up')">▲</el-button>
|
|
|
|
|
+ <el-button size="mini" @click="handleMove(row, 'down')">▼</el-button>
|
|
|
|
|
+ <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
|
|
|
|
|
+ <el-button size="mini" type="success" @click="handleCreate(row.id)">+ 子菜单</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-card>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 创建/编辑弹窗 -->
|
|
|
|
|
+ <el-dialog :title="isEdit ? '编辑菜单' : '新增菜单'" :visible.sync="menuDialogVisible" width="550px">
|
|
|
|
|
+ <el-form :model="menuForm" :rules="menuRules" ref="menuForm" label-width="100px">
|
|
|
|
|
+ <el-form-item label="上级菜单" prop="parentId">
|
|
|
|
|
+ <el-select v-model="menuForm.parentId" placeholder="请选择上级菜单" style="width:100%">
|
|
|
|
|
+ <el-option :value="0" label="顶级菜单">顶级菜单</el-option>
|
|
|
|
|
+ <el-option v-for="item in flatMenuOptions" :key="item.id" :label="item.title" :value="item.id"></el-option>
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="目录名称" prop="title">
|
|
|
|
|
+ <el-input v-model="menuForm.title" placeholder="有子菜单时填写"></el-input>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="菜单标签" prop="label">
|
|
|
|
|
+ <el-input v-model="menuForm.label" placeholder="叶子节点显示名"></el-input>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="路由路径" prop="path">
|
|
|
|
|
+ <el-input v-model="menuForm.path" placeholder="/admin/xxx"></el-input>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="图标" prop="icon">
|
|
|
|
|
+ <el-input v-model="menuForm.icon" placeholder="el-icon-s-custom"></el-input>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="权限标识" prop="perm">
|
|
|
|
|
+ <el-input v-model="menuForm.perm" placeholder="system:menu"></el-input>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="排序" prop="sort">
|
|
|
|
|
+ <el-input-number v-model="menuForm.sort" :min="0" style="width:100%"></el-input-number>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="是否显示" prop="visible">
|
|
|
|
|
+ <el-switch v-model="menuForm.visible"></el-switch>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </el-form>
|
|
|
|
|
+ <div slot="footer">
|
|
|
|
|
+ <el-button @click="menuDialogVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="submitting" @click="submitMenu">保存</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 角色授权弹窗 -->
|
|
|
|
|
+ <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="800px">
|
|
|
|
|
+ <div class="auth-container">
|
|
|
|
|
+ <div class="role-list">
|
|
|
|
|
+ <el-radio-group v-model="selectedRole" class="vertical-radio-group">
|
|
|
|
|
+ <el-radio-button v-for="role in roles" :key="role.value" :label="role.value">
|
|
|
|
|
+ {{ role.label }}
|
|
|
|
|
+ </el-radio-button>
|
|
|
|
|
+ </el-radio-group>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="menu-auth-tree">
|
|
|
|
|
+ <el-tree
|
|
|
|
|
+ ref="authTree"
|
|
|
|
|
+ :data="menuTree"
|
|
|
|
|
+ show-checkbox
|
|
|
|
|
+ node-key="id"
|
|
|
|
|
+ :props="{ label: 'title' }"
|
|
|
|
|
+ :default-checked-keys="checkedMenuIds"
|
|
|
|
|
+ @check="handleCheckChange"
|
|
|
|
|
+ ></el-tree>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div slot="footer">
|
|
|
|
|
+ <el-button @click="authDialogVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :loading="submitting" @click="saveRoleAuth">保存授权</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import {
|
|
|
|
|
+ getMenuTree, getRoleMenus, saveRoleMenus,
|
|
|
|
|
+ createMenu, updateMenu, deleteMenu, moveMenu
|
|
|
|
|
+} from '@/api/menu'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ name: 'MenuManage',
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ menuTree: [],
|
|
|
|
|
+ menuDialogVisible: false,
|
|
|
|
|
+ authDialogVisible: false,
|
|
|
|
|
+ isEdit: false,
|
|
|
|
|
+ submitting: false,
|
|
|
|
|
+
|
|
|
|
|
+ // 菜单表单
|
|
|
|
|
+ menuForm: this.getEmptyMenuForm(),
|
|
|
|
|
+ menuRules: {
|
|
|
|
|
+ title: [{ required: false, message: '请输入目录名称', trigger: 'blur' }],
|
|
|
|
|
+ label: [{ required: false, message: '请输入菜单标签', trigger: 'blur' }],
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // 角色授权
|
|
|
|
|
+ selectedRole: 'admin',
|
|
|
|
|
+ checkedMenuIds: [],
|
|
|
|
|
+ roles: [
|
|
|
|
|
+ { label: '管理员', value: 'admin' },
|
|
|
|
|
+ { label: '成长规划师', value: 'teacher' },
|
|
|
|
|
+ { label: '营养师', value: 'nutritionist' },
|
|
|
|
|
+ { label: '文章管理员', value: 'article_manager' },
|
|
|
|
|
+ { label: '活动管理员', value: 'activity_manager' },
|
|
|
|
|
+ { label: '供应商管理员', value: 'supplier_admin' },
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ tableHeight() {
|
|
|
|
|
+ return window.innerHeight - 250
|
|
|
|
|
+ },
|
|
|
|
|
+ flatMenuOptions() {
|
|
|
|
|
+ const options = []
|
|
|
|
|
+ const flatten = (nodes) => {
|
|
|
|
|
+ nodes.forEach(node => {
|
|
|
|
|
+ options.push({ id: node.id, title: node.title || node.label })
|
|
|
|
|
+ if (node.children) flatten(node.children)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ flatten(this.menuTree)
|
|
|
|
|
+ return options
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ created() {
|
|
|
|
|
+ this.loadMenuTree()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ getEmptyMenuForm() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: null,
|
|
|
|
|
+ parentId: 0,
|
|
|
|
|
+ title: '',
|
|
|
|
|
+ label: '',
|
|
|
|
|
+ path: '',
|
|
|
|
|
+ icon: '',
|
|
|
|
|
+ perm: '',
|
|
|
|
|
+ sort: 0,
|
|
|
|
|
+ visible: true
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async loadMenuTree() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getMenuTree()
|
|
|
|
|
+ if (res.data) this.menuTree = res.data
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error('加载菜单树失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ handleCreate(parentId) {
|
|
|
|
|
+ this.isEdit = false
|
|
|
|
|
+ this.menuForm = { ...this.getEmptyMenuForm(), parentId }
|
|
|
|
|
+ this.menuDialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ handleEdit(row) {
|
|
|
|
|
+ this.isEdit = true
|
|
|
|
|
+ this.menuForm = { ...row }
|
|
|
|
|
+ this.menuDialogVisible = true
|
|
|
|
|
+ },
|
|
|
|
|
+ async submitMenu() {
|
|
|
|
|
+ // 简单校验:title 和 label 至少填一个
|
|
|
|
|
+ if (!this.menuForm.title && !this.menuForm.label) {
|
|
|
|
|
+ this.$message.warning('目录名称或菜单标签必须填写其中一个')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.submitting = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (this.isEdit) {
|
|
|
|
|
+ await updateMenu(this.menuForm)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await createMenu(this.menuForm)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.$message.success('保存成功')
|
|
|
|
|
+ this.menuDialogVisible = false
|
|
|
|
|
+ this.loadMenuTree()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '保存失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.submitting = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleDelete(row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.$confirm('确定删除该菜单及其所有子菜单吗?', '警告', { type: 'warning' })
|
|
|
|
|
+ await deleteMenu({ id: row.id })
|
|
|
|
|
+ this.$message.success('删除成功')
|
|
|
|
|
+ this.loadMenuTree()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ if (e !== 'cancel') this.$message.error(e.message || '删除失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleMove(row, direction) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await moveMenu({ id: row.id, direction })
|
|
|
|
|
+ this.$message.success('排序已更新')
|
|
|
|
|
+ this.loadMenuTree()
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '调整失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleToggleVisible(row) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await updateMenu(row)
|
|
|
|
|
+ this.$message.success('状态已更新')
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error('更新失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async handleRoleAuth() {
|
|
|
|
|
+ this.authDialogVisible = true
|
|
|
|
|
+ await this.loadRoleMenus()
|
|
|
|
|
+ },
|
|
|
|
|
+ async loadRoleMenus() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getRoleMenus({ role: this.selectedRole })
|
|
|
|
|
+ this.checkedMenuIds = res.data || []
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error('加载授权菜单失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async saveRoleAuth() {
|
|
|
|
|
+ this.submitting = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 合并全选/半选节点
|
|
|
|
|
+ const checked = this.$refs.authTree.getCheckedKeys()
|
|
|
|
|
+ const halfChecked = this.$refs.authTree.getHalfCheckedKeys()
|
|
|
|
|
+ const allIds = [...new Set([...checked, ...halfChecked])]
|
|
|
|
|
+
|
|
|
|
|
+ await saveRoleMenus({
|
|
|
|
|
+ role: this.selectedRole,
|
|
|
|
|
+ menuIds: allIds
|
|
|
|
|
+ })
|
|
|
|
|
+ this.$message.success('授权保存成功')
|
|
|
|
|
+ this.authDialogVisible = false
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.$message.error(e.message || '保存失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.submitting = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ handleCheckChange() {
|
|
|
|
|
+ // 仅用于触发内部状态更新,实际保存由 saveRoleAuth 处理
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ watch: {
|
|
|
|
|
+ selectedRole() {
|
|
|
|
|
+ this.loadRoleMenus()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.menu-manage {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.auth-container {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 20px;
|
|
|
|
|
+ height: 400px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.role-list {
|
|
|
|
|
+ width: 200px;
|
|
|
|
|
+ border-right: 1px solid #ebeef5;
|
|
|
|
|
+ padding-right: 20px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.vertical-radio-group {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.vertical-radio-group .el-radio-button {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ margin-bottom: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.menu-auth-tree {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ padding-left: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.flex { display: flex; }
|
|
|
|
|
+.items-center { align-items: center; }
|
|
|
|
|
+.gap-sm { gap: 8px; }
|
|
|
|
|
+</style>
|