coder-common-thin-frontend/src/views/system/menu/index.vue
Leo ef1acb7368 feat(system): 新增菜单管理功能模块
- 实现完整的菜单管理CRUD功能
- 支持树形结构菜单展示和操作
- 集成权限控制和状态管理
- 使用CoiDialog组件统一弹框体验
- 遵循项目规范的图标和API使用标准
2025-07-07 22:36:07 +08:00

1728 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="menu-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和菜单表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="6" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="菜单名称" path="menuName">
<n-input
v-model:value="searchForm.menuName"
placeholder="请输入菜单名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="权限标识" path="auth">
<n-input
v-model:value="searchForm.auth"
placeholder="请输入权限标识"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="菜单状态" path="menuStatus">
<n-select
v-model:value="searchForm.menuStatus"
placeholder="请选择菜单状态"
clearable
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<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">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:plus />
</NIcon>
</template>
新增
</NButton>
<NButton
v-permission="PERMISSIONS.MENU.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-6 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
删除
</NButton>
<NButton class="px-6 flex items-center" @click="handleExpandToggle">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:expand-up v-if="isAllExpanded" />
<icon-park-outline:expand-down v-else />
</NIcon>
</template>
{{ isAllExpanded ? '全部折叠' : '全部展开' }}
</NButton>
</div>
<div class="flex items-center gap-2">
<NButton type="tertiary" @click="handleRefresh">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
v-model:expanded-row-keys="expandedKeys"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: MenuVo) => row.menuId"
:bordered="false"
:single-line="false"
children-key="children"
size="large"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
@update:expanded-row-keys="handleExpandedKeysChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
round
class="coi-empty__action-btn"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<icon-park-outline:refresh v-if="hasSearchConditions()" />
<icon-park-outline:plus v-else />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
</div>
<!-- 菜单表单弹框 -->
<CoiDialog
ref="menuDialogRef"
:title="modalTitle"
:width="800"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleSubmit"
@coi-cancel="handleCancel"
>
<template #content>
<div class="px-3 py-2">
<n-form
ref="formRef"
:model="formData"
:rules="rules"
label-placement="left"
label-width="90px"
require-mark-placement="right-hanging"
class="compact-form"
>
<!-- 菜单上级 -->
<n-form-item label="菜单上级" path="parentId" class="mb-2">
<n-popover
ref="parentSelectorRef"
trigger="click"
placement="bottom-start"
:show-arrow="false"
style="padding: 0"
:width="600"
>
<template #trigger>
<n-input
:value="getSelectedMenuText()"
placeholder="最顶级菜单"
readonly
class="w-full cursor-pointer"
clearable
@clear="handleClearParent"
>
<template #suffix>
<NIcon>
<icon-park-outline:down />
</NIcon>
</template>
</n-input>
</template>
<template #default>
<div class="menu-selector">
<n-grid :cols="2" :x-gap="0">
<!-- 一级菜单(包含最顶级菜单) -->
<n-grid-item>
<div class="menu-column">
<div class="column-header">
一级菜单
</div>
<div class="menu-options">
<!-- 最顶级菜单选项 -->
<div
class="menu-option"
:class="{ active: formData.parentId === '0' }"
:style="formData.parentId === '0' ? { color: themeColors.primary, fontWeight: '500' } : {}"
@click="handleSelectParent('0', '最顶级菜单')"
>
<n-radio
:checked="formData.parentId === '0'"
style="pointer-events: none;"
/>
<span
class="option-text"
:style="formData.parentId === '0' ? { color: themeColors.primary, fontWeight: '500', display: 'inline-block' } : {}"
>
最顶级菜单
</span>
</div>
<!-- 分隔线 -->
<div class="menu-separator" />
<!-- 其他一级菜单 -->
<div
v-for="menu in topLevelMenus"
:key="menu.value"
class="menu-option"
:class="{
active: formData.parentId === menu.value,
hoverable: menu.children?.length > 0,
}"
:style="formData.parentId === menu.value ? { color: themeColors.primary, fontWeight: '500' } : {}"
@click="handleSelectParent(menu.value, menu.label)"
@mouseenter="handleMenuHover(menu)"
>
<n-radio
:checked="formData.parentId === menu.value"
style="pointer-events: none;"
/>
<span
class="option-text"
:style="formData.parentId === menu.value ? { color: themeColors.primary, fontWeight: '500', display: 'inline-block' } : {}"
>
{{ menu.label }}
</span>
<span
v-if="menu.children?.length"
class="children-count"
:style="formData.parentId === menu.value ? { color: themeColors.primary, opacity: '0.7' } : {}"
>
({{ menu.children.length }})
</span>
<NIcon v-if="menu.children?.length" class="expand-icon">
<icon-park-outline:right />
</NIcon>
</div>
</div>
</div>
</n-grid-item>
<!-- 二级菜单 -->
<n-grid-item>
<div class="menu-column">
<div class="column-header">
二级菜单
</div>
<div class="menu-options">
<div
v-for="submenu in currentSubMenus"
:key="submenu.value"
class="menu-option"
:class="{ active: formData.parentId === submenu.value }"
:style="formData.parentId === submenu.value ? { color: themeColors.primary, fontWeight: '500' } : {}"
@click="handleSelectParent(submenu.value, submenu.label)"
>
<n-radio
:checked="formData.parentId === submenu.value"
style="pointer-events: none;"
/>
<span
class="option-text"
:style="formData.parentId === submenu.value ? { color: themeColors.primary, fontWeight: '500' } : {}"
>
{{ submenu.label }}
</span>
</div>
</div>
</div>
</n-grid-item>
</n-grid>
</div>
</template>
</n-popover>
</n-form-item>
<!-- 菜单类型 -->
<n-form-item label="菜单类型" path="menuType" class="mb-2">
<n-radio-group
v-model:value="formData.menuType"
@update:value="handleMenuTypeChange"
>
<n-radio value="1">
目录
</n-radio>
<n-radio value="2">
菜单
</n-radio>
<n-radio value="3">
按钮
</n-radio>
</n-radio-group>
</n-form-item>
<!-- 菜单图标 -->
<n-form-item v-if="formData.menuType !== '3'" label="菜单图标" path="icon" class="mb-2">
<IconSelect v-model:value="formData.icon" />
</n-form-item>
<!-- 菜单名称 + 显示排序 -->
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="菜单名称" path="menuName">
<n-input
v-model:value="formData.menuName"
placeholder="请输入菜单名称"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="显示排序" path="sorted">
<n-input-number
v-model:value="formData.sorted"
:min="0"
placeholder="0"
class="w-full"
/>
</n-form-item>
</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">
<n-input
v-model:value="formData.enName"
placeholder="请输入英文菜单"
/>
</n-form-item>
</n-grid-item>
<n-grid-item v-if="formData.menuType === '2'">
<n-form-item label="页面路径" path="component">
<n-input
v-model:value="formData.component"
placeholder="例如system/user/index"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 是否隐藏 + 权限字符 -->
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="是否隐藏" path="isHide">
<n-radio-group v-model:value="formData.isHide">
<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="auth">
<n-input
v-model:value="formData.auth"
placeholder="权限字符[system:user:list]"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<!-- 菜单类型为"菜单"时显示的字段 -->
<template v-if="formData.menuType === '2'">
<!-- 路由名称 + 路由Path -->
<n-grid :cols="2" :x-gap="10" class="mb-2">
<n-grid-item>
<n-form-item label="路由名称" path="name">
<n-input
v-model:value="formData.name"
placeholder="例如userPage[唯一]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="路由Path" path="path">
<n-input
v-model:value="formData.path"
placeholder="例如:/system/user[唯一]"
/>
</n-form-item>
</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="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>
<!-- 目录类型时显示是否展开 -->
<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>
</template>
<!-- 外链地址 -->
<n-form-item label="外链地址" path="isLink" class="mb-2">
<n-input
v-model:value="formData.isLink"
placeholder="请输入外链地址[输入值则判断为外链地址]"
class="w-full"
/>
</n-form-item>
</n-form>
</div>
</template>
</CoiDialog>
</div>
</template>
<script setup lang="ts">
import { computed, h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NSwitch, NTag, NTooltip } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineEdit from '~icons/icon-park-outline/edit'
import IconParkOutlinePlus from '~icons/icon-park-outline/plus'
import CoiDialog from '@/components/common/CoiDialog.vue'
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 {
addMenu,
batchDeleteMenu,
deleteMenu,
getMenuList,
updateMenu,
updateMenuSpread,
updateMenuStatus,
} from '@/service/api/system/menu'
import type { MenuCascaderBo, MenuForm, MenuQueryBo, MenuVo } from '@/service/api/system/menu'
import { useAppStore } from '@/store/app'
import { colord } from 'colord'
// 应用状态
const appStore = useAppStore()
// 主题色计算属性
const themeColors = computed(() => {
const primary = appStore.primaryColor
return {
primary,
primaryHover: colord(primary).lighten(0.1).toHex(),
primaryLight: colord(primary).lighten(0.3).toHex(),
primaryLighter: colord(primary).lighten(0.4).toHex(),
}
})
// 搜索表单
const searchFormRef = ref<FormInst>()
const searchForm = ref<MenuQueryBo>({})
// 表格数据
const tableData = ref<MenuVo[]>([])
const loading = ref(false)
const selectedRows = ref<MenuVo[]>([])
const expandedKeys = ref<string[]>([])
const isAllExpanded = ref(true)
// 弹框相关
const menuDialogRef = ref()
const formRef = ref<FormInst>()
const modalTitle = ref('')
const isEdit = ref(false)
// 表单数据
const formData = ref<MenuForm>({
menuName: '',
enName: '',
parentId: '0', // 改为字符串
menuType: '1',
path: '',
name: '',
component: '',
icon: '',
auth: '',
menuStatus: '0',
activeMenu: '',
isHide: '1',
isLink: '',
isKeepAlive: '1',
isFull: '1',
isAffix: '1',
isSpread: '1',
sorted: 0,
remark: '',
})
// 级联选择器数据
const menuCascaderOptions = ref<MenuCascaderBo[]>([])
// 自定义菜单选择器数据
const topLevelMenus = ref<MenuCascaderBo[]>([])
const currentSubMenus = ref<MenuCascaderBo[]>([])
const selectedMenuText = ref('')
const parentSelectorRef = ref()
// 表单校验规则
const rules: FormRules = {
menuName: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
],
parentId: [
{
required: false, // 改为非必填,因为'0'是有效的顶级菜单值
type: 'string',
message: '请选择上级菜单',
trigger: ['change', 'blur'],
validator: (rule: any, value: any, callback: any) => {
// parentId 应该是一个有效的字符串
if (typeof value === 'string' && value.length > 0) {
callback()
}
else {
callback(new Error('请选择上级菜单'))
}
},
},
],
menuType: [
{ required: true, message: '请选择菜单类型', trigger: 'change' },
],
sorted: [
{ required: true, type: 'number', message: '请输入显示顺序', trigger: 'blur' },
],
path: [
{
required: false,
message: '请输入路由地址',
trigger: 'blur',
validator: (rule: any, value: any, callback: any) => {
// 获取当前表单数据
const currentMenuType = formData.value.menuType
// 只有菜单类型需要路由地址
if (currentMenuType === '2' && (!value || value.trim() === '')) {
callback(new Error('请输入路由地址'))
}
else {
callback()
}
},
},
],
auth: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
],
}
// 表格列定义
const columns: DataTableColumns<MenuVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 80,
align: 'center',
render: (row) => {
return (row as any).globalIndex || '-'
},
},
{
title: '菜单名称',
key: 'menuName',
width: 150,
align: 'center',
ellipsis: {
tooltip: true,
},
tree: true,
},
{
title: '英文名称',
key: 'enName',
width: 150,
align: 'center',
ellipsis: {
tooltip: true,
},
render: row => row.enName || '-',
},
{
title: '菜单类型',
key: 'menuType',
width: 100,
align: 'center',
render: (row) => {
const typeMap = {
1: { label: '目录', color: 'primary' },
2: { label: '菜单', color: 'info' },
3: { label: '按钮', color: 'warning' },
}
const type = typeMap[Number(row.menuType) as keyof typeof typeMap]
return h(NTag, { type: type.color }, { default: () => type.label })
},
},
{
title: '展开/折叠',
key: 'isSpread',
width: 100,
align: 'center',
render: (row) => {
if (row.menuType === '3')
return '-' // 按钮类型不显示
return h(NSwitch, {
value: row.isSpread === '0',
checkedText: '展开',
uncheckedText: '收起',
onUpdateValue: value => handleSpreadChange(row, value ? '0' : '1'),
})
},
},
{
title: '图标',
key: 'icon',
width: 80,
align: 'center',
render: (row) => {
return row.icon ? h('div', { class: 'flex justify-center' }, h(CoiIcon, { icon: row.icon, size: 18 })) : '-'
},
},
{
title: '权限标识',
key: 'auth',
width: 200,
align: 'center',
ellipsis: {
tooltip: true,
},
render: row => row.auth || '-',
},
{
title: '页面路径',
key: 'component',
width: 200,
align: 'center',
ellipsis: {
tooltip: true,
},
render: row => row.component || '-',
},
{
title: '路由地址',
key: 'path',
width: 180,
align: 'center',
ellipsis: {
tooltip: true,
},
render: row => row.path || '-',
},
{
title: '路由名称',
key: 'name',
width: 130,
align: 'center',
ellipsis: {
tooltip: true,
},
render: row => row.name || '-',
},
{
title: '是否隐藏',
key: 'isHide',
width: 100,
align: 'center',
render: (row) => {
return h(NTag, {
type: row.isHide === '0' ? 'error' : 'success',
}, {
default: () => row.isHide === '0' ? '隐藏' : '显示',
})
},
},
{
title: '排序',
key: 'sorted',
width: 80,
align: 'center',
},
{
title: '菜单状态',
key: 'menuStatus',
width: 100,
align: 'center',
render: (row) => {
return h(NSwitch, {
value: row.menuStatus === '0',
onUpdateValue: value => handleStatusChange(row, value ? '0' : '1'),
})
},
},
{
title: '操作',
key: 'actions',
width: 160,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
// 新增子菜单按钮
if (row.menuType !== '3') {
buttons.push(h(NTooltip, {
trigger: 'hover',
}, {
default: () => '新增子菜单',
trigger: () => h(NButton, {
type: 'info',
size: 'medium',
circle: true,
class: 'action-btn action-btn-info',
onClick: () => handleAddChild(row),
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlinePlus) }),
}),
}))
}
// 编辑按钮
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, {
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)
},
},
]
// 获取菜单列表
async function getMenuData() {
try {
loading.value = true
const { data } = await getMenuList(searchForm.value)
tableData.value = buildMenuTree(data)
// 构建树形结构后,按显示顺序添加序号
addTreeIndexToMenus(tableData.value)
// 根据 isSpread 字段设置初始展开状态
nextTick(() => {
const initialExpandedKeys = getInitialExpandedKeys(tableData.value)
expandedKeys.value = initialExpandedKeys
// 强制更新全局状态
updateGlobalExpandedState(initialExpandedKeys, true)
})
}
catch {
coiMsgError('获取菜单列表失败')
}
finally {
loading.value = false
}
}
// 为树形菜单添加按显示顺序的序号
function addTreeIndexToMenus(menus: MenuVo[]) {
let globalIndex = 1
function addIndexRecursively(menuList: MenuVo[]) {
// 按sorted字段排序
const sortedMenus = [...menuList].sort((a, b) => a.sorted - b.sorted)
for (const menu of sortedMenus) {
// 给当前菜单添加序号
;(menu as any).globalIndex = globalIndex++
// 如果有子菜单,递归处理
if (menu.children && menu.children.length > 0) {
addIndexRecursively(menu.children)
}
}
}
addIndexRecursively(menus)
}
// 构建菜单树
function buildMenuTree(menus: MenuVo[]): MenuVo[] {
const menuMap = new Map<number, MenuVo>()
const result: MenuVo[] = []
// 先将所有菜单放入map
menus.forEach((menu) => {
menuMap.set(menu.menuId, { ...menu, children: [] })
})
// 构建树形结构
menus.forEach((menu) => {
const currentMenu = menuMap.get(menu.menuId)!
if (menu.parentId === 0) {
result.push(currentMenu)
}
else {
const parent = menuMap.get(menu.parentId)
if (parent) {
parent.children = parent.children || []
parent.children.push(currentMenu)
}
}
})
return result
}
// 获取所有菜单ID
function getAllMenuIds(menus: MenuVo[]): string[] {
const ids: string[] = []
function traverse(nodes: MenuVo[]) {
nodes.forEach((node) => {
ids.push(node.menuId) // 直接使用字符串,无需转换
if (node.children?.length) {
traverse(node.children)
}
})
}
traverse(menus)
return ids
}
// 根据 isSpread 字段获取初始展开的菜单ID
function getInitialExpandedKeys(menus: MenuVo[]): string[] {
const expandedIds: string[] = []
function traverse(nodes: MenuVo[]) {
nodes.forEach((node) => {
// isSpread === '0' 表示默认展开
if (node.isSpread === '0') {
expandedIds.push(node.menuId) // 直接使用字符串,无需转换
}
if (node.children?.length) {
traverse(node.children)
}
})
}
traverse(menus)
return expandedIds
}
// 更新本地数据中指定菜单项的 isSpread 状态
function updateMenuSpreadInData(menus: MenuVo[], menuId: string, isSpread: string) {
function traverse(nodes: MenuVo[]) {
for (const node of nodes) {
if (node.menuId === menuId) {
node.isSpread = isSpread
return true
}
if (node.children?.length && traverse(node.children)) {
return true
}
}
return false
}
traverse(menus)
}
// 更新本地数据中指定菜单项的 menuStatus 状态
function updateMenuStatusInData(menus: MenuVo[], menuId: string, menuStatus: string) {
function traverse(nodes: MenuVo[]) {
for (const node of nodes) {
if (node.menuId === menuId) {
node.menuStatus = menuStatus
return true
}
if (node.children?.length && traverse(node.children)) {
return true
}
}
return false
}
traverse(menus)
}
// 获取级联选择器数据
async function getMenuCascaderData() {
try {
// 使用菜单列表接口获取完整的树形数据,这样更准确
const { data: menuListData } = await getMenuList({})
// 构建树形结构
const treeData = buildMenuTree(menuListData)
// 转换为级联选择器格式
const cascaderData = convertToMenuCascader(treeData)
menuCascaderOptions.value = [
{ value: '0', label: '主目录', children: cascaderData },
]
// 顶级菜单就是树形数据的第一层
topLevelMenus.value = cascaderData
}
catch {
coiMsgError('获取菜单级联数据失败')
}
}
// 转换菜单数据为级联选择器格式
function convertToMenuCascader(menuTree: MenuVo[]): MenuCascaderBo[] {
return menuTree.map(menu => ({
value: menu.menuId,
label: menu.menuName,
children: menu.children && menu.children.length > 0
? convertToMenuCascader(menu.children)
: undefined,
}))
}
// 获取选中菜单的显示文本
function getSelectedMenuText() {
if (formData.value.parentId === '0') {
return '最顶级菜单'
}
// 先在顶级菜单中查找
const topMenu = topLevelMenus.value.find(menu => menu.value === formData.value.parentId)
if (topMenu) {
return topMenu.label
}
// 再在子菜单中查找
for (const menu of topLevelMenus.value) {
if (menu.children?.length) {
const subMenu = menu.children.find(child => child.value === formData.value.parentId)
if (subMenu) {
return subMenu.label
}
}
}
return selectedMenuText.value
}
// 处理选择父菜单
function handleSelectParent(value: string, label: string) {
formData.value.parentId = value
selectedMenuText.value = label
// 关闭弹窗
parentSelectorRef.value?.setShow(false)
// 手动触发 parentId 字段的验证
nextTick(() => {
formRef.value?.validate(['parentId']).catch(() => {
// 忽略验证错误,只是为了触发验证状态更新
})
})
}
// 处理清除父菜单选择
function handleClearParent() {
formData.value.parentId = '0'
selectedMenuText.value = ''
currentSubMenus.value = []
// 手动触发 parentId 字段的验证
nextTick(() => {
formRef.value?.validate(['parentId']).catch(() => {
// 忽略验证错误,只是为了触发验证状态更新
})
})
}
// 处理菜单悬停,显示子菜单
function handleMenuHover(menu: MenuCascaderBo) {
if (menu.children?.length) {
currentSubMenus.value = menu.children
}
else {
currentSubMenus.value = []
}
}
// 搜索
function handleSearch() {
// 搜索时使用默认的展开状态基于isSpread字段
getMenuData()
}
// 重置
function handleReset() {
searchForm.value = {}
// 重置时也使用默认的展开状态
handleSearch()
}
// 刷新
function handleRefresh() {
handleSearch()
}
// 全局展开/折叠切换基于isSpread状态智能操作
function handleExpandToggle() {
if (isAllExpanded.value) {
// 全部折叠恢复到基于isSpread字段的默认展开状态
const shouldExpandIds = getInitialExpandedKeys(tableData.value)
expandedKeys.value = [...shouldExpandIds]
isAllExpanded.value = false
}
else {
// 全部展开:展开所有菜单项
const allIds = getAllMenuIds(tableData.value)
expandedKeys.value = [...allIds]
isAllExpanded.value = true
}
}
// 统一更新全局展开状态的函数(仅在必要时自动更新)
function updateGlobalExpandedState(keys: string[], forceUpdate = false) {
if (forceUpdate) {
const allIds = getAllMenuIds(tableData.value)
isAllExpanded.value = keys.length === allIds.length
}
}
// 展开键变化
function handleExpandedKeysChange(_keys: string[]) {
// 使用 v-model 后expandedKeys 会自动更新
// 对于全局按钮操作,状态已经在 handleExpandToggle 中设置,这里不需要自动更新
}
// 行选择变化
function handleRowSelectionChange(keys: string[]) {
selectedRows.value = tableData.value.filter(item => keys.includes(item.menuId))
}
// 新增菜单
function handleAdd() {
modalTitle.value = '新增菜单'
isEdit.value = false
resetFormData()
menuDialogRef.value?.coiOpen()
}
// 设置父菜单显示文本的辅助函数
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}`
}
// 新增子菜单
function handleAddChild(parentMenu: MenuVo) {
modalTitle.value = `新增${parentMenu.menuName}的子菜单`
isEdit.value = false
resetFormData()
// 设置父菜单信息
formData.value.parentId = parentMenu.menuId // 直接使用字符串,无需转换
selectedMenuText.value = parentMenu.menuName // 设置显示文本
// 根据父菜单类型智能设置子菜单默认类型
if (parentMenu.menuType === '1') {
// 目录的子菜单默认为菜单
formData.value.menuType = '2'
}
else if (parentMenu.menuType === '2') {
// 菜单的子菜单默认为按钮
formData.value.menuType = '3'
// 按钮类型默认隐藏
formData.value.isHide = '0'
}
menuDialogRef.value?.coiOpen()
}
// 编辑菜单
function handleEdit(menu: MenuVo) {
modalTitle.value = '编辑菜单'
isEdit.value = true
formData.value = {
...menu,
sorted: Number(menu.sorted), // 确保 sorted 是数字类型
}
// 设置父菜单显示文本
setParentMenuText(menu.parentId)
menuDialogRef.value?.coiOpen()
}
// 删除菜单
async function handleDelete(menuId: string) {
try {
// 保存当前的展开状态
const currentExpandedKeys = [...expandedKeys.value]
const currentIsAllExpanded = isAllExpanded.value
await deleteMenu(menuId)
coiMsgSuccess('删除成功')
await getMenuData()
// 恢复之前的展开状态(排除已删除的项)
nextTick(() => {
const allIds = getAllMenuIds(tableData.value)
expandedKeys.value = currentExpandedKeys.filter(id => allIds.includes(id))
isAllExpanded.value = expandedKeys.value.length === allIds.length && currentIsAllExpanded
})
}
catch {
coiMsgError('删除失败')
}
}
// 批量删除
async function handleBatchDelete() {
if (selectedRows.value.length === 0) {
coiMsgError('请选择要删除的菜单')
return
}
try {
await coiMsgBox('确定要删除选中的菜单吗?', '删除确认')
// 保存当前的展开状态
const currentExpandedKeys = [...expandedKeys.value]
const currentIsAllExpanded = isAllExpanded.value
const ids = selectedRows.value.map(item => item.menuId)
await batchDeleteMenu(ids)
coiMsgSuccess('删除成功')
selectedRows.value = []
await getMenuData()
// 恢复之前的展开状态(排除已删除的项)
nextTick(() => {
const allIds = getAllMenuIds(tableData.value)
expandedKeys.value = currentExpandedKeys.filter(id => allIds.includes(id))
isAllExpanded.value = expandedKeys.value.length === allIds.length && currentIsAllExpanded
})
}
catch {
// 用户取消删除或删除失败
}
}
// 状态变更
async function handleStatusChange(menu: MenuVo, status: string) {
try {
await updateMenuStatus(menu.menuId, status)
// 更新本地数据的状态
updateMenuStatusInData(tableData.value, menu.menuId, status)
coiMsgSuccess('状态修改成功')
}
catch {
coiMsgError('状态修改失败')
}
}
// 展开状态变更
async function handleSpreadChange(menu: MenuVo, isSpread: string) {
try {
await updateMenuSpread(menu.menuId, isSpread)
// 更新本地数据的 isSpread 状态
updateMenuSpreadInData(tableData.value, menu.menuId, isSpread)
// 同步更新UI展开状态
const menuIdStr = menu.menuId // 直接使用字符串,无需转换
if (isSpread === '0') {
// 如果设置为展开,添加到展开列表
if (!expandedKeys.value.includes(menuIdStr)) {
expandedKeys.value.push(menuIdStr)
}
}
else {
// 如果设置为收起,从展开列表移除
expandedKeys.value = expandedKeys.value.filter(id => id !== menuIdStr)
}
// 更新全局展开状态(单行操作需要强制更新)
updateGlobalExpandedState(expandedKeys.value, true)
}
catch {
coiMsgError('展开状态修改失败')
}
}
// 菜单类型变化
function handleMenuTypeChange(value: string) {
// 按钮类型默认隐藏
if (value === '3') {
formData.value.isHide = '0'
}
else {
formData.value.isHide = '1'
}
}
// 提交表单
async function handleSubmit() {
try {
await formRef.value?.validate()
// 保存当前的展开状态
const currentExpandedKeys = [...expandedKeys.value]
const currentIsAllExpanded = isAllExpanded.value
if (isEdit.value) {
await updateMenu(formData.value)
coiMsgSuccess('修改成功')
}
else {
await addMenu(formData.value)
coiMsgSuccess('新增成功')
}
menuDialogRef.value?.coiClose()
await getMenuData()
await getMenuCascaderData()
// 恢复之前的展开状态
nextTick(() => {
expandedKeys.value = currentExpandedKeys
isAllExpanded.value = currentIsAllExpanded
})
}
catch (error) {
if (error instanceof Error) {
coiMsgError(error.message || '操作失败')
}
}
}
// 取消
function handleCancel() {
menuDialogRef.value?.coiClose()
}
// 重置表单数据
function resetFormData() {
formData.value = {
menuName: '',
enName: '',
parentId: '0',
menuType: '1',
path: '',
name: '',
component: '',
icon: '',
auth: '',
menuStatus: '0',
activeMenu: '',
isHide: '1',
isLink: '',
isKeepAlive: '1',
isFull: '1',
isAffix: '1',
isSpread: '1',
sorted: 0,
remark: '',
}
// 重置父菜单显示文本
selectedMenuText.value = ''
// 重置子菜单选项
currentSubMenus.value = []
}
// 空状态相关
function hasSearchConditions(): boolean {
return !!(searchForm.value.menuName || searchForm.value.auth || searchForm.value.menuStatus)
}
function getEmptyType() {
return hasSearchConditions() ? 'search' : 'data'
}
function getEmptyTitle() {
return hasSearchConditions() ? '暂无搜索结果' : '暂无菜单数据'
}
function getEmptyDescription() {
return hasSearchConditions() ? '请尝试调整搜索条件' : '请添加菜单数据'
}
function getEmptyActionText() {
return hasSearchConditions() ? '重置搜索' : '新增菜单'
}
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
}
else {
handleAdd()
}
}
// 初始化
onMounted(() => {
getMenuData()
getMenuCascaderData()
})
</script>
<style scoped>
.search-form {
margin-bottom: 0;
}
.custom-table {
font-size: 14px;
}
.custom-table :deep(.n-data-table-thead .n-data-table-th) {
background-color: #f8f9fa;
font-weight: 600;
}
.custom-table :deep(.n-data-table-tbody .n-data-table-tr:hover) {
background-color: #f5f7fa;
}
.table-wrapper {
background-color: #fff;
}
.coi-empty__action-btn {
min-width: 120px;
height: 40px;
font-size: 14px;
font-weight: 500;
}
/* 操作按钮样式 */
.action-btn {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
min-height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 50% !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
border: none !important;
position: relative !important;
overflow: hidden !important;
padding: 0 !important;
}
.action-btn .n-button__content {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
height: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
.action-btn .n-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
transform: none !important;
}
.action-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
.action-btn:active {
transform: translateY(0) !important;
transition: all 0.1s !important;
}
/* 编辑按钮 */
.action-btn-edit {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important;
color: white !important;
}
.action-btn-edit:hover {
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%) !important;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4) !important;
}
/* 删除按钮 */
.action-btn-delete {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
}
.action-btn-delete:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4) !important;
}
/* 信息按钮 */
.action-btn-info {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
color: white !important;
}
.action-btn-info:hover {
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%) !important;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.4) !important;
}
/* 紧凑表单样式 */
.compact-form :deep(.n-form-item) {
margin-bottom: 8px !important;
}
.compact-form :deep(.n-form-item .n-form-item-feedback-wrapper) {
min-height: 0 !important;
padding-top: 2px !important;
}
.compact-form :deep(.n-form-item .n-form-item-label) {
padding-bottom: 2px !important;
}
.compact-form :deep(.n-input),
.compact-form :deep(.n-input-number),
.compact-form :deep(.n-cascader),
.compact-form :deep(.n-radio-group) {
font-size: 14px !important;
}
.compact-form :deep(.n-input .n-input__input-el),
.compact-form :deep(.n-input-number .n-input__input-el) {
padding: 8px 12px !important;
min-height: 32px !important;
}
.compact-form :deep(.n-cascader .n-cascader-trigger) {
min-height: 32px !important;
padding: 8px 12px !important;
}
/* 自定义菜单选择器样式 */
.menu-selector {
border: 1px solid #e0e0e6;
border-radius: 6px;
overflow: hidden;
background: white;
}
.menu-column {
height: 300px;
border-right: 1px solid #e0e0e6;
}
.menu-column:last-child {
border-right: none;
}
.column-header {
background: #f5f5f5;
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: #666;
border-bottom: 1px solid #e0e0e6;
}
.menu-options {
height: calc(100% - 32px);
overflow-y: auto;
}
.menu-option {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
position: relative;
color: #333;
}
.menu-option:hover {
background-color: v-bind('themeColors.primaryLighter');
color: v-bind('themeColors.primary');
}
.menu-option.active {
color: v-bind('themeColors.primary') !important;
font-weight: 500 !important;
}
.menu-option.active:hover {
background-color: v-bind('themeColors.primaryLighter') !important;
color: v-bind('themeColors.primary') !important;
}
.menu-option.active .option-text,
.menu-option.active span.option-text,
.menu-selector .menu-option.active .option-text,
.menu-selector .menu-option.active span {
color: v-bind('themeColors.primary') !important;
opacity: 1 !important;
text-shadow: none !important;
font-weight: 500 !important;
}
.menu-option.active .children-count {
color: v-bind('themeColors.primary') !important;
opacity: 0.7 !important;
}
/* 额外保险的选择器 */
.menu-selector .menu-column .menu-options .menu-option.active * {
color: v-bind('themeColors.primary') !important;
}
.menu-selector .menu-column .menu-options .menu-option.active span.option-text {
color: v-bind('themeColors.primary') !important;
display: inline !important;
visibility: visible !important;
font-weight: 500 !important;
}
/* 针对Naive UI组件的覆盖 */
.menu-selector .menu-option.active .n-radio,
.menu-selector .menu-option.active .n-radio *,
.menu-selector .menu-option[data-active="true"],
.menu-selector .menu-option[data-active="true"] * {
color: v-bind('themeColors.primary') !important;
}
.menu-option.hoverable:hover {
background-color: v-bind('themeColors.primaryLighter');
color: v-bind('themeColors.primary');
}
.option-text {
flex: 1;
margin-left: 8px;
font-size: 14px;
}
.children-count {
font-size: 12px;
color: #999;
margin-left: 4px;
}
.expand-icon {
margin-left: 4px;
font-size: 12px;
color: #999;
}
.menu-option.active .expand-icon {
color: rgba(255, 255, 255, 0.8);
}
.menu-separator {
height: 1px;
background-color: #e0e0e6;
margin: 4px 0;
}
/* 滚动条样式 */
.menu-options::-webkit-scrollbar {
width: 4px;
}
.menu-options::-webkit-scrollbar-track {
background: transparent;
}
.menu-options::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 2px;
}
.menu-options::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
</style>