Files
familyactivity-front/src/components/layout.vue
T
2026-10-21 18:06:39 +08:00

357 lines
15 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, 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>