feat: 路由树扁平化处理

This commit is contained in:
WANGFAN\wangf 2025-04-08 22:16:17 +08:00
parent 512bfa7cad
commit 44ad51c7b0
13 changed files with 1303 additions and 1161 deletions

View File

@ -6,21 +6,22 @@ export const useMenuMethod = () => {
/**
*
* @param {Menu.MenuOptions} item item
* @returns childrentruefalse
* @returns type:1truefalse
*/
const menuShow = (item: Menu.MenuOptions) => {
if (item.children && item.children?.length > 0 && !item.meta.hide) return true;
if (item.meta.type == 1 && !item.meta.hide) return true;
return false;
};
/**
*
* @param {Menu.MenuOptions} item item
* @returns truefalse
* @returns type:2truefalse
*/
const aMenuShow = (item: Menu.MenuOptions) => {
if (!item.meta.hide) return true;
if (item.meta.type == 2 && !item.meta.hide) return true;
return false;
};
return {
menuShow,
aMenuShow

View File

@ -21,7 +21,19 @@ const i18n = createI18n({
legacy: false, // Composition API模式需要设为false
globalInjection: true, // 全局生效: $
locale: getLang(), // 默认语言
messages // 数据源
messages, // 数据源
missing: (_: string, key: string) => {
return removeBeforeFirstDot(key);
}
});
/**
* @param { string } str key
* @returns "menu.home" => "home"
*/
function removeBeforeFirstDot(str: string) {
const dotIndex = str.indexOf(".");
return dotIndex >= 0 ? str.slice(dotIndex + 1) : "未定义";
}
export default i18n;

View File

@ -22,7 +22,7 @@
:popup-max-height="600"
>
<template v-for="item in routeTree" :key="item.name">
<a-menu-item v-if="aMenuShow(item)" :key="item.name" :popup-max-height="600">
<a-menu-item v-if="!item.meta.hide" :key="item.name" :popup-max-height="600">
<template #icon v-if="item.meta.svgIcon || item.meta.icon">
<MenuItemIcon :svg-icon="item.meta.svgIcon" :icon="item.meta.icon" />
</template>
@ -52,7 +52,6 @@ import { useRoutesConfigStore } from "@/store/modules/route-config";
import { useRoutingMethod } from "@/hooks/useRoutingMethod";
import { storeToRefs } from "pinia";
import { useThemeConfig } from "@/store/modules/theme-config";
import { useMenuMethod } from "@/hooks/useMenuMethod";
import { useDevicesSize } from "@/hooks/useDevicesSize";
defineOptions({ name: "LayoutMixing" });
const route = useRoute();
@ -61,7 +60,6 @@ const routerStore = useRoutesConfigStore();
const themeStore = useThemeConfig();
const { isFooter, collapsed, asideDark, language } = storeToRefs(themeStore);
const { routeTree } = storeToRefs(routerStore);
const { aMenuShow } = useMenuMethod();
const { isPc } = useDevicesSize();
const drawing = ref<boolean>(true);
@ -89,10 +87,18 @@ const onMenuItem = (key: string) => {
if (find) {
//
setAsideMenu(find);
// path
//
//
//
router.push(find.path);
let path = "";
if (find.redirect) {
path = find.redirect;
} else if (find.children && find.children.length > 0) {
path = find.children[0].path;
} else {
path = find.path;
}
router.push(path);
} else {
router.push("/404");
}

File diff suppressed because it is too large Load Diff

View File

@ -52,8 +52,8 @@ export const treeSort = (tree: Menu.MenuOptions[]) => {
/**
*
* 1访
* 2
* 1
* 2访
* @param {array} tree
* @returns
*/
@ -65,7 +65,6 @@ export const filterByRole = (tree: any, userRoles: Array<string>) => {
}
// 过滤是否禁用
if (item?.meta?.disable) return false;
if (item.children) item.children = filterByRole(item.children, userRoles);
return true;
});
};
@ -122,3 +121,32 @@ export function deepClone(data: any) {
}
return cloned;
}
/**
*
* @param {array} nodes
* @returns
*/
export const buildTreeOptimized = (nodes: Menu.MenuOptions[]) => {
const nodeMap = new Map(); // 哈希映射存储节点引用
const roots = []; // 存储顶层节点
// 单次遍历构建结构(兼容乱序数据)
for (const node of nodes) {
const { id, parentId } = node;
node.children = []; // 初始化子节点数组
// 将当前节点存入哈希表
nodeMap.set(id, node);
// 处理父子关系
if (parentId === "0") {
roots.push(node); // 顶层节点直接加入结果
} else {
const parent = nodeMap.get(parentId);
parent?.children.push(node); // 子节点挂载到父级
}
}
return roots;
};

View File

@ -1,16 +1,17 @@
import type { MockMethod } from "vite-plugin-mock";
import { deepClone, filterByRole, treeSort, resultSuccess } from "../_utils";
import { deepClone, filterByRole, buildTreeOptimized, treeSort, resultSuccess } from "../_utils";
import systemMenu from "../_data/system_menu";
/**
*
* TODO
*
* 1token判断角色
* 2
* 2
* 3
* 3
* 4
*
* TODO
*
* 1
* 2
* 3
@ -29,7 +30,12 @@ export default [
// 这里模拟两个角色admin、common
let userRoles = token === "Admin-Token" ? ["admin"] : ["common"];
const originTree: any = deepClone(systemMenu);
return resultSuccess(treeSort(filterByRole(originTree, userRoles)));
// 1. 过滤扁平路由,根据角色返回有权限且非禁用的节点
const survivalTree = filterByRole(originTree, userRoles);
// 2. 将扁平路由转换为树结构
// 2. 给路由树排序
// 3. 返回路由树
return resultSuccess(treeSort(buildTreeOptimized(survivalTree)));
}
}
] as MockMethod[];

View File

@ -2,6 +2,8 @@
/* 路由-Menu */
declare namespace Menu {
interface MenuOptions {
id: string;
parentId: string;
path: string;
name: string;
redirect?: string;
@ -21,6 +23,7 @@ declare namespace Menu {
icon?: string;
svgIcon?: string;
sort?: number;
type?: number;
}
}
/* tabs菜单 */

View File

@ -1,33 +1,33 @@
<template>
<div class="snow-page">
<div class="snow-inner">
<p>{{ $t(`system.switch-language-to-preview`) }}</p>
<br />
<div>
<a-date-picker style="width: 200px" v-model="form.time" />
</div>
<br />
<div>
<a-time-picker type="time-range" style="width: 252px" v-model="form.timeRange" />
</div>
<br />
<div>
<a-range-picker style="width: 360px" show-time format="YYYY-MM-DD HH:mm" v-model="form.date" />
</div>
<br />
<div>
<a-pagination :total="50" show-total show-jumper show-page-size />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const form = reactive({
time: "",
timeRange: [],
date: []
});
</script>
<style lang="scss" scoped></style>
<template>
<div class="snow-page">
<div class="snow-inner">
<p>{{ $t(`system.switch-language-to-preview`) }}</p>
<br />
<div>
<a-date-picker style="width: 200px" v-model="form.time" />
</div>
<br />
<div>
<a-time-picker type="time-range" style="width: 252px" v-model="form.timeRange" />
</div>
<br />
<div>
<a-range-picker style="width: 360px" show-time format="YYYY-MM-DD HH:mm" v-model="form.date" />
</div>
<br />
<div>
<a-pagination :total="50" show-total show-jumper show-page-size />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const form = reactive({
time: "",
timeRange: [],
date: []
});
</script>
<style lang="scss" scoped></style>

View File

@ -83,7 +83,7 @@
<template #icon><icon-edit /></template>
<span>修改</span>
</a-button>
<a-popconfirm type="warning" content="确定删除该角色吗?">
<a-popconfirm type="warning" content="确定删除该账号吗?">
<a-button type="primary" status="danger" size="mini" :disabled="record.admin">
<template #icon><icon-delete /></template>
<span>删除</span>
@ -166,7 +166,7 @@
</a-switch>
</a-form-item>
<a-form-item field="description" label="描述" validate-trigger="blur">
<a-textarea v-model="addFrom.description" placeholder="请输入字典描述" allow-clear />
<a-textarea v-model="addFrom.description" placeholder="请输入描述" allow-clear />
</a-form-item>
</a-form>
</div>

View File

@ -67,7 +67,7 @@
<template #icon><icon-edit /></template>
<span>修改</span>
</a-button>
<a-popconfirm type="warning" content="确定删除该角色吗?" @ok="onDelete">
<a-popconfirm type="warning" content="确定删除该字典吗?" @ok="onDelete">
<a-button type="primary" status="danger" size="mini">
<template #icon><icon-delete /></template>
<span>删除</span>
@ -150,7 +150,7 @@
<template #icon><icon-edit /></template>
<span>修改</span>
</a-button>
<a-popconfirm type="warning" content="确定删除该角色吗?">
<a-popconfirm type="warning" content="确定删除该字典吗?">
<a-button type="primary" status="danger" size="mini">
<template #icon><icon-delete /></template>
<span>删除</span>

View File

@ -142,7 +142,7 @@
</a-col>
</a-row>
<a-form-item field="description" label="描述" validate-trigger="blur">
<a-textarea v-model="addFrom.description" placeholder="请输入字典描述" allow-clear />
<a-textarea v-model="addFrom.description" placeholder="请输入描述" allow-clear />
</a-form-item>
</a-form>
</div>

View File

@ -28,8 +28,10 @@
<span>新增</span>
</a-button>
<a-button type="primary" status="success" @click="onExpand">
<template #icon><icon-swap /></template>
<span>折叠</span>
<template #icon>
<icon-swap />
</template>
<span>{{ expand ? "收起" : "展开" }}</span>
</a-button>
</a-space>
</a-row>
@ -118,7 +120,7 @@
<template #icon><icon-edit /></template>
<span>修改</span>
</a-button>
<a-button size="mini" type="primary" status="success">
<a-button size="mini" type="primary" status="success" v-if="record.meta.type != 3">
<template #icon><icon-plus /></template>
<span>新增</span>
</a-button>
@ -138,26 +140,33 @@
<div>
<a-form ref="formRef" auto-label-width :rules="rules" :model="addFrom">
<a-form-item field="type" label="菜单类型" validate-trigger="blur">
<a-radio-group type="button" v-model="addFrom.type">
<a-radio-group type="button" v-model="addFrom.type" @change="typeChange">
<a-radio v-for="item in menuType" :key="item.value" :value="item.value">{{ item.name }}</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item field="id" label="上级菜单" validate-trigger="blur">
<a-form-item field="parentId" label="上级菜单" validate-trigger="blur">
<a-tree-select
v-model="addFrom.id"
v-model="addFrom.parentId"
:data="menuTree"
:field-names="{
key: 'id',
title: 'i18n',
children: 'children'
}"
allow-clear
placeholder="请选择上级菜单"
></a-tree-select>
<template #extra>
<div>未选择则默认第一级</div>
</template>
</a-form-item>
<a-row :gutter="24">
<a-row :gutter="24" v-if="[1, 2].includes(addFrom.type)">
<a-col :span="12">
<a-form-item field="svgIcon" label="自定义图标" validate-trigger="blur">
<SelectIcon type="svg" v-model="addFrom.svgIcon" />
<template #extra>
<div>自定义图标优先级高于菜单图标</div>
</template>
</a-form-item>
</a-col>
<a-col :span="12">
@ -166,42 +175,59 @@
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item field="name" label="菜单名称" validate-trigger="blur">
<a-input v-model="addFrom.name" placeholder="请输入菜单名称" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="title" label="国际化Key" validate-trigger="blur">
<a-input v-model="addFrom.title" placeholder="请输入国际化Key" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="path" label="路由路径" validate-trigger="blur">
<a-input v-model="addFrom.path" placeholder="请输入路由路径" allow-clear />
<a-form-item field="name" label="菜单名称" validate-trigger="blur">
<a-input v-model="addFrom.name" placeholder="请输入菜单名称home" allow-clear @change="nameChange" />
</a-form-item>
<a-form-item field="redirect" label="重定向路径" validate-trigger="blur">
<a-input v-model="addFrom.redirect" placeholder="请输入重定向路径" allow-clear />
<a-form-item v-if="[1, 2].includes(addFrom.type)" field="path" label="路由路径" validate-trigger="blur">
<a-input v-model="addFrom.path" placeholder="请输入路由路径,如:/home" allow-clear disabled />
</a-form-item>
<a-form-item field="title" label="菜单标题" validate-trigger="blur">
<a-input v-model="addFrom.title" placeholder="请输入菜单标题" allow-clear />
<template #extra>
<div>
优先匹配国际化Key
<span v-if="addFrom.title">menu.{{ addFrom.title }}</span>
无对应Key则直接取标题展示
</div>
</template>
</a-form-item>
<a-form-item v-if="addFrom.type == 3" field="permission" label="权限标识" validate-trigger="blur">
<a-input v-model="addFrom.permission" placeholder="请输入权限标识sys:btn:add" allow-clear />
</a-form-item>
<a-form-item v-if="[1, 2].includes(addFrom.type)" field="redirect" label="路由重定向" validate-trigger="blur">
<a-input v-model="addFrom.redirect" placeholder="请输入路由重定向" allow-clear />
</a-form-item>
<a-form-item
v-if="addFrom.type == 2"
field="component"
label="组件路径"
validate-trigger="blur"
:disabled="addFrom.isLink"
>
<a-input v-model="addFrom.component" placeholder="请输入组件路径" allow-clear>
<template #prepend>@/views/</template>
<template #append>.vue</template>
</a-input>
</a-form-item>
<a-row :gutter="24">
<a-col :span="8">
<a-col :span="8" v-if="[1, 2].includes(addFrom.type)">
<a-form-item field="hide" label="显示状态" validate-trigger="blur">
<a-switch type="round" v-model="addFrom.hide">
<a-switch type="round" v-model="addFrom.hide" :checked-value="false" :unchecked-value="true">
<template #checked> 显示 </template>
<template #unchecked> 隐藏 </template>
</a-switch>
</a-form-item>
</a-col>
<a-col :span="8">
<a-col :span="8" v-if="[1, 2].includes(addFrom.type)">
<a-form-item field="disable" label="启用状态" validate-trigger="blur">
<a-switch type="round" v-model="addFrom.disable">
<a-switch type="round" v-model="addFrom.disable" :checked-value="false" :unchecked-value="true">
<template #checked> 启用 </template>
<template #unchecked> 禁用 </template>
</a-switch>
</a-form-item>
</a-col>
<a-col :span="8">
<a-col :span="8" v-if="addFrom.type == 2">
<a-form-item field="keepAlive" label="是否缓存" validate-trigger="blur">
<a-switch type="round" v-model="addFrom.keepAlive">
<template #checked> </template>
@ -210,7 +236,7 @@
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-row :gutter="24" v-if="addFrom.type == 2">
<a-col :span="8">
<a-form-item field="affix" label="固定Tabs" validate-trigger="blur">
<a-switch type="round" v-model="addFrom.affix">
@ -221,22 +247,22 @@
</a-col>
<a-col :span="8">
<a-form-item field="isLink" label="是否外链" validate-trigger="blur">
<a-switch type="round" v-model="addFrom.isLink">
<a-switch type="round" v-model="addFrom.isLink" @change="onIsLink">
<template #checked> </template>
<template #unchecked> </template>
</a-switch>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="iframe" label="内嵌外链窗口" validate-trigger="blur" :disabled="!addFrom.isLink">
<a-switch type="round" v-model="addFrom.iframe">
<a-form-item field="iframe" label="内嵌窗口" validate-trigger="blur" :disabled="!addFrom.isLink">
<a-switch type="round" v-model="addFrom.iframe" @change="onIframe">
<template #checked> </template>
<template #unchecked> </template>
</a-switch>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="link" label="外链路径" validate-trigger="blur" v-if="addFrom.isLink">
<a-form-item field="link" label="外链路径" validate-trigger="blur" v-if="addFrom.type == 2 && addFrom.isLink">
<a-input v-model="addFrom.link" placeholder="请输入路由路径" allow-clear />
</a-form-item>
<a-form-item field="link" label="菜单排序" validate-trigger="blur">
@ -280,44 +306,14 @@ const onEdit = (row: Menu.MenuOptions) => {
};
//
const open = ref(false);
const rules = {
userName: [
{
required: true,
message: "请输入用户名称"
}
],
nickName: [
{
required: true,
message: "请输入昵称"
}
],
sex: [
{
required: true,
message: "请选择性别"
}
],
deptId: [
{
required: true,
message: "请选择所属部门"
}
],
roles: [
{
required: true,
message: "请选择角色"
}
],
status: [
{
required: true,
message: "请选择状态"
}
]
};
const rules = ref({
parentId: [{ required: false, message: "请选择上级菜单" }],
name: [{ required: true, message: "请输入菜单名称" }],
title: [{ required: true, message: "请输入菜单标题" }],
path: [{ required: true, message: "请输入路由路径" }],
permission: [{ required: true, message: "请输入权限标识" }]
});
const menuType = ref([
{ name: "目录", value: 1 },
{ name: "菜单", value: 2 },
@ -325,13 +321,15 @@ const menuType = ref([
]);
const addFrom = ref<any>({
type: 1,
id: "",
parentId: "",
svgIcon: "",
icon: "",
name: "",
title: "",
permission: "",
path: "",
redirect: "",
component: "",
hide: false,
disable: false,
keepAlive: true,
@ -353,19 +351,22 @@ const handleOk = async () => {
let state = await formRef.value.validate();
if (state) return (open.value = true); //
arcoMessage("success", "模拟提交成功");
console.log("提交", addFrom.value);
};
//
const afterClose = () => {
formRef.value.resetFields();
addFrom.value = {
type: 1,
id: "",
parentId: "",
svgIcon: "",
icon: "",
name: "",
title: "",
permission: "",
path: "",
redirect: "",
component: "",
hide: false,
disable: false,
keepAlive: true,
@ -377,17 +378,59 @@ const afterClose = () => {
};
};
//
const typeChange = (val: number) => {
rules.value.parentId[0].required = val == 3;
formRef.value.clearValidate();
};
//
const nameChange = (str: string) => {
//
addFrom.value.path = str ? "/" + str : "";
};
//
const onIsLink = (is: boolean) => {
//
if (!is) {
// iframelink
addFrom.value.iframe = false;
addFrom.value.link = "";
addFrom.value.component = "";
} else {
//
addFrom.value.component = "link/external/external";
}
};
//
const onIframe = (is: boolean) => {
//
if (!is) {
// iframelink
addFrom.value.component = "link/external/external";
} else {
//
addFrom.value.component = "link/internal/internal";
}
};
const onSearch = () => getMenu();
const loading = ref(false);
const tableRef = ref();
const tableTree = ref([]);
const menuTree = ref<any>([]);
const getMenu = async () => {
try {
loading.value = true;
let { data } = await getMenuListAPI();
//
translation(data);
menuTree.value = filterTree(data);
//
tableTree.value = data;
// type:3-
menuTree.value = filterTree(data);
console.log("数据", data, menuTree.value);
} finally {
loading.value = false;
@ -410,9 +453,12 @@ const translation = (tree: any) => {
});
};
// -type:3-
const menuTree = ref([]);
function filterTree(nodes: any) {
/**
* 过滤type:3的节点该节点是按钮权限不显示在菜单中-用于下拉选择
* @param {object} nodes 路由树
* @returns 节点过滤后的路由树
*/
const filterTree = (nodes: Menu.MenuOptions[]) => {
// type 3
return nodes
.filter((node: any) => node.meta.type !== 3)
@ -431,7 +477,7 @@ function filterTree(nodes: any) {
}
return newNode;
});
}
};
getMenu();
</script>

View File

@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => {
base: env.VITE_PUBLIC_PATH,
server: {
// host: "0.0.0.0",
open: true,
open: false,
// 为开发服务器配置自定义代理规则-用于开发时的代理
proxy: {
"/api": {