MenuManage.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  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: 'teacher' },
  225. { label: '营养师', value: 'nutritionist' },
  226. { label: '文章管理员', value: 'article_manager' },
  227. { label: '活动管理员', value: 'activity_manager' },
  228. { label: '供应商管理员', value: 'supplier_admin' },
  229. ]
  230. export default {
  231. name: 'MenuManage',
  232. data() {
  233. return {
  234. menuTree: [],
  235. searchKeyword: '',
  236. menuDialogVisible: false,
  237. isEdit: false,
  238. submitting: false,
  239. menuForm: this.getEmptyForm(),
  240. menuRules: {},
  241. // 授权弹窗
  242. authDialogVisible: false,
  243. authTab: 'byRole',
  244. selectedRole: 'admin',
  245. roles: ROLES,
  246. // 角色→菜单ID集合(Map<String, Set<Long>>)
  247. roleMenuMap: {},
  248. // 菜单→角色集合(Map<Long, Set<String>>)
  249. allMenuRoles: {},
  250. // 菜单授权选中
  251. selectedAuthMenu: null,
  252. selectedAuthMenuRoles: [],
  253. menuSearchKeyword: '',
  254. saveAuthLoading: false,
  255. }
  256. },
  257. computed: {
  258. tableHeight() {
  259. return window.innerHeight - 280
  260. },
  261. // 搜索过滤后的菜单树(含 displayName 派生字段)
  262. filteredMenuTree() {
  263. if (!this.searchKeyword) {
  264. return this.addDisplayName(this.menuTree)
  265. }
  266. const kw = this.searchKeyword.toLowerCase()
  267. return this.filterTree(this.menuTree, kw)
  268. },
  269. flatMenuOptions() {
  270. const options = []
  271. const flatten = (nodes) => {
  272. for (const node of nodes) {
  273. options.push({ id: node.id, displayName: node.title || node.label })
  274. if (node.children) flatten(node.children)
  275. }
  276. }
  277. flatten(this.menuTree)
  278. return options
  279. },
  280. // 按角色授权 Tab: 当前角色的已授权菜单 ID
  281. currentRoleMenuIds() {
  282. return this.roleMenuMap[this.selectedRole] || []
  283. },
  284. // 菜单搜索 Tab 的过滤树
  285. menuSearchTree() {
  286. if (!this.menuSearchKeyword) {
  287. return this.addDisplayName(this.menuTree)
  288. }
  289. const kw = this.menuSearchKeyword.toLowerCase()
  290. return this.filterTree(this.menuTree, kw)
  291. },
  292. // 扁平树数据(用于上级菜单选择器)
  293. treeData() {
  294. const result = [{ id: 0, label: '顶级菜单', icon: 'el-icon-arrow-up', disabled: false }]
  295. const flatten = (nodes, depth) => {
  296. nodes.forEach(node => {
  297. const padding = 'padding-left:' + (depth * 16) + 'px'
  298. const disabled = this.isEdit && (node.id === this.menuForm.id || this.excludedIds.has(node.id))
  299. result.push({ id: node.id, label: node.title || node.label, icon: node.icon || '', disabled, style: padding })
  300. if (node.children) flatten(node.children, depth + 1)
  301. })
  302. }
  303. flatten(this.menuTree, 0)
  304. return result
  305. },
  306. // 当前选中上级的面包屑路径
  307. currentLabel() {
  308. if (!this.menuForm.parentId) return ''
  309. const findPath = (nodes, targetId, path) => {
  310. for (const n of nodes) {
  311. const cur = (path ? path + ' / ' : '') + (n.title || n.label)
  312. if (n.id === targetId) return cur
  313. if (n.children) {
  314. const r = findPath(n.children, targetId, cur)
  315. if (r) return r
  316. }
  317. }
  318. return null
  319. }
  320. return findPath(this.menuTree, this.menuForm.parentId, '')
  321. },
  322. // 被编辑项的子孙ID集合(防止循环引用)
  323. excludedIds() {
  324. if (!this.menuForm.id) return new Set()
  325. const ids = new Set([this.menuForm.id])
  326. const collect = (nodes) => {
  327. nodes.forEach(n => {
  328. if (n.children) {
  329. n.children.forEach(c => { ids.add(c.id); collect([c]) })
  330. }
  331. })
  332. }
  333. const findNode = (nodes, targetId) => {
  334. for (const n of nodes) {
  335. if (n.id === targetId) return n
  336. if (n.children) {
  337. const f = findNode(n.children, targetId)
  338. if (f) return f
  339. }
  340. }
  341. return null
  342. }
  343. const node = findNode(this.menuTree, this.menuForm.id)
  344. if (node && node.children) collect(node.children)
  345. return ids
  346. }
  347. },
  348. created() {
  349. this.loadMenuTree()
  350. },
  351. methods: {
  352. getEmptyForm() {
  353. return { id: null, parentId: 0, title: '', label: '', path: '', icon: '', perm: '', sort: 0, visible: true }
  354. },
  355. // === 加载 & 展示 ===
  356. async loadMenuTree() {
  357. try {
  358. const res = await getMenuTree()
  359. this.menuTree = (res.data && res.data.length) ? res.data : []
  360. } catch (e) {
  361. this.$message.error('加载菜单树失败')
  362. }
  363. },
  364. // 递归添加 displayName
  365. addDisplayName(nodes) {
  366. return (nodes || []).map(n => {
  367. const item = { ...n }
  368. item.displayName = item.title || item.label || ''
  369. if (item.children) item.children = this.addDisplayName(item.children)
  370. return item
  371. })
  372. },
  373. // 递归搜索:保留匹配节点 + 其祖先
  374. filterTree(nodes, kw) {
  375. const result = []
  376. for (const n of nodes || []) {
  377. const name = (n.title || n.label || '').toLowerCase()
  378. const path = (n.path || '').toLowerCase()
  379. const perm = (n.perm || '').toLowerCase()
  380. const matchSelf = name.includes(kw) || path.includes(kw) || perm.includes(kw)
  381. const children = n.children ? this.filterTree(n.children, kw) : null
  382. if (matchSelf || (children && children.length > 0)) {
  383. const item = { ...n }
  384. item.displayName = item.title || item.label || ''
  385. if (children && children.length > 0) item.children = children
  386. result.push(item)
  387. }
  388. }
  389. return result
  390. },
  391. // === 菜单 CRUD ===
  392. selectParentNode(data) {
  393. this.menuForm.parentId = data.id
  394. },
  395. handleCreate(parentId) {
  396. this.isEdit = false
  397. this.menuForm = { ...this.getEmptyForm(), parentId }
  398. this.menuDialogVisible = true
  399. },
  400. handleEdit(row) {
  401. this.isEdit = true
  402. this.menuForm = { ...row }
  403. this.menuDialogVisible = true
  404. },
  405. async submitMenu() {
  406. if (!this.menuForm.title && !this.menuForm.label) {
  407. this.$message.warning('目录名称或菜单标签必须填写其中一个')
  408. return
  409. }
  410. this.submitting = true
  411. try {
  412. if (this.isEdit) await updateMenu(this.menuForm)
  413. else await createMenu(this.menuForm)
  414. this.$message.success('保存成功')
  415. this.menuDialogVisible = false
  416. await this.loadMenuTree()
  417. } catch (e) {
  418. this.$message.error(e.message || '保存失败')
  419. } finally {
  420. this.submitting = false
  421. }
  422. },
  423. async handleDelete(row) {
  424. try {
  425. await this.$confirm('确定删除该菜单吗?(如有子菜单将无法删除)', '警告', { type: 'warning' })
  426. await deleteMenu({ id: row.id })
  427. this.$message.success('删除成功')
  428. await this.loadMenuTree()
  429. } catch (e) {
  430. if (e !== 'cancel') this.$message.error(e.message || '删除失败')
  431. }
  432. },
  433. async handleMove(row, direction) {
  434. try {
  435. await moveMenu({ id: row.id, direction })
  436. await this.loadMenuTree()
  437. } catch (e) {
  438. this.$message.error('调整失败')
  439. }
  440. },
  441. async handleToggleVisible(row) {
  442. try {
  443. await updateMenu(row)
  444. } catch (e) {
  445. this.$message.error('更新失败')
  446. }
  447. },
  448. canMove(row, direction) {
  449. return true // 后端判断是否可移动
  450. },
  451. // === 角色授权 ===
  452. openRoleAuth() {
  453. this.authTab = 'byRole'
  454. this.authDialogVisible = true
  455. this.loadAllRoleMenus()
  456. },
  457. async loadAllRoleMenus() {
  458. // 并行拉取所有角色的已授权菜单 ID
  459. const promises = this.roles.map(r =>
  460. getRoleMenus({ role: r.value }).then(res => {
  461. const ids = (res.data || []).filter(id => id != null)
  462. this.roleMenuMap[r.value] = ids
  463. }).catch(() => {
  464. this.roleMenuMap[r.value] = []
  465. })
  466. )
  467. await Promise.all(promises)
  468. // 构建 menuId → Set<Role>
  469. const menuRoles = {}
  470. for (const role of Object.keys(this.roleMenuMap)) {
  471. for (const id of this.roleMenuMap[role]) {
  472. if (!menuRoles[id]) menuRoles[id] = new Set()
  473. menuRoles[id].add(role)
  474. }
  475. }
  476. this.allMenuRoles = menuRoles
  477. },
  478. // 角色→菜单 Tab: 树勾选变化时同步
  479. onRoleTreeCheck() {
  480. const checked = this.$refs.authTreeByRole.getCheckedKeys()
  481. const half = this.$refs.authTreeByRole.getHalfCheckedKeys()
  482. const ids = [...new Set([...checked, ...half])]
  483. this.roleMenuMap[this.selectedRole] = ids
  484. this.rebuildMenuRoles()
  485. },
  486. // 菜单→角色 Tab: 点击菜单节点
  487. onMenuNodeClick(node) {
  488. this.selectedAuthMenu = node
  489. const roles = this.allMenuRoles[node.id]
  490. this.selectedAuthMenuRoles = roles ? [...roles] : []
  491. },
  492. // 保存菜单→角色的授权(每个角色全覆盖保存)
  493. async saveMenuRoles() {
  494. if (!this.selectedAuthMenu) return
  495. const menuId = this.selectedAuthMenu.id
  496. const selectedRoles = new Set(this.selectedAuthMenuRoles)
  497. // 收集需要保存的角色
  498. const prevRoles = this.allMenuRoles[menuId] || new Set()
  499. const touchedRoles = new Set([...selectedRoles, ...prevRoles])
  500. // 更新 roleMenuMap
  501. for (const role of this.roles) {
  502. const shouldHave = selectedRoles.has(role.value)
  503. const currentSet = new Set(this.roleMenuMap[role.value] || [])
  504. if (shouldHave) currentSet.add(menuId)
  505. else currentSet.delete(menuId)
  506. this.roleMenuMap[role.value] = [...currentSet]
  507. }
  508. // 同步 allMenuRoles
  509. this.rebuildMenuRoles()
  510. // 只对触摸过的角色调用保存
  511. this.saveAuthLoading = true
  512. const promises = []
  513. for (const role of touchedRoles) {
  514. const ids = this.roleMenuMap[role] || []
  515. promises.push(saveRoleMenus({ role, menuIds: ids }))
  516. }
  517. try {
  518. await Promise.all(promises)
  519. this.$message.success('授权保存成功')
  520. } catch (e) {
  521. this.$message.error('授权保存失败')
  522. } finally {
  523. this.saveAuthLoading = false
  524. }
  525. },
  526. // 树操作
  527. expandAll() {
  528. if (!this.$refs.authTreeByRole) return
  529. const nodes = this.$refs.authTreeByRole.store._getAllNodes()
  530. nodes.forEach(n => { n.expanded = true })
  531. },
  532. collapseAll() {
  533. if (!this.$refs.authTreeByRole) return
  534. const nodes = this.$refs.authTreeByRole.store._getAllNodes()
  535. nodes.forEach(n => { n.expanded = false })
  536. },
  537. checkAll() {
  538. this.$refs.authTreeByRole.setCheckedNodes(this.menuTree)
  539. },
  540. uncheckAll() {
  541. this.$refs.authTreeByRole.setCheckedKeys([])
  542. },
  543. // 重建 menuId → Set<Role> 映射
  544. rebuildMenuRoles() {
  545. const menuRoles = {}
  546. for (const role of Object.keys(this.roleMenuMap)) {
  547. for (const id of this.roleMenuMap[role]) {
  548. if (!menuRoles[id]) menuRoles[id] = new Set()
  549. menuRoles[id].add(role)
  550. }
  551. }
  552. this.allMenuRoles = menuRoles
  553. },
  554. },
  555. watch: {
  556. // 切换角色时同步树选中状态
  557. selectedRole() {
  558. this.$nextTick(() => {
  559. if (this.$refs.authTreeByRole) {
  560. this.$refs.authTreeByRole.setCheckedKeys(this.roleMenuMap[this.selectedRole] || [])
  561. }
  562. })
  563. }
  564. }
  565. }
  566. </script>
  567. <style scoped>
  568. .menu-manage {
  569. display: flex;
  570. flex-direction: column;
  571. height: 100%;
  572. }
  573. .admin-page-header {
  574. display: flex;
  575. align-items: center;
  576. justify-content: space-between;
  577. gap: 16px;
  578. }
  579. .header-actions {
  580. display: flex;
  581. align-items: center;
  582. gap: 8px;
  583. }
  584. .menu-icon-cell {
  585. margin-right: 6px;
  586. font-size: 16px;
  587. }
  588. .empty-state {
  589. padding: 40px 0;
  590. }
  591. /* === 授权弹窗 === */
  592. .auth-tabs-wrap {
  593. min-height: 420px;
  594. }
  595. .auth-by-role {
  596. display: flex;
  597. gap: 20px;
  598. height: 420px;
  599. }
  600. .role-radio-col {
  601. width: 180px;
  602. border-right: 1px solid #ebeef5;
  603. padding-right: 16px;
  604. display: flex;
  605. flex-direction: column;
  606. }
  607. .role-col-header {
  608. font-weight: 600;
  609. color: #303133;
  610. margin-bottom: 12px;
  611. font-size: 13px;
  612. }
  613. .vertical-radio-group {
  614. display: flex;
  615. flex-direction: column;
  616. gap: 8px;
  617. }
  618. .vertical-radio-group .el-radio-button {
  619. width: 100%;
  620. }
  621. .menu-auth-tree {
  622. flex: 1;
  623. display: flex;
  624. flex-direction: column;
  625. overflow: hidden;
  626. }
  627. .tree-col-header {
  628. display: flex;
  629. align-items: center;
  630. justify-content: space-between;
  631. font-weight: 600;
  632. color: #303133;
  633. margin-bottom: 12px;
  634. font-size: 13px;
  635. }
  636. .tree-actions {
  637. display: flex;
  638. gap: 4px;
  639. }
  640. .el-tree {
  641. flex: 1;
  642. overflow-y: auto;
  643. max-height: 340px;
  644. }
  645. /* 按菜单授权 */
  646. .auth-by-menu {
  647. display: flex;
  648. gap: 20px;
  649. height: 420px;
  650. }
  651. .menu-tree-col {
  652. width: 340px;
  653. border-right: 1px solid #ebeef5;
  654. padding-right: 16px;
  655. display: flex;
  656. flex-direction: column;
  657. overflow: hidden;
  658. }
  659. .menu-role-col {
  660. flex: 1;
  661. display: flex;
  662. flex-direction: column;
  663. }
  664. .menu-title-bar {
  665. display: flex;
  666. align-items: center;
  667. gap: 6px;
  668. padding: 12px 0;
  669. border-bottom: 1px solid #ebeef5;
  670. font-size: 15px;
  671. font-weight: 600;
  672. }
  673. .menu-role-desc {
  674. color: #909399;
  675. font-size: 12px;
  676. margin: 12px 0;
  677. }
  678. .menu-role-checkbox-group {
  679. display: flex;
  680. flex-direction: column;
  681. gap: 10px;
  682. margin-bottom: 20px;
  683. }
  684. .menu-role-ops {
  685. display: flex;
  686. justify-content: flex-end;
  687. padding-top: 12px;
  688. border-top: 1px solid #ebeef5;
  689. }
  690. .menu-count {
  691. font-weight: normal;
  692. font-size: 12px;
  693. color: #909399;
  694. }
  695. .empty-state .el-empty {
  696. margin: 30px 0;
  697. }
  698. .flex { display: flex; }
  699. .items-center { align-items: center; }
  700. .gap-sm { gap: 8px; }
  701. /* 上级菜单树提示 */
  702. .parent-hint {
  703. margin-top: 8px;
  704. font-size: 12px;
  705. color: #909399;
  706. display: flex;
  707. align-items: center;
  708. gap: 4px;
  709. }
  710. .parent-hint-top {
  711. color: #67c23a;
  712. }
  713. .custom-tree-node {
  714. font-size: 13px;
  715. display: flex;
  716. align-items: center;
  717. width: 100%;
  718. }
  719. .custom-tree-node .el-tree-node__content {
  720. height: 32px;
  721. }
  722. </style>