mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 21:06:32 +00:00
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:
@@ -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-active,MenuItem 内样式已覆盖标题颜色
|
||||
</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-active(route.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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user