feat(dependencies): update asyncmy and pandas versions for improved performance and compatibility

- Upgraded asyncmy from version 0.2.9 to 0.2.11 to leverage enhancements and bug fixes.
- Updated pandas from version 2.2.2 to 2.2.3 for better data processing capabilities.
- Added a new configuration option LOGIN_RESOLVE_IP_LOCATION to control IP location resolution during login.
- Enhanced login service to handle IP location resolution based on the new configuration.
- Improved validation logic in menu request handling to ensure proper redirection address for specific menu types.
- Adjusted CRUD template generation to dynamically use primary key column names for better flexibility.
- Updated Vue components to ensure proper handling of dynamic primary key references in menu items and forms.
This commit is contained in:
zhangtao
2026-03-29 20:33:33 +08:00
parent 25f497a55b
commit 7081850d46
19 changed files with 602 additions and 177 deletions
@@ -1,6 +1,7 @@
<!-- 菜单组件 -->
<template>
<el-menu
ref="menuRef"
:default-active="activeMenuPath"
:collapse="!appStore.sidebar.opened"
:background-color="menuThemeProps.backgroundColor"
@@ -16,18 +17,18 @@
v-for="route in data"
:key="route.path"
:item="route"
:base-path="resolveFullPath(route.path)"
:base-path="menuParentBasePath"
/>
</el-menu>
</template>
<script lang="ts" setup>
import { nextTick } from "vue";
import type { MenuInstance } from "element-plus";
import { useRoute } from "vue-router";
import path from "path-browserify";
import type { RouteRecordRaw } from "vue-router";
import { SidebarColor } from "@/enums/settings/theme.enum";
import { useSettingsStore, useAppStore } from "@/store";
import { isExternal } from "@/utils/index";
import MenuItem from "./components/MenuItem.vue";
import variables from "@/styles/variables.module.scss";
@@ -51,6 +52,7 @@ const props = defineProps({
const settingsStore = useSettingsStore();
const appStore = useAppStore();
const currentRoute = useRoute();
const menuRef = ref<MenuInstance | null>(null);
// 获取主题
const theme = computed(() => settingsStore.theme);
@@ -70,42 +72,55 @@ const menuThemeProps = computed(() => {
};
});
// 计算当前激活的菜单项
function normalizeMenuPath(p: string): string {
if (!p) return "";
if (/^https?:\/\//i.test(p) || p.startsWith("//")) return p;
const s = p.trim();
if (s === "/") return "/";
return s.replace(/\/+$/, "") || "/";
}
/**
* 与 MenuItem 叶子项 index 一致:叶子用 router.resolve 得到 path,与 route.path 相同;
* default-active 用当前 route.path(及 meta.activeMenu)即可对齐。
*/
const activeMenuPath = computed((): string => {
const { meta, path } = currentRoute;
// 如果路由meta中设置了activeMenu,则使用它(用于处理一些特殊情况,如详情页)
if (meta?.activeMenu && typeof meta.activeMenu === "string") {
return meta.activeMenu;
const r = currentRoute;
if (r.meta?.activeMenu && typeof r.meta.activeMenu === "string") {
return normalizeMenuPath(r.meta.activeMenu);
}
// 否则使用当前路由路径
return path;
return normalizeMenuPath(r.path);
});
/**
* 获取完整路径
*
* @param routePath 当前路由的相对路径 /user
* @returns 完整的绝对路径 D://vue3-element-admin/system/user
* 侧栏树根前缀:左侧布局 base-path 为空时规范为 "/",再与每项 path 拼接。
* 勿用 resolveFullPath(route.path) 作为根 MenuItem 的 base-path,否则会与 item.path 再拼一次导致错位。
*/
function resolveFullPath(routePath: string) {
if (isExternal(routePath)) {
return routePath;
}
if (isExternal(props.basePath)) {
return props.basePath;
}
const menuParentBasePath = computed(() => {
const b = props.basePath;
if (b === undefined || b === null || b === "") return "/";
const t = b.replace(/\/+$/, "");
return t || "/";
});
// 如果 basePath 为空(顶部布局),直接返回 routePath
if (!props.basePath || props.basePath === "") {
return routePath;
}
// 解析路径,生成完整的绝对路径
return path.resolve(props.basePath, routePath);
function syncMenuActive(val: string) {
menuRef.value?.updateActiveIndex(val);
}
// 父级高亮(has-active-child)已改为由 MenuItem 依据路由状态计算并绑定 class,
// 不再依赖 DOM 查询,避免折叠/teleported 场景丢失。
watch(
activeMenuPath,
(val) => {
nextTick(() => {
syncMenuActive(val);
requestAnimationFrame(() => {
syncMenuActive(val);
setTimeout(() => syncMenuActive(val), 0);
setTimeout(() => syncMenuActive(val), 50);
});
});
},
{ immediate: true, flush: "post" }
);
// 父级激活样式由 Element Plus 在子项激活时为 el-sub-menu 添加 .is-activeMenuItem 内样式已覆盖标题颜色
</script>
@@ -27,6 +27,7 @@
</template>
<script lang="ts" setup>
import { nextTick } from "vue";
import MenuItemContent from "./components/MenuItemContent.vue";
defineOptions({
@@ -43,6 +44,37 @@ const appStore = useAppStore();
const permissionStore = usePermissionStore();
const settingsStore = useSettingsStore();
/**
* 根据当前完整路径解析混合布局的「顶级」菜单 path。
* 不能仅用第一段路径(如 /app 会误匹配 /application),应按最长前缀匹配顶级路由(BUG #7)。
*/
function resolveMixTopMenuPath(fullPath: string): string {
const path = (fullPath.split("?")[0] || "").replace(/\/$/, "") || "/";
const tops = permissionStore.routes.filter(
(r) => r.path && r.path !== "/" && !(r.meta as { hidden?: boolean } | undefined)?.hidden
);
const sorted = [...tops].sort((a, b) => (b.path?.length || 0) - (a.path?.length || 0));
for (const r of sorted) {
const p = r.path || "";
if (!p) continue;
if (path === p || path.startsWith(`${p}/`)) return p;
}
const first = path.match(/^\/[^/]+/)?.[0];
return first || "/";
}
/** 水平菜单点击后焦点留在 el-menu-item 上,:focus 样式像「悬浮背景」;仅在 @select 后 blur 一次即可 */
function blurTopMenuFocus() {
nextTick(() => {
requestAnimationFrame(() => {
const ae = document.activeElement;
if (ae instanceof HTMLElement && ae.closest?.(".layout__header-menu .el-menu")) {
ae.blur();
}
});
});
}
// 获取主题
const theme = computed(() => settingsStore.theme);
@@ -85,6 +117,7 @@ const topMenuItems = computed(() => {
*/
const handleTopMenuSelect = (routePath: string) => {
updateMenuState(routePath);
blurTopMenuFocus();
};
/**
@@ -144,12 +177,7 @@ watch(
() => router.currentRoute.value.path,
(newPath) => {
if (newPath) {
// 提取顶级路径
const topMenuPath =
newPath.split("/").filter(Boolean).length > 1 ? newPath.match(/^\/[^/]+/)?.[0] || "/" : "/";
// 使用公共方法更新菜单状态,但跳过导航(因为路由已经变化)
updateMenuState(topMenuPath, true);
updateMenuState(resolveMixTopMenuPath(newPath), true);
}
},
{ immediate: true }
@@ -13,13 +13,14 @@
>
<AppLink
v-if="onlyOneChild.meta"
:to="{
path: resolvePath(onlyOneChild.path),
query: onlyOneChild.meta.params,
}"
:to="
onlyOneChild.name
? { name: onlyOneChild.name, query: onlyOneChild.meta.params }
: { path: resolvePath(onlyOneChild.path || ''), query: onlyOneChild.meta.params }
"
>
<el-menu-item
:index="resolvePath(onlyOneChild.path)"
:index="menuItemIndex(onlyOneChild, resolvePath(onlyOneChild.path || ''))"
:class="{ 'submenu-title-noDropdown': !isNest }"
>
<MenuItemContent
@@ -34,23 +35,21 @@
<!--【非叶子节点】显示含多个子节点的父菜单,或始终显示的单子节点 -->
<el-sub-menu
v-else
:index="resolvePath(item.path)"
:data-path="resolvePath(item.path)"
:class="{ 'has-active-child': isActiveChild }"
teleported
:index="menuItemIndex(item, resolvePath(item.path || ''))"
:data-path="resolvePath(item.path || '')"
>
<template #title>
<span class="menu-title-wrapper" :data-path="resolvePath(item.path)">
<span class="menu-title-wrapper" :data-path="resolvePath(item.path || '')">
<MenuItemContent v-if="item.meta" :icon="item.meta.icon" :title="item.meta.title" />
</span>
</template>
<MenuItem
v-for="child in item.children"
:key="child.path"
:key="String(child.name ?? child.path)"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
:base-path="resolvePath(item.path || '')"
/>
</el-sub-menu>
</div>
@@ -66,11 +65,11 @@ defineOptions({
import path from "path-browserify";
import { RouteRecordRaw } from "vue-router";
import { useRoute } from "vue-router";
import { useRouter } from "vue-router";
import { isExternal } from "@/utils";
const route = useRoute();
const router = useRouter();
const props = defineProps({
/**
@@ -142,18 +141,46 @@ function resolvePath(routePath: string) {
if (isExternal(routePath)) return routePath;
if (isExternal(props.basePath)) return props.basePath;
// 拼接父路径和当前路径
return path.resolve(props.basePath, routePath);
const base = props.basePath && props.basePath !== "" ? props.basePath : "/";
return path.resolve(base, routePath);
}
// 父级菜单是否应展示“包含激活子菜单”的高亮状态(不依赖 DOM,避免折叠/teleported 时丢失)
const isActiveChild = computed(() => {
const currentPath =
typeof route.meta?.activeMenu === "string" ? route.meta.activeMenu : (route.path as string);
const selfPath = resolvePath(props.item.path || "");
if (!selfPath) return false;
return currentPath === selfPath || currentPath.startsWith(`${selfPath}/`);
});
/** 与 BasicMenu.default-activeroute.path)对齐 */
function normalizeMenuPath(p: string): string {
if (!p) return "";
if (/^https?:\/\//i.test(p) || p.startsWith("//")) return p;
const s = p.trim();
if (s === "/") return "/";
return s.replace(/\/+$/, "") || "/";
}
function hasVisibleChildren(node: RouteRecordRaw): boolean {
return !!node.children?.some((c) => !c.meta?.hidden);
}
/**
* el-menu 的 index 必须与 default-active 字符串完全一致。
* - 目录(有可见子节点):只用菜单树 path.resolve 结果,禁止 router.resolve(name)(常变成父级 /task,导致兄弟目录误亮)。
* - 叶子:用 router.resolve(name).path,与当前页 route.path 同源,避免纯拼路径与路由表不一致。
*/
function menuItemIndex(item: RouteRecordRaw, resolvedFromTree: string): string {
const treePath = normalizeMenuPath(resolvedFromTree);
if (hasVisibleChildren(item)) {
if (treePath) return treePath;
if (item.name != null && item.name !== "") return String(item.name);
return "";
}
if (item.name) {
try {
return normalizeMenuPath(router.resolve({ name: item.name as string }).path);
} catch {
/* fallthrough */
}
}
return treePath;
}
</script>
<style lang="scss">
@@ -208,8 +235,8 @@ const isActiveChild = computed(() => {
line-height: 1;
}
// 当父菜单包含激活子菜单时的样式
&.has-active-child .el-sub-menu__title {
// 子项激活时 Element Plus 会给父级 el-sub-menu 加 .is-active
&.is-active > .el-sub-menu__title {
color: var(--el-color-primary) !important;
.menu-icon {
@@ -217,9 +244,8 @@ const isActiveChild = computed(() => {
}
}
// 深色主题下的父菜单激活状态
html.dark & {
&.has-active-child .el-sub-menu__title {
&.is-active > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
.menu-icon {
@@ -228,9 +254,8 @@ const isActiveChild = computed(() => {
}
}
// 深蓝色侧边栏配色下的父菜单激活状态
html.sidebar-color-blue & {
&.has-active-child .el-sub-menu__title {
&.is-active > .el-sub-menu__title {
color: var(--el-color-primary-light-3) !important;
.menu-icon {
+5 -1
View File
@@ -25,7 +25,8 @@
<!-- 左侧菜单栏 -->
<div class="layout__sidebar--left" :class="{ 'layout__sidebar--collapsed': !isSidebarOpen }">
<el-scrollbar>
<BasicMenu :data="sideMenuRoutes" :base-path="leftMenuBasePath" />
<!-- 仅切换顶级模块时重建侧栏避免 :key=route.path 导致每次路由变化整表重建展开态丢失 -->
<BasicMenu :key="mixSideMenuKey" :data="sideMenuRoutes" :base-path="leftMenuBasePath" />
</el-scrollbar>
<!-- 侧边栏切换按钮 -->
<div class="layout__sidebar-toggle">
@@ -76,6 +77,9 @@ const leftMenuBasePath = computed(() => {
if (activeTopMenuPath.value) return activeTopMenuPath.value;
return route.path.match(/^\/[^/]+/)?.[0] || "/";
});
/** 与顶部一级模块一致,同模块内路由切换不重建侧栏(保留展开);切换模块时重建 */
const mixSideMenuKey = computed(() => activeTopMenuPath.value || leftMenuBasePath.value);
</script>
<style lang="scss" scoped>
+159 -24
View File
@@ -71,7 +71,7 @@
</template>
</el-table-column>
<el-table-column label="排序" prop="order" min-width="80" />
<el-table-column label="重定向" prop="redirect" min-width="200" />
<el-table-column label="重定向" prop="redirect" min-width="120" show-overflow-tooltip />
<el-table-column label="是否缓存" prop="keep_alive" min-width="100">
<template #default="scope">
<el-tag :type="scope.row.keep_alive ? 'success' : 'danger'">
@@ -100,7 +100,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="菜单标题" prop="title" min-width="200" />
<el-table-column label="菜单标题" prop="title" min-width="100" show-overflow-tooltip />
<el-table-column
label="权限标识"
prop="permission"
@@ -111,7 +111,7 @@
label="路由名称"
prop="route_name"
show-overflow-toolti
min-width="200"
min-width="100"
/>
<el-table-column
label="路由路径"
@@ -132,8 +132,20 @@
show-overflow-tooltip
min-width="200"
/>
<el-table-column label="创建时间" prop="created_time" min-width="200" sortable />
<el-table-column label="更新时间" prop="updated_time" min-width="200" sortable />
<el-table-column
label="创建时间"
prop="created_time"
min-width="200"
sortable
show-overflow-tooltip
/>
<el-table-column
label="更新时间"
prop="updated_time"
min-width="200"
sortable
show-overflow-tooltip
/>
<el-table-column fixed="right" label="操作" align="center" min-width="260">
<template #default="scope">
<el-button
@@ -145,7 +157,7 @@
link
size="small"
icon="plus"
@click.stop="handleOpenDialog('create', undefined, scope.row.id)"
@click.stop="handleOpenDialog('create', undefined, scope.row)"
>
新增
</el-button>
@@ -311,7 +323,11 @@
filterable
check-strictly
:render-after-expand="false"
:disabled="createParentLocked"
/>
<el-text v-if="createParentLocked" type="info" size="small" class="block mt-1">
在菜单下仅可新增按钮,父级已固定
</el-text>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
@@ -324,10 +340,30 @@
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type" @change="handleMenuTypeChange">
<el-radio :value="MenuTypeEnum.CATALOG">目录</el-radio>
<el-radio :value="MenuTypeEnum.MENU">菜单</el-radio>
<el-radio :value="MenuTypeEnum.BUTTON">按钮</el-radio>
<el-radio :value="MenuTypeEnum.EXTLINK">外链</el-radio>
<el-radio
v-if="allowedMenuTypeValues.includes(MenuTypeEnum.CATALOG)"
:value="MenuTypeEnum.CATALOG"
>
目录
</el-radio>
<el-radio
v-if="allowedMenuTypeValues.includes(MenuTypeEnum.MENU)"
:value="MenuTypeEnum.MENU"
>
菜单
</el-radio>
<el-radio
v-if="allowedMenuTypeValues.includes(MenuTypeEnum.BUTTON)"
:value="MenuTypeEnum.BUTTON"
>
按钮
</el-radio>
<el-radio
v-if="allowedMenuTypeValues.includes(MenuTypeEnum.EXTLINK)"
:value="MenuTypeEnum.EXTLINK"
>
外链
</el-radio>
</el-radio-group>
</el-form-item>
@@ -530,8 +566,16 @@
v-if="formData.type == MenuTypeEnum.CATALOG || formData.type === MenuTypeEnum.MENU"
label="重定向"
prop="redirect"
:required="formData.type === MenuTypeEnum.CATALOG"
>
<el-input v-model="formData.redirect" placeholder="请输入重定向路由" />
<el-input
v-model="formData.redirect"
:placeholder="
formData.type === MenuTypeEnum.CATALOG
? '目录必填,一般为默认子路由 path,如 /system/user'
: '可选,请输入重定向路由'
"
/>
</el-form-item>
<el-form-item v-if="formData.type != MenuTypeEnum.BUTTON" label="常驻标签栏" prop="affix">
@@ -581,7 +625,7 @@ defineOptions({
inheritAttrs: false,
});
import { ref, reactive, computed } from "vue";
import { ref, reactive, computed, watch, nextTick } from "vue";
import { useAppStore } from "@/store/modules/app.store";
import { useUserStore } from "@/store/modules/user.store";
import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -681,8 +725,71 @@ const dialogVisible = reactive({
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "600px" : "90%"));
// 顶级菜单下拉选项
// 顶级菜单下拉选项(仅目录、菜单可作为父级)
const menuOptions = ref<OptionType[]>([]);
/** 完整树,用于根据 parent_id 解析父级类型 */
const fullMenuTree = ref<MenuTable[]>([]);
/** 从表格「在菜单下新增」进入时锁定父级(仅允许按钮) */
const createParentLocked = ref(false);
function typesAllowedUnderParent(parentType: MenuTypeEnum): MenuTypeEnum[] {
switch (parentType) {
case MenuTypeEnum.CATALOG:
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK];
case MenuTypeEnum.MENU:
return [MenuTypeEnum.BUTTON];
case MenuTypeEnum.BUTTON:
case MenuTypeEnum.EXTLINK:
return [];
default:
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK];
}
}
function findMenuNodeById(
id: number | undefined,
nodes: MenuTable[] = fullMenuTree.value
): MenuTable | null {
if (id == null) return null;
for (const n of nodes) {
if (n.id === id) return n;
if (n.children?.length) {
const f = findMenuNodeById(id, n.children);
if (f) return f;
}
}
return null;
}
/** 新增/编辑表单项:当前父级下允许的菜单类型 */
const allowedMenuTypeValues = computed((): MenuTypeEnum[] => {
if (dialogVisible.type === "detail") {
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK];
}
const pid = formData.parent_id;
if (pid == null || pid === undefined) {
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK];
}
const parentNode = findMenuNodeById(pid);
if (!parentNode?.type) {
return [MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.BUTTON, MenuTypeEnum.EXTLINK];
}
return typesAllowedUnderParent(parentNode.type as MenuTypeEnum);
});
watch(
() => [formData.parent_id, dialogVisible.visible, dialogVisible.type],
() => {
if (!dialogVisible.visible || dialogVisible.type === "detail") return;
const allowed = allowedMenuTypeValues.value;
if (!allowed.length) return;
const t = formData.type as MenuTypeEnum;
if (!allowed.includes(t)) {
formData.type = allowed[0] as MenuForm["type"];
}
},
{ flush: "post" }
);
function filterMenuTypes(nodes: MenuTable[]) {
return nodes
@@ -704,6 +811,7 @@ const contentConfig = reactive<IContentConfig<MenuPageQuery>>({
indexAction: async (params) => {
const res = await MenuAPI.listMenu(params as MenuPageQuery);
const tree = res.data.data || [];
fullMenuTree.value = tree;
menuOptions.value = formatTree(filterMenuTypes(tree));
return tree;
},
@@ -760,6 +868,20 @@ const rules = reactive({
hidden: [{ required: true, message: "请选择是否隐藏", trigger: "change" }],
always_show: [{ required: true, message: "请选择始终显示", trigger: "change" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }],
redirect: [
{
validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => {
if (formData.type === MenuTypeEnum.CATALOG) {
if (value === undefined || value === null || String(value).trim() === "") {
callback(new Error("目录类型必须填写重定向地址"));
return;
}
}
callback();
},
trigger: "blur",
},
],
});
// 选择表格的行菜单ID
@@ -806,6 +928,7 @@ async function handleRowClick(row: MenuTable) {
// 关闭弹窗
async function handleCloseDialog() {
dialogVisible.visible = false;
createParentLocked.value = false;
resetForm();
}
@@ -813,9 +936,10 @@ async function handleCloseDialog() {
async function handleOpenDialog(
type: "create" | "update" | "detail",
id?: number,
parentId?: number
parentRow?: MenuTable
) {
dialogVisible.type = type;
createParentLocked.value = false;
if (id) {
const response = await MenuAPI.detailMenu(id);
if (type === "detail") {
@@ -827,11 +951,15 @@ async function handleOpenDialog(
}
} else {
dialogVisible.title = "新增菜单";
// 重置表单为初始状态
Object.assign(formData, initialFormData);
// 设置父级部门
if (parentId) {
formData.parent_id = parentId;
if (parentRow?.id != null) {
formData.parent_id = parentRow.id;
if (parentRow.type === MenuTypeEnum.MENU) {
createParentLocked.value = true;
formData.type = MenuTypeEnum.BUTTON;
} else if (parentRow.type === MenuTypeEnum.CATALOG) {
formData.type = MenuTypeEnum.MENU;
}
}
}
dialogVisible.visible = true;
@@ -839,17 +967,24 @@ async function handleOpenDialog(
// 菜单类型切换
function handleMenuTypeChange() {
// 如果菜单类型改变
if (formData.type !== formData.type) {
if (formData.type === MenuTypeEnum.MENU) {
// 目录切换到菜单时,清空组件路径
formData.component_path = "";
}
if (formData.type === MenuTypeEnum.MENU) {
formData.component_path = "";
}
nextTick(() => {
dataFormRef.value?.clearValidate("redirect");
if (formData.type === MenuTypeEnum.CATALOG) {
dataFormRef.value?.validateField("redirect").catch(() => {});
}
});
}
// 提交表单
async function handleSubmit() {
const allowed = allowedMenuTypeValues.value;
if (!allowed.includes(formData.type as MenuTypeEnum)) {
ElMessage.warning("当前父级下不允许该菜单类型");
return;
}
dataFormRef.value.validate(async (valid: any) => {
if (valid) {
submitLoading.value = true;
@@ -165,6 +165,9 @@ import { useAppStore } from "@/store/modules/app.store";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { useUserStore } from "@/store";
/** formatTree 后的菜单节点带 value(同原 id),与接口里的 id 二选一存在 */
type MenuTreeNode = permissionMenuType & { value?: number };
const props = defineProps<{
roleName: string;
roleId: number;
@@ -197,7 +200,7 @@ const isExpanded = ref(true);
const parentChildLinked = ref(false);
const loading = ref(false);
const deptTreeData = ref<permissionDeptType[]>([]);
const menuTreeData = ref<permissionMenuType[]>([]);
const menuTreeData = ref<MenuTreeNode[]>([]);
const permissionState = ref<permissionDataType>({
role_ids: [],
menu_ids: [],
@@ -256,9 +259,12 @@ async function handleDrawerSave() {
}
loading.value = true;
const rawChecked = (permTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key));
const menu_ids = expandMenuIdsWithAncestors(rawChecked, menuTreeData.value);
const submitData: permissionDataType = {
role_ids: [props.roleId],
menu_ids: (permTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key)),
menu_ids,
data_scope: permissionState.value.data_scope,
dept_ids: (deptTreeRef.value?.getCheckedKeys() || []).map((key) => Number(key)),
};
@@ -311,15 +317,20 @@ function handleFilter(value: string, data: { [key: string]: any }) {
return data.label.includes(value);
}
function checkParentChildLinked(menuIds: number[], menuTreeData: permissionMenuType[]): boolean {
function menuTreeNodeId(node: MenuTreeNode): number {
const v = node.value ?? node.id;
return Number(v);
}
function checkParentChildLinked(menuIds: number[], menuTreeData: MenuTreeNode[]): boolean {
if (!menuIds.length || !menuTreeData.length) return false;
const menuMap = new Map<number, permissionMenuType>();
const buildMenuMap = (menus: permissionMenuType[]) => {
const menuMap = new Map<number, MenuTreeNode>();
const buildMenuMap = (menus: MenuTreeNode[]) => {
menus.forEach((menu) => {
menuMap.set(menu.id, menu);
menuMap.set(menuTreeNodeId(menu), menu);
if (menu.children) {
buildMenuMap(menu.children);
buildMenuMap(menu.children as MenuTreeNode[]);
}
});
};
@@ -332,7 +343,9 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: permissionMenuT
if (!menu) continue;
if (menu.children && menu.children.length > 0) {
const hasUnselectedChildren = menu.children.some((child) => !menuIds.includes(child.id));
const hasUnselectedChildren = menu.children.some(
(child) => !menuIds.includes(menuTreeNodeId(child as MenuTreeNode))
);
if (hasUnselectedChildren) {
hasParentChildConflict = true;
break;
@@ -340,7 +353,7 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: permissionMenuT
}
const parentMenu = findParentMenu(menuId, menuTreeData);
if (parentMenu && !menuIds.includes(parentMenu.id)) {
if (parentMenu && !menuIds.includes(menuTreeNodeId(parentMenu))) {
hasParentChildConflict = true;
break;
}
@@ -349,17 +362,14 @@ function checkParentChildLinked(menuIds: number[], menuTreeData: permissionMenuT
return !hasParentChildConflict;
}
function findParentMenu(
menuId: number,
menuTreeData: permissionMenuType[]
): permissionMenuType | null {
function findParentMenu(menuId: number, menuTreeData: MenuTreeNode[]): MenuTreeNode | null {
for (const menu of menuTreeData) {
if (menu.children) {
for (const child of menu.children) {
if (child.id === menuId) {
if (menuTreeNodeId(child as MenuTreeNode) === menuId) {
return menu;
}
const found = findParentMenu(menuId, [child]);
const found = findParentMenu(menuId, [child as MenuTreeNode]);
if (found) return found;
}
}
@@ -367,6 +377,28 @@ function findParentMenu(
return null;
}
/** 为已选菜单补齐所有祖先 id,避免仅选子节点时父级未入库导致侧栏/路由缺入口(BUG #1/#14 */
function expandMenuIdsWithAncestors(checkedIds: number[], roots: MenuTreeNode[]): number[] {
const parentById = new Map<number, number | undefined>();
const walk = (nodes: MenuTreeNode[], parent: number | undefined) => {
for (const n of nodes) {
const id = menuTreeNodeId(n);
parentById.set(id, parent);
if (n.children?.length) walk(n.children as MenuTreeNode[], id);
}
};
walk(roots, undefined);
const out = new Set<number>();
for (const id of checkedIds) {
let cur: number | undefined = id;
while (cur !== undefined) {
out.add(cur);
cur = parentById.get(cur);
}
}
return [...out];
}
function handleParentChildLinkedChange(val: any) {
parentChildLinked.value = val;
}