fix: 修复退出跳错域、二级页高亮与落地页权限口径
build / build (push) Successful in 3m56s

This commit is contained in:
lijing
2026-09-21 12:14:00 +08:00
parent 66cdf7c5a1
commit cbc7c4f86d
5 changed files with 346 additions and 76 deletions
+119 -21
View File
@@ -1,14 +1,36 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from "vue";
import { ref, reactive, computed, onMounted, watch, provide } from "vue";
import layout from "./components/layout.vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/homework";
import { resolveMenuPage, dropDroppedModules } from "@/utils/menuRoute";
import { dropDroppedModules, normalizeModList, flattenMenuNodes, firstMenuPath } from "@/utils/menuRoute";
import routes from "@/router/routes";
import { Message } from "view-ui-plus";
const router = useRouter();
const route = useRoute();
const isLogin = ref(false);
/**
* 视图重挂载信号(配合下方 router-view 的 :key)。
*
* 解决的问题:路由表里有多组**共用同一组件**的路由 ——
* /list/detail ↔ /homeworklist/detail 都是 HomeworkDetails.vue
* /stu/today ↔ /stu/redo 都是 StudentHomeworks.vue
* Vue 遇到「同一组件」会复用实例:onMounted 不再执行,按 meta 只算一次的状态
* (学生端「待订正」默认落 todo 标签)也不会重算,
* 于是表现为「点左边栏右边没反应,多点几下/刷新才变」。
* 加 key 后每个路由 path 各持有一份实例,切换必然重建。
*
* 顺带把「点击当前菜单项」从 router.go(0)(整页 reload、重新拉身份与菜单、白屏一下)
* 换成自增 tick,只软重挂载内容区。
*/
const viewTick = ref(0);
provide("refreshView", () => {
viewTick.value += 1;
});
const viewKey = computed(() => `${route.fullPath}#${viewTick.value}`);
const layoutData = reactive({
appName: "课程作业",
baseHost: config.baseHost,
@@ -24,13 +46,92 @@ function roleOfPath(p: string): "teacher" | "student" {
return "teacher";
}
/* ============================================================================
* 路由权限:左侧菜单管「入口」,不管「点进去之后」
* ----------------------------------------------------------------------------
* 两类页面,两套口径:
*
* ① **入口页**(左侧菜单里的节点:我布置的 / 全部作业 / 作业统计 / 今日作业…)
* → 必须配在 GetUserMod 里才放行。菜单就是入口清单,没配的入口不该被 URL
* 直接打开(否则左边栏一片失选,用户以为系统坏了)。
*
* ② **落地页**(从页面上点进去的二级目录:发作业 / 提交情况 / 批改中心 /
* 作业详情 / 学生作业详情)→ **一律放行,不判任何权限**。它们不是目录,
* 左侧栏只负责高亮「是从哪个入口节点点进来的」。
*
* 落地页的判定不看"它在没在菜单里",而是看路由表里是否声明了 meta.parentId
* —— 这是"我从哪个入口进来"的显式契约。**踩过的坑**:/submissions(提交情况)
* 既能从左侧菜单进、也能从作业列表行操作进,若没声明 parentId,就会被当成
* ① 类入口去判权限;真实环境账号菜单里常常没配这个节点 → 点按钮直接被弹回
* 第一个节点(用户报"这个按钮不让点")。凡是能从页面内点进去的路由,
* 必须在 routes.ts 声明 meta.parentId。
* ========================================================================== */
/** 菜单是否已就绪。加载期间一律放行,等菜单到达后由下方 watch 复核,
* 否则首屏会因为"菜单还没到"把正常页面误判成无权 */
const menuReady = ref(false);
const normalizedMenu = computed(() => normalizeModList(layoutData.modList));
const menuNodes = computed(() => flattenMenuNodes(normalizedMenu.value));
/** 当前用户可访问的 route.path 集合:菜单入口 ∪ 全部落地页 */
const allowedPaths = computed(() => {
const allow = new Set<string>();
// ① 入口页:菜单里配了才放行
menuNodes.value.forEach((n: any) => {
if (n.page) allow.add(n.page);
});
// ② 落地页:声明了 meta.parentId 的一律放行(点进去的二级目录不判权限)
routes.forEach((r: any) => {
if (r.redirect) return; // 别名由 vue-router 先解析到真实路由,守卫看到的是目标 path
if ((r.meta as any)?.parentId != null) allow.add(r.path);
});
return allow;
});
/** 无权/无匹配时落地的第一个菜单页面(与 initShell 的登录落地规则一致) */
const firstPath = computed(() => firstMenuPath(normalizedMenu.value));
/** 菜单为空视为"降级/加载失败",不拦路由,避免把用户困在死循环重定向里 */
const guardActive = computed(() => menuReady.value && allowedPaths.value.size > 0);
const routeAllowed = computed(() => !guardActive.value || allowedPaths.value.has(route.path));
router.beforeEach((to) => {
if (!guardActive.value) return true;
if (allowedPaths.value.has(to.path)) return true;
const first = firstPath.value;
if (!first || first === to.path) return false; // 没有可用目标:直接不渲染,好过显示无权页
Message.warning(`当前账号没有「${(to.meta as any)?.title || to.path}」的权限,已回到第一个可用页面`);
return { path: first, replace: true };
});
/* 首屏 URL 直达无权页的场景:守卫在菜单未就绪时放行了,菜单到达后必须复核并纠正 */
watch(guardActive, () => {
if (!guardActive.value) return;
if (routeAllowed.value) return;
const first = firstPath.value;
if (first && first !== route.path) router.replace(first);
});
async function initShell() {
/* ★ 必须先等 router 的首次导航解析完成。
main.ts 里是同步 app.mount(),App 的 onMounted 有机会早于 vue-router 的
初始导航 —— 此时 router.currentRoute 还停在 START_LOCATION(path === "/"),
于是「深链接」会被整片误判成"访问应用入口":
· 冷启动 /#/stats、/#/list/new、/#/list/detail 一律被 replace 到第一个节点;
· 学生端 URL(/stu/*)的 role 也会被判成教师端,导致视角与菜单都错。
等 isReady 之后再读 route,上述判断才基于真实目标。 */
await router.isReady();
const role = roleOfPath(router.currentRoute.value.path);
try {
// 端分离:先切换 mock 当前视角,再拉身份与菜单
await baseApi.setRole(role).send();
} catch {
/* 忽略:非 mock 环境可能无此接口 */
menuReady.value = false; // 重新拉菜单期间不拦路由,避免用上一个角色的菜单误判
if (config.isMock) {
// 端分离:仅 mock 后端有此端点(切换教师/学生视角),真后端不存在,不发起请求
try {
await baseApi.setRole(role).send();
} catch {
/* mock 下该端点不应失败,此处仅兜底 */
}
}
try {
// 1. 获取当前用户信息
@@ -59,19 +160,11 @@ async function initShell() {
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
layoutData.isAlone = (modList || []).length <= 1 && !hasChildren;
menuReady.value = true;
isLogin.value = true;
// 登录后跳转第一个可用模块("/" 本身即已收到页,无需跳转)
if (router.currentRoute.value.path === "/") {
const firstModule = (modList || [])[0];
const firstRaw = firstModule?.children?.length
? firstModule.children[0]
: firstModule;
const first = resolveMenuPage(firstRaw?.page, firstRaw?.modId ?? firstRaw?.id, firstRaw?.name);
if (!first.external && first.path && first.path !== "/") {
router.replace(first.path);
}
}
// 注:不再有「path === "/" 就跳第一个节点」的分支 —— 入口地址由路由表的
// "/" → "/list" 重定向处理(router.isReady 之前读 currentRoute 会踩上面那个坑),
// 「当前路由没有对应菜单节点 → 落第一个节点」统一交给守卫与上方复核处理。
} catch (error: any) {
console.error("加载用户信息失败", error);
if (error?.message !== "redirect") {
@@ -81,6 +174,7 @@ async function initShell() {
layoutData.userInfo = { name: "管理员", avatar: "" };
layoutData.modList = [];
layoutData.isAlone = true;
menuReady.value = true;
isLogin.value = true;
}
}
@@ -100,7 +194,11 @@ watch(
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<router-view />
<!-- routeAllowed:当前账号菜单里没有该路由时不渲染内容(守卫已拦,这里兜住
「菜单晚于路由到达」的瞬间,避免无权页闪一下再被 replace 掉) -->
<router-view v-if="routeAllowed" v-slot="{ Component }">
<component :is="Component" :key="viewKey" />
</router-view>
</div>
</div>
</template>
+28 -1
View File
@@ -9,7 +9,7 @@ import { BaseConf } from "@/types/Basics";
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
* ssoHost 另由 ④ 按 base.* → sso.* 的运维约定推导,保证登出仍能跳到 SSO 域
*/
const env = import.meta.env;
@@ -61,6 +61,33 @@ if (runtime && typeof runtime === "object" && runtime.relayConf) {
applyConf(conf.relayConf, runtime.relayConf);
}
/**
* ④ 登出地址兜底:ssoHost 为空时 logout 会退化成同源相对路径
* `/logout?service=…`,落到 base 域而不是 SSO 域 —— 会话根本没注销,
* 只是从当前页跳回首页,用户看着像"点了退出没反应"。
*
* 触发条件很常见:projman 侧 env-config 未配 ssoHost 时接口返回 `{}`
* (实测同族多个应用都存在这种空下发,而 campusnotice / notice 下发了完整三键)。
*
* 运维约定 SSO 与 base 同域不同子域(base.x.com ↔ sso.x.com),据此**按当前站点推导**,
* 而不是把域名写死进产物 —— 保持"构建产物不含任何环境值"这一设计前提。
* 部署在非 base.* 域名(如 IP、localhost)时不推导,维持原行为。
*/
function deriveSsoHostFrom(base: string): string {
if (!base) return "";
try {
const u = new URL(base);
if (!/^base\./i.test(u.hostname)) return "";
u.hostname = u.hostname.replace(/^base\./i, "sso.");
return u.origin;
} catch {
return "";
}
}
if (!conf.ssoHost) {
conf.ssoHost = deriveSsoHostFrom(conf.baseHost);
}
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
if (ishttps) {
+118 -50
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref, onBeforeMount, watch, computed } from "vue";
import { ref, onBeforeMount, watch, computed, inject } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import { normalizeModList, flattenMenuNodes } from "@/utils/menuRoute";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
const router = useRouter();
/** 由 App.vue 提供的「软重挂载内容区」信号(替代 router.go(0) 的整页 reload) */
const refreshView = inject<(() => void) | null>("refreshView", null);
const isPanel = ref(false);
const currPath = ref<string>("");
@@ -31,69 +33,126 @@ watch(
() => routeChange(route)
);
const flaten = (tree: any[], pid = 0): any[] => {
const flaten = (tree: any[], pid = 0): any[] => flattenMenuNodes(tree, pid);
/* 侧边栏渲染用的菜单树:page 已由 normalizeModList 统一归一为内部路由。
跳转(linkMenu)与高亮(currPath 对照)必须共用这一份,否则平台里只配了
名称的模块点不动、且当前项高亮对不上。
用 ref + watch 承载而非 computed:菜单项需要可变的 childState(展开/收起),
computed 里 { ...m } 生成的是普通对象,直接改不会触发重渲染。 */
const displayModList = ref<any[]>([]);
watch(
modList,
(list) => {
displayModList.value = normalizeModList(list);
},
{ immediate: true }
);
const menuArr = computed(() => flaten(displayModList.value));
/** 菜单页面集合:判断某个 route.path 本身是否就是一个菜单节点 */
const menuPageSet = computed(
() => new Set(menuArr.value.map((x: any) => x.page).filter(Boolean) as string[])
);
/**
* 上一个「菜单节点」高亮值 —— 落地页(二级页)高亮的关键状态。
*
* 需求:从哪个节点点按钮进的二级页面(发作业 / 提交情况 / 批改中心 / 作业详情等
* 不在菜单里的路由),左侧就选中那个节点。冷启动或刷新时没有来源,
* 退回路由 meta.parentId 的静态契约。
*/
const lastMenuPage = ref<string>("");
/** 沿 pid 向上收集祖先(支持多层嵌套),得到面包屑的前缀 */
const ancestorsOf = (node: any): any[] => {
const arr: any[] = [];
tree.forEach((element: any) => {
const { id, name, modId } = element;
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
if (element.children && element.children.length) {
arr.push(...flaten(element.children, id));
}
});
let p = node?.pid ? menuArr.value.find((x: any) => x.id === node.pid) : null;
while (p) {
arr.unshift({ name: p.name, page: p.page });
p = p.pid ? menuArr.value.find((x: any) => x.id === p.pid) : null;
}
return arr;
};
const menuArr = computed(() => flaten(modList.value));
const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title, parentId } = meta;
const navArr: any[] = [];
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
menuArr.value.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name });
}
navArr.push({ name: find.name || title, page: find.page });
navPath.value = navArr;
// 1) 目标路由本身就是菜单节点 → 选中它(to.path 不含 query,列表页翻页/筛选不会改高亮)
const self: any = menuArr.value.find((x: any) => x.page && x.page === to.path);
if (self) {
currPath.value = self.page;
lastMenuPage.value = self.page;
navPath.value = [...ancestorsOf(self), { name: self.name || title, page: self.page }];
return;
}
if (parentId) {
const parent = menuArr.value.find((x: any) => x.modId === parentId);
if (parent) {
currPath.value = parent.page || "";
if (parent.pid) {
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name });
}
navArr.push({ name: parent.name, page: parent.page });
navArr.push({ name: title });
navPath.value = navArr;
return;
}
// 2) 落地页(不在菜单里的路由:发作业 / 提交情况 / 批改中心 / 作业详情等):
// 父节点优先取「进入它的那个菜单节点」,这样从「全部作业」进发作业页就选
// 「全部作业」,而不是写死的 meta.parentId;没有来源(冷启动/刷新/直接粘 URL)
// 时才退回 meta.parentId → modId 的静态契约
const origin: any = lastMenuPage.value
? menuArr.value.find((x: any) => x.page === lastMenuPage.value)
: null;
const declared: any =
(parentId != null && menuArr.value.find((x: any) => x.modId === parentId)) ||
(parentId != null && menuArr.value.find((x: any) => x.id === parentId)) ||
(modId != null && menuArr.value.find((x: any) => x.modId === modId));
const parent: any = origin || declared;
if (parent) {
currPath.value = parent.page || lastMenuPage.value;
if (parent.page) lastMenuPage.value = parent.page;
navPath.value = [
...ancestorsOf(parent),
{ name: parent.name, page: parent.page },
{ name: title },
];
return;
}
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
// 3) 菜单里没有、但路由表里按 modId 能查到(单模块 isAlone 等):只更新面包屑
const r: any = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
if (menuPageSet.value.has(r.path)) lastMenuPage.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
return;
}
currPath.value = "";
navPath.value = [];
// 4) 全都匹配不到:保持现有高亮与面包屑,**不清空**
// 清空会让左侧菜单突然全部失选(比"高亮停在上一个节点"更像 bug)
};
/**
* 展开当前选中项所在的分组。
* 分组默认是收起的(childState 未设置),选中项若在收起的分组里就"看不见选中"了;
* 落地页高亮到某个一级分组下的子节点时尤其明显。只展开、不收起,
* 避免用户手动展开的分组被自动关掉。
*/
const expandToActive = () => {
const p = currPath.value;
if (!p) return;
(displayModList.value || []).forEach((item: any) => {
if (Array.isArray(item.children) && item.children.some((c: any) => c?.page === p)) {
item.childState = true;
}
});
};
/* 菜单到达后重跑一次高亮计算:首屏路由早于菜单就位(menuArr 为空时算不出高亮),
刷新停在落地页时尤其明显。只在菜单内容变化时触发,展开/收起不触发 */
watch(
() => menuArr.value.map((x: any) => `${x.modId}|${x.page}`).join(","),
() => {
routeChange(route);
expandToActive();
}
);
watch(currPath, expandToActive);
const linkMenu = (item: any) => {
const page: string = item?.page || "";
if (!page) {
@@ -107,7 +166,10 @@ const linkMenu = (item: any) => {
return;
}
if (page === route.path) {
router.go(0);
// 已在目标页:软重挂载内容区即可。原来用 router.go(0) 会整页 reload
// (重新拉身份/菜单、白屏一下),且「点一次没反应」的观感更重
if (refreshView) refreshView();
else router.go(0);
} else {
router.push(page);
}
@@ -117,12 +179,18 @@ const linkUserInfo = () => {
window.location.href = baseHost.value + "/v/userinfo";
};
const logout = () => {
let url = window.location.href;
url = "/logout?service=" + url;
window.location.href = ssoHost.value + url;
// 必须带 ssoHost:ssoHost 为空时 "/logout" 是**同源相对路径**,会落到 base 域,
// 会话不会注销(只是从当前页跳回首页,看着像点了没反应)。
// 线上 ssoHost 来自 env-config;未下发时由 host.config 的 ④ 按 base.* → sso.* 推导。
if (!ssoHost.value) {
Message.warning("未取到单点登录地址,无法安全退出,请稍后重试或联系管理员");
return;
}
const url = window.location.href;
window.location.href = ssoHost.value + "/logout?service=" + url;
};
const childChange = (index: number) => {
const arr = modList.value || [];
const arr = displayModList.value || [];
if (arr[index]) {
arr[index].childState = !arr[index].childState;
}
@@ -189,7 +257,7 @@ function linkHomePage() {
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<ul>
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
<template v-for="(item, index) in displayModList" :key="'menu_li_' + index">
<template v-if="item.children && item.children.length > 0">
<li>
<a href="javascript:;" @click="childChange(index)">
+11 -4
View File
@@ -24,19 +24,25 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/homework/TeacherHomeworks.vue"),
meta: { title: "我布置的", modId: 2 },
},
// 提交情况(某作业的已交/未交名单;从列表行或菜单进入,无 serid 时页内选择作业)
/* 提交情况(某作业的已交/未交名单;从列表行或菜单进入,无 serid 时页内选择作业)
★ parentId 是「落地页」的显式声明:从「我布置的」「全部作业」的行操作点进来,
不参与菜单权限判定(真实环境账号菜单常常没配「提交情况」这个节点,
按入口判会被拦掉 → 点按钮"不让点")。
冷启动/刷新停在 /submissions 时父节点优先用进入它的菜单节点
(layout 的 lastMenuPage),取不到才退回 parentId=2「我布置的」。 */
{
path: "/submissions",
name: "homework_submissions",
component: () => import("@/views/homework/Submissions.vue"),
meta: { title: "提交情况", modId: 25 },
meta: { title: "提交情况", modId: 25, parentId: 2 },
},
// 批改中心(待批作业→逐份批改工作台;无 serid 时先看待批作业列表)
// 同样是从列表行 / 提交情况页点进来的落地页,parentId 声明入口来源
{
path: "/grade",
name: "homework_grade",
component: () => import("@/views/homework/Grade.vue"),
meta: { title: "批改中心", modId: 26 },
meta: { title: "批改中心", modId: 26, parentId: 2 },
},
// 发作业(新建/编辑/转发/模板):已发送的深层子页,不在菜单里,
// layout.vue 按.meta.parentId 推导面包屑「已发送>发作业」并高亮父菜单
@@ -91,11 +97,12 @@ const routes: RouteRecordRaw[] = [
meta: { title: "待订正", modId: 32, role: "student", initTab: "todo" },
},
// 学生·作业详情/提交/批阅结果(PRD 学生页 2/3 载体)
// 从「今日作业」/「待订正」列表点某条作业进来 → 落地页,parentId=31(今日作业)声明入口
{
path: "/stu/detail",
name: "student_detail",
component: () => import("@/views/homework/HomeworkAccept.vue"),
meta: { title: "作业详情", modId: 33, role: "student" },
meta: { title: "作业详情", modId: 33, role: "student", parentId: 31 },
},
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
+70
View File
@@ -197,3 +197,73 @@ export function resolveMenuPage(
console.warn("[menuRoute] 模块未配置路径且名称/modId 无法识别:", name, "modId =", modId);
return EMPTY_MENU_PAGE;
}
/**
* 平台 GetUserMod 模块列表 → 侧边栏菜单项(含子项),page 统一归一为内部路由。
*
* 侧边栏的跳转与高亮都消费 page,必须走同一条解析链:平台里模块只配了名称
* (path 为空)或配成本应用入口地址时,解析结果仍能按名称/modId 兜底命中路由
* (如 modId 31「今日作业」→ /stu/today);确实解析不出时保持空串,
* 由 layout 的点击处理显式提示,而不是静默跳到默认页。
*
* 顺序以平台(用户中心)返回为准,不在前端重排;返回新对象,不修改入参。
*/
export function normalizeModList(list?: any[] | null): any[] {
const mapNode = (m: any): any => {
const resolved = resolveMenuPage(m?.page, m?.modId ?? m?.id, m?.name);
return {
...m,
page: resolved.path,
external: resolved.external,
children:
Array.isArray(m?.children) && m.children.length ? m.children.map(mapNode) : m?.children,
};
};
return Array.isArray(list) ? list.map(mapNode) : [];
}
export interface MenuNode {
id: any;
name?: string;
modId: any;
/** 已归一为内部路由;解析不出时为空串 */
page: string;
external?: boolean;
/** 父节点 id(顶层为 0) */
pid: any;
}
/**
* 摊平归一后的菜单树(顺序与侧边栏渲染一致:带 page 的父节点先于其子节点)。
*
* 侧边栏高亮、面包屑祖先、路由权限判定三处必须共用同一份摊平口径,
* 否则「第一个节点」「当前节点」这类判断会在不同地方得出不同答案。
*/
export function flattenMenuNodes(list?: any[] | null, pid: any = 0): MenuNode[] {
const arr: MenuNode[] = [];
(Array.isArray(list) ? list : []).forEach((element: any) => {
if (!element) return;
const { id, name, modId, page, external } = element;
arr.push({ id, name, modId, page, external, pid });
if (Array.isArray(element.children) && element.children.length) {
arr.push(...flattenMenuNodes(element.children, id));
}
});
return arr;
}
/**
* 用户「第一个可用页面」:登录落地、以及访问无权路由时的兜底目标。
*
* 规则与 App.initShell 的登录跳转保持一致 —— 取第一个模块,它有子节点时
* 落第一个子节点,否则落它自己。两处若不一致,会出现"登录后进 A、被拦后又跳 B"。
* 入参必须是 normalizeModList 归一后的树(page 已解析为内部路由)。
*/
export function firstMenuPath(list?: any[] | null): string {
const arr = Array.isArray(list) ? list : [];
const first = arr.find((m: any) => m && (m.page || (m.children && m.children.length)));
if (!first) return "";
const target =
Array.isArray(first.children) && first.children.length ? first.children[0] : first;
return target?.page || "";
}