feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
build / build (push) Successful in 2m43s

This commit is contained in:
chenxing
2026-09-10 11:11:23 +08:00
parent 9ba4cb135a
commit 6ee9ab5684
26 changed files with 1931 additions and 222 deletions
+101 -25
View File
@@ -1,14 +1,20 @@
<script setup lang="ts">
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
* header 左侧仅超管可见「↩返回学校列表」图标(校级账号不显示);
* 面包屑首段=校名(落点为院系子站时后跟当前站点名),超管在校名前多一段可点「学校列表」(与返回图标同效)。
* 学校/站点切换一律离壳完成:学校在 /workbench,站点在站点管理行「进入管理」。
* 面包屑 = 学校列表(超管)> 校名 > 站点身份条 > 页面。
* 站点分层(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();
@@ -31,18 +37,21 @@ 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);
/** 面包屑校名后缀:操作落点非主站时跟随展示当前站点名(院系子站管理可见性,
* 站点切换入口在站点管理行「进入管理」,壳上不再放切换器) */
const currentSiteSuffix = computed(() => {
const s = store.currentSite;
const sc = school.value;
if (!s || !sc || s.id === sc.mainSiteId) return "";
return s.name || "";
});
/** 当前落点是否院系子站(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[] = [];
@@ -70,6 +79,8 @@ 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) => {
@@ -95,7 +106,7 @@ const routeChange = (to: any) => {
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: parent.name, page: withSiteSeg(parent.page) });
navArr.push({ name: title });
navPath.value = navArr;
return;
@@ -105,7 +116,7 @@ const routeChange = (to: 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.page && x.page === toLogic) ||
menuArr.value.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
@@ -114,7 +125,7 @@ const routeChange = (to: any) => {
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 });
navArr.push({ name: find.name || title, page: withSiteSeg(find.page) });
navPath.value = navArr;
return;
}
@@ -122,8 +133,8 @@ const routeChange = (to: any) => {
if (modId != null) {
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 }];
currPath.value = stripSiteSeg(r.path);
navPath.value = [{ name: (r.meta as any).title, page: withSiteSeg(r.path) }];
return;
}
}
@@ -144,10 +155,12 @@ const linkMenu = (item: any) => {
window.location.href = page;
return;
}
if (page === route.path) {
// 子站上下文下菜单目标补站点段(主站形态 → 当前站点形态)
const target = withSiteSeg(page);
if (target === route.path) {
router.go(0);
} else {
router.push(page);
router.push(target);
}
};
@@ -192,7 +205,34 @@ function goWorkbench() {
router.push("/workbench");
}
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
/**
* 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 => {
@@ -216,6 +256,8 @@ watch(
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
);
}
syncSiteFromRoute();
routeChange(route);
},
{ immediate: true }
);
@@ -226,13 +268,19 @@ onBeforeMount(() => {
watch(
() => route.fullPath,
() => routeChange(route)
() => {
syncSiteFromRoute();
routeChange(route);
}
);
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
watch(
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
() => routeChange(route)
() => {
syncSiteFromRoute();
routeChange(route);
}
);
</script>
@@ -305,14 +353,18 @@ watch(
<div class="clear-box"></div>
</div>
<div class="path-nav" :style="pathNavStyle">
<div class="path-nav" :class="pathNavClass" :style="pathNavStyle">
<Breadcrumb separator=">">
<!-- 超管:校名前多一段可点「学校列表」,与 header 返回图标同效(回工作台切换学校) -->
<BreadcrumbItem v-if="isSuper" to="/workbench">学校列表</BreadcrumbItem>
<!-- 面包屑首段 = 学校上下文(常驻);落点为院系子站时跟出当前站点名 -->
<!-- 面包屑第二段 = 学校上下文(常驻,不承载站点信息) -->
<BreadcrumbItem v-if="school">
<Icon type="ios-school" />
{{ school.schName || school.mainSiteName }}<template v-if="currentSiteSuffix"> · {{ currentSiteSuffix }}</template>
{{ 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">
@@ -373,11 +425,35 @@ watch(
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 是本组件模板内节点(:348-349) */
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%;
}
</style>