feat:初步动态菜单完成

This commit is contained in:
fangyunong 2025-07-16 16:40:30 +08:00
parent 36a217aec9
commit 5644602b3a
8 changed files with 402 additions and 223 deletions

View File

@ -7,6 +7,7 @@ export interface RawMenu {
label: string;
icon: string;
menuCode: string;
menuName:string;
adaptability: string;
component: string;
sort: number;
@ -21,6 +22,7 @@ export type MenuTree = {
label: string;
icon: string;
menuCode: string;
menuName:string;
adaptability: string;
component: string;
sort: number;
@ -36,6 +38,7 @@ export type MenuNode = {
label: string;
icon: string;
menuCode: string;
menuName:string;
adaptability: string;
component: string;
sort: number;

View File

@ -37,36 +37,9 @@ const routes: Array<RouteRecordRaw> = [
children: [
{
path: "", // 匹配 /layout
name: "home",
name: "HOME",
component: Home,
},
{
path: "role",
name: "Role",
component: RouterView,
children: [
{
path: "auth", // 匹配 /layout/role
name: "roleAuth",
component: Auth,
},
],
},
{
path: "menu",
name: "menu",
component: Menu,
},
{
path: "dict",
name: "dict",
component: Dict,
},
{
path: "globalSys",
name: "globalSys",
component: GlobalSys,
},
}
],
},
];

View File

@ -1,14 +1,25 @@
import type { MenuNode } from "@/api/menu";
import { defineStore } from "pinia";
export const useAppStore = defineStore('App', () => {
//左侧菜单相关
const menuApp = reactive({
isExpaned:true, //折叠控制
});
return {
menuApp
}
},
export const useAppStore = defineStore(
"App",
() => {
//左侧菜单相关
const menuApp = reactive({
isExpaned: true, //折叠控制
menuList: [],
});
// 设置菜单列表
const setMenuList = (menu: MenuNode[]) => {
menuApp.menuList.length = 0;
menu.forEach(item => {
menuApp.menuList.push(item);
})
};
return {
menuApp,
setMenuList
};
},
{ persist: true }
)
);

View File

@ -1,4 +1,4 @@
import { RouteMeta, RouteRecordRaw, RouterView } from "vue-router";
import { RouteRecordRaw, RouterView } from "vue-router";
import NotFound from "@/views/404/index.vue"
interface MenuItem {
uuid: string;
@ -24,7 +24,7 @@ function getComponent(componentPath: string) {
return () => {
try {
// 尝试动态导入组件
const componentPromise = import(`@/views/${componentPath}`)
const componentPromise = import(`../views/${componentPath}`)
// 成功加载则返回组件
return componentPromise.catch(() => {

138
src/views/404/index.vue Normal file
View File

@ -0,0 +1,138 @@
<template>
<div class="not-found-container">
<n-space vertical justify="center" align="center" class="content">
<!-- 动画图标 -->
<n-icon
size="120"
color="#4e88e5"
:component="Warning"
class="shake-animation"
/>
<!-- 标题 -->
<n-gradient-text type="error" :size="48" class="title">
404
</n-gradient-text>
<!-- 副标题 -->
<n-text depth="3" class="subtitle">
哎呀页面迷路了...
</n-text>
<!-- 描述 -->
<n-text depth="3" class="description">
您访问的页面不存在或已被移除<br>
请检查URL或返回首页
</n-text>
<!-- 返回按钮 -->
<n-button
type="primary"
size="large"
round
@click="goHome"
class="home-button"
>
<template #icon>
<n-icon :component="Home" />
</template>
返回首页
</n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { Warning } from '@vicons/ionicons5'
import { Home } from '@vicons/ionicons5'
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/layout')
}
</script>
<style lang="scss" scoped>
.not-found-container {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
.content {
text-align: center;
padding: 20px;
max-width: 600px;
.title {
font-weight: 800;
margin: 20px 0;
letter-spacing: 2px;
}
.subtitle {
font-size: 24px;
margin-bottom: 16px;
}
.description {
font-size: 16px;
line-height: 1.8;
margin-bottom: 32px;
}
.home-button {
padding: 0 32px;
height: 48px;
font-size: 16px;
box-shadow: 0 4px 12px rgba(78, 136, 229, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(78, 136, 229, 0.4);
}
}
}
}
//
.shake-animation {
animation: shake 1.5s ease infinite;
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-5px);
}
20%, 40%, 60%, 80% {
transform: translateX(5px);
}
}
//
@media (max-width: 768px) {
.not-found-container {
.content {
.title {
font-size: 36px;
}
.subtitle {
font-size: 20px;
}
.description {
font-size: 14px;
}
}
}
}
</style>

View File

@ -20,11 +20,13 @@ import { MenuOption, NIcon } from 'naive-ui';
import { useAppStore } from '@/store/app';
import { storeToRefs } from 'pinia';
import { RouterLink } from 'vue-router';
import SvgIcon from '@/components/SvgIcon.vue';
import type { MenuNode } from '@/api/menu';
const appStore = useAppStore();
const activeKey = ref('default');
const activeKey = ref('Home');
const router = useRouter();
const { menuApp } = storeToRefs(appStore);
import SvgIcon from '@/components/SvgIcon.vue';
const expandIcon = () => {
return h(NIcon, null, { default: () => h(CaretDownOutline) })
}
@ -32,12 +34,36 @@ const expandIcon = () => {
function renderIcon(iconClass: string) {
return () => h(SvgIcon, { iconClass, width: '16', height: '16' })
};
watch(() => router.currentRoute.value, (newName) => {
if (newName) {
activeKey.value = newName.fullPath as string;
function renderLabel(item: MenuNode) {
return () => h(
RouterLink,
{
to: {
name: item.path.toUpperCase()
}
},
{ default: () => item.label }
)
}
watch(() => router.currentRoute.value, (path) => {
if (path) {
activeKey.value = path.name as string;
}
},{ immediate: true });
//
}, { immediate: true });
const generatorMenuList = (menu: MenuNode | MenuNode[]): MenuOption[] => {
// Handle both array and single object input
const menuItems = Array.isArray(menu) ? menu : [menu];
return menuItems.map(item => {
const menuItem: MenuOption = {
label: item.component === 'view-router' ? item.label : renderLabel(item),
key: item.path.toUpperCase(),
icon: renderIcon(item.icon),
children: item.children && item.children.length > 0 ? generatorMenuList(item.children) : undefined
};
return menuItem;
});
}
const menuOptions: MenuOption[] = [
{
label: () =>
@ -45,80 +71,15 @@ const menuOptions: MenuOption[] = [
RouterLink,
{
to: {
name: 'home'
name: 'HOME'
}
},
{ default: () => '概览' }
),
key: '/layout',
key: 'HOME',
icon: renderIcon('mainproject')
},
{
label: '角色管理',
key: '/role',
icon: renderIcon('mainproject'),
children: [
{
label: () => h(
RouterLink,
{
to: {
name: 'roleAuth'
}
},
{ default: () => '权限分配' }
),
key: '/layout/role',
icon: renderIcon('mainproject')
}
]
},
{
label: '系统配置',
key: '/system',
icon: renderIcon('mainproject'),
children: [
{
label: () => h(
RouterLink,
{
to: {
name: 'menu'
}
},
{ default: () => '菜单管理' }
),
key: '/layout/menu',
icon: renderIcon('mainproject')
},
{
label: () => h(
RouterLink,
{
to: {
name: 'dict'
}
},
{ default: () => '字典配置' }
),
key: '/layout/dict',
icon: renderIcon('mainproject')
},
{
label: () => h(
RouterLink,
{
to: {
name: 'globalSys'
}
},
{ default: () => '全局参数' }
),
key: '/layout/globalSys',
icon: renderIcon('mainproject')
}
]
},
...generatorMenuList(menuApp.value.menuList)
];
</script>
<style scoped lang='scss'>

View File

@ -9,9 +9,13 @@ import { removeToken, setToken } from '@/utils/auth';
import { useMessage } from 'naive-ui';
import { getUserInfo } from '@/api/userApi';
import { useUserStore } from '@/store/user';
import { useAppStore } from '@/store/app';
import { getAllMenu } from '@/api/menu';
import { generateRoutes } from '@/utils/permission'
const router = useRouter();
const message = useMessage();
const userStore = useUserStore();
const appStore = useAppStore();
const init = async () => {
try {
const route = useRoute();
@ -19,6 +23,12 @@ const init = async () => {
setToken(token)
const result = await getUserInfo();
userStore.setUserInfo(result);
const menu = await getAllMenu();
appStore.setMenuList(menu);
const dynamicRoutes = generateRoutes(menu);
dynamicRoutes.forEach(route => {
router.addRoute('Layout', route)
})
message.success('登录成功!');
router.push('/layout');
} catch (error) {

View File

@ -14,8 +14,11 @@
<n-layout-content class="menu-management__content">
<n-spin :show="loading">
<n-tree :data="menuTree" key-field="uuid" label-field="label" children-field="children"
:expanded-keys="expandedKeys" :render-label="renderTreeLabel" :render-switcher-icon="renderSwitcherIcon" />
<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>
@ -27,13 +30,24 @@
<n-form-item label="菜单名称" path="label">
<n-input v-model:value="parentForm.label" placeholder="请输入菜单名称" />
</n-form-item>
<n-form-item label="菜单路径" path="path">
<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-form-item label="菜单图标" path="icon">
<n-select v-model:value="parentForm.icon" :options="iconOptions" filterable clearable placeholder="请选择图标名称"
:render-label="renderLabel" />
</n-form-item>
@ -97,13 +111,24 @@
<n-form-item label="菜单名称" path="label">
<n-input v-model:value="childForm.label" placeholder="请输入菜单名称" />
</n-form-item>
<n-form-item label="菜单路径" path="path">
<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-form-item label="菜单图标" path="icon">
<n-select v-model:value="childForm.icon" :options="iconOptions" filterable clearable placeholder="请选择图标名称"
:render-label="renderLabel" />
</n-form-item>
@ -156,7 +181,7 @@
</template>
<script setup lang="ts">
import { Add, Create, Trash, Document, FolderOpen, Folder, HelpCircle } from '@vicons/ionicons5'
import { Add, Create, Trash, HelpCircle } from '@vicons/ionicons5'
import {
addParentMenu,
editParentMenu,
@ -164,17 +189,15 @@ import {
eidtChildMenu,
getAllMenu,
deleteMenu,
type MenuTree,
type MenuNode,
type RawMenu
} from '@/api/menu'
import { FormInst, FormRules, NButton, NIcon, NSpace, SelectOption, TreeOption, useMessage } from 'naive-ui'
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<MenuTree[]>([])
const expandedKeys = ref<string[]>([])
const menuTree = ref<TreeOption[]>([])
//
const showParentModal = ref(false);
@ -185,6 +208,7 @@ const parentForm = ref<Omit<RawMenu, 'uuid' | 'parentId'>>({
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
@ -195,11 +219,12 @@ 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();
// ICON
const dialog = useDialog();
//
interface IconOption extends SelectOption {
value: string
@ -252,6 +277,7 @@ const childForm = ref<Omit<RawMenu, 'uuid'>>({
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
@ -263,6 +289,7 @@ const childRules: 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 isEditChild = ref(false)
const currentChildId = ref('')
@ -280,9 +307,10 @@ 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.uuid
value: menu.key
}))
})
@ -305,15 +333,121 @@ onBeforeUnmount(() => {
stop();
})
const transformMenuData = (menuData: MenuTree[]): TreeOption[] => {
return menuData.map(item => ({
key: item.uuid,
label: item.label,
icon: item.icon,
children: item.children ? transformMenuData(item.children) : undefined,
isLeaf: !item.children || item.children.length === 0,
rawData: item //
}))
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
})
}
//
@ -321,26 +455,15 @@ const fetchMenuData = async () => {
try {
loading.value = true
const res = await getAllMenu()
menuTree.value = transformMenuData(res) //
expandedKeys.value = getAllKeys(res)
if (!Array.isArray(res) || !res) throw new Error('菜单数据格式错误!');
menuTree.value = transformTreeData(res)
} catch (error) {
console.error('获取菜单数据失败:', error)
message.error(error.message || '获取菜单数据失败!');
} finally {
loading.value = false
loading.value = false;
}
}
const getAllKeys = (tree: MenuTree[]): string[] => {
let keys: string[] = []
tree.forEach(node => {
keys.push(node.uuid)
if (node.children && node.children.length > 0) {
keys = keys.concat(getAllKeys(node.children as MenuTree[]))
}
})
return keys
}
const handleAddParent = () => {
isEditParent.value = false
parentForm.value = {
@ -348,6 +471,7 @@ const handleAddParent = () => {
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
@ -358,7 +482,7 @@ const handleAddParent = () => {
showParentModal.value = true;
}
const handleEditParent = (menu: MenuTree) => {
const handleEditParent = (menu: MenuNode) => {
isEditParent.value = true
currentParentId.value = menu.uuid
parentForm.value = {
@ -366,6 +490,7 @@ const handleEditParent = (menu: MenuTree) => {
label: menu.label,
icon: menu.icon,
menuCode: menu.menuCode,
menuName: menu.menuName,
adaptability: menu.adaptability,
component: menu.component,
sort: menu.sort,
@ -388,7 +513,8 @@ const handleSubmitParent = () => {
} else {
await addParentMenu(parentForm.value)
}
showParentModal.value = false
showParentModal.value = false;
message.success('操作成功!');
await fetchMenuData()
} catch (error) {
message.error(error.message || '操作失败!');
@ -405,6 +531,7 @@ const handleAddChild = (parentId: string) => {
label: '',
icon: '',
menuCode: '',
menuName: '',
adaptability: 'pc',
component: '',
sort: 0,
@ -424,6 +551,7 @@ const handleEditChild = (menu: MenuNode) => {
label: menu.label,
icon: menu.icon,
menuCode: menu.menuCode,
menuName: menu.menuName,
adaptability: menu.adaptability,
component: menu.component,
sort: menu.sort,
@ -446,81 +574,36 @@ const handleSubmitChild = () => {
} else {
await addChildMenu(childForm.value)
}
showChildModal.value = false
showChildModal.value = false;
message.success('操作成功!');
await fetchMenuData()
} catch (error) {
console.error('操作失败:', error)
message.error(error.message || '删除失败!');
}
}
})
}
const handleDelete = async (uuid: string) => {
try {
await deleteMenu(uuid)
await fetchMenuData()
} catch (error) {
console.error('删除失败:', error)
}
}
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('取消操作')
}
})
const renderTreeLabel = ({ option }: { option: TreeOption }) => {
const menu = option.rawData as MenuNode | MenuTree
return h('div', { class: 'menu-management__tree-node' }, [
h('span', { class: 'menu-management__tree-label' }, menu.label),
h(NSpace, { class: 'menu-management__tree-actions' }, {
default: () => [
!option.isLeaf
? h(NButton, {
size: 'tiny',
tertiary: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleAddChild(menu.uuid)
}
}, {
icon: () => h(NIcon, { size: 14 }, () => h(Add))
})
: null,
h(NButton, {
size: 'tiny',
tertiary: true,
onClick: (e: MouseEvent) => {
e.stopPropagation()
if (menu.parentId === null) {
handleEditParent(menu as MenuTree)
} else {
handleEditChild(menu as MenuNode)
}
}
}, {
icon: () => h(NIcon, { size: 14 }, () => h(Create))
}),
h(NButton, {
size: 'tiny',
tertiary: true,
type: 'error',
onClick: (e: MouseEvent) => {
e.stopPropagation()
handleDelete(menu.uuid)
}
}, {
icon: () => h(NIcon, { size: 14 }, () => h(Trash))
})
]
})
])
}
//
const renderSwitcherIcon = ({ expanded, option }: { expanded: boolean; option: TreeOption }) => {
return h(NIcon, { size: 16 }, () =>
option.isLeaf
? h(Document)
: expanded
? h(FolderOpen)
: h(Folder)
)
}
</script>