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 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>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:plus />
@ -83,10 +83,11 @@
</NButton>
<NButton
v-permission="PERMISSIONS.MENU.DELETE"
v-if="hasButton(PERMISSIONS.MENU.DELETE)"
type="error"
size="medium"
:disabled="selectedRows.length === 0"
class="px-6 flex items-center"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
@ -97,7 +98,7 @@
删除
</NButton>
<NButton class="px-6 flex items-center" @click="handleExpandToggle">
<NButton size="medium" class="px-3 flex items-center" @click="handleExpandToggle">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:expand-up v-if="isAllExpanded" />
@ -130,7 +131,7 @@
:bordered="false"
:single-line="false"
children-key="children"
size="large"
size="medium"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
@update:expanded-row-keys="handleExpandedKeysChange"
@ -142,13 +143,14 @@
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:show-action="getShowEmptyAction()"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
v-if="getShowEmptyAction()"
type="primary"
size="medium"
round
@ -366,7 +368,7 @@
</n-grid-item>
</n-grid>
<!-- 英文菜单 + 页面路径 -->
<!-- 英文菜单 + 选择路由 -->
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item v-if="formData.menuType !== '3'">
<n-form-item label="英文菜单" path="enName">
@ -376,11 +378,11 @@
/>
</n-form-item>
</n-grid-item>
<n-grid-item v-if="formData.menuType === '2'">
<n-form-item label="页面路径" path="component">
<n-grid-item v-if="formData.menuType !== '3'">
<n-form-item label="选择路由" path="component">
<n-input
v-model:value="formData.component"
placeholder="例如:system/user/index"
placeholder="例如:/system/dict/type"
/>
</n-form-item>
</n-grid-item>
@ -432,17 +434,29 @@
</n-grid-item>
</n-grid>
<!-- 是否缓存 -->
<n-form-item label="是否缓存" path="isKeepAlive" class="mb-2">
<n-radio-group v-model:value="formData.isKeepAlive">
<n-radio value="0">
</n-radio>
<n-radio value="1">
</n-radio>
</n-radio-group>
</n-form-item>
<!-- 是否缓存 + 页面路径 -->
<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 value="0">
</n-radio>
<n-radio value="1">
</n-radio>
</n-radio-group>
</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">
@ -473,18 +487,34 @@
</n-grid>
</template>
<!-- 目录类型时显示是否展开 -->
<!-- 目录类型时显示是否展开 + 是否固钉 -->
<template v-if="formData.menuType === '1'">
<n-form-item label="是否展开" path="isSpread" class="mb-2">
<n-radio-group v-model:value="formData.isSpread">
<n-radio value="0">
</n-radio>
<n-radio value="1">
</n-radio>
</n-radio-group>
</n-form-item>
<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 value="0">
</n-radio>
<n-radio value="1">
</n-radio>
</n-radio-group>
</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>
<!-- 外链地址 -->
@ -514,7 +544,8 @@ import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiIcon from '@/components/common/CoiIcon.vue'
import IconSelect from '@/components/common/IconSelect.vue'
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 {
addMenu,
batchDeleteMenu,
@ -531,6 +562,9 @@ import { colord } from 'colord'
//
const appStore = useAppStore()
//
const { hasButton } = usePermission()
//
const themeColors = computed(() => {
const primary = appStore.primaryColor
@ -598,13 +632,13 @@ const rules: FormRules = {
],
parentId: [
{
required: false, // '0'
type: 'string',
required: true,
message: '请选择上级菜单',
trigger: ['change', 'blur'],
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()
}
else {
@ -728,7 +762,7 @@ const columns: DataTableColumns<MenuVo> = [
render: row => row.auth || '-',
},
{
title: '页面路径',
title: '选择路由',
key: 'component',
width: 200,
align: 'center',
@ -798,16 +832,16 @@ const columns: DataTableColumns<MenuVo> = [
const buttons = []
//
if (row.menuType !== '3') {
if (row.menuType !== '3' && hasButton(PERMISSIONS.MENU.ADD)) {
buttons.push(h(NTooltip, {
trigger: 'hover',
}, {
default: () => '新增子菜单',
trigger: () => h(NButton, {
type: 'info',
type: 'success',
size: 'medium',
circle: true,
class: 'action-btn action-btn-info',
class: 'action-btn action-btn-success',
onClick: () => handleAddChild(row),
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlinePlus) }),
@ -816,42 +850,46 @@ const columns: DataTableColumns<MenuVo> = [
}
//
buttons.push(h(NTooltip, {
trigger: 'hover',
}, {
default: () => '编辑',
trigger: () => h(NButton, {
type: 'primary',
size: 'medium',
circle: true,
class: 'action-btn action-btn-edit',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineEdit) }),
}),
}))
//
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.menuId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此菜单吗?',
trigger: () => h(NTooltip, {
if (hasButton(PERMISSIONS.MENU.UPDATE)) {
buttons.push(h(NTooltip, {
trigger: 'hover',
}, {
default: () => '删除',
default: () => '编辑',
trigger: () => h(NButton, {
type: 'error',
type: 'primary',
size: 'medium',
circle: true,
class: 'action-btn action-btn-delete',
class: 'action-btn action-btn-edit',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineDelete) }),
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineEdit) }),
}),
}),
}))
}))
}
//
if (hasButton(PERMISSIONS.MENU.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.menuId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此菜单吗?',
trigger: () => h(NTooltip, {
trigger: 'hover',
}, {
default: () => '删除',
trigger: () => h(NButton, {
type: 'error',
size: 'medium',
circle: true,
class: 'action-btn action-btn-delete',
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineDelete) }),
}),
}),
}))
}
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[] {
const menuMap = new Map<number, MenuVo>()
const menuMap = new Map<string, MenuVo>()
const result: MenuVo[] = []
// map
// mapID
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) => {
const currentMenu = menuMap.get(menu.menuId)!
if (menu.parentId === 0) {
const menuId = String(menu.menuId)
const parentId = String(menu.parentId)
const currentMenu = menuMap.get(menuId)!
if (parentId === '0') {
result.push(currentMenu)
}
else {
const parent = menuMap.get(menu.parentId)
const parent = menuMap.get(parentId)
if (parent) {
parent.children = parent.children || []
parent.children.push(currentMenu)
@ -1028,7 +1075,7 @@ async function getMenuCascaderData() {
//
function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] {
return menuTree.map(menu => ({
value: menu.menuId,
value: String(menu.menuId), // value
label: menu.menuName,
children: menu.children && menu.children.length > 0
? convertToMenuCascader(menu.children)
@ -1036,14 +1083,14 @@ function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] {
}))
}
//
function getSelectedMenuText() {
if (formData.value.parentId === '0') {
//
function buildMenuPath(menuId: string): string {
if (menuId === '0') {
return '最顶级菜单'
}
//
const topMenu = topLevelMenus.value.find(menu => menu.value === formData.value.parentId)
const topMenu = topLevelMenus.value.find(menu => menu.value === menuId)
if (topMenu) {
return topMenu.label
}
@ -1051,9 +1098,9 @@ function getSelectedMenuText() {
//
for (const menu of topLevelMenus.value) {
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) {
return subMenu.label
return `${menu.label} / ${subMenu.label}`
}
}
}
@ -1061,10 +1108,15 @@ function getSelectedMenuText() {
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
selectedMenuText.value = label
selectedMenuText.value = buildMenuPath(value) // 使
//
parentSelectorRef.value?.setShow(false)
@ -1163,28 +1215,7 @@ function handleAdd() {
//
function setParentMenuText(parentId: string) {
if (parentId === '0') {
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}`
selectedMenuText.value = buildMenuPath(parentId)
}
//
@ -1194,8 +1225,8 @@ function handleAddChild(parentMenu: MenuVo) {
resetFormData()
//
formData.value.parentId = parentMenu.menuId // 使
selectedMenuText.value = parentMenu.menuName //
formData.value.parentId = String(parentMenu.menuId) //
selectedMenuText.value = buildMenuPath(String(parentMenu.menuId)) //
//
if (parentMenu.menuType === '1') {
@ -1219,10 +1250,11 @@ function handleEdit(menu: MenuVo) {
formData.value = {
...menu,
sorted: Number(menu.sorted), // sorted
parentId: String(menu.parentId), // parentId
}
//
setParentMenuText(menu.parentId)
setParentMenuText(String(menu.parentId))
menuDialogRef.value?.coiOpen()
}
@ -1339,9 +1371,21 @@ function handleMenuTypeChange(value: string) {
//
async function handleSubmit() {
try {
await formRef.value?.validate()
if (!formRef.value)
return
try {
//
await formRef.value.validate()
}
catch {
//
coiMsgWarning('验证失败,请检查填写内容')
return
}
// API
try {
//
const currentExpandedKeys = [...expandedKeys.value]
const currentIsAllExpanded = isAllExpanded.value
@ -1367,7 +1411,10 @@ async function handleSubmit() {
}
catch (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() ? '重置搜索' : '新增菜单'
}
function getShowEmptyAction() {
//
if (hasSearchConditions()) {
return true
}
//
return hasButton(PERMISSIONS.MENU.ADD)
}
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
@ -1449,18 +1505,35 @@ onMounted(() => {
}
.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) {
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) {
background-color: #f5f7fa;
}
.custom-table :deep(.n-data-table-tbody .n-data-table-td) {
font-size: 13px !important;
}
.table-wrapper {
background-color: #fff;
}
@ -1551,6 +1624,17 @@ onMounted(() => {
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) {
margin-bottom: 8px !important;
@ -1574,13 +1658,13 @@ onMounted(() => {
.compact-form :deep(.n-input .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;
}
.compact-form :deep(.n-cascader .n-cascader-trigger) {
min-height: 32px !important;
padding: 8px 12px !important;
padding: 2px 1px !important;
}
/* 自定义菜单选择器样式 */
@ -1602,7 +1686,7 @@ onMounted(() => {
.column-header {
background: #f5f5f5;
padding: 8px 12px;
padding: 8px 10px;
font-size: 12px;
font-weight: 500;
color: #666;
@ -1617,7 +1701,7 @@ onMounted(() => {
.menu-option {
display: flex;
align-items: center;
padding: 8px 12px;
padding: 8px 10px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
position: relative;