From f80abb26f2ea9a11bdd83934cff7d026d1e4d98d Mon Sep 17 00:00:00 2001 From: wanghu Date: Wed, 21 Oct 2026 15:57:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 116 +++++++++++++++++++++++++++++++++++++------ src/router/routes.ts | 2 +- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/src/App.vue b/src/App.vue index ba95c0a..81b20e9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,11 +19,74 @@ const layoutData = reactive({ isAlone: false, }); +/** + * 路由级访问拦截:以 GetUserMod 返回的已授权模块为准。 + * 平台侧未授权的模块(含深链直访)不放行——跳转首个已授权模块, + * 一个授权都没有(或 GetUserMod 失败)时出示无权限页,宁可错杀不放过。 + */ +const authPaths = new Set(); +const authModIds = new Set(); +const accessDenied = ref(false); +const accessTitle = ref(""); +const accessSub = ref(""); + function roleOfPath(p: string): "teacher" | "parent" { if (p.startsWith("/par")) return "parent"; return "teacher"; } +/** 从平台模块树收集已授权的内部路由 path 与 modId(与侧边栏同用 resolveMenuPage 解析) */ +function collectAuthorized(mods: any[]) { + authPaths.clear(); + authModIds.clear(); + const walk = (list: any[]) => { + (list || []).forEach((m: any) => { + const r = resolveMenuPage(m.page, m.modId ?? m.id, m.name); + if (r.path && !r.external) authPaths.add(r.path); + const mid = Number(m.modId ?? m.id); + if (!Number.isNaN(mid)) authModIds.add(mid); + if (m.children && m.children.length) walk(m.children); + }); + }; + walk(mods); +} + +/** 首个已授权模块的内部路由 path(菜单顺序,顶层优先取其第一个子项),无可解析项返回空串 */ +function firstAuthorizedPath(): string { + for (const m of layoutData.modList) { + const items: any[] = m.children && m.children.length ? m.children : [m]; + for (const item of items) { + const r = resolveMenuPage(item.page, item.modId ?? item.id, item.name); + if (r.path && !r.external) return r.path; + } + } + return ""; +} + +function evaluateAccess() { + const cur = router.currentRoute.value; + const meta: any = cur.meta || {}; + // 无 modId 的辅助路由("/" 等重定向)放行,由重定向目标再受检 + if (!meta.modId) { + accessDenied.value = false; + return; + } + if (authPaths.has(cur.path) || authModIds.has(Number(meta.modId))) { + accessDenied.value = false; + return; + } + const first = firstAuthorizedPath(); + if (first && first !== cur.path) { + router.replace(first).catch(() => { + accessDenied.value = true; + }); + return; + } + accessTitle.value = "暂无访问权限"; + accessSub.value = "当前账号未获授权使用分班管理,请联系管理员在基础平台配置模块权限。"; + accessDenied.value = true; +} + async function initShell() { // 深链/刷新时初始导航是异步懒加载,不等就绪会读到初始路径 "/", // 下方「登录后跳转第一个模块」就会把 /assign/middle 等直达路由弹回首页 @@ -64,38 +127,34 @@ async function initShell() { isLogin.value = true; - // 登录后跳转第一个可用模块(仅限落在默认页:"/" 与其静态重定向 /assign/high; - // 深链到 /assign/middle 等具体页时保持原位不动) - const curPath = router.currentRoute.value.path; - if (curPath === "/" || curPath === "/assign/high") { - 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); - } - } + // 登录/刷新后按已授权模块校验当前路由:默认落地页会被送往第一个可用模块, + // 未授权的深链同样在此被拦截(不再"平台菜单未配置也可直访") + collectAuthorized(modList || []); + evaluateAccess(); } catch (error: any) { console.error("加载用户信息失败", error); if (error?.message !== "redirect") { Message.error("载入用户信息失败,请检查后端服务是否正常!"); } - // 降级:使用默认值("/" 即我发布的页,无需跳转) + // 降级:使用默认值;拿不到授权列表时按无权限处理(fail-closed),不放行业务页面 layoutData.userInfo = { name: "管理员", avatar: "" }; layoutData.modList = []; layoutData.isAlone = true; isLogin.value = true; + accessTitle.value = "用户信息加载失败"; + accessSub.value = "无法确认当前账号权限,请刷新页面重试;若持续失败请联系管理员。"; + accessDenied.value = true; } } onMounted(initShell); -// 端分离:路由跨角色前缀(/par 家长端、其它发布端)切换时,重新设定视角并初始化壳层 +// 路由变化即复检授权(含菜单点击、深链);跨角色前缀(/par 家长端、其它发布端) +// 切换时再整体重拉身份与菜单 watch( () => router.currentRoute.value.path, (p, prev) => { + evaluateAccess(); if (roleOfPath(p) !== roleOfPath(prev || "")) initShell(); } ); @@ -105,7 +164,11 @@ watch(
- +
+

{{ accessTitle }}

+

{{ accessSub }}

+
+
@@ -134,4 +197,25 @@ body { vertical-align: middle; margin: 0 2px; } +/* 无权限/加载失败占位页:内容区居中,顶部栏与已授权菜单保持可用 */ +.no-access-panel { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 24px; +} +.no-access-panel .no-access-title { + margin: 0 0 8px; + font-size: 18px; + font-weight: 600; + color: #1d2939; +} +.no-access-panel .no-access-sub { + margin: 0; + font-size: 13px; + color: #667085; +} \ No newline at end of file diff --git a/src/router/routes.ts b/src/router/routes.ts index b5b1fe8..1ae69f3 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -5,7 +5,7 @@ import type { RouteRecordRaw } from "vue-router"; * * modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 数据): * 120=高中分班、121=初中分班、122=小学分班 - * (平台菜单未配置时也可直访对应路由;平台实际分配的 modId 与建议值不同时, + * (未授权模块的深链由 App.vue 按 GetUserMod 结果拦截;平台实际分配的 modId 与建议值不同时, * 只需改此处 meta.modId 一处,见《分班管理-计划任务.md》§5) */ const routes: RouteRecordRaw[] = [