fix(menu): 统一权限验证机制并优化表单布局

- 修复表格头部操作栏权限验证方式,从v-permission指令改为hasButton函数
- 统一表格头部和表格行的权限验证逻辑,确保权限控制一致性
- 优化菜单表单字段布局:
  * 英文菜单+选择路由并排显示
  * 目录类型添加是否固钉字段
  * 菜单类型的是否缓存+页面路径并排显示
- 调整input框padding值,改善表单字段间距
- 添加完整的菜单路径显示功能,支持层级路径展示
- 修复空状态组件的权限控制逻辑
This commit is contained in:
Leo 2025-07-08 10:52:17 +08:00
parent ef1acb7368
commit 7b1c300937

View File

@ -73,7 +73,7 @@
<!-- 表格头部操作栏 --> <!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100"> <div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<NButton v-permission="PERMISSIONS.MENU.ADD" type="primary" class="px-6 flex items-center" @click="handleAdd"> <NButton v-if="hasButton(PERMISSIONS.MENU.ADD)" type="primary" size="medium" class="px-3 flex items-center" @click="handleAdd">
<template #icon> <template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)"> <NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:plus /> <icon-park-outline:plus />
@ -83,10 +83,11 @@
</NButton> </NButton>
<NButton <NButton
v-permission="PERMISSIONS.MENU.DELETE" v-if="hasButton(PERMISSIONS.MENU.DELETE)"
type="error" type="error"
size="medium"
:disabled="selectedRows.length === 0" :disabled="selectedRows.length === 0"
class="px-6 flex items-center" class="px-3 flex items-center"
@click="handleBatchDelete" @click="handleBatchDelete"
> >
<template #icon> <template #icon>
@ -97,7 +98,7 @@
删除 删除
</NButton> </NButton>
<NButton class="px-6 flex items-center" @click="handleExpandToggle"> <NButton size="medium" class="px-3 flex items-center" @click="handleExpandToggle">
<template #icon> <template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)"> <NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:expand-up v-if="isAllExpanded" /> <icon-park-outline:expand-up v-if="isAllExpanded" />
@ -130,7 +131,7 @@
:bordered="false" :bordered="false"
:single-line="false" :single-line="false"
children-key="children" children-key="children"
size="large" size="medium"
class="custom-table" class="custom-table"
@update:checked-row-keys="handleRowSelectionChange" @update:checked-row-keys="handleRowSelectionChange"
@update:expanded-row-keys="handleExpandedKeysChange" @update:expanded-row-keys="handleExpandedKeysChange"
@ -142,13 +143,14 @@
:type="getEmptyType()" :type="getEmptyType()"
:title="getEmptyTitle()" :title="getEmptyTitle()"
:description="getEmptyDescription()" :description="getEmptyDescription()"
:show-action="true" :show-action="getShowEmptyAction()"
:action-text="getEmptyActionText()" :action-text="getEmptyActionText()"
size="medium" size="medium"
@action="handleEmptyAction" @action="handleEmptyAction"
> >
<template #action> <template #action>
<NButton <NButton
v-if="getShowEmptyAction()"
type="primary" type="primary"
size="medium" size="medium"
round round
@ -366,7 +368,7 @@
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
<!-- 英文菜单 + 页面路径 --> <!-- 英文菜单 + 选择路由 -->
<n-grid :cols="2" :x-gap="10" class="mb-2"> <n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item v-if="formData.menuType !== '3'"> <n-grid-item v-if="formData.menuType !== '3'">
<n-form-item label="英文菜单" path="enName"> <n-form-item label="英文菜单" path="enName">
@ -376,11 +378,11 @@
/> />
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item v-if="formData.menuType === '2'"> <n-grid-item v-if="formData.menuType !== '3'">
<n-form-item label="页面路径" path="component"> <n-form-item label="选择路由" path="component">
<n-input <n-input
v-model:value="formData.component" v-model:value="formData.component"
placeholder="例如:system/user/index" placeholder="例如:/system/dict/type"
/> />
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
@ -432,8 +434,10 @@
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
<!-- 是否缓存 --> <!-- 是否缓存 + 页面路径 -->
<n-form-item label="是否缓存" path="isKeepAlive" class="mb-2"> <n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否缓存" path="isKeepAlive">
<n-radio-group v-model:value="formData.isKeepAlive"> <n-radio-group v-model:value="formData.isKeepAlive">
<n-radio value="0"> <n-radio value="0">
@ -443,6 +447,16 @@
</n-radio> </n-radio>
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="页面路径" path="component">
<n-input
v-model:value="formData.component"
placeholder="请输入页面路径[system/user/ind]"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 是否展开 + 是否固钉 --> <!-- 是否展开 + 是否固钉 -->
<n-grid :cols="2" :x-gap="10" class="mb-2"> <n-grid :cols="2" :x-gap="10" class="mb-2">
@ -473,9 +487,11 @@
</n-grid> </n-grid>
</template> </template>
<!-- 目录类型时显示是否展开 --> <!-- 目录类型时显示是否展开 + 是否固钉 -->
<template v-if="formData.menuType === '1'"> <template v-if="formData.menuType === '1'">
<n-form-item label="是否展开" path="isSpread" class="mb-2"> <n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否展开" path="isSpread">
<n-radio-group v-model:value="formData.isSpread"> <n-radio-group v-model:value="formData.isSpread">
<n-radio value="0"> <n-radio value="0">
@ -485,6 +501,20 @@
</n-radio> </n-radio>
</n-radio-group> </n-radio-group>
</n-form-item> </n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="是否固钉" path="isAffix">
<n-radio-group v-model:value="formData.isAffix">
<n-radio value="0">
</n-radio>
<n-radio value="1">
</n-radio>
</n-radio-group>
</n-form-item>
</n-grid-item>
</n-grid>
</template> </template>
<!-- 外链地址 --> <!-- 外链地址 -->
@ -514,7 +544,8 @@ import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiIcon from '@/components/common/CoiIcon.vue' import CoiIcon from '@/components/common/CoiIcon.vue'
import IconSelect from '@/components/common/IconSelect.vue' import IconSelect from '@/components/common/IconSelect.vue'
import { PERMISSIONS } from '@/constants/permissions' import { PERMISSIONS } from '@/constants/permissions'
import { coiMsgBox, coiMsgError, coiMsgSuccess } from '@/utils/coi' import { usePermission } from '@/hooks'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { import {
addMenu, addMenu,
batchDeleteMenu, batchDeleteMenu,
@ -531,6 +562,9 @@ import { colord } from 'colord'
// //
const appStore = useAppStore() const appStore = useAppStore()
//
const { hasButton } = usePermission()
// //
const themeColors = computed(() => { const themeColors = computed(() => {
const primary = appStore.primaryColor const primary = appStore.primaryColor
@ -598,13 +632,13 @@ const rules: FormRules = {
], ],
parentId: [ parentId: [
{ {
required: false, // '0' required: true,
type: 'string',
message: '请选择上级菜单', message: '请选择上级菜单',
trigger: ['change', 'blur'], trigger: ['change', 'blur'],
validator: (rule: any, value: any, callback: any) => { validator: (rule: any, value: any, callback: any) => {
// parentId //
if (typeof value === 'string' && value.length > 0) { const stringValue = String(value)
if (stringValue && stringValue.length > 0 && stringValue !== 'undefined' && stringValue !== 'null') {
callback() callback()
} }
else { else {
@ -728,7 +762,7 @@ const columns: DataTableColumns<MenuVo> = [
render: row => row.auth || '-', render: row => row.auth || '-',
}, },
{ {
title: '页面路径', title: '选择路由',
key: 'component', key: 'component',
width: 200, width: 200,
align: 'center', align: 'center',
@ -798,16 +832,16 @@ const columns: DataTableColumns<MenuVo> = [
const buttons = [] const buttons = []
// //
if (row.menuType !== '3') { if (row.menuType !== '3' && hasButton(PERMISSIONS.MENU.ADD)) {
buttons.push(h(NTooltip, { buttons.push(h(NTooltip, {
trigger: 'hover', trigger: 'hover',
}, { }, {
default: () => '新增子菜单', default: () => '新增子菜单',
trigger: () => h(NButton, { trigger: () => h(NButton, {
type: 'info', type: 'success',
size: 'medium', size: 'medium',
circle: true, circle: true,
class: 'action-btn action-btn-info', class: 'action-btn action-btn-success',
onClick: () => handleAddChild(row), onClick: () => handleAddChild(row),
}, { }, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlinePlus) }), icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlinePlus) }),
@ -816,6 +850,7 @@ const columns: DataTableColumns<MenuVo> = [
} }
// //
if (hasButton(PERMISSIONS.MENU.UPDATE)) {
buttons.push(h(NTooltip, { buttons.push(h(NTooltip, {
trigger: 'hover', trigger: 'hover',
}, { }, {
@ -830,8 +865,10 @@ const columns: DataTableColumns<MenuVo> = [
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineEdit) }), icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineEdit) }),
}), }),
})) }))
}
// //
if (hasButton(PERMISSIONS.MENU.DELETE)) {
buttons.push(h(NPopconfirm, { buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.menuId), onPositiveClick: () => handleDelete(row.menuId),
negativeText: '取消', negativeText: '取消',
@ -852,6 +889,7 @@ const columns: DataTableColumns<MenuVo> = [
}), }),
}), }),
})) }))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons) return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
}, },
@ -908,22 +946,31 @@ function addTreeIndexToMenus(menus: MenuVo[]) {
// //
function buildMenuTree(menus: MenuVo[]): MenuVo[] { function buildMenuTree(menus: MenuVo[]): MenuVo[] {
const menuMap = new Map<number, MenuVo>() const menuMap = new Map<string, MenuVo>()
const result: MenuVo[] = [] const result: MenuVo[] = []
// map // mapID
menus.forEach((menu) => { menus.forEach((menu) => {
menuMap.set(menu.menuId, { ...menu, children: [] }) const normalizedMenu = {
...menu,
menuId: String(menu.menuId),
parentId: String(menu.parentId),
children: [],
}
menuMap.set(normalizedMenu.menuId, normalizedMenu)
}) })
// //
menus.forEach((menu) => { menus.forEach((menu) => {
const currentMenu = menuMap.get(menu.menuId)! const menuId = String(menu.menuId)
if (menu.parentId === 0) { const parentId = String(menu.parentId)
const currentMenu = menuMap.get(menuId)!
if (parentId === '0') {
result.push(currentMenu) result.push(currentMenu)
} }
else { else {
const parent = menuMap.get(menu.parentId) const parent = menuMap.get(parentId)
if (parent) { if (parent) {
parent.children = parent.children || [] parent.children = parent.children || []
parent.children.push(currentMenu) parent.children.push(currentMenu)
@ -1028,7 +1075,7 @@ async function getMenuCascaderData() {
// //
function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] { function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] {
return menuTree.map(menu => ({ return menuTree.map(menu => ({
value: menu.menuId, value: String(menu.menuId), // value
label: menu.menuName, label: menu.menuName,
children: menu.children && menu.children.length > 0 children: menu.children && menu.children.length > 0
? convertToMenuCascader(menu.children) ? convertToMenuCascader(menu.children)
@ -1036,14 +1083,14 @@ function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] {
})) }))
} }
// //
function getSelectedMenuText() { function buildMenuPath(menuId: string): string {
if (formData.value.parentId === '0') { if (menuId === '0') {
return '最顶级菜单' return '最顶级菜单'
} }
// //
const topMenu = topLevelMenus.value.find(menu => menu.value === formData.value.parentId) const topMenu = topLevelMenus.value.find(menu => menu.value === menuId)
if (topMenu) { if (topMenu) {
return topMenu.label return topMenu.label
} }
@ -1051,9 +1098,9 @@ function getSelectedMenuText() {
// //
for (const menu of topLevelMenus.value) { for (const menu of topLevelMenus.value) {
if (menu.children?.length) { if (menu.children?.length) {
const subMenu = menu.children.find(child => child.value === formData.value.parentId) const subMenu = menu.children.find(child => child.value === menuId)
if (subMenu) { if (subMenu) {
return subMenu.label return `${menu.label} / ${subMenu.label}`
} }
} }
} }
@ -1061,10 +1108,15 @@ function getSelectedMenuText() {
return selectedMenuText.value return selectedMenuText.value
} }
//
function getSelectedMenuText() {
return buildMenuPath(formData.value.parentId)
}
// //
function handleSelectParent(value: string, label: string) { function handleSelectParent(value: string, _label: string) {
formData.value.parentId = value formData.value.parentId = value
selectedMenuText.value = label selectedMenuText.value = buildMenuPath(value) // 使
// //
parentSelectorRef.value?.setShow(false) parentSelectorRef.value?.setShow(false)
@ -1163,28 +1215,7 @@ function handleAdd() {
// //
function setParentMenuText(parentId: string) { function setParentMenuText(parentId: string) {
if (parentId === '0') { selectedMenuText.value = buildMenuPath(parentId)
selectedMenuText.value = '最顶级菜单'
return
}
//
function findMenuNameById(menus: MenuCascaderBo[], targetId: string): string | null {
for (const menu of menus) {
if (menu.value === targetId) {
return menu.label
}
if (menu.children?.length) {
const found = findMenuNameById(menu.children, targetId)
if (found)
return found
}
}
return null
}
const parentMenuName = findMenuNameById(topLevelMenus.value, parentId)
selectedMenuText.value = parentMenuName || `菜单ID: ${parentId}`
} }
// //
@ -1194,8 +1225,8 @@ function handleAddChild(parentMenu: MenuVo) {
resetFormData() resetFormData()
// //
formData.value.parentId = parentMenu.menuId // 使 formData.value.parentId = String(parentMenu.menuId) //
selectedMenuText.value = parentMenu.menuName // selectedMenuText.value = buildMenuPath(String(parentMenu.menuId)) //
// //
if (parentMenu.menuType === '1') { if (parentMenu.menuType === '1') {
@ -1219,10 +1250,11 @@ function handleEdit(menu: MenuVo) {
formData.value = { formData.value = {
...menu, ...menu,
sorted: Number(menu.sorted), // sorted sorted: Number(menu.sorted), // sorted
parentId: String(menu.parentId), // parentId
} }
// //
setParentMenuText(menu.parentId) setParentMenuText(String(menu.parentId))
menuDialogRef.value?.coiOpen() menuDialogRef.value?.coiOpen()
} }
@ -1339,9 +1371,21 @@ function handleMenuTypeChange(value: string) {
// //
async function handleSubmit() { async function handleSubmit() {
try { if (!formRef.value)
await formRef.value?.validate() return
try {
//
await formRef.value.validate()
}
catch {
//
coiMsgWarning('验证失败,请检查填写内容')
return
}
// API
try {
// //
const currentExpandedKeys = [...expandedKeys.value] const currentExpandedKeys = [...expandedKeys.value]
const currentIsAllExpanded = isAllExpanded.value const currentIsAllExpanded = isAllExpanded.value
@ -1367,7 +1411,10 @@ async function handleSubmit() {
} }
catch (error) { catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
coiMsgError(error.message || '操作失败') coiMsgError(error.message || (isEdit.value ? '修改失败,请检查网络连接' : '新增失败,请检查网络连接'))
}
else {
coiMsgError(isEdit.value ? '修改失败,请检查网络连接' : '新增失败,请检查网络连接')
} }
} }
} }
@ -1427,6 +1474,15 @@ function getEmptyActionText() {
return hasSearchConditions() ? '重置搜索' : '新增菜单' return hasSearchConditions() ? '重置搜索' : '新增菜单'
} }
function getShowEmptyAction() {
//
if (hasSearchConditions()) {
return true
}
//
return hasButton(PERMISSIONS.MENU.ADD)
}
function handleEmptyAction() { function handleEmptyAction() {
if (hasSearchConditions()) { if (hasSearchConditions()) {
handleReset() handleReset()
@ -1449,18 +1505,35 @@ onMounted(() => {
} }
.custom-table { .custom-table {
font-size: 14px; font-size: 13px;
}
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px !important;
}
.custom-table :deep(.n-data-table-th) {
padding: 8px 12px !important;
font-size: 13px !important;
} }
.custom-table :deep(.n-data-table-thead .n-data-table-th) { .custom-table :deep(.n-data-table-thead .n-data-table-th) {
background-color: #f8f9fa; background-color: #f8f9fa;
font-weight: 600; font-weight: normal;
}
.custom-table :deep(.n-data-table-tbody .n-data-table-tr) {
line-height: 1.4 !important;
} }
.custom-table :deep(.n-data-table-tbody .n-data-table-tr:hover) { .custom-table :deep(.n-data-table-tbody .n-data-table-tr:hover) {
background-color: #f5f7fa; background-color: #f5f7fa;
} }
.custom-table :deep(.n-data-table-tbody .n-data-table-td) {
font-size: 13px !important;
}
.table-wrapper { .table-wrapper {
background-color: #fff; background-color: #fff;
} }
@ -1551,6 +1624,17 @@ onMounted(() => {
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.4) !important; box-shadow: 0 4px 16px rgba(6, 182, 212, 0.4) !important;
} }
/* 成功按钮 */
.action-btn-success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
color: white !important;
}
.action-btn-success:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4) !important;
}
/* 紧凑表单样式 */ /* 紧凑表单样式 */
.compact-form :deep(.n-form-item) { .compact-form :deep(.n-form-item) {
margin-bottom: 8px !important; margin-bottom: 8px !important;
@ -1574,13 +1658,13 @@ onMounted(() => {
.compact-form :deep(.n-input .n-input__input-el), .compact-form :deep(.n-input .n-input__input-el),
.compact-form :deep(.n-input-number .n-input__input-el) { .compact-form :deep(.n-input-number .n-input__input-el) {
padding: 8px 12px !important; padding: 2px 1px !important;
min-height: 32px !important; min-height: 32px !important;
} }
.compact-form :deep(.n-cascader .n-cascader-trigger) { .compact-form :deep(.n-cascader .n-cascader-trigger) {
min-height: 32px !important; min-height: 32px !important;
padding: 8px 12px !important; padding: 2px 1px !important;
} }
/* 自定义菜单选择器样式 */ /* 自定义菜单选择器样式 */
@ -1602,7 +1686,7 @@ onMounted(() => {
.column-header { .column-header {
background: #f5f5f5; background: #f5f5f5;
padding: 8px 12px; padding: 8px 10px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #666; color: #666;
@ -1617,7 +1701,7 @@ onMounted(() => {
.menu-option { .menu-option {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px 12px; padding: 8px 10px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s, color 0.2s; transition: background-color 0.2s, color 0.2s;
position: relative; position: relative;