|
|
@@ -3,81 +3,99 @@
|
|
|
<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>
|
|
|
+ <div class="header-actions">
|
|
|
+ <div class="search-box">
|
|
|
+ <el-input
|
|
|
+ v-model="searchKeyword"
|
|
|
+ placeholder="搜索菜单名称 / 路径 / 权限标识"
|
|
|
+ size="small"
|
|
|
+ prefix-icon="el-icon-search"
|
|
|
+ clearable
|
|
|
+ style="width: 260px"
|
|
|
+ ></el-input>
|
|
|
+ </div>
|
|
|
+ <el-button type="primary" size="small" @click="openRoleAuth">角色授权</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
|
|
|
+ <el-table
|
|
|
+ :data="filteredMenuTree"
|
|
|
+ row-key="id"
|
|
|
+ :tree-props="{ children: 'children' }"
|
|
|
+ :default-expand-all="!searchKeyword"
|
|
|
stripe
|
|
|
:max-height="tableHeight"
|
|
|
+ style="min-width: 1200px; width: 100%"
|
|
|
>
|
|
|
- <el-table-column prop="title" label="菜单名称">
|
|
|
+ <el-table-column prop="displayName" label="菜单名称" min-width="220" show-overflow-tooltip>
|
|
|
<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>
|
|
|
+ <i v-if="scope.row.icon" :class="scope.row.icon" class="menu-icon-cell"></i>
|
|
|
+ <span>{{ scope.row.displayName }}</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">
|
|
|
+ <el-table-column prop="icon" label="图标" min-width="140" show-overflow-tooltip></el-table-column>
|
|
|
+ <el-table-column prop="path" label="路由路径" min-width="200" show-overflow-tooltip>
|
|
|
+ <template slot-scope="scope">
|
|
|
+ {{ scope.row.path || '-' }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="perm" label="权限标识" min-width="180" show-overflow-tooltip>
|
|
|
<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">
|
|
|
+ <el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
|
|
|
+ <el-table-column prop="visible" label="显示" width="80" align="center">
|
|
|
<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">
|
|
|
+ <el-table-column label="操作" width="320" 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" @click="handleMove(row, 'up')" :disabled="!canMove(row, 'up')">▲</el-button>
|
|
|
+ <el-button size="mini" @click="handleMove(row, 'down')" :disabled="!canMove(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 v-if="!filteredMenuTree.length" class="empty-state">
|
|
|
+ <el-empty description="暂无菜单数据"></el-empty>
|
|
|
+ </div>
|
|
|
</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-form-item label="上级菜单">
|
|
|
<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-option v-for="item in flatMenuOptions" :key="item.id" :label="item.displayName" :value="item.id"></el-option>
|
|
|
</el-select>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="目录名称" prop="title">
|
|
|
+ <el-form-item label="目录名称">
|
|
|
<el-input v-model="menuForm.title" placeholder="有子菜单时填写"></el-input>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="菜单标签" prop="label">
|
|
|
+ <el-form-item 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 label="路由路径">
|
|
|
+ <el-input v-model="menuForm.path" placeholder="/xxx"></el-input>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="图标" prop="icon">
|
|
|
+ <el-form-item label="图标">
|
|
|
<el-input v-model="menuForm.icon" placeholder="el-icon-s-custom"></el-input>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="权限标识" prop="perm">
|
|
|
+ <el-form-item label="权限标识">
|
|
|
<el-input v-model="menuForm.perm" placeholder="system:menu"></el-input>
|
|
|
</el-form-item>
|
|
|
- <el-form-item label="排序" prop="sort">
|
|
|
+ <el-form-item label="排序">
|
|
|
<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-form-item label="是否显示">
|
|
|
<el-switch v-model="menuForm.visible"></el-switch>
|
|
|
</el-form-item>
|
|
|
</el-form>
|
|
|
@@ -87,116 +105,226 @@
|
|
|
</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>
|
|
|
+ <!-- 角色授权弹窗(双Tab) -->
|
|
|
+ <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="950px" :close-on-click-modal="false">
|
|
|
+ <div class="auth-tabs-wrap">
|
|
|
+ <el-tabs v-model="authTab">
|
|
|
+ <el-tab-pane label="按角色授权" name="byRole">
|
|
|
+ <div class="auth-by-role">
|
|
|
+ <div class="role-radio-col">
|
|
|
+ <div class="role-col-header">选择角色</div>
|
|
|
+ <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">
|
|
|
+ <div class="tree-col-header">
|
|
|
+ <span>选择菜单</span>
|
|
|
+ <div class="tree-actions">
|
|
|
+ <el-button size="mini" @click="expandAll">全部展开</el-button>
|
|
|
+ <el-button size="mini" @click="collapseAll">全部折叠</el-button>
|
|
|
+ <el-button size="mini" @click="checkAll">全选</el-button>
|
|
|
+ <el-button size="mini" @click="uncheckAll">全不选</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-tree
|
|
|
+ ref="authTreeByRole"
|
|
|
+ :data="menuTree"
|
|
|
+ show-checkbox
|
|
|
+ node-key="id"
|
|
|
+ :props="{ label: 'displayName' }"
|
|
|
+ :default-checked-keys="currentRoleMenuIds"
|
|
|
+ @check="onRoleTreeCheck"
|
|
|
+ ></el-tree>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-tab-pane>
|
|
|
+ <el-tab-pane label="按菜单授权" name="byMenu">
|
|
|
+ <div class="auth-by-menu">
|
|
|
+ <div class="menu-tree-col">
|
|
|
+ <div class="tree-col-header">
|
|
|
+ <span>菜单(点击选中)</span>
|
|
|
+ <div class="tree-actions">
|
|
|
+ <el-input
|
|
|
+ v-model="menuSearchKeyword"
|
|
|
+ size="mini"
|
|
|
+ placeholder="搜索菜单"
|
|
|
+ prefix-icon="el-icon-search"
|
|
|
+ clearable
|
|
|
+ style="width:180px"
|
|
|
+ ></el-input>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-tree
|
|
|
+ ref="authTreeByMenu"
|
|
|
+ :data="menuSearchTree"
|
|
|
+ node-key="id"
|
|
|
+ highlight-current
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ :props="{ label: 'displayName' }"
|
|
|
+ @node-click="onMenuNodeClick"
|
|
|
+ ></el-tree>
|
|
|
+ </div>
|
|
|
+ <div class="menu-role-col">
|
|
|
+ <div class="role-col-header">
|
|
|
+ <span>已授权角色</span>
|
|
|
+ <span class="menu-count">(共 {{ Object.keys(allMenuRoles).length }} 个菜单)</span>
|
|
|
+ </div>
|
|
|
+ <div class="menu-role-list">
|
|
|
+ <template v-if="selectedAuthMenu">
|
|
|
+ <div class="menu-title-bar">
|
|
|
+ <i v-if="selectedAuthMenu.icon" :class="selectedAuthMenu.icon"></i>
|
|
|
+ <span>{{ selectedAuthMenu.displayName }}</span>
|
|
|
+ <el-tag size="mini" type="info">{{ selectedAuthMenu.perm || '无' }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="menu-role-desc">勾选表示该角色可访问此菜单</div>
|
|
|
+ <el-checkbox-group v-model="selectedAuthMenuRoles" class="menu-role-checkbox-group">
|
|
|
+ <el-checkbox v-for="role in roles" :key="role.value" :label="role.value">
|
|
|
+ {{ role.label }}
|
|
|
+ </el-checkbox>
|
|
|
+ </el-checkbox-group>
|
|
|
+ <div class="menu-role-ops">
|
|
|
+ <el-button type="primary" size="small" @click="saveMenuRoles">保存授权</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <el-empty v-else description="请先在左侧选择菜单"></el-empty>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-tab-pane>
|
|
|
+ </el-tabs>
|
|
|
</div>
|
|
|
</el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import {
|
|
|
- getMenuTree, getRoleMenus, saveRoleMenus,
|
|
|
- createMenu, updateMenu, deleteMenu, moveMenu
|
|
|
+import {
|
|
|
+ getMenuTree, getRoleMenus, saveRoleMenus,
|
|
|
+ createMenu, updateMenu, deleteMenu, moveMenu
|
|
|
} from '@/api/menu'
|
|
|
|
|
|
+const ROLES = [
|
|
|
+ { label: '管理员', value: 'admin' },
|
|
|
+ { label: '成长规划师', value: 'teacher' },
|
|
|
+ { label: '营养师', value: 'nutritionist' },
|
|
|
+ { label: '文章管理员', value: 'article_manager' },
|
|
|
+ { label: '活动管理员', value: 'activity_manager' },
|
|
|
+ { label: '供应商管理员', value: 'supplier_admin' },
|
|
|
+]
|
|
|
+
|
|
|
export default {
|
|
|
name: 'MenuManage',
|
|
|
data() {
|
|
|
return {
|
|
|
menuTree: [],
|
|
|
+ searchKeyword: '',
|
|
|
menuDialogVisible: false,
|
|
|
- authDialogVisible: false,
|
|
|
isEdit: false,
|
|
|
submitting: false,
|
|
|
-
|
|
|
- // 菜单表单
|
|
|
- menuForm: this.getEmptyMenuForm(),
|
|
|
- menuRules: {
|
|
|
- title: [{ required: false, message: '请输入目录名称', trigger: 'blur' }],
|
|
|
- label: [{ required: false, message: '请输入菜单标签', trigger: 'blur' }],
|
|
|
- },
|
|
|
-
|
|
|
- // 角色授权
|
|
|
+ menuForm: this.getEmptyForm(),
|
|
|
+ menuRules: {},
|
|
|
+ // 授权弹窗
|
|
|
+ authDialogVisible: false,
|
|
|
+ authTab: 'byRole',
|
|
|
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' },
|
|
|
- ]
|
|
|
+ roles: ROLES,
|
|
|
+ // 角色→菜单ID集合(Map<String, Set<Long>>)
|
|
|
+ roleMenuMap: {},
|
|
|
+ // 菜单→角色集合(Map<Long, Set<String>>)
|
|
|
+ allMenuRoles: {},
|
|
|
+ // 菜单授权选中
|
|
|
+ selectedAuthMenu: null,
|
|
|
+ selectedAuthMenuRoles: [],
|
|
|
+ menuSearchKeyword: '',
|
|
|
+ saveAuthLoading: false,
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
tableHeight() {
|
|
|
- return window.innerHeight - 250
|
|
|
+ return window.innerHeight - 280
|
|
|
+ },
|
|
|
+ // 搜索过滤后的菜单树(含 displayName 派生字段)
|
|
|
+ filteredMenuTree() {
|
|
|
+ if (!this.searchKeyword) {
|
|
|
+ return this.addDisplayName(this.menuTree)
|
|
|
+ }
|
|
|
+ const kw = this.searchKeyword.toLowerCase()
|
|
|
+ return this.filterTree(this.menuTree, kw)
|
|
|
},
|
|
|
flatMenuOptions() {
|
|
|
const options = []
|
|
|
const flatten = (nodes) => {
|
|
|
- nodes.forEach(node => {
|
|
|
- options.push({ id: node.id, title: node.title || node.label })
|
|
|
+ for (const node of nodes) {
|
|
|
+ options.push({ id: node.id, displayName: node.title || node.label })
|
|
|
if (node.children) flatten(node.children)
|
|
|
- })
|
|
|
+ }
|
|
|
}
|
|
|
flatten(this.menuTree)
|
|
|
return options
|
|
|
+ },
|
|
|
+ // 按角色授权 Tab: 当前角色的已授权菜单 ID
|
|
|
+ currentRoleMenuIds() {
|
|
|
+ return this.roleMenuMap[this.selectedRole] || []
|
|
|
+ },
|
|
|
+ // 菜单搜索 Tab 的过滤树
|
|
|
+ menuSearchTree() {
|
|
|
+ if (!this.menuSearchKeyword) {
|
|
|
+ return this.addDisplayName(this.menuTree)
|
|
|
+ }
|
|
|
+ const kw = this.menuSearchKeyword.toLowerCase()
|
|
|
+ return this.filterTree(this.menuTree, kw)
|
|
|
}
|
|
|
},
|
|
|
created() {
|
|
|
this.loadMenuTree()
|
|
|
},
|
|
|
methods: {
|
|
|
- getEmptyMenuForm() {
|
|
|
- return {
|
|
|
- id: null,
|
|
|
- parentId: 0,
|
|
|
- title: '',
|
|
|
- label: '',
|
|
|
- path: '',
|
|
|
- icon: '',
|
|
|
- perm: '',
|
|
|
- sort: 0,
|
|
|
- visible: true
|
|
|
- }
|
|
|
+ getEmptyForm() {
|
|
|
+ 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
|
|
|
+ this.menuTree = (res.data && res.data.length) ? res.data : []
|
|
|
} catch (e) {
|
|
|
this.$message.error('加载菜单树失败')
|
|
|
}
|
|
|
},
|
|
|
+ // 递归添加 displayName
|
|
|
+ addDisplayName(nodes) {
|
|
|
+ return (nodes || []).map(n => {
|
|
|
+ const item = { ...n }
|
|
|
+ item.displayName = item.title || item.label || ''
|
|
|
+ if (item.children) item.children = this.addDisplayName(item.children)
|
|
|
+ return item
|
|
|
+ })
|
|
|
+ },
|
|
|
+ // 递归搜索:保留匹配节点 + 其祖先
|
|
|
+ filterTree(nodes, kw) {
|
|
|
+ const result = []
|
|
|
+ for (const n of nodes || []) {
|
|
|
+ const name = (n.title || n.label || '').toLowerCase()
|
|
|
+ const path = (n.path || '').toLowerCase()
|
|
|
+ const perm = (n.perm || '').toLowerCase()
|
|
|
+ const matchSelf = name.includes(kw) || path.includes(kw) || perm.includes(kw)
|
|
|
+ const children = n.children ? this.filterTree(n.children, kw) : null
|
|
|
+ if (matchSelf || (children && children.length > 0)) {
|
|
|
+ const item = { ...n }
|
|
|
+ item.displayName = item.title || item.label || ''
|
|
|
+ if (children && children.length > 0) item.children = children
|
|
|
+ result.push(item)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result
|
|
|
+ },
|
|
|
+ // === 菜单 CRUD ===
|
|
|
handleCreate(parentId) {
|
|
|
this.isEdit = false
|
|
|
- this.menuForm = { ...this.getEmptyMenuForm(), parentId }
|
|
|
+ this.menuForm = { ...this.getEmptyForm(), parentId }
|
|
|
this.menuDialogVisible = true
|
|
|
},
|
|
|
handleEdit(row) {
|
|
|
@@ -205,22 +333,17 @@ export default {
|
|
|
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)
|
|
|
- }
|
|
|
+ if (this.isEdit) await updateMenu(this.menuForm)
|
|
|
+ else await createMenu(this.menuForm)
|
|
|
this.$message.success('保存成功')
|
|
|
this.menuDialogVisible = false
|
|
|
- this.loadMenuTree()
|
|
|
+ await this.loadMenuTree()
|
|
|
} catch (e) {
|
|
|
this.$message.error(e.message || '保存失败')
|
|
|
} finally {
|
|
|
@@ -229,10 +352,10 @@ export default {
|
|
|
},
|
|
|
async handleDelete(row) {
|
|
|
try {
|
|
|
- await this.$confirm('确定删除该菜单及其所有子菜单吗?', '警告', { type: 'warning' })
|
|
|
+ await this.$confirm('确定删除该菜单吗?(如有子菜单将无法删除)', '警告', { type: 'warning' })
|
|
|
await deleteMenu({ id: row.id })
|
|
|
this.$message.success('删除成功')
|
|
|
- this.loadMenuTree()
|
|
|
+ await this.loadMenuTree()
|
|
|
} catch (e) {
|
|
|
if (e !== 'cancel') this.$message.error(e.message || '删除失败')
|
|
|
}
|
|
|
@@ -240,59 +363,133 @@ export default {
|
|
|
async handleMove(row, direction) {
|
|
|
try {
|
|
|
await moveMenu({ id: row.id, direction })
|
|
|
- this.$message.success('排序已更新')
|
|
|
- this.loadMenuTree()
|
|
|
+ await this.loadMenuTree()
|
|
|
} catch (e) {
|
|
|
- this.$message.error(e.message || '调整失败')
|
|
|
+ this.$message.error('调整失败')
|
|
|
}
|
|
|
},
|
|
|
async handleToggleVisible(row) {
|
|
|
try {
|
|
|
await updateMenu(row)
|
|
|
- this.$message.success('状态已更新')
|
|
|
} catch (e) {
|
|
|
this.$message.error('更新失败')
|
|
|
}
|
|
|
},
|
|
|
- async handleRoleAuth() {
|
|
|
+ canMove(row, direction) {
|
|
|
+ return true // 后端判断是否可移动
|
|
|
+ },
|
|
|
+ // === 角色授权 ===
|
|
|
+ openRoleAuth() {
|
|
|
+ this.authTab = 'byRole'
|
|
|
this.authDialogVisible = true
|
|
|
- await this.loadRoleMenus()
|
|
|
+ this.loadAllRoleMenus()
|
|
|
},
|
|
|
- async loadRoleMenus() {
|
|
|
- try {
|
|
|
- const res = await getRoleMenus({ role: this.selectedRole })
|
|
|
- this.checkedMenuIds = res.data || []
|
|
|
- } catch (e) {
|
|
|
- this.$message.error('加载授权菜单失败')
|
|
|
+ async loadAllRoleMenus() {
|
|
|
+ // 并行拉取所有角色的已授权菜单 ID
|
|
|
+ const promises = this.roles.map(r =>
|
|
|
+ getRoleMenus({ role: r.value }).then(res => {
|
|
|
+ const ids = (res.data || []).filter(id => id != null)
|
|
|
+ this.roleMenuMap[r.value] = ids
|
|
|
+ }).catch(() => {
|
|
|
+ this.roleMenuMap[r.value] = []
|
|
|
+ })
|
|
|
+ )
|
|
|
+ await Promise.all(promises)
|
|
|
+ // 构建 menuId → Set<Role>
|
|
|
+ const menuRoles = {}
|
|
|
+ for (const role of Object.keys(this.roleMenuMap)) {
|
|
|
+ for (const id of this.roleMenuMap[role]) {
|
|
|
+ if (!menuRoles[id]) menuRoles[id] = new Set()
|
|
|
+ menuRoles[id].add(role)
|
|
|
+ }
|
|
|
}
|
|
|
+ this.allMenuRoles = menuRoles
|
|
|
},
|
|
|
- async saveRoleAuth() {
|
|
|
- this.submitting = true
|
|
|
+ // 角色→菜单 Tab: 树勾选变化时同步
|
|
|
+ onRoleTreeCheck() {
|
|
|
+ const checked = this.$refs.authTreeByRole.getCheckedKeys()
|
|
|
+ const half = this.$refs.authTreeByRole.getHalfCheckedKeys()
|
|
|
+ const ids = [...new Set([...checked, ...half])]
|
|
|
+ this.roleMenuMap[this.selectedRole] = ids
|
|
|
+ this.rebuildMenuRoles()
|
|
|
+ },
|
|
|
+ // 菜单→角色 Tab: 点击菜单节点
|
|
|
+ onMenuNodeClick(node) {
|
|
|
+ this.selectedAuthMenu = node
|
|
|
+ const roles = this.allMenuRoles[node.id]
|
|
|
+ this.selectedAuthMenuRoles = roles ? [...roles] : []
|
|
|
+ },
|
|
|
+ // 保存菜单→角色的授权(每个角色全覆盖保存)
|
|
|
+ async saveMenuRoles() {
|
|
|
+ if (!this.selectedAuthMenu) return
|
|
|
+ const menuId = this.selectedAuthMenu.id
|
|
|
+ const selectedRoles = new Set(this.selectedAuthMenuRoles)
|
|
|
+ // 收集需要保存的角色
|
|
|
+ const prevRoles = this.allMenuRoles[menuId] || new Set()
|
|
|
+ const touchedRoles = new Set([...selectedRoles, ...prevRoles])
|
|
|
+ // 更新 roleMenuMap
|
|
|
+ for (const role of this.roles) {
|
|
|
+ const shouldHave = selectedRoles.has(role.value)
|
|
|
+ const currentSet = new Set(this.roleMenuMap[role.value] || [])
|
|
|
+ if (shouldHave) currentSet.add(menuId)
|
|
|
+ else currentSet.delete(menuId)
|
|
|
+ this.roleMenuMap[role.value] = [...currentSet]
|
|
|
+ }
|
|
|
+ // 同步 allMenuRoles
|
|
|
+ this.rebuildMenuRoles()
|
|
|
+ // 只对触摸过的角色调用保存
|
|
|
+ this.saveAuthLoading = true
|
|
|
+ const promises = []
|
|
|
+ for (const role of touchedRoles) {
|
|
|
+ const ids = this.roleMenuMap[role] || []
|
|
|
+ promises.push(saveRoleMenus({ role, menuIds: ids }))
|
|
|
+ }
|
|
|
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
|
|
|
- })
|
|
|
+ await Promise.all(promises)
|
|
|
this.$message.success('授权保存成功')
|
|
|
- this.authDialogVisible = false
|
|
|
} catch (e) {
|
|
|
- this.$message.error(e.message || '保存失败')
|
|
|
+ this.$message.error('授权保存失败')
|
|
|
} finally {
|
|
|
- this.submitting = false
|
|
|
+ this.saveAuthLoading = false
|
|
|
}
|
|
|
},
|
|
|
- handleCheckChange() {
|
|
|
- // 仅用于触发内部状态更新,实际保存由 saveRoleAuth 处理
|
|
|
- }
|
|
|
+ // 树操作
|
|
|
+ expandAll() {
|
|
|
+ if (!this.$refs.authTreeByRole) return
|
|
|
+ const nodes = this.$refs.authTreeByRole.store._getAllNodes()
|
|
|
+ nodes.forEach(n => { n.expanded = true })
|
|
|
+ },
|
|
|
+ collapseAll() {
|
|
|
+ if (!this.$refs.authTreeByRole) return
|
|
|
+ const nodes = this.$refs.authTreeByRole.store._getAllNodes()
|
|
|
+ nodes.forEach(n => { n.expanded = false })
|
|
|
+ },
|
|
|
+ checkAll() {
|
|
|
+ this.$refs.authTreeByRole.setCheckedNodes(this.menuTree)
|
|
|
+ },
|
|
|
+ uncheckAll() {
|
|
|
+ this.$refs.authTreeByRole.setCheckedKeys([])
|
|
|
+ },
|
|
|
+ // 重建 menuId → Set<Role> 映射
|
|
|
+ rebuildMenuRoles() {
|
|
|
+ const menuRoles = {}
|
|
|
+ for (const role of Object.keys(this.roleMenuMap)) {
|
|
|
+ for (const id of this.roleMenuMap[role]) {
|
|
|
+ if (!menuRoles[id]) menuRoles[id] = new Set()
|
|
|
+ menuRoles[id].add(role)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.allMenuRoles = menuRoles
|
|
|
+ },
|
|
|
},
|
|
|
watch: {
|
|
|
+ // 切换角色时同步树选中状态
|
|
|
selectedRole() {
|
|
|
- this.loadRoleMenus()
|
|
|
+ this.$nextTick(() => {
|
|
|
+ if (this.$refs.authTreeByRole) {
|
|
|
+ this.$refs.authTreeByRole.setCheckedKeys(this.roleMenuMap[this.selectedRole] || [])
|
|
|
+ }
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -305,36 +502,155 @@ export default {
|
|
|
height: 100%;
|
|
|
}
|
|
|
|
|
|
-.auth-container {
|
|
|
+.admin-page-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.header-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-icon-cell {
|
|
|
+ margin-right: 6px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state {
|
|
|
+ padding: 40px 0;
|
|
|
+}
|
|
|
+
|
|
|
+/* === 授权弹窗 === */
|
|
|
+.auth-tabs-wrap {
|
|
|
+ min-height: 420px;
|
|
|
+}
|
|
|
+
|
|
|
+.auth-by-role {
|
|
|
display: flex;
|
|
|
gap: 20px;
|
|
|
- height: 400px;
|
|
|
+ height: 420px;
|
|
|
}
|
|
|
|
|
|
-.role-list {
|
|
|
- width: 200px;
|
|
|
+.role-radio-col {
|
|
|
+ width: 180px;
|
|
|
border-right: 1px solid #ebeef5;
|
|
|
- padding-right: 20px;
|
|
|
+ padding-right: 16px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.role-col-header {
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 13px;
|
|
|
}
|
|
|
|
|
|
.vertical-radio-group {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
- gap: 10px;
|
|
|
+ gap: 8px;
|
|
|
}
|
|
|
|
|
|
.vertical-radio-group .el-radio-button {
|
|
|
width: 100%;
|
|
|
- margin-bottom: 8px;
|
|
|
}
|
|
|
|
|
|
.menu-auth-tree {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-col-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.el-tree {
|
|
|
flex: 1;
|
|
|
overflow-y: auto;
|
|
|
- padding-left: 10px;
|
|
|
+ max-height: 340px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 按菜单授权 */
|
|
|
+.auth-by-menu {
|
|
|
+ display: flex;
|
|
|
+ gap: 20px;
|
|
|
+ height: 420px;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-tree-col {
|
|
|
+ width: 340px;
|
|
|
+ border-right: 1px solid #ebeef5;
|
|
|
+ padding-right: 16px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-role-col {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-title-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 12px 0;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-role-desc {
|
|
|
+ color: #909399;
|
|
|
+ font-size: 12px;
|
|
|
+ margin: 12px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-role-checkbox-group {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-role-ops {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.menu-count {
|
|
|
+ font-weight: normal;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state .el-empty {
|
|
|
+ margin: 30px 0;
|
|
|
}
|
|
|
|
|
|
.flex { display: flex; }
|
|
|
.items-center { align-items: center; }
|
|
|
.gap-sm { gap: 8px; }
|
|
|
-</style>
|
|
|
+</style>
|