feat(shell): T5 前端·壳与管理端框架——fork notice/front 基线并剥领域
build / build (push) Successful in 3m10s

- 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)
This commit is contained in:
chenxing
2026-10-14 20:35:41 +08:00
parent cebc32fbef
commit fdd9aaaa3b
81 changed files with 12410 additions and 1473 deletions
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* 页面骨架占位件(T5):只验证「壳 + page-wrapper 骨架」到位,不含领域逻辑。
*
* 骨架类名对齐中央静态通道(规范 §2.2 回收步 / §4.4 卡片外壳):
* .page-wrapper.yh-fill(height:100% + flex 纵向)> .main-container(白卡,margin/padding 各 16)
* 列表页真身应在此之下再挂 .table-region(flex:1 1 auto + min-height:0 + overflow:hidden,
* 零 padding/border,规范 §4.3)并用 useElementHeight 实测喂 <Table :height>——
* 占位页没有表格,故只铺骨架不做实测,避免留下「有 .table-region 却无量高」的假范式。
*/
defineProps<{
/** 页面名(与 UserBase 模块行名称、routes.ts 的 meta.title 同一口径) */
title: string;
/** 本体交付批次(T6/T7/T8) */
stage: string;
/** 本批次要在此页落地什么,写给接手的人看 */
note: string;
}>();
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<h4 class="subtitle">{{ title }}</h4>
<Alert show-icon type="info">
本页为 T5 交付的路由骨架占位,页面本体在 {{ stage }} 落地;当前壳(顶栏/左导航/面包屑)已生效。
</Alert>
<p class="stub-note">{{ note }}</p>
</div>
</div>
</template>
<style scoped>
.stub-note {
margin: 16px 0 0;
color: var(--brand, #577afb);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref, computed, withDefaults } from "vue";
interface Props {
prefix?: string;
name?: string;
color?: string;
hoverColor?: string;
size?: string | number;
}
const props = withDefaults(defineProps<Props>(), {
prefix: "icon",
name: "",
color: "#000",
});
const isHover = ref(false);
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
const svgStyle = computed(() => {
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
else return null;
});
const compColor = computed(() => {
if (!props.hoverColor) return props.color;
else {
return !!isHover.value ? props.hoverColor : props.color;
}
});
</script>
<template>
<svg
@mouseover="isHover = true"
@mouseout="isHover = false"
aria-hidden="true"
:color="compColor"
:style="svgStyle"
>
<use :xlink:href="symbolId" :fill="compColor" :color="compColor" />
</svg>
</template>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
@on-page-size-change="onSizeChange" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, useSlots, watch } from 'vue'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
dataApi: any
columns: any[]
tableHeight?: number
autoLoad?: boolean
dataFilter?: (resp: any) => any[]
formatResponse?: (res: any) => { total: number; list: any[] }
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
formatResponse: (res: any) => ({ total: res.total, list: res.content })
})
const emit = defineEmits<{
(e: 'selection-change', list: number[]): void
}>()
const slots = useSlots()
const regionRef = ref<HTMLElement | null>(null)
const measuredH = useElementHeight(regionRef)
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([])
function selectChange(rows: any[]) {
selectList.value = rows.map((r: any) => r.id)
emit('selection-change', selectList.value)
}
const {
loading,
data: resp,
send: sendLoad,
page,
pageSize,
total,
reload
} = usePagination(
(current: number, size: number) =>
props.dataApi({ pageIndex: current, pageSize: size }),
{
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad,
}
)
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => {
pageSize.value = s
localStorage.setItem('customPageCount', String(s))
}
function refreshData() {
page.value = 1
reload()
}
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
let p = page.value
const s = pageSize.value
if (type === OperatorEnum.add && p * s === total.value) p += 1
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
if (type === OperatorEnum.sel) p = 1
if (p < 1) p = 1
page.value = p
sendLoad()
}
defineExpose({ list, refreshData, reloadData })
</script>
<style scoped></style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<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>