669 lines
20 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>
<n-layout class="menu-management">
<n-layout-header bordered class="menu-management__header">
<div class="title">菜单管理</div>
<n-button type="primary" @click="handleAddParent" size="small">
<template #icon>
<n-icon>
<Add />
</n-icon>
</template>
添加父级菜单
</n-button>
</n-layout-header>
<n-layout-content class="menu-management__content">
<n-spin :show="loading">
<n-tree block-line :data="menuTree" :renderLabel="renderTreeLabel" :selectable="false">
<template #empty>
<ls-empty type="no-data" description="Not Found 404" title="无数据"></ls-empty>
</template>
</n-tree>
</n-spin>
</n-layout-content>
<!-- 父级菜单表单弹窗 -->
<n-modal v-model:show="showParentModal" preset="dialog" style="width:640px" :title="parentModalTitle">
<n-card style="width: 600px;">
<n-form ref="parentFormRef" :model="parentForm" :rules="parentRules" label-placement="left" label-width="auto"
require-mark-placement="right-hanging">
<n-form-item label="菜单名称" path="label">
<n-input v-model:value="parentForm.label" placeholder="请输入菜单名称" />
</n-form-item>
<n-form-item path="path">
<template #label>
菜单路径
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16">
<HelpCircle />
</n-icon>
</template>
前缀不要加 /代码已经统一处理请勿加/
</n-tooltip>
</template>
<n-input v-model:value="parentForm.path" placeholder="请输入菜单路径" />
</n-form-item>
<n-form-item label="菜单编码" path="menuCode">
<n-input v-model:value="parentForm.menuCode" placeholder="请输入菜单编码" />
</n-form-item>
<n-form-item label="菜单图标" path="icon">
<n-select v-model:value="parentForm.icon" :options="iconOptions" filterable clearable placeholder="请选择图标名称"
:render-label="renderLabel" />
</n-form-item>
<n-form-item label="组件路径" path="component">
<template #label>
组件路径
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16">
<HelpCircle />
</n-icon>
</template>
/src/views/下的组件比如/src/views/home/index.vue就填写home/index.vue
</n-tooltip>
</template>
<n-input-group>
<n-input :style="{ width: '60%' }" v-model:value="parentForm.component" placeholder="请输入组件路径"
:disabled="menuType === 'list'" />
<n-radio-group :style="{ width: '40%' }" v-model:value="menuType">
<n-radio-button label="菜单" value="menu" />
<n-radio-button label="目录" value="list"></n-radio-button>
</n-radio-group>
</n-input-group>
</n-form-item>
<n-form-item label="适配方式" path="adaptability">
<n-select v-model:value="parentForm.adaptability" :options="adaptabilityOptions" placeholder="请选择适配方式" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number v-model:value="parentForm.sort" :min="0" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-radio-group v-model:value="parentForm.status">
<n-space>
<n-radio value="enable">启用</n-radio>
<n-radio value="disabled">禁用</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="查询参数" path="query">
<n-input v-model:value="parentForm.query" type="textarea" placeholder="请输入查询参数(JSON格式)" />
</n-form-item>
</n-form>
</n-card>
<template #action>
<n-space justify="end">
<n-button @click="showParentModal = false">取消</n-button>
<n-button type="primary" @click="handleSubmitParent">确认</n-button>
</n-space>
</template>
</n-modal>
<!-- 子级菜单表单弹窗 -->
<n-modal v-model:show="showChildModal" preset="dialog" :title="childModalTitle" style="width:680px">
<n-card style="width: 640px;">
<n-form ref="childFormRef" :model="childForm" :rules="childRules" label-placement="left" label-width="auto"
require-mark-placement="right-hanging">
<n-form-item label="父级菜单" path="parentId">
<n-select v-model:value="childForm.parentId" :options="parentMenuOptions" placeholder="请选择父级菜单"
:disabled="isEditChild" />
</n-form-item>
<n-form-item label="菜单名称" path="label">
<n-input v-model:value="childForm.label" placeholder="请输入菜单名称" />
</n-form-item>
<n-form-item path="path">
<template #label>
菜单路径
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16">
<HelpCircle />
</n-icon>
</template>
前缀不要加 /代码已经统一处理请勿加/
</n-tooltip>
</template>
<n-input v-model:value="childForm.path" placeholder="请输入菜单路径" />
</n-form-item>
<n-form-item label="菜单编码" path="menuCode">
<n-input v-model:value="childForm.menuCode" placeholder="请输入菜单编码" />
</n-form-item>
<n-form-item label="菜单图标" path="icon">
<n-select v-model:value="childForm.icon" :options="iconOptions" filterable clearable placeholder="请选择图标名称"
:render-label="renderLabel" />
</n-form-item>
<n-form-item path="component">
<template #label>
组件路径
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16">
<HelpCircle />
</n-icon>
</template>
/src/views/下的组件比如/src/views/home/index.vue就填写home/index.vue
</n-tooltip>
</template>
<n-input :style="{ width: '60%' }" v-model:value="childForm.component" placeholder="请输入组件路径"
:disabled="menuType === 'list'" />
<n-radio-group :style="{ width: '40%' }" v-model:value="menuType">
<n-radio-button label="菜单" value="menu" />
<n-radio-button label="目录" value="list"></n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="适配方式" path="adaptability">
<n-select v-model:value="childForm.adaptability" :options="adaptabilityOptions" placeholder="请选择适配方式" />
</n-form-item>
<n-form-item label="排序" path="sort">
<n-input-number v-model:value="childForm.sort" :min="0" />
</n-form-item>
<n-form-item label="状态" path="status">
<n-radio-group v-model:value="childForm.status">
<n-space>
<n-radio value="enable">启用</n-radio>
<n-radio value="disabled">禁用</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item label="查询参数" path="query">
<n-input v-model:value="childForm.query" type="textarea" placeholder="请输入查询参数(JSON格式)" />
</n-form-item>
</n-form>
</n-card>
<template #action>
<n-space justify="end">
<n-button @click="showChildModal = false">取消</n-button>
<n-button type="primary" @click="handleSubmitChild">确认</n-button>
</n-space>
</template>
</n-modal>
</n-layout>
</template>
<script setup lang="ts">
import { Add, Create, Trash, HelpCircle } from '@vicons/ionicons5'
import {
addParentMenu,
editParentMenu,
addChildMenu,
eidtChildMenu,
getAllMenu,
deleteMenu,
type MenuNode,
type RawMenu
} from '@/api/menu'
import { FormInst, FormRules, NButton, NIcon, NSpace, NTag, SelectOption, TreeOption, useDialog, useMessage } from 'naive-ui'
import SvgIcon from '@/components/SvgIcon.vue'
// 状态管理
const loading = ref(false)
const menuTree = ref<TreeOption[]>([])
// 父级菜单表单相关
const showParentModal = ref(false);
const parentFormRef = ref<FormInst | null>(null);
const menuType = ref<'menu' | 'list'>('menu'); //菜单类型
const parentForm = ref<Omit<RawMenu, 'uuid' | 'parentId'>>({
path: '',
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
status: 'enable',
query: ''
})
const parentRules: FormRules = {
label: { required: true, message: '请输入菜单名称', trigger: 'blur' },
path: { required: true, message: '请输入菜单路径', trigger: 'blur' },
component: { required: true, message: '请输入组件路径', trigger: 'blur' },
icon: { required: true, message: '请选择菜单图标', trigger: 'blur' }
}
const isEditParent = ref(false)
const currentParentId = ref('')
const message = useMessage();
const dialog = useDialog();
// 定义图标选项类型
interface IconOption extends SelectOption {
value: string
label: string
icon: string
}
const svgIcons = ref<string[]>([])
// 加载 SVG 图标文件列表
const loadSvgIcons = async () => {
try {
// 使用 import.meta.glob 获取所有 SVG 文件
const svgModules = import.meta.glob('/src/assets/icons/*.svg', { eager: true })
// 提取文件名(不带后缀)
svgIcons.value = Object.keys(svgModules).map(path => {
const fileName = path.split('/').pop() || ''
return fileName.replace('.svg', '')
})
} catch (error) {
console.error('加载图标失败:', error)
svgIcons.value = []
}
}
// 转换为选择器选项
const iconOptions = computed<IconOption[]>(() => {
return svgIcons.value.map(icon => ({
value: icon,
label: icon,
icon: icon
}))
})
// 自定义渲染选项
const renderLabel = (option: IconOption) => {
return h('div', { class: 'flex-content between' }, [
h('span', { class: 'mr-2' }, option.label),
h(SvgIcon, {
'icon-class': option.icon,
width: '16',
height: '16',
color: '#4090EF'
})
])
}
// 子级菜单表单相关
const showChildModal = ref(false)
const childFormRef = ref<FormInst | null>(null)
const childForm = ref<Omit<RawMenu, 'uuid'>>({
parentId: '',
path: '',
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
status: 'enable',
query: ''
})
const childRules: FormRules = {
parentId: { required: true, message: '请选择父级菜单', trigger: 'blur' },
label: { required: true, message: '请输入菜单名称', trigger: 'blur' },
path: { required: true, message: '请输入菜单路径', trigger: 'blur' },
component: { required: true, message: '请输入组件路径', trigger: 'blur' },
icon: { required: true, message: '请选择菜单图标', trigger: 'blur' }
}
const isEditChild = ref(false)
const currentChildId = ref('')
// 适配方式选项
const adaptabilityOptions = ref([]);
getDict("page_adaptability").then((res) => {
console.log('获取适配方式选项', res);
adaptabilityOptions.value = res.map(item => {
const {label, value, ...rest} = item;
return {
label: label,
value: value,
}
})
}).catch((errors) => {
console.log('获取适配方式选项失败', errors);
message.error('获取适配方式选项失败');
})
// 计算属性
const parentModalTitle = computed(() => (isEditParent.value ? '编辑父级菜单' : '添加父级菜单'))
const childModalTitle = computed(() => (isEditChild.value ? '编辑子级菜单' : '添加子级菜单'))
// 父级菜单选项
const parentMenuOptions = computed<SelectOption[]>(() => {
console.log(menuTree.value, 'menuTree.value');
return menuTree.value.map(menu => ({
label: menu.label,
value: menu.key
}))
})
const { stop } = watch(menuType, (newVal) => {
if (newVal === 'list') {
parentForm.value.component = 'view-router';
childForm.value.component = 'view-router';
} else {
parentForm.value.component = '';
childForm.value.component = '';
}
})
// 生命周期钩子
onMounted(() => {
loadSvgIcons();
fetchMenuData();
});
onBeforeUnmount(() => {
stop();
})
const renderTreeLabel = ({ option }) => {
console.log(option,'option');
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
// justifyContent: 'space-between',
width: '100%'
}
}, [
h('span', {
style: {
fontSize: '14px',
color: '#808080',
fontWeight: 'bold',
marginRight: '12px'
}
}, option.label),
h(NTag, {
type: option?.rawData.status === 'enable' ? 'success' : 'error',
size: 'small'
}, option?.rawData.status === 'enable' ? 'Enabled' : 'Disabled')
])
}
/**
* 将菜单数据转换为树形组件所需的数据结构
* @param data 原始菜单数据
* @returns 转换后的树形数据
*/
const transformTreeData = (data: MenuNode[]): TreeOption[] => {
return data.map((item) => {
const treeNode: TreeOption = {
label: item.label,
key: item.uuid, // 使用 uuid 作为唯一标识
icon: item.icon, // 保留图标信息
isLeaf: item.component !== 'view-router',
// 保留原始数据,方便后续操作
rawData: {
...item,
parentId: item.parentId
},
// 添加自定义前缀(图标)
prefix: () =>
item.icon
? h(SvgIcon, {
'icon-class': item.icon,
width: '16',
height: '16',
color: '#4090EF',
style: 'margin-right:6px'
})
: null,
// 添加自定义后缀(操作按钮)
suffix: () =>
h(
NSpace,
{ justify: 'end', style: 'margin-left: 12px' },
{
default: () => [
// 添加子菜单按钮(非叶子节点显示)
!treeNode.isLeaf
? h(
NButton,
{
size: 'large',
text: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleAddChild(item.uuid)
}
},
{ icon: () => h(NIcon, { color: 'green' }, { default: () => h(Add) }) }
)
: null,
// 编辑按钮
h(
NButton,
{
size: 'large',
text: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
item.parentId === null
? handleEditParent(item)
: handleEditChild(item)
}
},
{ icon: () => h(NIcon, { color: '#4090EF' }, { default: () => h(Create) }) }
),
// 删除按钮
h(
NButton,
{
size: 'large',
text: true,
type: 'error',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleDelete(item.uuid)
}
},
{ icon: () => h(NIcon, null, { default: () => h(Trash) }) }
)
]
}
)
}
// 递归处理子节点
if (item.children && item.children.length > 0) {
treeNode.children = transformTreeData(item.children)
}
return treeNode
})
}
// 获取数据后
const fetchMenuData = async () => {
try {
loading.value = true
const res = await getAllMenu()
if (!Array.isArray(res) || !res) throw new Error('菜单数据格式错误!');
menuTree.value = transformTreeData(res)
} catch (error) {
message.error(error.message || '获取菜单数据失败!');
} finally {
loading.value = false;
}
}
const handleAddParent = () => {
isEditParent.value = false
parentForm.value = {
path: '',
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
status: 'enable',
query: ''
}
menuType.value = 'menu';
showParentModal.value = true;
}
const handleEditParent = (menu: MenuNode) => {
isEditParent.value = true
currentParentId.value = menu.uuid
parentForm.value = {
path: menu.path,
label: menu.label,
icon: menu.icon,
menuCode: menu.menuCode,
menuName: menu.menuName,
adaptability: menu.adaptability,
component: menu.component,
sort: menu.sort,
status: menu.status,
query: menu.query
}
menuType.value = menu.component === 'view-router' ? 'list' : 'menu';
showParentModal.value = true
}
const handleSubmitParent = () => {
parentFormRef.value?.validate(async errors => {
if (!errors) {
try {
if (isEditParent.value) {
await editParentMenu({
uuid: currentParentId.value,
...parentForm.value
})
} else {
await addParentMenu(parentForm.value)
}
showParentModal.value = false;
message.success('操作成功!');
await fetchMenuData()
} catch (error) {
message.error(error.message || '操作失败!');
}
}
})
}
const handleAddChild = (parentId: string) => {
isEditChild.value = false
childForm.value = {
parentId,
path: '',
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
status: 'enable',
query: ''
}
menuType.value = 'menu';
showChildModal.value = true
}
const handleEditChild = (menu: MenuNode) => {
isEditChild.value = true
currentChildId.value = menu.uuid
childForm.value = {
parentId: menu.parentId,
path: menu.path,
label: menu.label,
icon: menu.icon,
menuCode: menu.menuCode,
menuName: menu.menuName,
adaptability: menu.adaptability,
component: menu.component,
sort: menu.sort,
status: menu.status,
query: menu.query
}
menuType.value = menu.component === 'view-router' ? 'list' : 'menu';
showChildModal.value = true
}
const handleSubmitChild = () => {
childFormRef.value?.validate(async errors => {
if (!errors) {
try {
if (isEditChild.value) {
await eidtChildMenu({
uuid: currentChildId.value,
...childForm.value
})
} else {
await addChildMenu(childForm.value)
}
showChildModal.value = false;
message.success('操作成功!');
await fetchMenuData()
} catch (error) {
message.error(error.message || '删除失败!');
}
}
})
}
const handleDelete = (uuid: string) => {
dialog.warning({
title: '警告',
content: '你确定删除此菜单及下面的子菜单吗?',
positiveText: '确定',
negativeText: '不确定',
draggable: true,
onPositiveClick: async () => {
try {
await deleteMenu(uuid)
await fetchMenuData()
} catch (error) {
message.error(error.message || '删除失败!');
}
},
onNegativeClick: () => {
message.info('取消操作')
}
})
}
</script>
<style lang="scss" scoped>
.menu-management {
height: 100vh;
display: flex;
flex-direction: column;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--n-color);
padding: $normolGap;
.title {
font-size: 18px;
font-weight: bold;
color: $titleTextColor;
}
}
&__content {
padding: 20px;
flex: 1;
overflow: auto;
}
&__tree-node {
display: flex;
align-items: center;
width: 100%;
padding: 4px 0;
&:hover {
.menu-management__tree-actions {
opacity: 1;
}
}
}
&__tree-label {
flex: 1;
margin-right: 8px;
}
&__tree-actions {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
}
</style>