From 580b05a5ebb0cea6db954888602dac873b70f87e Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 21 Sep 2026 12:09:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=80=80=E5=87=BA?= =?UTF-8?q?=E8=B7=B3=E9=94=99=E5=9F=9F=E3=80=81=E4=BA=8C=E7=BA=A7=E9=A1=B5?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E4=B8=8E=E8=90=BD=E5=9C=B0=E9=A1=B5=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 101 +++++++++++++++++--- src/assets/config/host.config.ts | 29 +++++- src/components/layout.vue | 159 +++++++++++++++++++++---------- src/router/routes.ts | 9 +- src/utils/menuRoute.ts | 70 ++++++++++++++ 5 files changed, 303 insertions(+), 65 deletions(-) diff --git a/src/App.vue b/src/App.vue index 876fd65..878cc6b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,7 +4,8 @@ import layout from "./components/layout.vue"; import { useRoute, useRouter } from "vue-router"; import config from "@/assets/config/host.config"; import { baseApi } from "@/api/module/dorm"; -import { resolveMenuPage } from "@/utils/menuRoute"; +import { normalizeModList, flattenMenuNodes, firstMenuPath } from "@/utils/menuRoute"; +import routes from "@/router/routes"; import { Message } from "view-ui-plus"; const router = useRouter(); @@ -45,8 +46,85 @@ function roleOfPath(p: string): "teacher" | "parent" { return "teacher"; } +/* ============================================================================ + * 路由权限:左侧菜单管「入口」,不管「点进去之后」 + * ---------------------------------------------------------------------------- + * 两类页面,两套口径: + * + * ① **入口页**(左侧菜单里的节点:我发布的 / 全部通知 / 通知统计 / 宿舍设置 / + * 家长端各页)→ 必须配在 GetUserMod 里才放行。菜单就是入口清单,没配的入口 + * 不该被 URL 直接打开(否则左边栏一片失选,用户以为系统坏了)。 + * + * ② **落地页**(从页面上点进去的二级目录:发布通知 / 通知详情 / 回执情况) + * → **一律放行,不判任何权限**。它们不是目录,左侧栏只负责高亮 + * 「是从哪个入口节点点进来的」。 + * + * 落地页的判定不看"它在没在菜单里",而是看路由表里是否声明了 meta.parentId + * —— 这是"我从哪个入口进来"的显式契约。**踩过的坑**:/receipts(回执情况) + * 既能从左侧菜单进、也能从通知列表行操作进,若没声明 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(); + // ① 入口页:菜单里配了才放行 + 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(/par/*)的 role 也会被判成发布端,导致视角与菜单都错。 + 等 isReady 之后再读 route,上述判断才基于真实目标。 */ + await router.isReady(); + const role = roleOfPath(router.currentRoute.value.path); + menuReady.value = false; // 重新拉菜单期间不拦路由,避免用上一个角色的菜单误判 try { // 端分离:先按 URL 前缀切换当前视角(teacher/parent),再拉身份与菜单 await baseApi.setRole(role).send(); @@ -80,19 +158,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") { @@ -102,6 +172,7 @@ async function initShell() { layoutData.userInfo = { name: "管理员", avatar: "" }; layoutData.modList = []; layoutData.isAlone = true; + menuReady.value = true; isLogin.value = true; } } @@ -121,7 +192,9 @@ watch(
- + +
diff --git a/src/assets/config/host.config.ts b/src/assets/config/host.config.ts index 31aebc5..e5cc820 100644 --- a/src/assets/config/host.config.ts +++ b/src/assets/config/host.config.ts @@ -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) { diff --git a/src/components/layout.vue b/src/components/layout.vue index 4820fb1..5e6fc4a 100644 --- a/src/components/layout.vue +++ b/src/components/layout.vue @@ -2,7 +2,7 @@ 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 }>(); const route = useRoute(); @@ -33,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([]); +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(""); + +/** 沿 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) { @@ -124,12 +181,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; } @@ -194,7 +257,7 @@ function linkHomePage() {
    -