From cff43259479d167bb29c69513c7d7995fa3468da Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 18:32:13 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-VISUAL]=20=E7=AB=99=E7=82=B9=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=B9=E7=89=88:=20=E8=A1=A8=E6=A0=BC=E2=86=92?= =?UTF-8?q?=E7=AB=99=E7=82=B9=E5=B7=A5=E4=BD=9C=E5=8F=B0=EF=BC=88=E4=B8=BB?= =?UTF-8?q?=E7=AB=99=E5=AE=BD=E5=8D=A1+=E9=99=A2=E7=B3=BB=E5=AD=90?= =?UTF-8?q?=E7=AB=99=E5=8D=A1=E7=89=87=E7=BD=91=E6=A0=BC=EF=BC=89+=20?= =?UTF-8?q?=E9=A1=B5=E5=A4=B4=E6=89=BF=E8=BD=BD=E7=AB=99=E7=82=B9=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20+=20v3.1=20=E8=A7=86=E8=A7=89=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=EF=BC=88182=20=E6=B5=8B=E8=AF=95=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SiteManage.vue: 8 列表格改为站点工作台,按 schId 分组「主站宽卡 + 院系子站网格」, 保留卡片⇄表格视图切换与搜索过滤;scope=all 全局视图支持多校分组,搜索时主站卡始终保留(层级锚点) - 新增 views/cms/components/SiteCard.vue、SiteMainCard.vue - 删除 components/SiteContextCard.vue: 站点身份改由页头「学校名.站点名」承担(SchoolLayout), 侧栏不再铺第二份站点列表,切站入口从 3 处收敛到 2 处(面包屑 + 站点工作台) - v3.1 视觉修订: 色带语义统一为「当前站」(全页同时只有一张卡带选中态,不再一页两处蓝); 删除面包屑 band-sub 橙色接缝条(身份已收回页头);返回按钮 ios-undo 18×18 → ios-arrow-back 26×26 可点方块; 删除 SiteManage .zone-tip 设计说明文字(误当 UI 渲染) - 测试: siteLayer.spec.ts 站点卡 4 用例重写为页头反向/正向断言,siteManage.spec.ts 补卡片视图断言;182 用例绿 - 方案: docs/22-站点管理修订方案.md(含 §9 v3.1 视觉修订与像素对照表) --- src/__tests__/siteLayer.spec.ts | 74 ++---- src/__tests__/siteManage.spec.ts | 72 +++++- src/assets/styles/yh-theme.css | 5 +- src/components/SchoolLayout.vue | 90 ++++--- src/components/SiteContextCard.vue | 194 --------------- src/views/cms/SiteManage.vue | 279 +++++++++++++++++++--- src/views/cms/components/SiteCard.vue | 261 ++++++++++++++++++++ src/views/cms/components/SiteMainCard.vue | 236 ++++++++++++++++++ 8 files changed, 902 insertions(+), 309 deletions(-) delete mode 100644 src/components/SiteContextCard.vue create mode 100644 src/views/cms/components/SiteCard.vue create mode 100644 src/views/cms/components/SiteMainCard.vue diff --git a/src/__tests__/siteLayer.spec.ts b/src/__tests__/siteLayer.spec.ts index dafcfa9..252af1c 100644 --- a/src/__tests__/siteLayer.spec.ts +++ b/src/__tests__/siteLayer.spec.ts @@ -298,78 +298,56 @@ describe("docs/13 身份条次级入口与幂等切换(docs/15 T5)", () => { }); }); -/* ===== v2 §C.3 方案 A(P-C1 / T-C-01~03):站点身份常驻侧栏站点卡 + 壳层染色 + 标题带站点 ===== - 口径:站点卡承载切换与身份,功能菜单仍只有一份(stripSiteSeg 剥段高亮是方案 A 的前置设计,不改)。 */ -describe("P-C1 站点卡:身份常驻侧栏(v2 §C.3 方案 A)", () => { - it("T-C-01 主站态渲染站点卡:站名 + 层级(学校 › 主站)+ 皮肤包名 + 展开的站点列表(当前站高亮)", async () => { +/* ===== docs/22 v3:页头身份「学校名.站点名」 + 侧栏站点卡撤除 ===== + 口径:页头 .school-cap(> .sc-sep + .sc-site)是唯一常驻身份点,主站不写站全名统一「主站」; + 切站入口收敛到面包屑站点身份条(T-B-* / SiteSwitcher 用例)与站点工作台卡片两处。 + 原 v2 的卡内切站用例(T-C-01b)随卡撤除而删除。 */ +describe("P-C1 页头身份与侧栏卡撤除(docs/22 v3)", () => { + it("T-C-01 主站态:侧栏 .menu-top-box 复归空档(卡已撤),页头 = 学校名.主站", 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("机电学院"); + // 站点卡撤除:侧栏顶部空档恢复为空 div(回到加卡之前的 layout.css 形态) + const topBox = el.querySelector(".left-menu .menu-top-box"); + expect(topBox).toBeTruthy(); + expect(topBox!.children).toHaveLength(0); + + // 页头身份:学校名.站点名 —— 主站档位统一「主站」,不写站全名 + expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.主站"); + expect(el.querySelector(".header-container .school-cap")!.textContent).toContain("云海大学"); + expect(el.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站"); }); - 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 () => { + it("T-C-02 身份染色:子站态 .school-cap 挂 band-sub、.sc-site=站名;主站态 band-main、.sc-site=主站", async () => { setRoute("/sch/demo/site/jdxy/article", { schCode: "demo", siteCode: "jdxy" }); const sub = mountLayout(); await flush(); + // 侧栏/面包屑色带档位保留(identityClass 仍在 .left-menu / .path-nav 上工作) 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(); + expect(sub.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(true); + expect(sub.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("机电学院"); 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); + expect(main.querySelector(".header-container .school-cap")!.classList.contains("band-sub")).toBe(false); + expect(main.querySelector(".header-container .sc-site")!.textContent.trim()).toBe("主站"); }); - it("T-C-03 标题带站点:页头标题与 document.title 均为「页面名 · 站点名」,切站后跟随", async () => { + 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("· 机电学院"); + expect(document.title).toBe("文章列表 · 云海大学.机电学院"); + expect(el.querySelector(".header-container h4")!.textContent).toContain("云海大学.机电学院"); - // 同一壳内切到主站形态:标题与 document.title 同步换站名 + // 同一壳内切回主站形态:标题与 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("文章列表 · 云海大学门户"); + expect(document.title).toBe("文章列表 · 云海大学.主站"); }); }); diff --git a/src/__tests__/siteManage.spec.ts b/src/__tests__/siteManage.spec.ts index 0566f22..7c3d7d0 100644 --- a/src/__tests__/siteManage.spec.ts +++ b/src/__tests__/siteManage.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** - * 站点管理列表页回归(docs/16 主线A 整页化后): - * 行「编辑」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式; + * 站点管理页回归:docs/22 站点工作台(默认卡片视图)+ 表格视图保留。 + * 行「站点设置」→ 跳站点设置中心 site/setting/:id(弹窗已退役);「新建站点」→ site/new 创建模式; * 子站行带 URL 站点段(docs/13 adminPathOf 口径)。沿用手写 createApp 挂载桩风格。 */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -93,6 +93,13 @@ function clickRowAction(el: ParentNode, iconClass: string, rowIndex = 0) { return btn; } +/** docs/22 后默认视图为卡片;表格行操作用例先切到表格视图 */ +async function switchToTable(el: ParentNode) { + const btn = [...el.querySelectorAll(".seg button")].find(b => b.textContent?.includes("表格")); + btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); +} + beforeEach(() => { vi.clearAllMocks(); siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([siteRow]))); @@ -105,9 +112,10 @@ afterEach(() => { }); describe("SiteManage 列表页(docs/16 整页化后)", () => { - it("① 行「编辑」跳站点设置中心(主站形态无站点段)", async () => { + it("① 行「站点设置」跳站点设置中心(主站形态无站点段)", async () => { const el = mountPage(); await flush(); + await switchToTable(el); const btn = clickRowAction(el, "icon-edit"); expect(btn).toBeTruthy(); @@ -116,13 +124,14 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => { expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/setting/1"); }); - it("② 子站行「编辑」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => { + it("② 子站行「站点设置」带 URL 站点段(/sch/{main}/site/{sub}/site/setting/{id})", async () => { siteListMock.mockResolvedValue(JSON.parse(JSON.stringify([ siteRow, { ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机械学院" }, ]))); const el = mountPage(); await flush(); + await switchToTable(el); clickRowAction(el, "icon-edit", 1); await flush(); @@ -143,3 +152,58 @@ describe("SiteManage 列表页(docs/16 整页化后)", () => { expect(routerPushMock).toHaveBeenCalledWith("/sch/demo/site/new"); }); }); + +describe("站点工作台(docs/22 v3)", () => { + /** 1 主站 + 2 子站:卡片工作台的最小分组样本 */ + function mockOneMainTwoSubs() { + siteListMock.mockResolvedValue( + JSON.parse( + JSON.stringify([ + siteRow, + { ...siteRow, id: 2, code: "jdxy", path: "demo/jdxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "机电学院" }, + { ...siteRow, id: 3, code: "wtxy", path: "demo/wtxy", parentSiteId: 1, parentName: "云海大学门户网站", name: "文学院" }, + ]) + ) + ); + } + + it("T-SM-01 默认卡片视图:主站宽卡 1 + 子站网格 2,当前站唯一高亮;表格视图可切回", async () => { + mockOneMainTwoSubs(); + const el = mountPage(store => { + store.user = { superAdmin: true } as any; + store.currentSite = { id: 2, code: "jdxy", name: "机电学院" } as any; + }); + await flush(); + + expect(el.querySelectorAll(".main-card")).toHaveLength(1); + expect(el.querySelectorAll(".site-card")).toHaveLength(2); + expect(el.querySelectorAll(".site-card.is-cur")).toHaveLength(1); + // 主站卡「子站」统计 = 过滤前真实规模(SiteVo 无栏目/文章字段,仅子站数必显) + expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2"); + + // 切表格视图:卡片网格消失、ivu-table 出现 + await switchToTable(el); + expect(el.querySelector(".card-grid")).toBeNull(); + expect(el.querySelector(".ivu-table")).toBeTruthy(); + }); + + it("T-SM-02 搜索过滤:命中子站保留、主站宽卡不消失(层级锚点),子站统计不随过滤变化", async () => { + mockOneMainTwoSubs(); + const el = mountPage(store => { + store.user = { superAdmin: true } as any; + store.currentSite = { id: 1, code: "demo", name: "云海大学门户网站" } as any; + }); + await flush(); + + const input = el.querySelector(".seg-search input") as HTMLInputElement; + input.value = "机电"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await flush(); + + expect(el.querySelectorAll(".main-card")).toHaveLength(1); + expect(el.querySelectorAll(".site-card")).toHaveLength(1); + expect(el.querySelector(".site-card")!.textContent).toContain("机电学院"); + // rawSubs 按过滤前全量行统计:仍是 2 个子站 + expect(el.querySelector(".main-card .mc-stats")!.textContent).toContain("2"); + }); +}); diff --git a/src/assets/styles/yh-theme.css b/src/assets/styles/yh-theme.css index 8d53204..adea84d 100644 --- a/src/assets/styles/yh-theme.css +++ b/src/assets/styles/yh-theme.css @@ -159,11 +159,12 @@ html.side-plain{--strip-bg:linear-gradient(180deg,#f2f4f8 0,#fff 100%)} .layout-wrap .path-nav .ivu-breadcrumb a:hover{color:var(--brand)} .layout-wrap .path-nav .crumb-home{color:var(--brand)} .layout-wrap .path-nav .ivu-breadcrumb-item-separator{color:#ccd4e0} -/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变;子站让位身份橙 */ +/* 定稿:不再画独立 3px 横杠——顶部 3px 由接缝条承接顶栏渐变。 + v3:站点身份已由页头「学校名.站点名」承担,原先 band-sub 让位身份橙的覆盖已删, + 否则子站态上面包屑顶部常驻一条橙色横杠,与页头身份重复,且被误读成边框。 */ .layout-wrap .path-nav::before{ content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--topbar-bg);z-index:1; } -.layout-wrap .path-nav.band-sub::before{background:#ff9900} .layout-wrap .path-nav::after{display:none} /* ---- 内容区:灰底 + 白卡 ---- */ diff --git a/src/components/SchoolLayout.vue b/src/components/SchoolLayout.vue index 9715937..49ae33a 100644 --- a/src/components/SchoolLayout.vue +++ b/src/components/SchoolLayout.vue @@ -15,7 +15,6 @@ 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"; @@ -48,13 +47,27 @@ const isSubSite = computed(() => store.isSubSite); /** 身份档位类:同名类挂在 .left-menu 与 .path-nav 两处壳层节点,主站不染色(默认为主站、偏离才提示) */ const identityClass = computed(() => (isSubSite.value ? "band-sub" : "band-main")); -/* 标题带站点(§C.3 #3):页头标题与 document.title 都写成「页面名 · 站点名」,多标签页也能分辨 */ +/* 页头身份(docs/22 v3):学校名.站点名 —— 一处文字同时回答「哪个学校 + 哪个站点」。 + 子站取站点名(云海大学.机电学院),主站固定写「主站」(云海大学.主站); + 沿用既有 .shell-site 胶囊语汇(band-sub 橙 / band-main 中性白),不引新色值。 */ const siteName = computed(() => store.currentSite?.name || ""); -const shellTitle = computed(() => (siteName.value ? `${appName.value} · ${siteName.value}` : appName.value)); +const schoolName = computed(() => school.value?.schName || school.value?.mainSiteName || ""); +/** 页头站点档位文案:主站不写站全名(会很长),统一「主站」 */ +const siteLabel = computed(() => (isSubSite.value ? siteName.value : "主站")); +/** 完整身份串:学校名.站点名(学校未就绪时退化为站点名) */ +const identityText = computed(() => { + const label = siteLabel.value; + if (!label) return ""; + return schoolName.value ? `${schoolName.value}.${label}` : label; +}); +const shellTitle = computed(() => + identityText.value ? `${appName.value} · ${identityText.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; + // 多标签页可分辨:文档标题同样带「学校名.站点名」,与页头口径一致 + if (!identityText.value) return t; + return t ? `${t} · ${identityText.value}` : identityText.value; }); watch(docTitle, v => { if (v) document.title = v; }, { immediate: true }); @@ -324,11 +337,17 @@ watch( title="返回学校列表" @click="goWorkbench" > - +

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

@@ -344,8 +363,8 @@ watch(
- - + +