权限
build / build (push) Successful in 3m23s

This commit is contained in:
wanghu
2026-10-21 15:57:46 +08:00
parent e8067bab2e
commit f80abb26f2
2 changed files with 101 additions and 17 deletions
+100 -16
View File
@@ -19,11 +19,74 @@ const layoutData = reactive({
isAlone: false, isAlone: false,
}); });
/**
* 路由级访问拦截:以 GetUserMod 返回的已授权模块为准。
* 平台侧未授权的模块(含深链直访)不放行——跳转首个已授权模块,
* 一个授权都没有(或 GetUserMod 失败)时出示无权限页,宁可错杀不放过。
*/
const authPaths = new Set<string>();
const authModIds = new Set<number>();
const accessDenied = ref(false);
const accessTitle = ref("");
const accessSub = ref("");
function roleOfPath(p: string): "teacher" | "parent" { function roleOfPath(p: string): "teacher" | "parent" {
if (p.startsWith("/par")) return "parent"; if (p.startsWith("/par")) return "parent";
return "teacher"; 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() { async function initShell() {
// 深链/刷新时初始导航是异步懒加载,不等就绪会读到初始路径 "/", // 深链/刷新时初始导航是异步懒加载,不等就绪会读到初始路径 "/",
// 下方「登录后跳转第一个模块」就会把 /assign/middle 等直达路由弹回首页 // 下方「登录后跳转第一个模块」就会把 /assign/middle 等直达路由弹回首页
@@ -64,38 +127,34 @@ async function initShell() {
isLogin.value = true; isLogin.value = true;
// 登录后跳转第一个可用模块(仅限落在默认页:"/" 与其静态重定向 /assign/high; // 登录/刷新后按已授权模块校验当前路由:默认落地页会被送往第一个可用模块,
// 深链到 /assign/middle 等具体页时保持原位不动) // 未授权的深链同样在此被拦截(不再"平台菜单未配置也可直访")
const curPath = router.currentRoute.value.path; collectAuthorized(modList || []);
if (curPath === "/" || curPath === "/assign/high") { evaluateAccess();
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);
}
}
} catch (error: any) { } catch (error: any) {
console.error("加载用户信息失败", error); console.error("加载用户信息失败", error);
if (error?.message !== "redirect") { if (error?.message !== "redirect") {
Message.error("载入用户信息失败,请检查后端服务是否正常!"); Message.error("载入用户信息失败,请检查后端服务是否正常!");
} }
// 降级:使用默认值("/" 即我发布的页,无需跳转) // 降级:使用默认值;拿不到授权列表时按无权限处理(fail-closed),不放行业务页面
layoutData.userInfo = { name: "管理员", avatar: "" }; layoutData.userInfo = { name: "管理员", avatar: "" };
layoutData.modList = []; layoutData.modList = [];
layoutData.isAlone = true; layoutData.isAlone = true;
isLogin.value = true; isLogin.value = true;
accessTitle.value = "用户信息加载失败";
accessSub.value = "无法确认当前账号权限,请刷新页面重试;若持续失败请联系管理员。";
accessDenied.value = true;
} }
} }
onMounted(initShell); onMounted(initShell);
// 端分离:路由跨角色前缀(/par 家长端、其它发布端)切换时,重新设定视角并初始化壳层 // 路由变化即复检授权(含菜单点击、深链);跨角色前缀(/par 家长端、其它发布端)
// 切换时再整体重拉身份与菜单
watch( watch(
() => router.currentRoute.value.path, () => router.currentRoute.value.path,
(p, prev) => { (p, prev) => {
evaluateAccess();
if (roleOfPath(p) !== roleOfPath(prev || "")) initShell(); if (roleOfPath(p) !== roleOfPath(prev || "")) initShell();
} }
); );
@@ -105,7 +164,11 @@ watch(
<layout v-if="isLogin" :layoutData="layoutData" /> <layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }"> <div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper"> <div class="main-wrapper">
<router-view /> <div v-if="accessDenied" class="no-access-panel">
<p class="no-access-title">{{ accessTitle }}</p>
<p class="no-access-sub">{{ accessSub }}</p>
</div>
<router-view v-else />
</div> </div>
</div> </div>
</template> </template>
@@ -134,4 +197,25 @@ body {
vertical-align: middle; vertical-align: middle;
margin: 0 2px; 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;
}
</style> </style>
+1 -1
View File
@@ -5,7 +5,7 @@ import type { RouteRecordRaw } from "vue-router";
* *
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 数据): * modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 数据):
* 120=高中分班、121=初中分班、122=小学分班 * 120=高中分班、121=初中分班、122=小学分班
* (平台菜单未配置时也可直访对应路由;平台实际分配的 modId 与建议值不同时, * (未授权模块的深链由 App.vue 按 GetUserMod 结果拦截;平台实际分配的 modId 与建议值不同时,
* 只需改此处 meta.modId 一处,见《分班管理-计划任务.md》§5) * 只需改此处 meta.modId 一处,见《分班管理-计划任务.md》§5)
*/ */
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [