Files
familyactivity-front/src/App.vue
T
lijing a9ba1281c2
build / build (push) Successful in 5m3s
fix: 修复退出跳错域、二级页高亮与落地页权限口径
- 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 共用口径
2026-09-21 10:48:13 +08:00

247 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>