From 1a913490ef83f2b8efa8f7a1ac48e7c3717cb09b Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 12:52:57 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-VISUAL]=20F2:=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E4=B8=89=E6=A0=8F=E5=8C=96=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=B8=AD=E9=97=B4=20iframe=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E9=A2=84=E8=A7=88=EF=BC=88500ms=20debounce=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5+=E6=89=8B=E5=8A=A8=E5=88=B7=E6=96=B0=E5=85=9C?= =?UTF-8?q?=E5=BA=95+=E4=B8=89=E6=A1=A3=E8=A7=86=E5=8F=A3=EF=BC=89?= =?UTF-8?q?=E3=80=81=E7=B1=BB=E5=9E=8B=E5=8D=A1=20SVG=20=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE=EF=BC=88R-40=EF=BC=89=E3=80=81=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=BD=A2=E6=80=81=20RadioCard=E3=80=81=E9=A6=96=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BC=96=E6=8E=92=E3=80=81=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E9=AB=98=E5=BA=A6=E7=A4=BA=E6=84=8F=EF=BC=88171=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/homeLayoutEditor.spec.ts | 114 +++++++- src/components/BlockTypeThumb.vue | 252 ++++++++++++++++ src/views/cms/SiteSetting.vue | 13 +- src/views/cms/homeLayout/HomeLayoutEditor.vue | 273 ++++++++++++++++-- src/views/cms/homeLayout/blockTypes.ts | 4 +- 5 files changed, 617 insertions(+), 39 deletions(-) create mode 100644 src/components/BlockTypeThumb.vue diff --git a/src/__tests__/homeLayoutEditor.spec.ts b/src/__tests__/homeLayoutEditor.spec.ts index 32f926b..1639efa 100644 --- a/src/__tests__/homeLayoutEditor.spec.ts +++ b/src/__tests__/homeLayoutEditor.spec.ts @@ -1,21 +1,24 @@ // @vitest-environment jsdom /** - * 首页布局编辑器(v2 工作项 B / 手册 §2.8)挂载回归:T-B-09—— - * quick_links 的属性面板必须是「数组编辑器」(每行 name + url,可增删、受 12 项上限约束), - * notice_bar 必须是正文 + 可选跳转 + level 选择;序列行摘要走 blockSummary 新增分支。 - * 沿用 siteSetting.spec.ts 的手写 createApp + vi.mock 桩风格。 + * 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归: + * T-B-09 quick_links/notice_bar 属性面板行为不变; + * F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、 + * 展示形态 RadioCard、首用自动编排(无 home_layout + 有列表栏目 → 自动生成)。 */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createApp, defineComponent, h, nextTick, ref } from "vue"; import ViewUIPlus from "view-ui-plus"; Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {}); +const treeMock = vi.hoisted(() => ({ send: vi.fn() })); + vi.mock("@/api/module/cms", () => ({ - categoryApi: { tree: () => ({ send: async () => [] }) }, + categoryApi: { tree: () => treeMock }, })); import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue"; +import { CategoryTreeNode } from "@/types/cms"; const cleanups: (() => void)[] = []; @@ -41,13 +44,24 @@ const CONFIG = JSON.stringify({ }, }); -async function mountEditor(configJson: string) { +const TREE: CategoryTreeNode[] = [ + { id: 11, name: "校园时讯", type: 1, children: [] }, + { id: 12, name: "媒体聚焦", type: 1, children: [] }, +] as unknown as CategoryTreeNode[]; + +async function mountEditor(configJson: string, opts?: { siteId?: number; portalUrl?: string }) { const el = document.createElement("div"); document.body.appendChild(el); const model = ref(configJson); const app = createApp( defineComponent({ - render: () => h(HomeLayoutEditor, { modelValue: model.value, "onUpdate:modelValue": (v: string) => (model.value = v) }), + render: () => + h(HomeLayoutEditor, { + modelValue: model.value, + siteId: opts?.siteId, + portalUrl: opts?.portalUrl, + "onUpdate:modelValue": (v: string) => (model.value = v), + }), }), ); app.use(ViewUIPlus); @@ -74,11 +88,16 @@ function del(field: HTMLElement, index: number): HTMLElement { return field.querySelectorAll(".hle-link-del")[index]; } +beforeEach(() => { + treeMock.send.mockReset(); + treeMock.send.mockResolvedValue([]); +}); + afterEach(() => { cleanups.splice(0).forEach(fn => fn()); }); -describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)", () => { +describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09,F2 改造后回归)", () => { it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => { const { el, model } = await mountEditor(CONFIG); // 序列行摘要走 blockSummary 新增分支 @@ -140,3 +159,80 @@ describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)", expect(levelField.textContent).toContain("醒目"); }); }); + +describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(docs/20)", () => { + it("三栏结构:序列 / 实时预览 iframe / 属性面板;视口三档与刷新按钮齐备", async () => { + const { el } = await mountEditor(CONFIG, { portalUrl: "http://x/portal/demo/" }); + expect(el.querySelector(".hle-sequence")).toBeTruthy(); + expect(el.querySelector(".hle-props")).toBeTruthy(); + const preview = el.querySelector(".hle-preview")!; + expect(preview).toBeTruthy(); + expect(preview.querySelector("iframe")).toBeTruthy(); + expect(preview.querySelector("iframe")!.getAttribute("src")).toBe("http://x/portal/demo/"); + // 视口三档 + expect(preview.textContent).toContain("桌面 1200"); + expect(preview.textContent).toContain("平板 768"); + expect(preview.textContent).toContain("手机 390"); + const refresh = [...preview.querySelectorAll("button")].find(b => b.textContent?.includes("刷新预览")); + expect(refresh).toBeTruthy(); + }); + + it("添加抽屉:每个类型卡配 SVG 缩略示意图(R-40,注册表全量)", async () => { + const { el } = await mountEditor(CONFIG); + const addBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("添加区块"))!; + addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await settle(); + // Drawer 渲染到 body 级传送门 + const drawer = document.querySelector(".hle-drawer") || document.body; + const cards = drawer.querySelectorAll(".hle-type-card"); + expect(cards.length).toBeGreaterThanOrEqual(6); + cards.forEach(c => { + expect(c.querySelector(".hle-type-thumb svg")).toBeTruthy(); + }); + }); + + it("展示形态 = RadioCard 可视化选择(5 张缩略图 + 文字,非纯文字下拉)", async () => { + const config = JSON.stringify({ + home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] }, + }); + const { el, model } = await mountEditor(config); + (el.querySelector(".hle-row") as HTMLElement).click(); + await settle(); + const styleField = fieldOf(el as HTMLElement, "展示形态")!; + expect(styleField.querySelector(".hle-style-cards")).toBeTruthy(); + expect(styleField.querySelectorAll(".hle-style-card").length).toBe(5); + expect(styleField.querySelectorAll(".hle-style-card svg").length).toBe(5); + // 点击第 2 张卡(news_grid)→ 选中态 + 回写布局 + (styleField.querySelectorAll(".hle-style-card")[1] as HTMLElement).click(); + await settle(); + expect(styleField.querySelectorAll(".hle-style-card")[1].className).toContain("active"); + expect(JSON.parse(model.value).home_layout.blocks[0].style).toBe("news_grid"); + }); + + it("首用自动编排:无 home_layout 键 + 站点有列表栏目 → 自动生成初始序列(D-4)", async () => { + treeMock.send.mockResolvedValue(TREE); + const noLayoutConfig = JSON.stringify({ logo: "/a.png" }); + const { model } = await mountEditor(noLayoutConfig, { siteId: 1 }); + const layout = JSON.parse(model.value).home_layout; + expect(layout).toBeTruthy(); + expect(layout.blocks.length).toBeGreaterThanOrEqual(4); // ticker + 2 栏目区块 + gallery + expect(layout.blocks[0].type).toBe("ticker"); + expect(layout.blocks.some((b: { type: string }) => b.type === "article_list" && b.title === "校园时讯")).toBe(true); + expect(layout.blocks.some((b: { type: string }) => b.type === "gallery")).toBe(true); + // 其余键无损 + expect(JSON.parse(model.value).logo).toBe("/a.png"); + }); + + it("显式空布局(blocks=[])不触发自动编排(用户选择优先)", async () => { + treeMock.send.mockResolvedValue(TREE); + const emptyLayout = JSON.stringify({ home_layout: { version: 1, blocks: [] } }); + const { model } = await mountEditor(emptyLayout, { siteId: 1 }); + expect(JSON.parse(model.value).home_layout.blocks).toEqual([]); + }); + + it("无列表栏目不触发自动编排", async () => { + treeMock.send.mockResolvedValue([]); + const { model } = await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteId: 1 }); + expect(JSON.parse(model.value).home_layout).toBeUndefined(); + }); +}); diff --git a/src/components/BlockTypeThumb.vue b/src/components/BlockTypeThumb.vue new file mode 100644 index 0000000..0d9b59c --- /dev/null +++ b/src/components/BlockTypeThumb.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/views/cms/SiteSetting.vue b/src/views/cms/SiteSetting.vue index 2bef78a..5a10bea 100644 --- a/src/views/cms/SiteSetting.vue +++ b/src/views/cms/SiteSetting.vue @@ -120,6 +120,10 @@ const portalPreviewUrl = computed(() => ? portalUrlOf(row.value) : "" ); +/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */ +const portalFrameUrl = computed(() => + !isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined +); const rules = { code: [ @@ -268,6 +272,7 @@ async function persistTab(tab: TabKey, patch: Record | null) { seeding = true; if (tab === "layout") { layoutModel.value = lastSavedConfigJson.value; + layoutEditorRef.value?.refreshAfterSave?.(); } else if (tab === "css") { customCss.value = readCustomCss(lastSavedConfigJson.value); } @@ -530,7 +535,13 @@ watch(() => store.currentSchool?.schId, () => loadSites()); 首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)
- +