- .logo-badge img 改动态 src:localStorage["yh-logo-url"](yh-theme-init 异步补齐段写入)优先,本仓 yunhai-logo.svg 兜底,@error 回落兜底——绝无空 src/裂图 - mounted 读一次 + 监听 yh-appearance-updated 即时切换(无需刷新)
309 lines
13 KiB
Vue
309 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { Message } from "view-ui-plus";
|
||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
||
import UserAvatar from "@/components/UserAvatar.vue";
|
||
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||
|
||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
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: "" });
|
||
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
|
||
const modList = computed(() => props.layoutData?.modList || []);
|
||
|
||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||
|
||
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
|
||
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
|
||
const logoUrl = ref<string>(defaultLogo);
|
||
const readLogoUrl = () => {
|
||
let v: string | null = null;
|
||
try {
|
||
v = localStorage.getItem("yh-logo-url");
|
||
} catch {
|
||
v = null;
|
||
}
|
||
logoUrl.value = v || defaultLogo;
|
||
};
|
||
const onLogoError = () => {
|
||
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||
};
|
||
onMounted(() => {
|
||
readLogoUrl();
|
||
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||
});
|
||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||
|
||
onBeforeMount(() => {
|
||
routeChange(route);
|
||
});
|
||
|
||
watch(
|
||
() => route.fullPath,
|
||
() => routeChange(route)
|
||
);
|
||
|
||
const flaten = (tree: any[], pid = 0): any[] => {
|
||
const arr: any[] = [];
|
||
tree.forEach((element: any) => {
|
||
const { id, name, modId } = element;
|
||
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)
|
||
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
|
||
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
|
||
if (element.children && element.children.length) {
|
||
arr.push(...flaten(element.children, id));
|
||
}
|
||
});
|
||
return arr;
|
||
};
|
||
|
||
const menuArr = computed(() => flaten(modList.value));
|
||
|
||
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;
|
||
return;
|
||
}
|
||
|
||
if (parentId) {
|
||
const parent = menuArr.value.find((x: any) => x.modId === parentId);
|
||
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;
|
||
return;
|
||
}
|
||
}
|
||
|
||
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||
if (r) {
|
||
currPath.value = r.path;
|
||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||
return;
|
||
}
|
||
|
||
currPath.value = "";
|
||
navPath.value = [];
|
||
};
|
||
|
||
/** 统一解析菜单项目标:容忍平台配置的尾空格/旧 /notice 前缀/外链,返回内部路由 path 或外链 URL。
|
||
* 模板高亮与点击都走这里,避免「raw page(带尾空格)vs 解析后 path」不一致导致点不动/不高亮。 */
|
||
const resolveItemPage = (item: any) =>
|
||
resolveMenuPage(item?.page, item?.modId ?? item?.id, item?.name);
|
||
const pageOf = (item: any): string => resolveItemPage(item).path;
|
||
|
||
const linkMenu = (item: any) => {
|
||
const { path: page, external } = resolveItemPage(item);
|
||
if (!page) {
|
||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||
return;
|
||
}
|
||
// 平台配置的外部链接模块:整页跳转
|
||
if (external) {
|
||
window.location.href = page;
|
||
return;
|
||
}
|
||
if (page === route.path) {
|
||
router.go(0);
|
||
} else {
|
||
router.push(page);
|
||
}
|
||
};
|
||
|
||
const linkUserInfo = () => {
|
||
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
|
||
window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile";
|
||
};
|
||
const logout = () => {
|
||
let url = window.location.href;
|
||
url = "/logout?service=" + url;
|
||
window.location.href = ssoHost.value + url;
|
||
};
|
||
const childChange = (index: number) => {
|
||
const arr = modList.value || [];
|
||
if (arr[index]) {
|
||
arr[index].childState = !arr[index].childState;
|
||
}
|
||
};
|
||
|
||
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
|
||
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)现由中央静态通道下发;
|
||
* 平台约定留空不下发图标,此时返回空串由模板 v-if 不渲染 */
|
||
const getMenuIcon = (item?: any): string => {
|
||
const icon = String(item?.icon || "").trim();
|
||
return icon ? `icon-${icon}` : "";
|
||
};
|
||
|
||
function linkHomePage() {
|
||
if (baseHost.value) {
|
||
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
|
||
window.location.href = baseHost.value + "/userhome/front/index.html";
|
||
} else {
|
||
window.location.href = "/";
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="layout-wrap">
|
||
<header class="header-container fix">
|
||
<!-- 云海 logo 徽标(结构与 Cms SchoolLayout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||
<div class="delimiter fl align-middle"></div>
|
||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||
<div class="school-bg"></div>
|
||
<div class="header-aside fr">
|
||
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @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>
|
||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
||
</header>
|
||
<div class="left-menu" v-if="!isAlone">
|
||
<div class="menu-top-box"></div>
|
||
<ul>
|
||
<template v-for="(item, index) in modList" :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="pageOf(citem) == 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="pageOf(item) == 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>
|
||
<span class="crumb-home">
|
||
<i class="iconfont icon-shouye"></i>
|
||
首页
|
||
</span>
|
||
</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 + '/userhome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||
全部应用
|
||
<i>→</i>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
|
||
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
|
||
.left-menu a .iconfont {
|
||
margin-right: 8px;
|
||
}
|
||
/* 顶栏已由通道皮肤改为 flex 垂直居中(skin.src.css .header-container),
|
||
这里不再用 float 时代的 13px 顶偏移补偿,仅留右侧间距;徽标大小/圆底/阴影属皮肤 */
|
||
.header-container .logo-badge {
|
||
margin: 0 10px 0 0;
|
||
}
|
||
.theme-cap {
|
||
height: var(--topbar-h);
|
||
margin-right: 6px;
|
||
}
|
||
/* 面包屑「首页」垂直居中:骨架遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||
这里用 inline-flex 真居中并关掉该补偿(不动通道产物,覆盖收在本组件内) */
|
||
.crumb-home {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
.crumb-home .iconfont {
|
||
position: static;
|
||
top: 0;
|
||
}
|
||
</style> |