- 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 共用口径
This commit is contained in:
@@ -99,6 +99,11 @@ src/
|
||||
| 必填项「选完/填完值,红字还挂着」 | `FormItem.getFilteredRule(trigger)` 只保留 `!rule.trigger \|\| rule.trigger 命中` 的规则。控件只发它自己那种事件(Select/DatePicker/RadioGroup/Number 发 `change`,Input 发 `change`+`blur`):规则写了 `blur` 就漏掉 `change`,写了 `change` 就漏掉 `blur`,校验根本不执行,上一次的红字原地不动 | 规则**一律不写 `trigger`**(`NewActivity`、`ParentActivities`、`ActivitySetting`、`DictCardManager` 均已去掉)。数字字段必须显式 `type: "number"` |
|
||||
| 同上,但值来自**程序化赋值** | 勾「全部班级」把数组填满、编辑/复用回显、弹窗回填都不产生控件事件,trigger 修法也救不了 | `Utils.clearFilledValidateState(formRef, props)` 按「值已填但仍标 error」兜底清状态;发布页 watch 字段值 + `nextTick` 调用 |
|
||||
| 点左边栏「右边不刷新,点两次才刷」/ 家长端点菜单没反应 | `/list` ↔ `/activitylist`、`/par/activities` ↔ `/par/mine` 各自共用同一组件,Vue 复用实例 → `setup()`/`onMounted` 不重跑;点当前项又走 `router.go(0)`(整页 reload、白屏一下) | `App.vue` 给 `<router-view>` 加 `:key="viewKey"`(`route.fullPath + tick`)并提供 `refreshView`;`layout.vue` 的 `linkMenu` 点当前页时调 `refreshView` 而非 `router.go(0)` |
|
||||
| 从一级节点点进二级页(发布活动 / 活动详情),**左边栏失去高亮** | 二级页(`/list/new`、`/list/detail`…)不在菜单树里,原代码只按 `meta.parentId` 静态兜底,与「你从哪个节点点进来的」无关 | `layout.vue` 用 `lastMenuPage` 记住**上一次命中的菜单节点**:二级页优先拿它当父节点(从「全部活动」进发布活动就选「全部活动」),无来源(冷启动/刷新/直接粘 URL)才退回 `meta.parentId`;末位兜底改成**保持**现有高亮而不再清空。另:分组默认收起时自动展开选中项所在分组(`expandToActive`),否则「选中了但看不见」 |
|
||||
| 二级页被当成「无权页面」弹回第一个节点 | 曾把二级页纳入菜单白名单(要求父模块也得在 `GetUserMod` 里) | **只有一级节点(菜单节点)参与权限判断**:`App.vue` 的 `allowedPaths` = 菜单节点 ∪ 全部 `meta.parentId` 二级页。二级页是「点进去的落地页」,只谈高亮、不谈权限,一律放行 |
|
||||
| **点「报名情况」按钮"不让点"**(被弹回第一个节点 + 一条无权提示) | 「落地页」是靠 `meta.parentId` 声明的,**不是**靠"在不在菜单里"。`/signups` 既能从左侧菜单进、又能从活动列表行操作进,却漏声明了 `parentId` → 被归入「一级入口」判权限;而真实环境账号的 `GetUserMod` 里常常没配「报名情况」节点 → 点按钮直接被守卫拦掉(按钮本身没有 `disabled`,极易误判成交互/样式问题) | 给 `/signups` 补 `meta.parentId: 2`。**规则:凡是页面内 `router.push` 得到的目标路由,都必须在 `routes.ts` 声明 `meta.parentId`**,否则会被当入口拦。自查:`grep -rn "router\.push\|router\.replace" src --include=*.vue \| grep -v router/index` 穷举全部跳转目标(本工程只有 `/list/new`、`/list/detail`、`/activitylist/detail`、`/signups` 四个)。补声明不影响高亮:`layout.vue` 会先命中「目标本身就是菜单节点」分支 |
|
||||
| 教师账号能直接打开 `/par/mine` 等家长端页面 | 曾为解决上一条把权限放宽成「按端放行」(`/par` 前缀整端放行)—— 而教师菜单里有「家长端预览」指向 `/par/activities`,于是 `/par` 整端被放行 | **权限边界落在「入口页 vs 落地页」,不是「哪一端」**:跨端访问(目标页是入口页、且菜单里没有)照旧 `replace` 到第一个可用节点 |
|
||||
| 冷启动 / 刷新任何非首节点路由都被弹回 `/#/list`(深链接、书签、刷新详情页全废) | `main.ts` 是同步 `app.mount()`,`onMounted` 里的初始化有机会**早于** router 首次导航完成,此时 `currentRoute` 还停在 `START_LOCATION`(`path === "/"`),于是每个深链接都被当成「访问应用入口」而重定向 | `initShell()` 首行 `await router.isReady()`;入口地址交给路由表自身的 `/ → /list` 重定向 |
|
||||
| Tabs 高亮不跟随变量/路由 | `Tabs` 的 props 里**没有 `value`**,只有 `modelValue`。`:value` 会被当成普通 HTML 属性 fallthrough 到根元素,高亮永远退回第一个 tab(点击有反应是因为组件内部自己改 `activeKey`,所以只表现为「点进来高亮不对」) | 统一 `v-model` / `:model-value` |
|
||||
| 表格压住分页 | 表格高度用 `Utils.useTableHeight({...})` 常量估算,漏算 Tabs 栏与容器内边距(约 78px),而 `.page-panel` 是 absolute 吸底 | 列表页改用**真实 DOM 实测**:`分页顶边 − 表格顶边 − 12 − (列宽合计超出容器 ? 15 : 0)`;`onMounted` + `resize` 重算 |
|
||||
| 表头挤成两行 | 为了「收进一屏」合并列 / 把列宽压太窄 | 让表格**横向滚动**(列宽给足),操作列 `fixed: "right"`;序号列宽 ≥ 76px(单元格左右各 18px padding,「序号」两字约需 36px)。横向滚动条样式在 `App.vue` 里覆写 `.ivu-table-overflowX::-webkit-scrollbar`(8px 圆角) |
|
||||
|
||||
+92
-15
@@ -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/activity";
|
||||
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,89 @@ function roleOfPath(p: string): "teacher" | "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();
|
||||
@@ -80,28 +162,21 @@ 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") {
|
||||
Message.error("载入用户信息失败,请检查后端服务是否正常!");
|
||||
}
|
||||
// 降级:使用默认值("/" 即我发布的页,无需跳转)
|
||||
// 降级:使用默认值("/" 即我发布的页,无需跳转);modList 为空 → 守卫自动失效
|
||||
layoutData.userInfo = { name: "管理员", avatar: "" };
|
||||
layoutData.modList = [];
|
||||
layoutData.isAlone = true;
|
||||
menuReady.value = true;
|
||||
isLogin.value = true;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +196,9 @@ watch(
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view v-slot="{ Component }">
|
||||
<!-- routeAllowed:当前账号菜单里没有该路由时不渲染内容(守卫已拦,这里兜住
|
||||
「菜单晚于路由到达」的瞬间,避免无权页闪一下再被 replace 掉) -->
|
||||
<router-view v-if="routeAllowed" v-slot="{ Component }">
|
||||
<component :is="Component" :key="viewKey" />
|
||||
</router-view>
|
||||
</div>
|
||||
|
||||
@@ -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,34 @@ if (runtime && typeof runtime === "object" && runtime.relayConf) {
|
||||
applyConf(conf.relayConf, runtime.relayConf);
|
||||
}
|
||||
|
||||
/**
|
||||
* ④ 登出地址兜底:ssoHost 为空时 logout 会退化成同源相对路径
|
||||
* `/logout?service=…`,落到 base 域而不是 SSO 域 —— 会话根本没注销,
|
||||
* 只是从当前页跳回首页,用户看着像"点了退出没反应"。
|
||||
*
|
||||
* 触发条件很常见:projman 侧 env-config 未配 ssoHost 时接口返回 `{}`
|
||||
* (实测 /familyactivity/api/env-config?target=front 就是空对象,而同族
|
||||
* campusnotice / notice 下发了完整的 baseHost+ssoHost+serviceHost)。
|
||||
*
|
||||
* 运维约定 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) {
|
||||
|
||||
+92
-43
@@ -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 { normalizeModList } from "@/utils/menuRoute";
|
||||
import { normalizeModList, flattenMenuNodes } from "@/utils/menuRoute";
|
||||
|
||||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||||
const route = useRoute();
|
||||
@@ -33,17 +33,7 @@ watch(
|
||||
() => routeChange(route)
|
||||
);
|
||||
|
||||
const flaten = (tree: any[], pid = 0): any[] => {
|
||||
const arr: any[] = [];
|
||||
tree.forEach((element: any) => {
|
||||
const { id, name, modId, page, external } = element;
|
||||
arr.push({ id, name, modId, page, external, pid });
|
||||
if (element.children && element.children.length) {
|
||||
arr.push(...flaten(element.children, id));
|
||||
}
|
||||
});
|
||||
return arr;
|
||||
};
|
||||
const flaten = (tree: any[], pid = 0): any[] => flattenMenuNodes(tree, pid);
|
||||
|
||||
/* 侧边栏渲染用的菜单树:page 已由 normalizeModList 统一归一为内部路由。
|
||||
跳转(linkMenu)与高亮(currPath 对照)必须共用这一份,否则平台里只配了
|
||||
@@ -61,53 +51,106 @@ watch(
|
||||
|
||||
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<string>("");
|
||||
|
||||
/** 沿 pid 向上收集祖先(支持多层嵌套),得到面包屑的前缀 */
|
||||
const ancestorsOf = (node: any): any[] => {
|
||||
const arr: any[] = [];
|
||||
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 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);
|
||||
// 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 || "";
|
||||
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;
|
||||
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) {
|
||||
@@ -134,9 +177,15 @@ 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 = displayModList.value || [];
|
||||
|
||||
@@ -53,12 +53,17 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/activity/ActivityDetails.vue"),
|
||||
meta: { title: "活动详情", modId: 24, parentId: 3 },
|
||||
},
|
||||
// 报名情况(某活动的家长回执/报名名单;从列表行或菜单进入,无 serid 时页内选择活动)
|
||||
/* 报名情况(某活动的家长回执/报名名单;从列表行或菜单进入,无 serid 时页内选择活动)
|
||||
★ parentId 是「落地页」的显式声明:从「我发布的」「全部活动」的行操作点进来,
|
||||
不参与菜单权限判定(真实环境账号菜单常常没配「报名情况」这个节点,
|
||||
按入口判会被拦掉 → 用户报"报名情况不让点")。
|
||||
冷启动/刷新停在 /signups 时,父节点优先用进入它的那个菜单节点
|
||||
(layout 的 lastMenuPage),取不到才退回这里的 parentId=2「我发布的」。 */
|
||||
{
|
||||
path: "/signups",
|
||||
name: "activity_signups",
|
||||
component: () => import("@/views/activity/Signups.vue"),
|
||||
meta: { title: "报名情况", modId: 25 },
|
||||
meta: { title: "报名情况", modId: 25, parentId: 2 },
|
||||
},
|
||||
// 活动统计
|
||||
{
|
||||
|
||||
@@ -192,3 +192,48 @@ export function normalizeModList(list?: any[] | null): any[] {
|
||||
};
|
||||
return Array.isArray(list) ? list.map(mapNode) : [];
|
||||
}
|
||||
|
||||
export interface MenuNode {
|
||||
id: any;
|
||||
name?: string;
|
||||
modId: any;
|
||||
/** 已归一为内部路由;解析不出时为空串 */
|
||||
page: string;
|
||||
external?: boolean;
|
||||
/** 父节点 id(顶层为 0) */
|
||||
pid: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摊平归一后的菜单树(顺序与侧边栏渲染一致:带 page 的父节点先于其子节点)。
|
||||
*
|
||||
* 侧边栏高亮、面包屑祖先、路由权限判定三处必须共用同一份摊平口径,
|
||||
* 否则「第一个节点」「当前节点」这类判断会在不同地方得出不同答案。
|
||||
*/
|
||||
export function flattenMenuNodes(list?: any[] | null, pid: any = 0): MenuNode[] {
|
||||
const arr: MenuNode[] = [];
|
||||
(Array.isArray(list) ? list : []).forEach((element: any) => {
|
||||
if (!element) return;
|
||||
const { id, name, modId, page, external } = element;
|
||||
arr.push({ id, name, modId, page, external, pid });
|
||||
if (Array.isArray(element.children) && element.children.length) {
|
||||
arr.push(...flattenMenuNodes(element.children, id));
|
||||
}
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户「第一个可用页面」:登录落地、以及访问无权路由时的兜底目标。
|
||||
*
|
||||
* 规则与 App.initShell 的登录跳转保持一致 —— 取第一个模块,它有子节点时
|
||||
* 落第一个子节点,否则落它自己。两处若不一致,会出现"登录后进 A、被拦后又跳 B"。
|
||||
*/
|
||||
export function firstMenuPath(list?: any[] | null): string {
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
const first = arr.find((m: any) => m && (m.page || (m.children && m.children.length)));
|
||||
if (!first) return "";
|
||||
const target =
|
||||
Array.isArray(first.children) && first.children.length ? first.children[0] : first;
|
||||
return target?.page || "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user