build / build (push) Successful in 5m3s
- host.config:新增④按站推导 ssoHost(base.* → sso.*),logout 加空值保护,修线上退出落到 base 域 - layout:记 lastMenuPage,二级页高亮跟随来源节点并自动展开所在分组(expandToActive) - App.vue:权限口径=入口页(菜单节点)∪落地页(meta.parentId),落地页一律放行只谈高亮;initShell 首行 await router.isReady() 修深链接竞态 - routes:/signups 补 meta.parentId,修「报名情况不让点」(落地页漏声明会被当入口拦) - menuRoute:抽 flattenMenuNodes / firstMenuPath 共用口径
247 lines
11 KiB
Vue
247 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, watch, provide } from "vue";
|
||
import layout from "./components/layout.vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import config from "@/assets/config/host.config";
|
||
import { baseApi } from "@/api/module/activity";
|
||
import { 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 ↔ /activitylist 都是 ActivityList.vue
|
||
* /par/activities ↔ /par/mine 都是 ParentActivities.vue
|
||
* Vue 遇到「同一组件」会复用实例:onMounted 不再执行,按 meta 只算一次的状态
|
||
* (家长端「我的报名」默认落「已报名」标签)也不会重算,
|
||
* 于是表现为「点左边栏右边没反应,多点几下/刷新才变」——家长端尤其明显。
|
||
* 加 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,
|
||
ssoHost: config.ssoHost,
|
||
appList: [] as any[],
|
||
userInfo: null as any,
|
||
modList: [] as any[],
|
||
isAlone: false,
|
||
});
|
||
|
||
function roleOfPath(p: string): "teacher" | "parent" {
|
||
if (p.startsWith("/par")) return "parent";
|
||
return "teacher";
|
||
}
|
||
|
||
/* ============================================================================
|
||
* 路由权限:左侧菜单管「入口」,不管「点进去之后」
|
||
* ----------------------------------------------------------------------------
|
||
* 两类页面,两套口径:
|
||
*
|
||
* ① **入口页**(左侧菜单里的节点:我发布的 / 全部活动 / 活动统计 / 应用设置 /
|
||
* 家长端各页)→ 必须配在 GetUserMod 里才放行。菜单就是入口清单,
|
||
* 没配的入口不该被 URL 直接打开(否则左边栏一片失选,用户以为系统坏了)。
|
||
*
|
||
* ② **落地页**(从页面上点进去的二级目录:发布活动 / 活动详情 / 报名情况)
|
||
* → **一律放行,不判任何权限**。它们不是目录,左侧栏只负责高亮
|
||
* 「是从哪个入口节点点进来的」。
|
||
*
|
||
* 落地页的判定不看"它在没在菜单里",而是看路由表里是否声明了 meta.parentId
|
||
* —— 这是"我从哪个入口进来"的显式契约。**踩过的坑**:/signups(报名情况)
|
||
* 既能从左侧菜单进、也能从活动列表行操作进,之前没声明 parentId,于是被当成
|
||
* ① 类入口去判权限,真实环境账号菜单里没配「报名情况」→ 点按钮直接被弹回第一个
|
||
* 节点(用户报"报名情况不让点")。凡是能从页面内点进去的路由,必须在
|
||
* routes.ts 声明 meta.parentId,否则会被当成入口被拦。
|
||
*
|
||
* 左侧菜单(allowedPaths)之外的路由,只有两类能进来:声明了 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 === "/"),
|
||
于是「深链接」会被整片误判成"访问应用入口":
|
||
· 冷启动 /#/setting、/#/list/new、/#/list/detail 一律被 replace 到第一个节点
|
||
(实测原版行为:/#/activitylist、/#/setting 冷启动全部落到 /#/list);
|
||
· 家长端 URL 的 role 也会被判成教师端,导致视角与菜单都错。
|
||
等 isReady 之后再读 route,上述判断才基于真实目标。 */
|
||
await router.isReady();
|
||
|
||
const role = roleOfPath(router.currentRoute.value.path);
|
||
menuReady.value = false; // 重新拉菜单期间不拦路由,避免用上一个角色的菜单误判
|
||
try {
|
||
// 端分离:先切换 mock 当前视角,再拉身份与菜单
|
||
await baseApi.setRole(role).send();
|
||
} catch {
|
||
/* 忽略:非 mock 环境可能无此接口 */
|
||
}
|
||
try {
|
||
// 1. 获取当前用户信息
|
||
const user: any = await baseApi.getUserInfo().send();
|
||
const { id, name, profilePicture } = user || {};
|
||
|
||
// 2. 获取当前用户已授权的模块列表
|
||
const modList: any[] = await baseApi.getUserMod().send();
|
||
|
||
layoutData.userInfo = { id, name: name || "管理员", avatar: profilePicture || "" };
|
||
layoutData.modList = modList || [];
|
||
|
||
// 开发环境打印平台原始模块配置,便于排查菜单路径解析问题
|
||
if (import.meta.env.DEV) {
|
||
console.table(
|
||
(modList || []).map((m: any) => ({
|
||
name: m.name,
|
||
id: m.id,
|
||
modId: m.modId,
|
||
path: m.page,
|
||
}))
|
||
);
|
||
}
|
||
|
||
// 有子菜单时不算「单模块独占」模式
|
||
const hasChildren = (modList || []).some((m: any) => m.children && m.children.length > 0);
|
||
layoutData.isAlone = (modList || []).length <= 1 && !hasChildren;
|
||
|
||
menuReady.value = true;
|
||
isLogin.value = true;
|
||
// 注:不再有「path === "/" 就跳第一个节点」的分支 —— 入口地址由路由表的
|
||
// "/" → "/list" 重定向处理(router.isReady 之前读 currentRoute 会踩上面那个坑),
|
||
// 「当前路由没有对应菜单节点 → 落第一个节点」统一交给守卫与下方复核处理。
|
||
} catch (error: any) {
|
||
console.error("加载用户信息失败", error);
|
||
if (error?.message !== "redirect") {
|
||
Message.error("载入用户信息失败,请检查后端服务是否正常!");
|
||
}
|
||
// 降级:使用默认值("/" 即我发布的页,无需跳转);modList 为空 → 守卫自动失效
|
||
layoutData.userInfo = { name: "管理员", avatar: "" };
|
||
layoutData.modList = [];
|
||
layoutData.isAlone = true;
|
||
menuReady.value = true;
|
||
isLogin.value = true;
|
||
}
|
||
}
|
||
|
||
onMounted(initShell);
|
||
|
||
// 端分离:路由跨角色前缀(/par 家长端、其它发布端)切换时,重新设定视角并初始化壳层
|
||
watch(
|
||
() => router.currentRoute.value.path,
|
||
(p, prev) => {
|
||
if (roleOfPath(p) !== roleOfPath(prev || "")) initShell();
|
||
}
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||
<div class="main-wrapper">
|
||
<!-- routeAllowed:当前账号菜单里没有该路由时不渲染内容(守卫已拦,这里兜住
|
||
「菜单晚于路由到达」的瞬间,避免无权页闪一下再被 replace 掉) -->
|
||
<router-view v-if="routeAllowed" v-slot="{ Component }">
|
||
<component :is="Component" :key="viewKey" />
|
||
</router-view>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss">
|
||
html,
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
#app {
|
||
height: 100%;
|
||
}
|
||
/* 页面级滚动容器:main-wrapper 只有 min-height 时内容超一屏会被 body 的
|
||
overflow:hidden 直接裁掉(如发布活动页),无任何滚动条。定高 + 纵向滚动,
|
||
放得下的列表页(表格高度由 Utils.useTableHeight 算好)不受影响 */
|
||
.base-wrap-main .main-wrapper {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
}
|
||
/* 表格操作列(operatingHandle 渲染的 Tooltip 包裹层)横排且随单元格居中 */
|
||
.ivu-table-cell > .ivu-tooltip {
|
||
display: inline-block;
|
||
vertical-align: middle;
|
||
margin: 0 2px;
|
||
}
|
||
/* 表格横向滚动条:列多时必然横向滚动(不要靠合并列收进一屏,会把表头挤成两行),
|
||
把浏览器默认的粗灰条收成 8px 浅灰圆角,避免压住下方的分页条 */
|
||
.ivu-table-overflowX::-webkit-scrollbar {
|
||
height: 8px;
|
||
}
|
||
.ivu-table-overflowX::-webkit-scrollbar-thumb {
|
||
background: #dcdee2;
|
||
border-radius: 4px;
|
||
}
|
||
.ivu-table-overflowX::-webkit-scrollbar-thumb:hover {
|
||
background: #c5c8ce;
|
||
}
|
||
.ivu-table-overflowX::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
</style> |