- 实现完整的菜单管理CRUD功能 - 支持树形结构菜单展示和操作 - 集成权限控制和状态管理 - 使用CoiDialog组件统一弹框体验 - 遵循项目规范的图标和API使用标准
1728 lines
48 KiB
Vue
1728 lines
48 KiB
Vue
<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>
|