Files
YschcmsFront/src/components/SchoolLayout.vue
T
chenxing a05b0ded55
build / build (push) Successful in 3m14s
feat(svcitem): 事项库+办事大厅 R2 前端三页,并复用富文本组件消除双份实现
落地(依据《事项库与办事大厅详细设计 v1》§7)
- 路由:办事事项 modId=21 / 服务字典 modId=22 + 子站镜像(/sch/{code}/site/{siteCode}/svc/*)
  + 3 条 legacyRedirect 旧链兼容;SchoolLayout 菜单图标兜底
- ServiceItemList:三维筛选/关键字/分页/发布下架删除/跳转编辑
- ServiceItemEdit:三段富文本 + 封面 + 指南附件上传回显(attList)+「我要办理外链」提示
  + 部门选择器不可达时提示"可留空保存"
- ServiceDictManage:object/category 双 Tab,被引用键编辑锁定 + 删除引用守护提示
- api/类型:SvcItemApi/SvcDictApi 与 types/cms.ts 契约补齐;站点设置补 svc_hall_base 键

审计修复(docs/18)
- D3 接线:列表页新增 ServiceItemSortModal 拖拽排序(顺序源 selList,保存按下标重排 sort_no)
  与「同步部门名」按钮,此前后端 sort/syncDeptName 已备但前端无入口
- 附件场景拆分:封面与三个富文本编辑器改传 scene=svc_item_asset,指南附件保持 svc_item
  (内容资产由后端按内容派生归属,不再依赖前端收集上传 id——删除只写不读的 uploadedIds 记账)
- 部门组树:SvcDeptVo 增 children,前端 flattenDepts 缩进扁平化(后端改嵌套下发,交互不变)
- RichTextEditor 补 articleId/slateMinHeight prop 并去掉死状态 editorLoading;
  ArticleEdit 改为复用该组件(正文区最小高度按原口径传入,观感不变),消除双份 wangEditor 实现

测试与 mock
- mock 补 /api/svc/** 全量桩(事项分页/字典/部门树/排序/同步部门名/附件回显 + 站点 1、2 示例数据),
  dev:mock 下三页可用(实测 curl 各项响应正确)
- cms.spec 补 save/syncDeptName/selList/dict save+delete 契约;siteRouter.spec 补事项三条路由
  modId 与子站镜像 resolve 断言;新增 RichTextEditor.spec 5 例(上传参数三键/成功 emit/失败不 emit/工具栏排除项)
- vitest 127 用例全绿 + vite build 通过
2026-09-11 14:14:02 +08:00

498 lines
20 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">
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
* 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
* 站点分层(docs/13 方案 C):
* URL 为站点上下文唯一事实源——主站 /sch/{code}/{page},子站 /sch/{code}/site/{siteCode}/{page};
* 站点身份条(SiteSwitcher)一步切换并停留当前功能页,上下文由 syncSiteFromRoute 同步;
* 菜单 page 保持主站形态,高亮剥段匹配、跳转按当前站点补段。
* 学校切换仍在壳外完成:/workbench 工作台选校。
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
import { ref, computed, watch, onBeforeMount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import { useCmsStore } from "@/store";
import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath";
import SiteSwitcher from "@/components/SiteSwitcher.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
const router = useRouter();
const store = useCmsStore();
const isPanel = ref(false);
const currPath = ref<string>("");
const navPath = ref<any[]>([]);
const baseHost = computed(() => config.baseHost);
const ssoHost = computed(() => config.ssoHost);
const appName = computed(() => "云海CMS");
const userInfo = computed(() => ({
name: store.session.baseUserName || "管理员",
avatar: store.session.baseUserAvatar,
}));
const isAlone = computed(() => store.session.isAlone);
const modList = computed(() => store.session.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/** 当前学校上下文(面包屑校名段) */
const school = computed(() => store.currentSchool);
const isSuper = computed(() => store.isSuperAdmin);
/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */
const isSubSite = computed(() => store.isSubSite);
const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main"));
/** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。
* 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */
const withSiteSeg = (page: string): string => {
const code = school.value?.mainSiteCode;
if (!page || !code || !page.startsWith("/sch/")) return page;
return adminPathOf(code, store.siteSegCode, schoolSubPath(page));
};
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
tree.forEach((element: any) => {
const { id, name, modId } = element;
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)。
// 解析结果回写原对象:模板高亮(citem.page == currPath)与 linkMenu 消费的是
// 原始 children,旧扁平路由下 raw page 与解析结果恰好相等,学校前缀后必须显式回写
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
if (resolved.path) {
element.page = resolved.path;
}
element.external = resolved.external;
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, subOf } = meta;
const navArr: any[] = [];
// 菜单 page 为主站形态,匹配前剥掉站点中缀(docs/13 §4 剥段匹配)
const toLogic = stripSiteSeg(to.path || "");
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
const expandGroupOf = (page: string) => {
if (!page) return;
modList.value.forEach((m: any) => {
if (m.children?.some((c: any) => c.page === page)) {
m.childState = true;
}
});
};
// 列表子页面(docs/09 §3.2,如新增/编辑文章):面包屑挂到所属列表下、
// 侧边栏高亮锁定列表项,优先级高于本页菜单匹配。
// subOf = 所属列表的业务子路径(/article);菜单解析后带学校前缀,按后缀匹配,modId 兜底
if (subOf) {
const parent =
menuArr.value.find((x: any) => x.page && (x.page === subOf || x.page.endsWith(subOf))) ||
menuArr.value.find((x: any) => x.modId === subOf);
if (parent) {
currPath.value = parent.page || "";
expandGroupOf(currPath.value);
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: withSiteSeg(parent.page) });
navArr.push({ name: title });
navPath.value = navArr;
return;
}
}
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
const find: any =
menuArr.value.find((x: any) => x.page && x.page === toLogic) ||
menuArr.value.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
expandGroupOf(currPath.value);
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: withSiteSeg(find.page) });
navPath.value = navArr;
return;
}
if (modId != null) {
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = stripSiteSeg(r.path);
navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(r.path) }];
return;
}
}
currPath.value = "";
navPath.value = [];
};
const linkMenu = (item: any) => {
const page: string = item?.page || "";
if (!page) {
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
return;
}
// 平台配置的外部链接模块:整页跳转
if (item?.external) {
window.location.href = page;
return;
}
// 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
const target = withSiteSeg(page);
if (target === route.path) {
router.go(0);
} else {
router.push(target);
}
};
const linkUserInfo = () => {
window.location.href = baseHost.value + "/v/userinfo";
};
const logout = () => {
let url = window.location.href;
url = "/logout?service=" + url;
window.location.href = ssoHost.value + url;
};
/** 返回首页:旧 BschHome 应用已下线(线上 /BschHome/front/index.html 返回 404),统一跳用户中心 userhome */
const linkHomePage = () => {
window.location.href = (baseHost.value || "") + "/userhome/front/index.html";
};
const childChange = (index: number) => {
const arr = modList.value || [];
if (arr[index]) {
arr[index].childState = !arr[index].childState;
}
};
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留 modId 对照。图标为 ysch-icons(CSS mask 类,见 styles/icons.css,与 Notice 同一套);
* 友情链接/附件库内无完全对应语义,分别取 cshxd(路牌指引)、sjlm(文档层叠)近似 */
const getMenuIcon = (item?: any): string => {
const page: string = item?.page || "";
if (page === "/article" || page.startsWith("/article")) return "icon-wenzhang";
if (page === "/category") return "icon-liebiao";
if (page === "/banner") return "icon-tupian";
if (page === "/link") return "icon-cshxd";
if (page === "/attachment") return "icon-sjlm";
if (page === "/site") return "icon-zhandian";
// 事项库 R2(§7.5):办事事项取"申请"图标,服务字典取列表图标(前缀分支,优先于 modId 对照)
if (page === "/svc/item" || page.startsWith("/svc/item")) return "icon-shenqing";
if (page === "/svc/dict") return "icon-liebiao";
switch (item?.modId ?? item?.id) {
case 1: return "icon-wenzhang";
case 2: return "icon-liebiao";
case 3: return "icon-tupian";
case 4: return "icon-cshxd";
case 5: return "icon-sjlm";
case 6: return "icon-zhandian";
case 21: return "icon-shenqing";
case 22: return "icon-liebiao";
default: return "";
}
};
/* ===== 学校上下文(docs/09 §3.3)===== */
function goWorkbench() {
router.push("/workbench");
}
/**
* URL 站点段 → store 同步(docs/13 §3:URL 是站点上下文唯一事实源)。
* 无 siteCode = 主站形态;有则按 code 匹配本校站点(parentSiteId 归属校验),
* 非法/跨校 siteCode 打回同页主站形态并告警。
* 启动引导期(user 未装载 → schoolSites 为空)跳过,由 user 就绪后的 watch 兜底重跑。
*/
function syncSiteFromRoute() {
const sc = school.value;
if (!sc || !store.user) return;
const sites = (store.schoolSites || []) as any[];
if (!sites.length) return;
const { site: target, invalid } = resolveRouteSite(sites, sc.mainSiteId, route.params.siteCode as string);
if (invalid) {
Message.warning("站点不存在或无权限,已返回主站");
router.replace({ path: stripSiteSeg(route.path), query: route.query, hash: route.hash });
return;
}
if (target && store.currentSite?.id !== target.id) {
store.setCurrentSite({
id: target.id,
code: target.code,
path: target.path || target.code,
name: target.name,
});
}
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文,再按 URL 同步站点 */
watch(
() => store.user,
u => {
if (!u) return;
const code = route.params.schCode as string;
if (route.path.startsWith("/sch/") && !school.value) {
const sites = u.sites || [];
const main = sites.find((s: any) => s.code === code);
if (!main) {
router.replace("/workbench");
return;
}
store.enterSchool(
{
schId: main.schId,
schName: main.schName,
mainSiteId: main.id,
mainSiteCode: main.code,
mainSiteName: main.name,
},
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
);
}
syncSiteFromRoute();
routeChange(route);
},
{ immediate: true }
);
onBeforeMount(() => {
routeChange(route);
});
watch(
() => route.fullPath,
() => {
syncSiteFromRoute();
routeChange(route);
}
);
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
watch(
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
() => {
syncSiteFromRoute();
routeChange(route);
}
);
</script>
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="首页" class="icon-btn" @click="linkHomePage">
<i class="iconfont icon-shouye"></i>
</div>
</div>
<div class="delimiter fl align-middle"></div>
<!-- 返回学校列表:仅超管显示(校级账号固定本校,无需返回入口) -->
<a
v-if="isSuper"
class="fl align-middle back-workbench"
href="javascript:void(0);"
title="返回学校列表"
@click="goWorkbench"
>
<Icon type="ios-undo" :size="18" />
</a>
<div v-if="isSuper" class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="header-aside fr">
<img :src="userInfo.avatar" v-if="userInfo.avatar" 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 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" :class="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" :class="getMenuIcon(item)"></i>
{{ item.name }}
</a>
</li>
</template>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
<Breadcrumb separator=">">
<!-- 首页段(常驻第一段):旧 BschHome 已下线,统一跳用户中心 userhome -->
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<span class="crumb-home">
<i class="iconfont icon-category"></i>
首页
</span>
</BreadcrumbItem>
</a>
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
<!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
<BreadcrumbItem v-if="school">
<Icon type="ios-school" />
{{ school.schName || school.mainSiteName }}
</BreadcrumbItem>
<!-- 站点身份条(docs/13 §1):徽章+站点名▼,下拉一步切换;不传 to(否则 mixins-link 抢走点击) -->
<BreadcrumbItem v-if="store.currentSite">
<SiteSwitcher />
</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 []" :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 class="base-wrap-main" :class="{ 'alone-page': isAlone }">
<div class="main-wrapper">
<router-view v-if="store.currentSite" />
<div v-else class="no-site-tip">当前学校暂无可管理站点,请联系平台管理员在学校工作台添加。</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/* 超管返回学校列表图标:header 常驻入口(校级账号不渲染) */
.back-workbench {
color: #fff;
display: inline-flex;
align-items: center;
&:hover {
opacity: 0.85;
}
}
.no-site-tip {
height: 60vh;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
/* 站点色带(docs/13 §1.4):::after 挂底边——.path-nav 高度 56px 固定且未设 border-box,
直接加 border-bottom 会改盒高(W2 高度链教训),绝对定位不参与布局 */
.path-nav::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
}
.path-nav.band-main::after {
background: #577afb;
}
.path-nav.band-sub::after {
background: #ff9900;
}
/* W2(D6):左树高度链修复——UserBase App.vue:222-224 同款覆盖式。
layout.css 全局 .main-wrapper 只有 min-height:100% 无 height,
子级 .tree-wrapper{height:100%} 因父链高度 auto 解析失败,栏目树缩在一起。
scoped 生效前提:.base-wrap-main/.main-wrapper 是本组件模板内节点 */
.base-wrap-main .main-wrapper {
height: 100%;
}
/* 高度链顶层:layout.css 只定义到 .base-wrap-main{height:calc(100% - 56px)},
其百分比基于 .layout-wrap——本组件根节点,无高度定义则整条链解析失败
(UserBase 同名壳在 layout.vue 全局样式中补了这一层,此前 CMS 缺失) */
.layout-wrap {
height: 100%;
}
/* 左侧菜单图标与文字间距(ysch-icons mask 类,写法与 Notice layout.vue 一致) */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内)。
scoped 特异性 .crumb-home .iconfont[data-v-x](0,2,1)> .path-nav .icon-category(0,2,0),可覆盖 */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
.crumb-home .iconfont {
position: static;
top: 0;
}
</style>