From 5d048abdae78db977ff490b82cc55cf88fd95075 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 22 Sep 2026 21:54:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(site-layer):=20S8=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8D=A1=20+=20=E5=AD=90=E7=AB=99?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E6=9F=93=E8=89=B2=EF=BC=88=E6=96=B9=E6=A1=88?= =?UTF-8?q?=20A=EF=BC=8C=E9=9B=B6=E5=90=8E=E7=AB=AF=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 components/SiteContextCard.vue(194):数据源 store.schoolSites(含 SiteVo.theme), 展示学校›站点层级与当前站皮肤包名;切站沿用既有纯 URL 通道 adminPathOf,不新增状态机 - SchoolLayout.vue 挂载卡片到原空 .menu-top-box 槽 + 子站态身份染色(侧栏淡底 #fff7ec、 卡区顶 3px 橙条、页头胶囊转橙);主站态不染色 - 硬约束:未动 utils/sitePath.ts 的 stripSiteSeg/withSiteSeg(剥段高亮全靠它)、未动 router/routes.ts - siteLayer.spec.ts +4 例(纯加,SITES 夹具补 theme 字段);护栏 siteRouter.spec(8)/sitePath.spec(14)/ menuRoute.spec(5) 原样绿 - 遗留立案:R-39 isAlone 档不可见 / R-40 门户地址未展示 / R-41 面包屑是否退役 SiteSwitcher 待裁决 / R-42 显示包 code 而非中文名 - 前端 164/164 绿 + build:base exit=0 --- src/__tests__/siteLayer.spec.ts | 81 +++++++++++- src/components/SchoolLayout.vue | 63 +++++++++- src/components/SiteContextCard.vue | 194 +++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 src/components/SiteContextCard.vue diff --git a/src/__tests__/siteLayer.spec.ts b/src/__tests__/siteLayer.spec.ts index 31b2cbe..dafcfa9 100644 --- a/src/__tests__/siteLayer.spec.ts +++ b/src/__tests__/siteLayer.spec.ts @@ -30,9 +30,10 @@ vi.mock("vue-router", () => ({ import SchoolLayout from "@/components/SchoolLayout.vue"; import { useCmsStore } from "@/store"; +/** 站点卡皮肤包名取 user.sites[].theme(后端 AuthUserVo.sites = List,字段已具备,零后端改动) */ const SITES = [ - { id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, schId: 1001, schName: "云海大学" }, - { id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, schId: 1001, schName: "云海大学" }, + { id: 1, code: "demo", name: "云海大学门户", path: "demo", parentSiteId: 0, theme: "scholar-blue", schId: 1001, schName: "云海大学" }, + { id: 2, code: "jdxy", name: "机电学院", path: "demo/jdxy", parentSiteId: 1, theme: "campus-glow", schId: 1001, schName: "云海大学" }, { id: 3, code: "wtxy", name: "文学院", path: "demo/wtxy", parentSiteId: 1, schId: 1001, schName: "云海大学" }, ]; @@ -296,3 +297,79 @@ describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => { expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/site"); }); }); + +/* ===== v2 §C.3 方案 A(P-C1 / T-C-01~03):站点身份常驻侧栏站点卡 + 壳层染色 + 标题带站点 ===== + 口径:站点卡承载切换与身份,功能菜单仍只有一份(stripSiteSeg 剥段高亮是方案 A 的前置设计,不改)。 */ +describe("P-C1 站点卡:身份常驻侧栏(v2 §C.3 方案 A)", () => { + it("T-C-01 主站态渲染站点卡:站名 + 层级(学校 › 主站)+ 皮肤包名 + 展开的站点列表(当前站高亮)", async () => { + setRoute("/sch/demo/article", { schCode: "demo" }); + const el = mountLayout(); + await flush(); + + // 站点卡挂在侧栏原空位 .menu-top-box 内(零布局改造落点,v2 §C.2) + const card = el.querySelector(".left-menu .menu-top-box .site-context-card"); + expect(card).toBeTruthy(); + expect(card!.querySelector(".scc-name")!.textContent).toContain("云海大学门户"); + expect(card!.querySelector(".scc-tier")!.textContent).toContain("云海大学"); + expect(card!.querySelector(".scc-tier")!.textContent).toContain("学校主站"); + // 皮肤可见(§C.3 #4):数据源 user.sites[].theme(SiteVo 已带,零后端改动) + expect(card!.querySelector(".scc-theme")!.textContent).toContain("scholar-blue"); + // 站点列表展开常驻(非下拉):主站 1 + 院系 2 = 3 行,当前站唯一高亮 + const rows = el.querySelectorAll(".scc-site"); + expect(rows).toHaveLength(3); + expect(el.querySelectorAll(".scc-site.active")).toHaveLength(1); + expect(rows[0].classList.contains("active")).toBe(true); + expect(rows[1].textContent).toContain("机电学院"); + }); + + it("T-C-01b 站点卡点行即切换:只改 URL、停留当前功能页(复用 SiteSwitcher 的 adminPathOf 补段语义)", async () => { + setRoute("/sch/demo/article", { schCode: "demo" }); + const el = mountLayout(); + await flush(); + + const jdxy = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("机电学院"))!; + jdxy.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(box.push).toHaveBeenCalledWith("/sch/demo/site/jdxy/article"); + + // 点当前站行幂等:目标 === 当前路径,不产生 push + box.push.mockClear(); + const main = [...el.querySelectorAll(".scc-site")].find(r => r.textContent?.includes("云海大学门户"))!; + main.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); + expect(box.push).not.toHaveBeenCalled(); + }); + + it("T-C-02 身份染色:子站态壳层挂 band-sub(卡片 sub 态),主站态挂 band-main 且不染色", async () => { + setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" }); + const sub = mountLayout(); + await flush(); + expect(sub.querySelector(".left-menu")!.className).toContain("band-sub"); + expect(sub.querySelector(".site-context-card")!.classList.contains("sub")).toBe(true); + // 皮肤随站而变(§C.3 #4):子站独立皮肤 = 最直观的"两站不同"信号 + expect(sub.querySelector(".scc-theme")!.textContent).toContain("campus-glow"); + // 皮肤缺省也要有身份:色点常驻 + expect(sub.querySelector(".scc-dot")).toBeTruthy(); + + setRoute("/sch/demo/article", { schCode: "demo" }); + const main = mountLayout(); + await flush(); + expect(main.querySelector(".left-menu")!.className).toContain("band-main"); + expect(main.querySelector(".site-context-card")!.classList.contains("sub")).toBe(false); + }); + + it("T-C-03 标题带站点:页头标题与 document.title 均为「页面名 · 站点名」,切站后跟随", async () => { + setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" }); + const el = mountLayout(); + await flush(); + expect(document.title).toBe("文章列表 · 机电学院"); + expect(el.querySelector(".header-container h4")!.textContent).toContain("· 机电学院"); + + // 同一壳内切到主站形态:标题与 document.title 同步换站名 + box.route.path = "/sch/demo/article"; + box.route.fullPath = "/sch/demo/article"; + box.route.params = { schCode: "demo" }; + await flush(); + expect(document.title).toBe("文章列表 · 云海大学门户"); + }); +}); diff --git a/src/components/SchoolLayout.vue b/src/components/SchoolLayout.vue index 5195ae7..9715937 100644 --- a/src/components/SchoolLayout.vue +++ b/src/components/SchoolLayout.vue @@ -15,6 +15,7 @@ import { resolveMenuPage } from "@/utils/menuRoute"; import { useCmsStore } from "@/store"; import { adminPathOf, stripSiteSeg, schoolSubPath, resolveRouteSite } from "@/utils/sitePath"; import SiteSwitcher from "@/components/SiteSwitcher.vue"; +import SiteContextCard from "@/components/SiteContextCard.vue"; import ThemeSwitcher from "@/components/ThemeSwitcher.vue"; import config from "@/assets/config/host.config"; @@ -42,9 +43,20 @@ const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" const school = computed(() => store.currentSchool); const isSuper = computed(() => store.isSuperAdmin); -/** 当前落点是否院系子站(store 契约):决定身份条徽章与底边色带 */ +/** 当前落点是否院系子站(store 契约):决定身份条徽章、侧栏/面包屑身份色带与页头站点名(v2 §C.3 #2/#3) */ const isSubSite = computed(() => store.isSubSite); -const pathNavClass = computed(() => (isSubSite.value ? "band-sub" : "band-main")); +/** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */ +const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main")); + +/* 标题带站点(§C.3 #3):页头标题与 document.title 都写成「页面名 · 站点名」,多标签页也能分辨 */ +const siteName = computed(() => store.currentSite?.name || ""); +const shellTitle = computed(() => (siteName.value ? `${appName.value} · ${siteName.value}` : appName.value)); +const docTitle = computed(() => { + const t = String((route.meta as any)?.title || ""); + if (!siteName.value) return t; + return t ? `${t} · ${siteName.value}` : siteName.value; +}); +watch(docTitle, v => { if (v) document.title = v; }, { immediate: true }); /** 菜单/面包屑 page(主站形态完整路径)→ 当前站点形态:子站插入 /site/{code} 段(docs/13 §4)。 * 非 /sch/ 前缀(外链模块)原样返回,防拼坏 */ @@ -315,7 +327,9 @@ watch(
-

{{ appName }}

+

+ {{ appName }} · {{ siteName }} +