Files
form-center-front/src/components/layout.vue
T
chenxing fdd9aaaa3b
build / build (push) Successful in 3m10s
feat(shell): T5 前端·壳与管理端框架——fork notice/front 基线并剥领域
- A 类主题 loader 原文接入 + env-config 引导 + vite /static 与 /api(8086) 代理(P6/P11)
- GetUserMod 左导航:utils/menuNav.ts 单点实现 path 空锚点跳过、分组收敛、icon 兜底、面包屑基线(对齐 Notice 13afb22 refined 版)
- alova 封装 + api/module/base.ts(getUserMod 可选 appCode,P2 定名;H4 前空树不回落,P3)
- 路由骨架一 SPA 四形态:/admin/{forms,data,stats,dicts}、/f/:code、/app/:appCode/*、/my;self!=top 剥壳(方案 §3.3)
- 规范件落位:tableActions.ts(UserBase 同构源,正文 MD5 不变)、TablePageAlovaNew + useElementHeight、page-wrapper yh-fill 骨架
- view-ui-plus 精确锁 1.3.24;本地零 layout.css/icons.css;avatar 两件由 YschTheme sync 下发
- 剥除:notice 领域 views/api/mock/menu 兜底、alova-vant(mobiapi)、ztree+jquery 与重依赖(按需回补,见 service/docs/T5-report.md 问题单 P52)
2026-10-14 20:35:41 +08:00

267 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 { computed, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import { flattenNav, type NavNode } from "@/utils/menuNav";
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 currPath = ref<string>("");
const navPath = ref<Array<{ name: string; page?: string }>>([]);
/** 分组展开态(按分组行 id 记忆,初始全折叠——与 Notice 现行行为一致) */
const openGroups = ref<Record<number, boolean>>({});
const baseHost = computed(() => props.layoutData?.baseHost || "");
const ssoHost = computed(() => props.layoutData?.ssoHost || "");
const appName = computed(() => props.layoutData?.appName || "表单中心");
const userInfo = computed(() => props.layoutData?.userInfo || { name: "用户", avatar: "" });
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
const navList = computed<NavNode[]>(() => props.layoutData?.navList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/** 扁平化导航项(高亮/面包屑对照表),由 utils/menuNav 的同一棵树导出,跳过规则只有一处实现 */
const flatNav = computed(() => flattenNav(navList.value));
/**
* 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,
* 本仓资产兜底——绝无空 src/裂图。刷新:mounted 读一次 + 监听 yh-appearance-updated(规范 §3)。
*/
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;
};
onBeforeMount(() => routeChange(route));
watch(
() => route.fullPath,
() => routeChange(route)
);
onMounted(() => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
/** 面包屑/高亮:优先按 path 匹配导航项,匹配不到再退回 modId 对照 */
const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title, parentId } = meta;
const navArr: Array<{ name: string; page?: string }> = [];
const items = flatNav.value;
const find: any =
items.find((x: any) => x.page && x.page === to.path) || items.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
const parent = navList.value.find((x) => x.id === find.pid);
if (parent) navArr.push({ name: parent.name });
}
navArr.push({ name: find.name || title, page: find.page });
navPath.value = navArr;
return;
}
if (parentId) {
const parent = items.find((x: any) => x.modId === parentId);
if (parent) {
currPath.value = parent.page || "";
if (parent.pid) {
const gp = navList.value.find((x) => 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 = [];
};
/**
* 菜单图标:读用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)由中央静态通道下发;
* 平台未配置图标时统一兜底 icon-list(规范 §4 / Notice 13afb22:菜单项恒有图标,保整列缩进/行高一致)
*/
const getMenuIcon = (item?: any): string => `icon-${String(item?.icon || "").trim() || "list"}`;
/** 统一解析菜单项目标:模板高亮与点击都走这里,避免 raw page 与解析后 path 不一致 */
const resolveItemPage = (item: NavNode) => resolveMenuPage(item.raw, item.modId, item.name);
const pageOf = (item: NavNode): string => resolveItemPage(item).path;
const linkMenu = (item: NavNode) => {
const { path, external } = resolveItemPage(item);
if (!path) {
// 解析不出有效页面:显式提示,而不是静默不动或被兜底路由吞到入口页
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
return;
}
if (external) {
window.location.href = path;
return;
}
if (path === route.path) router.go(0);
else router.push(path);
};
const childChange = (item: NavNode) => {
openGroups.value = { ...openGroups.value, [item.id]: !openGroups.value[item.id] };
};
const linkUserInfo = () => {
// 头像/姓名统一跳 UserHome 设置页「个人信息」pane(Notice 口径,旧 /v/userinfo 已下线)
window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile";
};
const logout = () => {
const url = "/logout?service=" + window.location.href;
window.location.href = ssoHost.value + url;
};
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 徽标(结构与通道 base.layout.css .logo-badge 对齐;子应用不展示 LOGO 文字,规范 §3 红线) -->
<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>
<!-- 配色切换器不建(规范 §3 红线):主题设置入口已收敛 UserHome,本地切换会被服务端生效值打回 -->
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<ul>
<template v-for="item in navList" :key="'menu_li_' + item.id">
<template v-if="item.group">
<li>
<a href="javascript:;" @click="childChange(item)">
<i :class="openGroups[item.id] ? 'arrow-down' : 'arrow-up'"></i>
<i :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
<transition name="fade_child_ul">
<div class="child-item-wrap" v-show="openGroups[item.id]">
<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 :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>
</div>
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余由中央静态通道的 base.layout.css(骨架 + 皮肤)提供。
本地零 layout.css/icons.css(规范 §2.2 回收步,V22) */
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 顶栏已由通道皮肤改为 flex 垂直居中,这里不再用 float 时代的 13px 顶偏移补偿,
仅留右侧间距;徽标大小/圆底/阴影属皮肤 */
.header-container .logo-badge {
margin: 0 10px 0 0;
}
/* 面包屑「首页」与后续级目的文字对齐(Notice 13afb22 refined 版,规范 §4):
此前用 inline-flex,行内级盒的基线取首项——16px 无字图标(基线=方块底缘)把整盒顶高约 4px,
首页文字比相邻级目高。改回普通行内流:图标以 -0.125em 基线偏移做光学居中,
与相邻级目共用同一行盒基线,天然对齐 */
.crumb-home .iconfont {
position: static;
top: 0;
margin-right: 4px;
vertical-align: -0.125em;
}
</style>