357 lines
15 KiB
Vue
357 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onBeforeMount, watch, computed, inject, onMounted, onBeforeUnmount } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { Message } from "view-ui-plus";
|
||
import { normalizeModList, flattenMenuNodes } from "@/utils/menuRoute";
|
||
|
||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
/** 由 App.vue 提供的「软重挂载内容区」信号(替代 router.go(0) 的整页 reload) */
|
||
const refreshView = inject<(() => void) | null>("refreshView", null);
|
||
|
||
const isPanel = ref(false);
|
||
const currPath = ref<string>("");
|
||
const navPath = ref<any[]>([]);
|
||
|
||
const baseHost = computed(() => props.layoutData?.baseHost || "");
|
||
const ssoHost = computed(() => props.layoutData?.ssoHost || "");
|
||
const appName = computed(() => props.layoutData?.appName || "家校活动");
|
||
const appList = computed(() => props.layoutData?.appList || []);
|
||
const userInfo = computed(() => props.layoutData?.userInfo || { name: "管理员", avatar: "" });
|
||
/* 头像:平台键 yh-avatar(yh-theme-init 外观补齐段写入)优先,本仓 userInfo 兜底;
|
||
监听平台事件重读,不轮询、不手写清理(清键归 init,规范 §3) */
|
||
const yhAvatar = ref("");
|
||
const readYhAvatar = () => {
|
||
let v: string | null = null;
|
||
try {
|
||
v = localStorage.getItem("yh-avatar");
|
||
} catch {
|
||
v = null;
|
||
}
|
||
yhAvatar.value = v || "";
|
||
};
|
||
onMounted(() => {
|
||
readYhAvatar();
|
||
document.addEventListener("yh-appearance-updated", readYhAvatar);
|
||
});
|
||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readYhAvatar));
|
||
const avatarUrl = computed(() => yhAvatar.value || userInfo.value?.avatar || "");
|
||
|
||
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
|
||
const modList = computed(() => props.layoutData?.modList || []);
|
||
|
||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||
|
||
onBeforeMount(() => {
|
||
routeChange(route);
|
||
});
|
||
|
||
watch(
|
||
() => route.fullPath,
|
||
() => routeChange(route)
|
||
);
|
||
|
||
const flaten = (tree: any[], pid = 0): any[] => flattenMenuNodes(tree, pid);
|
||
|
||
/* 侧边栏渲染用的菜单树:page 已由 normalizeModList 统一归一为内部路由。
|
||
跳转(linkMenu)与高亮(currPath 对照)必须共用这一份,否则平台里只配了
|
||
名称的模块(如「活动广场」)点不动、且当前项高亮对不上。
|
||
用 ref + watch 承载而非 computed:菜单项需要可变的 childState(展开/收起),
|
||
computed 里 { ...m } 生成的是普通对象,直接改不会触发重渲染。 */
|
||
const displayModList = ref<any[]>([]);
|
||
watch(
|
||
modList,
|
||
(list) => {
|
||
displayModList.value = normalizeModList(list);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
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;
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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 || lastMenuPage.value;
|
||
if (parent.page) lastMenuPage.value = parent.page;
|
||
navPath.value = [
|
||
...ancestorsOf(parent),
|
||
{ name: parent.name, page: parent.page },
|
||
{ name: title },
|
||
];
|
||
return;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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) {
|
||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||
return;
|
||
}
|
||
// 平台配置的外部链接模块:整页跳转
|
||
if (item?.external) {
|
||
window.location.href = page;
|
||
return;
|
||
}
|
||
if (page === route.path) {
|
||
// 已在目标页:软重挂载内容区即可。原来用 router.go(0) 会整页 reload
|
||
// (重新拉身份/菜单、白屏一下),且「点一次没反应」的观感更重
|
||
if (refreshView) refreshView();
|
||
else router.go(0);
|
||
} else {
|
||
router.push(page);
|
||
}
|
||
};
|
||
|
||
const linkUserInfo = () => {
|
||
window.location.href = baseHost.value + "/v/userinfo";
|
||
};
|
||
const logout = () => {
|
||
// 必须带 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 || [];
|
||
if (arr[index]) {
|
||
arr[index].childState = !arr[index].childState;
|
||
}
|
||
};
|
||
|
||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
|
||
const getMenuIcon = (item?: any): string => {
|
||
const page: string = item?.page || "";
|
||
if (page === "/list/new") return "icon-xiugaijl";
|
||
if (page === "/list") return "icon-fasong";
|
||
if (page === "/signups") return "icon-liebiao";
|
||
if (page === "/activitylist") return "icon-liebiao";
|
||
if (page === "/stats") return "icon-tongji";
|
||
if (page === "/setting") return "icon-shezhi";
|
||
if (page === "/par/activities") return "icon-rili";
|
||
if (page === "/par/mine") return "icon-chengji";
|
||
switch (item?.modId ?? item?.id) {
|
||
case 2: return "icon-fasong";
|
||
case 3: return "icon-liebiao";
|
||
case 4: return "icon-tongji";
|
||
case 21: return "icon-shezhi";
|
||
case 22: return "icon-xiugaijl";
|
||
case 25: return "icon-liebiao";
|
||
case 31: return "icon-rili";
|
||
case 32: return "icon-chengji";
|
||
default: return "";
|
||
}
|
||
};
|
||
|
||
function linkHomePage() {
|
||
if (baseHost.value) {
|
||
window.location.href = baseHost.value + "/BschHome/front/index.html";
|
||
} else {
|
||
window.location.href = "/";
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="layout-wrap">
|
||
<header class="header-container fix">
|
||
<div class="fl align-middle icon-box">
|
||
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
|
||
<i class="iconfont icon-apps"></i>
|
||
</div>
|
||
</div>
|
||
<div class="delimiter fl align-middle"></div>
|
||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||
<div class="school-bg"></div>
|
||
<div class="header-aside fr">
|
||
<img :src="avatarUrl" class="fl align-middle" @click="linkUserInfo" />
|
||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||
{{ userInfo.name }}
|
||
</a>
|
||
<div class="delimiter fl align-middle"></div>
|
||
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
||
<i class="iconfont icon-logout"></i>
|
||
</a>
|
||
</div>
|
||
</header>
|
||
<div class="left-menu" v-if="!isAlone">
|
||
<div class="menu-top-box"></div>
|
||
<ul>
|
||
<template v-for="(item, index) in displayModList" :key="'menu_li_' + index">
|
||
<template v-if="item.children && item.children.length > 0">
|
||
<li>
|
||
<a href="javascript:;" @click="childChange(index)">
|
||
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||
{{ item.name }}
|
||
</a>
|
||
</li>
|
||
<transition name="fade_child_ul">
|
||
<div class="child-item-wrap" v-show="item.childState">
|
||
<li
|
||
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
|
||
v-for="citem in item.children"
|
||
:key="citem.id"
|
||
>
|
||
<a href="javascript:;" @click="linkMenu(citem)">
|
||
{{ citem.name }}
|
||
</a>
|
||
</li>
|
||
</div>
|
||
</transition>
|
||
</template>
|
||
<li v-else :class="item.page == currPath ? 'active' : ''">
|
||
<a href="javascript:;" @click="linkMenu(item)">
|
||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||
{{ item.name }}
|
||
</a>
|
||
</li>
|
||
</template>
|
||
</ul>
|
||
<div class="clear-box"></div>
|
||
</div>
|
||
|
||
<div class="path-nav" :style="pathNavStyle">
|
||
<Breadcrumb separator=">">
|
||
<a href="javascript:;" @click="linkHomePage">
|
||
<BreadcrumbItem>
|
||
<i class="iconfont icon-category"></i>
|
||
首页
|
||
</BreadcrumbItem>
|
||
</a>
|
||
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
|
||
<template v-for="(item, index) in navPath" :key="index">
|
||
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||
{{ item.name }}
|
||
</BreadcrumbItem>
|
||
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
|
||
</template>
|
||
</Breadcrumb>
|
||
</div>
|
||
|
||
<transition name="fade_apps">
|
||
<div class="app-panel" v-if="isPanel">
|
||
<div class="tips-box"></div>
|
||
<ul class="fix">
|
||
<li class="fl" v-for="(item, index) in appList" :key="index">
|
||
<a :href="item.url" target="_blank" rel="noopener noreferrer">
|
||
<div class="icon-box">
|
||
<img :src="item.icon" alt="" />
|
||
</div>
|
||
<p class="text-ellipsis">{{ item.name }}</p>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
<div class="link-panel">
|
||
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||
全部应用
|
||
<i>→</i>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
|
||
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
|
||
.left-menu a .iconfont {
|
||
margin-right: 8px;
|
||
}
|
||
</style> |