MenuManage.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. <template>
  2. <div class="menu-manage admin-page">
  3. <el-card>
  4. <div slot="header" class="admin-page-header">
  5. <span class="admin-page-title">菜单管理</span>
  6. <div class="header-actions">
  7. <div class="search-box">
  8. <el-input
  9. v-model="searchKeyword"
  10. placeholder="搜索菜单名称 / 路径 / 权限标识"
  11. size="small"
  12. prefix-icon="el-icon-search"
  13. clearable
  14. style="width: 260px"
  15. ></el-input>
  16. </div>
  17. <el-button type="primary" size="small" @click="openRoleAuth">角色授权</el-button>
  18. <el-button type="primary" size="small" @click="handleCreate(0)">新增顶级菜单</el-button>
  19. </div>
  20. </div>
  21. <div class="table-scroll-wrap">
  22. <el-table
  23. :data="filteredMenuTree"
  24. row-key="id"
  25. :tree-props="{ children: 'children' }"
  26. :default-expand-all="!searchKeyword"
  27. stripe
  28. :max-height="tableHeight"
  29. style="min-width: 1200px; width: 100%"
  30. >
  31. <el-table-column prop="displayName" label="菜单名称" min-width="220" show-overflow-tooltip>
  32. <template slot-scope="scope">
  33. <i v-if="scope.row.icon" :class="scope.row.icon" class="menu-icon-cell"></i>
  34. <span>{{ scope.row.displayName }}</span>
  35. </template>
  36. </el-table-column>
  37. <el-table-column prop="icon" label="图标" min-width="140" show-overflow-tooltip></el-table-column>
  38. <el-table-column prop="path" label="路由路径" min-width="200" show-overflow-tooltip>
  39. <template slot-scope="scope">
  40. {{ scope.row.path || '-' }}
  41. </template>
  42. </el-table-column>
  43. <el-table-column prop="perm" label="权限标识" min-width="180" show-overflow-tooltip>
  44. <template slot-scope="scope">
  45. <el-tag size="mini" type="info">{{ scope.row.perm || '-' }}</el-tag>
  46. </template>
  47. </el-table-column>
  48. <el-table-column prop="sort" label="排序" width="80" align="center"></el-table-column>
  49. <el-table-column prop="visible" label="显示" width="80" align="center">
  50. <template slot-scope="scope">
  51. <el-switch v-model="scope.row.visible" size="mini" @change="handleToggleVisible(scope.row)"></el-switch>
  52. </template>
  53. </el-table-column>
  54. <el-table-column label="操作" width="320" fixed="right">
  55. <template slot-scope="{ row }">
  56. <el-button size="mini" @click="handleEdit(row)">编辑</el-button>
  57. <el-button size="mini" @click="handleMove(row, 'up')" :disabled="!canMove(row, 'up')">▲</el-button>
  58. <el-button size="mini" @click="handleMove(row, 'down')" :disabled="!canMove(row, 'down')">▼</el-button>
  59. <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
  60. <el-button size="mini" type="success" @click="handleCreate(row.id)">+ 子菜单</el-button>
  61. </template>
  62. </el-table-column>
  63. </el-table>
  64. <div v-if="!filteredMenuTree.length" class="empty-state">
  65. <el-empty description="暂无菜单数据"></el-empty>
  66. </div>
  67. </div>
  68. </el-card>
  69. <!-- 创建/编辑弹窗 -->
  70. <el-dialog :title="isEdit ? '编辑菜单' : '新增菜单'" :visible.sync="menuDialogVisible" width="550px">
  71. <el-form :model="menuForm" :rules="menuRules" ref="menuForm" label-width="100px">
  72. <el-form-item label="上级菜单" prop="parentId">
  73. <el-tree
  74. ref="parentTree"
  75. :data="treeData"
  76. :props="{ label: 'label', disabled: 'disabled' }"
  77. node-key="id"
  78. :default-expanded-keys="[0]"
  79. :highlight-current="true"
  80. :expand-on-click-node="false"
  81. @node-click="selectParentNode"
  82. accordion
  83. >
  84. <span class="custom-tree-node" slot-scope="{ node, data }">
  85. <i :class="data.icon" style="margin-right:4px;font-size:13px"></i>
  86. <span>{{ node.label }}</span>
  87. </span>
  88. </el-tree>
  89. <div v-if="currentLabel" class="parent-hint">
  90. <i class="el-icon-location-outline"></i> 当前上级:{{ currentLabel }}
  91. </div>
  92. <div v-else class="parent-hint parent-hint-top">
  93. <i class="el-icon-arrow-up"></i> 顶级菜单(无上级)
  94. </div>
  95. </el-form-item>
  96. <el-form-item label="目录名称">
  97. <el-input v-model="menuForm.title" placeholder="有子菜单时填写"></el-input>
  98. </el-form-item>
  99. <el-form-item label="菜单标签">
  100. <el-input v-model="menuForm.label" placeholder="叶子节点显示名"></el-input>
  101. </el-form-item>
  102. <el-form-item label="路由路径">
  103. <el-input v-model="menuForm.path" placeholder="/xxx"></el-input>
  104. </el-form-item>
  105. <el-form-item label="图标">
  106. <el-input v-model="menuForm.icon" placeholder="el-icon-s-custom"></el-input>
  107. </el-form-item>
  108. <el-form-item label="权限标识">
  109. <el-input v-model="menuForm.perm" placeholder="system:menu"></el-input>
  110. </el-form-item>
  111. <el-form-item label="排序">
  112. <el-input-number v-model="menuForm.sort" :min="0" style="width:100%"></el-input-number>
  113. </el-form-item>
  114. <el-form-item label="是否显示">
  115. <el-switch v-model="menuForm.visible"></el-switch>
  116. </el-form-item>
  117. </el-form>
  118. <div slot="footer">
  119. <el-button @click="menuDialogVisible = false">取消</el-button>
  120. <el-button type="primary" :loading="submitting" @click="submitMenu">保存</el-button>
  121. </div>
  122. </el-dialog>
  123. <!-- 角色授权弹窗(双Tab) -->
  124. <el-dialog title="角色菜单授权" :visible.sync="authDialogVisible" width="950px" :close-on-click-modal="false">
  125. <div class="auth-tabs-wrap">
  126. <el-tabs v-model="authTab">
  127. <el-tab-pane label="按角色授权" name="byRole">
  128. <div class="auth-by-role">
  129. <div class="role-radio-col">
  130. <div class="role-col-header">选择角色</div>
  131. <el-radio-group v-model="selectedRole" class="vertical-radio-group">
  132. <el-radio-button v-for="role in roles" :key="role.value" :label="role.value">
  133. {{ role.label }}
  134. </el-radio-button>
  135. </el-radio-group>
  136. </div>
  137. <div class="menu-auth-tree">
  138. <div class="tree-col-header">
  139. <span>选择菜单</span>
  140. <div class="tree-actions">
  141. <el-button size="mini" @click="expandAll">全部展开</el-button>
  142. <el-button size="mini" @click="collapseAll">全部折叠</el-button>
  143. <el-button size="mini" @click="checkAll">全选</el-button>
  144. <el-button size="mini" @click="uncheckAll">全不选</el-button>
  145. </div>
  146. </div>
  147. <el-tree
  148. ref="authTreeByRole"
  149. :data="menuTree"
  150. show-checkbox
  151. node-key="id"
  152. :props="{ label: 'displayName' }"
  153. :default-checked-keys="currentRoleMenuIds"
  154. @check="onRoleTreeCheck"
  155. ></el-tree>
  156. </div>
  157. </div>
  158. </el-tab-pane>
  159. <el-tab-pane label="按菜单授权" name="byMenu">
  160. <div class="auth-by-menu">
  161. <div class="menu-tree-col">
  162. <div class="tree-col-header">
  163. <span>菜单(点击选中)</span>
  164. <div class="tree-actions">
  165. <el-input
  166. v-model="menuSearchKeyword"
  167. size="mini"
  168. placeholder="搜索菜单"
  169. prefix-icon="el-icon-search"
  170. clearable
  171. style="width:180px"
  172. ></el-input>
  173. </div>
  174. </div>
  175. <el-tree
  176. ref="authTreeByMenu"
  177. :data="menuSearchTree"
  178. node-key="id"
  179. highlight-current
  180. :expand-on-click-node="false"
  181. :props="{ label: 'displayName' }"
  182. @node-click="onMenuNodeClick"
  183. ></el-tree>
  184. </div>
  185. <div class="menu-role-col">
  186. <div class="role-col-header">
  187. <span>已授权角色</span>
  188. <span class="menu-count">(共 {{ Object.keys(allMenuRoles).length }} 个菜单)</span>
  189. </div>
  190. <div class="menu-role-list">
  191. <template v-if="selectedAuthMenu">
  192. <div class="menu-title-bar">
  193. <i v-if="selectedAuthMenu.icon" :class="selectedAuthMenu.icon"></i>
  194. <span>{{ selectedAuthMenu.displayName }}</span>
  195. <el-tag size="mini" type="info">{{ selectedAuthMenu.perm || '无' }}</el-tag>
  196. </div>
  197. <div class="menu-role-desc">勾选表示该角色可访问此菜单</div>
  198. <el-checkbox-group v-model="selectedAuthMenuRoles" class="menu-role-checkbox-group">
  199. <el-checkbox v-for="role in roles" :key="role.value" :label="role.value">
  200. {{ role.label }}
  201. </el-checkbox>
  202. </el-checkbox-group>
  203. <div class="menu-role-ops">
  204. <el-button type="primary" size="small" @click="saveMenuRoles">保存授权</el-button>
  205. </div>
  206. </template>
  207. <el-empty v-else description="请先在左侧选择菜单"></el-empty>
  208. </div>
  209. </div>
  210. </div>
  211. </el-tab-pane>
  212. </el-tabs>
  213. </div>
  214. </el-dialog>
  215. </div>
  216. </template>
  217. <script>
  218. import {
  219. getMenuTree, getRoleMenus, saveRoleMenus,
  220. createMenu, updateMenu, deleteMenu, moveMenu
  221. } from '@/api/menu'
  222. const ROLES = [
  223. { label: '超级管理员', value: 'admin' },
  224. { label: '平台运营', value: 'operator' },
  225. { label: '平台财务', value: 'finance' },
  226. { label: '管家', value: 'butler' },
  227. { label: '知识管理员', value: 'knowledge_admin' },
  228. { label: '成长规划师', value: 'teacher' },
  229. { label: '营养师', value: 'nutritionist' },
  230. { label: '供应商', value: 'supplier' },
  231. { label: '活动管理员', value: 'activity_manager' },
  232. ]
  233. export default {
  234. name: 'MenuManage',
  235. data() {
  236. return {
  237. menuTree: [],
  238. searchKeyword: '',
  239. menuDialogVisible: false,
  240. isEdit: false,
  241. submitting: false,
  242. menuForm: this.getEmptyForm(),
  243. menuRules: {},
  244. // 授权弹窗
  245. authDialogVisible: false,
  246. authTab: 'byRole',
  247. selectedRole: 'admin',
  248. roles: ROLES,
  249. // 角色→菜单ID集合(Map<String, Set<Long>>)
  250. roleMenuMap: {},
  251. // 菜单→角色集合(Map<Long, Set<String>>)
  252. allMenuRoles: {},
  253. // 菜单授权选中
  254. selectedAuthMenu: null,
  255. selectedAuthMenuRoles: [],
  256. menuSearchKeyword: '',
  257. saveAuthLoading: false,
  258. }
  259. },
  260. computed: {
  261. tableHeight() {
  262. return window.innerHeight - 280
  263. },
  264. // 搜索过滤后的菜单树(含 displayName 派生字段)
  265. filteredMenuTree() {
  266. if (!this.searchKeyword) {
  267. return this.addDisplayName(this.menuTree)
  268. }
  269. const kw = this.searchKeyword.toLowerCase()
  270. return this.filterTree(this.menuTree, kw)
  271. },
  272. flatMenuOptions() {
  273. const options = []
  274. const flatten = (nodes) => {
  275. for (const node of nodes) {
  276. options.push({ id: node.id, displayName: node.title || node.label })
  277. if (node.children) flatten(node.children)
  278. }
  279. }
  280. flatten(this.menuTree)
  281. return options
  282. },
  283. // 按角色授权 Tab: 当前角色的已授权菜单 ID
  284. currentRoleMenuIds() {
  285. return this.roleMenuMap[this.selectedRole] || []
  286. },
  287. // 菜单搜索 Tab 的过滤树
  288. menuSearchTree() {
  289. if (!this.menuSearchKeyword) {
  290. return this.addDisplayName(this.menuTree)
  291. }
  292. const kw = this.menuSearchKeyword.toLowerCase()
  293. return this.filterTree(this.menuTree, kw)
  294. },
  295. // 扁平树数据(用于上级菜单选择器)
  296. treeData() {
  297. const result = [{ id: 0, label: '顶级菜单', icon: 'el-icon-arrow-up', disabled: false }]
  298. const flatten = (nodes, depth) => {
  299. nodes.forEach(node => {
  300. const padding = 'padding-left:' + (depth * 16) + 'px'
  301. const disabled = this.isEdit && (node.id === this.menuForm.id || this.excludedIds.has(node.id))
  302. result.push({ id: node.id, label: node.title || node.label, icon: node.icon || '', disabled, style: padding })
  303. if (node.children) flatten(node.children, depth + 1)
  304. })
  305. }
  306. flatten(this.menuTree, 0)
  307. return result
  308. },
  309. // 当前选中上级的面包屑路径
  310. currentLabel() {
  311. if (!this.menuForm.parentId) return ''
  312. const findPath = (nodes, targetId, path) => {
  313. for (const n of nodes) {
  314. const cur = (path ? path + ' / ' : '') + (n.title || n.label)
  315. if (n.id === targetId) return cur
  316. if (n.children) {
  317. const r = findPath(n.children, targetId, cur)
  318. if (r) return r
  319. }
  320. }
  321. return null
  322. }
  323. return findPath(this.menuTree, this.menuForm.parentId, '')
  324. },
  325. // 被编辑项的子孙ID集合(防止循环引用)
  326. excludedIds() {
  327. if (!this.menuForm.id) return new Set()
  328. const ids = new Set([this.menuForm.id])
  329. const collect = (nodes) => {
  330. nodes.forEach(n => {
  331. if (n.children) {
  332. n.children.forEach(c => { ids.add(c.id); collect([c]) })
  333. }
  334. })
  335. }
  336. const findNode = (nodes, targetId) => {
  337. for (const n of nodes) {
  338. if (n.id === targetId) return n
  339. if (n.children) {
  340. const f = findNode(n.children, targetId)
  341. if (f) return f
  342. }
  343. }
  344. return null
  345. }
  346. const node = findNode(this.menuTree, this.menuForm.id)
  347. if (node && node.children) collect(node.children)
  348. return ids
  349. }
  350. },
  351. created() {
  352. this.loadMenuTree()
  353. },
  354. methods: {
  355. getEmptyForm() {
  356. return { id: null, parentId: 0, title: '', label: '', path: '', icon: '', perm: '', sort: 0, visible: true }
  357. },
  358. // === 加载 & 展示 ===
  359. async loadMenuTree() {
  360. try {
  361. const res = await getMenuTree()
  362. this.menuTree = (res.data && res.data.length) ? res.data : []
  363. } catch (e) {
  364. this.$message.error('加载菜单树失败')
  365. }
  366. },
  367. // 递归添加 displayName
  368. addDisplayName(nodes) {
  369. return (nodes || []).map(n => {
  370. const item = { ...n }
  371. item.displayName = item.title || item.label || ''
  372. if (item.children) item.children = this.addDisplayName(item.children)
  373. return item
  374. })
  375. },
  376. // 递归搜索:保留匹配节点 + 其祖先
  377. filterTree(nodes, kw) {
  378. const result = []
  379. for (const n of nodes || []) {
  380. const name = (n.title || n.label || '').toLowerCase()
  381. const path = (n.path || '').toLowerCase()
  382. const perm = (n.perm || '').toLowerCase()
  383. const matchSelf = name.includes(kw) || path.includes(kw) || perm.includes(kw)
  384. const children = n.children ? this.filterTree(n.children, kw) : null
  385. if (matchSelf || (children && children.length > 0)) {
  386. const item = { ...n }
  387. item.displayName = item.title || item.label || ''
  388. if (children && children.length > 0) item.children = children
  389. result.push(item)
  390. }
  391. }
  392. return result
  393. },
  394. // === 菜单 CRUD ===
  395. selectParentNode(data) {
  396. this.menuForm.parentId = data.id
  397. },
  398. handleCreate(parentId) {
  399. this.isEdit = false
  400. this.menuForm = { ...this.getEmptyForm(), parentId }
  401. this.menuDialogVisible = true
  402. },
  403. handleEdit(row) {
  404. this.isEdit = true
  405. this.menuForm = { ...row }
  406. this.menuDialogVisible = true
  407. },
  408. async submitMenu() {
  409. if (!this.menuForm.title && !this.menuForm.label) {
  410. this.$message.warning('目录名称或菜单标签必须填写其中一个')
  411. return
  412. }
  413. this.submitting = true
  414. try {
  415. if (this.isEdit) await updateMenu(this.menuForm)
  416. else await createMenu(this.menuForm)
  417. this.$message.success('保存成功')
  418. this.menuDialogVisible = false
  419. await this.loadMenuTree()
  420. } catch (e) {
  421. this.$message.error(e.message || '保存失败')
  422. } finally {
  423. this.submitting = false
  424. }
  425. },
  426. async handleDelete(row) {
  427. try {
  428. await this.$confirm('确定删除该菜单吗?(如有子菜单将无法删除)', '警告', { type: 'warning' })
  429. await deleteMenu({ id: row.id })
  430. this.$message.success('删除成功')
  431. await this.loadMenuTree()
  432. } catch (e) {
  433. if (e !== 'cancel') this.$message.error(e.message || '删除失败')
  434. }
  435. },
  436. async handleMove(row, direction) {
  437. try {
  438. await moveMenu({ id: row.id, direction })
  439. await this.loadMenuTree()
  440. } catch (e) {
  441. this.$message.error('调整失败')
  442. }
  443. },
  444. async handleToggleVisible(row) {
  445. try {
  446. await updateMenu(row)
  447. } catch (e) {
  448. this.$message.error('更新失败')
  449. }
  450. },
  451. canMove(row, direction) {
  452. return true // 后端判断是否可移动
  453. },
  454. // === 角色授权 ===
  455. openRoleAuth() {
  456. this.authTab = 'byRole'
  457. this.authDialogVisible = true
  458. this.loadAllRoleMenus()
  459. },
  460. async loadAllRoleMenus() {
  461. // 并行拉取所有角色的已授权菜单 ID
  462. const promises = this.roles.map(r =>
  463. getRoleMenus({ role: r.value }).then(res => {
  464. const ids = (res.data || []).filter(id => id != null)
  465. this.roleMenuMap[r.value] = ids
  466. }).catch(() => {
  467. this.roleMenuMap[r.value] = []
  468. })
  469. )
  470. await Promise.all(promises)
  471. // 构建 menuId → Set<Role>
  472. const menuRoles = {}
  473. for (const role of Object.keys(this.roleMenuMap)) {
  474. for (const id of this.roleMenuMap[role]) {
  475. if (!menuRoles[id]) menuRoles[id] = new Set()
  476. menuRoles[id].add(role)
  477. }
  478. }
  479. this.allMenuRoles = menuRoles
  480. },
  481. // 角色→菜单 Tab: 树勾选变化时同步
  482. onRoleTreeCheck() {
  483. const checked = this.$refs.authTreeByRole.getCheckedKeys()
  484. const half = this.$refs.authTreeByRole.getHalfCheckedKeys()
  485. const ids = [...new Set([...checked, ...half])]
  486. this.roleMenuMap[this.selectedRole] = ids
  487. this.rebuildMenuRoles()
  488. },
  489. // 菜单→角色 Tab: 点击菜单节点
  490. onMenuNodeClick(node) {
  491. this.selectedAuthMenu = node
  492. const roles = this.allMenuRoles[node.id]
  493. this.selectedAuthMenuRoles = roles ? [...roles] : []
  494. },
  495. // 保存菜单→角色的授权(每个角色全覆盖保存)
  496. async saveMenuRoles() {
  497. if (!this.selectedAuthMenu) return
  498. const menuId = this.selectedAuthMenu.id
  499. const selectedRoles = new Set(this.selectedAuthMenuRoles)
  500. // 收集需要保存的角色
  501. const prevRoles = this.allMenuRoles[menuId] || new Set()
  502. const touchedRoles = new Set([...selectedRoles, ...prevRoles])
  503. // 更新 roleMenuMap
  504. for (const role of this.roles) {
  505. const shouldHave = selectedRoles.has(role.value)
  506. const currentSet = new Set(this.roleMenuMap[role.value] || [])
  507. if (shouldHave) currentSet.add(menuId)
  508. else currentSet.delete(menuId)
  509. this.roleMenuMap[role.value] = [...currentSet]
  510. }
  511. // 同步 allMenuRoles
  512. this.rebuildMenuRoles()
  513. // 只对触摸过的角色调用保存
  514. this.saveAuthLoading = true
  515. const promises = []
  516. for (const role of touchedRoles) {
  517. const ids = this.roleMenuMap[role] || []
  518. promises.push(saveRoleMenus({ role, menuIds: ids }))
  519. }
  520. try {
  521. await Promise.all(promises)
  522. this.$message.success('授权保存成功')
  523. } catch (e) {
  524. this.$message.error('授权保存失败')
  525. } finally {
  526. this.saveAuthLoading = false
  527. }
  528. },
  529. // 树操作
  530. expandAll() {
  531. if (!this.$refs.authTreeByRole) return
  532. const nodes = this.$refs.authTreeByRole.store._getAllNodes()
  533. nodes.forEach(n => { n.expanded = true })
  534. },
  535. collapseAll() {
  536. if (!this.$refs.authTreeByRole) return
  537. const nodes = this.$refs.authTreeByRole.store._getAllNodes()
  538. nodes.forEach(n => { n.expanded = false })
  539. },
  540. checkAll() {
  541. this.$refs.authTreeByRole.setCheckedNodes(this.menuTree)
  542. },
  543. uncheckAll() {
  544. this.$refs.authTreeByRole.setCheckedKeys([])
  545. },
  546. // 重建 menuId → Set<Role> 映射
  547. rebuildMenuRoles() {
  548. const menuRoles = {}
  549. for (const role of Object.keys(this.roleMenuMap)) {
  550. for (const id of this.roleMenuMap[role]) {
  551. if (!menuRoles[id]) menuRoles[id] = new Set()
  552. menuRoles[id].add(role)
  553. }
  554. }
  555. this.allMenuRoles = menuRoles
  556. },
  557. },
  558. watch: {
  559. // 切换角色时同步树选中状态
  560. selectedRole() {
  561. this.$nextTick(() => {
  562. if (this.$refs.authTreeByRole) {
  563. this.$refs.authTreeByRole.setCheckedKeys(this.roleMenuMap[this.selectedRole] || [])
  564. }
  565. })
  566. }
  567. }
  568. }
  569. </script>
  570. <style scoped>
  571. .menu-manage {
  572. display: flex;
  573. flex-direction: column;
  574. height: 100%;
  575. }
  576. .admin-page-header {
  577. display: flex;
  578. align-items: center;
  579. justify-content: space-between;
  580. gap: 16px;
  581. }
  582. .header-actions {
  583. display: flex;
  584. align-items: center;
  585. gap: 8px;
  586. }
  587. .menu-icon-cell {
  588. margin-right: 6px;
  589. font-size: 16px;
  590. }
  591. .empty-state {
  592. padding: 40px 0;
  593. }
  594. /* === 授权弹窗 === */
  595. .auth-tabs-wrap {
  596. min-height: 420px;
  597. }
  598. .auth-by-role {
  599. display: flex;
  600. gap: 20px;
  601. height: 420px;
  602. }
  603. .role-radio-col {
  604. width: 180px;
  605. border-right: 1px solid #ebeef5;
  606. padding-right: 16px;
  607. display: flex;
  608. flex-direction: column;
  609. }
  610. .role-col-header {
  611. font-weight: 600;
  612. color: #303133;
  613. margin-bottom: 12px;
  614. font-size: 13px;
  615. }
  616. .vertical-radio-group {
  617. display: flex;
  618. flex-direction: column;
  619. gap: 8px;
  620. }
  621. .vertical-radio-group .el-radio-button {
  622. width: 100%;
  623. }
  624. .menu-auth-tree {
  625. flex: 1;
  626. display: flex;
  627. flex-direction: column;
  628. overflow: hidden;
  629. }
  630. .tree-col-header {
  631. display: flex;
  632. align-items: center;
  633. justify-content: space-between;
  634. font-weight: 600;
  635. color: #303133;
  636. margin-bottom: 12px;
  637. font-size: 13px;
  638. }
  639. .tree-actions {
  640. display: flex;
  641. gap: 4px;
  642. }
  643. .el-tree {
  644. flex: 1;
  645. overflow-y: auto;
  646. max-height: 340px;
  647. }
  648. /* 按菜单授权 */
  649. .auth-by-menu {
  650. display: flex;
  651. gap: 20px;
  652. height: 420px;
  653. }
  654. .menu-tree-col {
  655. width: 340px;
  656. border-right: 1px solid #ebeef5;
  657. padding-right: 16px;
  658. display: flex;
  659. flex-direction: column;
  660. overflow: hidden;
  661. }
  662. .menu-role-col {
  663. flex: 1;
  664. display: flex;
  665. flex-direction: column;
  666. }
  667. .menu-title-bar {
  668. display: flex;
  669. align-items: center;
  670. gap: 6px;
  671. padding: 12px 0;
  672. border-bottom: 1px solid #ebeef5;
  673. font-size: 15px;
  674. font-weight: 600;
  675. }
  676. .menu-role-desc {
  677. color: #909399;
  678. font-size: 12px;
  679. margin: 12px 0;
  680. }
  681. .menu-role-checkbox-group {
  682. display: flex;
  683. flex-direction: column;
  684. gap: 10px;
  685. margin-bottom: 20px;
  686. }
  687. .menu-role-ops {
  688. display: flex;
  689. justify-content: flex-end;
  690. padding-top: 12px;
  691. border-top: 1px solid #ebeef5;
  692. }
  693. .menu-count {
  694. font-weight: normal;
  695. font-size: 12px;
  696. color: #909399;
  697. }
  698. .empty-state .el-empty {
  699. margin: 30px 0;
  700. }
  701. .flex { display: flex; }
  702. .items-center { align-items: center; }
  703. .gap-sm { gap: 8px; }
  704. /* 上级菜单树提示 */
  705. .parent-hint {
  706. margin-top: 8px;
  707. font-size: 12px;
  708. color: #909399;
  709. display: flex;
  710. align-items: center;
  711. gap: 4px;
  712. }
  713. .parent-hint-top {
  714. color: #67c23a;
  715. }
  716. .custom-tree-node {
  717. font-size: 13px;
  718. display: flex;
  719. align-items: center;
  720. width: 100%;
  721. }
  722. .custom-tree-node .el-tree-node__content {
  723. height: 32px;
  724. }
  725. </style>