[CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿)

This commit is contained in:
chenxing
2026-09-28 12:52:57 +08:00
parent af451703e7
commit 1a913490ef
5 changed files with 617 additions and 39 deletions
+105 -9
View File
@@ -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<HTMLElement>(".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();
});
});
+252
View File
@@ -0,0 +1,252 @@
<script setup lang="ts">
/**
* 区块类型缩略示意图(docs/20 F2 / R-40):添加抽屉类型卡 + 属性面板形态 RadioCard 共用。
* 纯内联 SVG 线框示意(本地资产,无外部 CDN——皮肤包契约红线仅约束门户,管理端同样不引);
* 每种 type/variant 一幅极简骨架图,新组件类型 = 在 THUMBS 补一支绘制函数(注册表驱动配套)。
*/
const props = withDefaults(
defineProps<{
/** 区块类型(BLOCK_TYPES.type) */
type: string;
/** article_list 展示形态(style),其余类型忽略 */
variant?: string;
}>(),
{ variant: "" },
);
/** 图底色板(示意线框用,与皮肤无关) */
const C = {
band: "#dce6f5",
block: "#e8eef8",
strong: "#9db8e0",
text: "#c3cfe2",
accent: "#5b8dd9",
};
</script>
<template>
<svg class="block-type-thumb" viewBox="0 0 120 64" preserveAspectRatio="xMidYMid meet" role="img" aria-hidden="true">
<!-- article_list 五形态 -->
<template v-if="type === 'article_list'">
<!-- card:头条图 + 摘要 + 行列表 -->
<template v-if="variant === 'card' || !variant">
<rect x="6" y="6" width="34" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="45" y="9" width="44" height="5" rx="1.5" :fill="C.strong" />
<rect x="45" y="18" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="45" y="24" width="56" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="34" width="108" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="50" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="58" width="92" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- news_grid:色带底 + 双栏行 -->
<template v-else-if="variant === 'news_grid'">
<rect x="0" y="0" width="120" height="64" :fill="C.band" />
<rect x="8" y="8" width="104" height="48" rx="2" fill="#ffffff" />
<rect x="14" y="14" width="40" height="4" rx="2" :fill="C.accent" />
<rect x="14" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="14" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="26" width="42" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="34" width="38" height="3" rx="1.5" :fill="C.text" />
<rect x="64" y="42" width="40" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- media_focus:来源徽标 + 行 -->
<template v-else-if="variant === 'media_focus'">
<rect x="6" y="8" width="24" height="8" rx="2" :fill="C.accent" />
<rect x="36" y="11" width="60" height="3" rx="1.5" :fill="C.strong" />
<rect x="6" y="24" width="20" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="32" y="27" width="70" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="18" height="8" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="30" y="43" width="64" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="54" width="34" height="4" rx="1.5" :fill="C.band" />
</template>
<!-- compact:纯文字行 -->
<template v-else-if="variant === 'compact'">
<rect x="8" y="10" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="10" width="84" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="21" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="21" width="76" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="32" width="88" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="43" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="43" width="72" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="54" width="8" height="3" rx="1.5" :fill="C.accent" />
<rect x="20" y="54" width="80" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- image_wall:图片格阵 -->
<template v-else-if="variant === 'image_wall'">
<rect x="6" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="8" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="8" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="6" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="35" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="64" y="34" width="24" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="93" y="34" width="21" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
</template>
<!-- ticker:横条 + 滚动标题 -->
<template v-else-if="type === 'ticker'">
<rect x="0" y="20" width="120" height="24" :fill="C.accent" />
<rect x="6" y="28" width="26" height="8" rx="2" fill="#ffffff" opacity="0.9" />
<rect x="38" y="30" width="34" height="4" rx="2" fill="#ffffff" opacity="0.75" />
<rect x="78" y="30" width="30" height="4" rx="2" fill="#ffffff" opacity="0.6" />
</template>
<!-- gallery:居中标题 + 图墙 -->
<template v-else-if="type === 'gallery'">
<rect x="46" y="4" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="38" y="11" width="44" height="2.5" rx="1.25" :fill="C.text" />
<rect x="8" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="20" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="20" width="19" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="44" width="19" height="16" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- links:胶囊按钮行 -->
<template v-else-if="type === 'links'">
<rect x="46" y="6" width="28" height="5" rx="2" :fill="C.strong" />
<rect x="10" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="22" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="94" y="22" width="18" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="24" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="52" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="80" y="42" width="22" height="12" rx="6" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- quick_links:图标宫格 -->
<template v-else-if="type === 'quick_links'">
<circle cx="26" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="16" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="29" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="26" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="46" r="9" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="59" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- notice_bar:通栏提示条 -->
<template v-else-if="type === 'notice_bar'">
<rect x="0" y="22" width="120" height="20" :fill="C.band" />
<rect x="0" y="22" width="120" height="1" :fill="C.strong" />
<rect x="0" y="41" width="120" height="1" :fill="C.strong" />
<rect x="8" y="29" width="20" height="6" rx="2" :fill="C.accent" />
<rect x="34" y="31" width="70" height="3" rx="1.5" :fill="C.strong" />
</template>
<!-- D 系新组件(docs/20 D-1~D-9):D 步上线时同步补绘 -->
<!-- D-1 stats_band:大数字横排 -->
<template v-else-if="type === 'stats_band'">
<rect x="0" y="10" width="120" height="44" :fill="C.band" />
<text x="24" y="34" font-size="13" font-weight="700" :fill="C.accent">3.2万</text>
<rect x="20" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="60" y="34" font-size="13" font-weight="700" :fill="C.accent">2800</text>
<rect x="56" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
<text x="94" y="34" font-size="13" font-weight="700" :fill="C.accent">12</text>
<rect x="90" y="40" width="24" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-2 icon_nav:六边形图标阵 -->
<template v-else-if="type === 'icon_nav'">
<polygon points="26,6 38,13 38,27 26,34 14,27 14,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,6 72,13 72,27 60,34 48,27 48,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,6 106,13 106,27 94,34 82,27 82,13" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="39" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="26,46 38,53 38,67 26,74 14,67 14,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="16" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="60,46 72,53 72,67 60,74 48,67 48,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="50" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
<polygon points="94,46 106,53 106,67 94,74 82,67 82,53" transform="translate(0,-12)" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="61" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-3 featured_topics:专题大卡 -->
<template v-else-if="type === 'featured_topics'">
<rect x="6" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="12" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="12" y="48" width="18" height="4" rx="2" :fill="C.accent" />
<rect x="62" y="8" width="52" height="48" rx="3" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="68" y="40" width="30" height="4" rx="2" fill="#ffffff" />
<rect x="68" y="48" width="18" height="4" rx="2" :fill="C.accent" />
</template>
<!-- D-5 video_tour:视频封面 + 播放键 -->
<template v-else-if="type === 'video_tour'">
<rect x="6" y="8" width="108" height="48" rx="3" :fill="C.band" />
<circle cx="60" cy="32" r="12" fill="#ffffff" opacity="0.9" />
<polygon points="56,25 70,32 56,39" :fill="C.accent" />
</template>
<!-- D-6 events:大日期块 + 活动列表 -->
<template v-else-if="type === 'events'">
<rect x="6" y="8" width="26" height="26" rx="2" :fill="C.accent" />
<rect x="10" y="13" width="18" height="5" rx="1.5" fill="#ffffff" opacity="0.9" />
<rect x="38" y="12" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="21" width="66" height="3" rx="1.5" :fill="C.text" />
<rect x="6" y="40" width="26" height="20" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="38" y="44" width="50" height="4" rx="2" :fill="C.strong" />
<rect x="38" y="53" width="66" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-7 people:圆头像卡 -->
<template v-else-if="type === 'people'">
<circle cx="24" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="12" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="14" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="48" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="50" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
<circle cx="96" cy="24" r="14" :fill="C.block" :stroke="C.accent" stroke-width="1.2" />
<rect x="84" y="43" width="24" height="4" rx="2" :fill="C.strong" />
<rect x="86" y="51" width="20" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- D-8 campus_wall:瀑布流(错落图) -->
<template v-else-if="type === 'campus_wall'">
<rect x="8" y="6" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="6" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="6" width="24" height="32" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="6" width="19" height="22" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="8" y="40" width="24" height="18" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="37" y="30" width="24" height="28" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="66" y="44" width="24" height="14" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
<rect x="95" y="34" width="19" height="24" rx="2" :fill="C.block" :stroke="C.strong" stroke-width="1" />
</template>
<!-- D-9 badges:徽章横排 -->
<template v-else-if="type === 'badges'">
<circle cx="26" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="18" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="60" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="52" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
<circle cx="94" cy="26" r="15" :fill="C.band" :stroke="C.accent" stroke-width="1.2" />
<rect x="86" y="46" width="16" height="3" rx="1.5" :fill="C.text" />
</template>
<!-- 兜底:区块头 + 行 -->
<template v-else>
<rect x="8" y="8" width="32" height="5" rx="2" :fill="C.strong" />
<rect x="8" y="22" width="104" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="32" width="96" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="42" width="100" height="3" rx="1.5" :fill="C.text" />
<rect x="8" y="52" width="88" height="3" rx="1.5" :fill="C.text" />
</template>
</svg>
</template>
<style scoped lang="scss">
.block-type-thumb {
display: block;
width: 100%;
height: 100%;
background: #fbfcfe;
border-radius: 4px;
}
</style>
+12 -1
View File
@@ -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<string, unknown> | 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());
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
</div>
<div class="card-body">
<HomeLayoutEditor v-model="layoutModel" :site-id="basic.id" ref="layoutEditorRef" @save="saveLayout" />
<HomeLayoutEditor
v-model="layoutModel"
:site-id="basic.id"
:portal-url="portalFrameUrl"
ref="layoutEditorRef"
@save="saveLayout"
/>
</div>
<div class="card-footer">
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
+245 -28
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { categoryApi } from "@/api/module/cms";
import { CategoryTreeNode } from "@/types/cms";
import MyConfirm from "@/components/MyConfirm";
import BlockTypeThumb from "@/components/BlockTypeThumb.vue";
import {
BlockType,
HOME_LAYOUT_VERSION,
@@ -19,16 +20,19 @@ import {
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
/**
* 首页布局编辑器(docs/16 主线B §4.3):嵌入站点设置「首页布局」分区。
* v-model 绑定整页共享的 configJson(仅读写 home_layout 键,其余键无损回写)。
* 左侧区块序列(上移/下移/隐藏/删除)+ 右侧属性面板(注册表 schema 驱动);
* 添加抽屉列组件库类型卡;「从当前栏目一键生成」复刻现状渲染为初始序列(首用救生索)。
* 保存由父分区发起(emit("save")),校验经 defineExpose.validate()。
* 首页布局编辑器(docs/16 主线B → docs/20 F2 三栏升级):
* 左=区块序列(类型小图标 + 高度示意骨架)· 中=实时预览 iframe(加载正式门户,
* 编排变更 debounce 500ms postMessage 注入近似模拟,含手动刷新兜底,D-3 方案 B)· 右=属性面板(schema 驱动)。
* 添加抽屉类型卡与「展示形态」RadioCard 均配 SVG 缩略示意图(R-40)。
* 首用自动编排(D-4):无 home_layout 键且站点有列表型栏目时进入即自动生成初始序列;
* 显式空布局(blocks=[]) 是用户选择,不再自动生成。v-model 仍只读写 home_layout 键。
*/
const props = defineProps<{
modelValue: string;
/** 布局数据源栏目树归属站点(编辑模式必传) */
siteId?: number;
/** 实时预览 iframe 的正式门户地址(编辑模式传入;创建模式无站点则隐藏预览栏) */
portalUrl?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
@@ -64,6 +68,7 @@ function emitUp() {
: null;
lastEmitted = serializeHomeLayout(props.modelValue, layout);
emit("update:modelValue", lastEmitted);
schedulePreview();
}
watch(
@@ -91,7 +96,78 @@ async function loadTree() {
}
}
onMounted(loadTree);
/** F2 首用自动编排:进入编辑器时无 home_layout 键(回落态)且站点有列表型栏目 → 静默生成初始序列 */
async function autoGenOnFirstUse() {
if (parseHomeLayout(props.modelValue) || !props.siteId) {
return;
}
await loadTree();
if (!blocks.value.length && flat.value.length) {
blocks.value = genFromCategories(tree.value, props.modelValue).blocks;
selectedId.value = null;
emitUp();
}
}
onMounted(autoGenOnFirstUse);
// ===== 中栏:实时预览(iframe + 视口切换 + postMessage 近似模拟 + 手动刷新兜底)=====
const VIEWPORTS = [
{ value: 1200, label: "桌面 1200" },
{ value: 768, label: "平板 768" },
{ value: 390, label: "手机 390" },
] as const;
const viewport = ref<number>(1200);
/** iframe 重载键(key 变更 = 重新挂载 = 重新加载正式门户) */
const frameKey = ref(0);
const frameWrap = ref<HTMLElement | null>(null);
/** 预览容器宽 → 缩放比(视口宽超出容器时等比缩小,保证整页可见) */
const frameScale = computed(() => {
const w = frameWrap.value?.clientWidth || 0;
return w && viewport.value > w ? w / viewport.value : 1;
});
const frameHeight = computed(() => Math.round(560 * (1 / frameScale.value)));
/** 预览就绪/模拟状态提示(注入成功置"已同步",未保存新区块走手动刷新) */
const previewState = ref("");
let previewTimer: ReturnType<typeof setTimeout> | null = null;
/** 编排变更 → debounce 500ms → postMessage 注入(R-41) */
function schedulePreview() {
if (!props.portalUrl) return;
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(sendPreview, 500);
}
function sendPreview() {
const frame = document.querySelector<HTMLIFrameElement>(".hle-preview-frame iframe");
const win = frame?.contentWindow;
if (!win) return;
const order = blocks.value.filter(b => b.visible).map(b => b.id);
try {
win.postMessage({ type: "cms-layout-preview", order }, "*");
previewState.value = "已同步未保存的顺序/显隐(新区块保存后可见)";
} catch {
previewState.value = "预览注入不可用,请点「刷新预览」";
}
}
function reloadPreview() {
frameKey.value++;
previewState.value = "";
}
onBeforeUnmount(() => {
if (previewTimer) clearTimeout(previewTimer);
});
/** 保存成功后由父级调用:重载 iframe 呈现真实效果(验收 5「保存即可见」闭环) */
function refreshAfterSave() {
reloadPreview();
}
// ===== 序列操作 =====
function addBlock(type: string) {
const def = blockDefOf(type);
@@ -138,6 +214,7 @@ function clearLayout() {
selectedId.value = null;
lastEmitted = serializeHomeLayout(props.modelValue, null);
emit("update:modelValue", lastEmitted);
reloadPreview();
},
});
}
@@ -202,7 +279,7 @@ function validate(): string | null {
return errors.length ? errors.join(";") : null;
}
defineExpose({ validate });
defineExpose({ validate, refreshAfterSave });
</script>
<template>
@@ -222,8 +299,9 @@ defineExpose({ validate });
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
</div>
<!-- 序列 + 属性面板 -->
<!-- 三栏:序列 + 实时预览 + 属性面板 -->
<div v-else class="hle-body">
<!-- 左:区块序列(图标 + 高度示意骨架 + 摘要) -->
<div class="hle-sequence">
<div
v-for="(b, i) in blocks"
@@ -232,6 +310,7 @@ defineExpose({ validate });
:class="{ active: b.id === selectedId, hidden: !b.visible }"
@click="selectedId = b.id"
>
<span class="hle-row-vis" :class="`vis-${b.type}${b.type === 'article_list' ? '-' + (b.style || 'card') : ''}`"></span>
<span class="hle-row-main">
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" />
<span class="hle-row-title">{{ b.title || blockDefOf(b.type)?.title || b.type }}</span>
@@ -247,6 +326,30 @@ defineExpose({ validate });
</div>
</div>
<!-- 中:实时预览(正式门户 iframe,方案 B 近似模拟) -->
<div class="hle-preview">
<div class="hle-preview-bar">
<RadioGroup v-model="viewport" size="small" @on-change="schedulePreview">
<Radio v-for="v in VIEWPORTS" :key="v.value" :label="v.value">{{ v.label }}</Radio>
</RadioGroup>
<Button size="small" icon="md-refresh" @click="reloadPreview">刷新预览</Button>
</div>
<div ref="frameWrap" class="hle-preview-frame">
<iframe
:key="frameKey"
:src="portalUrl"
:style="{ width: viewport + 'px', height: frameHeight + 'px', transform: `scale(${frameScale})` }"
title="门户首页实时预览"
loading="lazy"
@load="previewState = ''"
></iframe>
</div>
<p class="hle-preview-tip">
{{ previewState || "预览为未保存编排的近似模拟(顺序/显隐即时注入;新增或改属性区块保存后点「刷新预览」可见)" }}
</p>
</div>
<!-- 右:属性面板 -->
<div class="hle-props">
<template v-if="selected && selectedDef">
<div class="hle-props-head">
@@ -256,8 +359,21 @@ defineExpose({ validate });
</div>
<div v-for="f in visibleFields" :key="f.key" class="hle-field">
<label class="hle-field-label">{{ f.label }}</label>
<!-- F2/R-40:展示形态 = RadioCard 可视化选择(缩略图 + 文字),不盲选下拉 -->
<div v-if="f.kind === 'select' && f.card" class="hle-style-cards">
<label
v-for="o in f.options"
:key="o.value"
class="hle-style-card"
:class="{ active: (selected[f.key] || 'card') === o.value }"
@click.prevent="selected![f.key] = o.value; onFieldChange()"
>
<BlockTypeThumb type="article_list" :variant="o.value" />
<span class="hle-style-name">{{ o.label }}</span>
</label>
</div>
<Input
v-if="f.kind === 'text'"
v-else-if="f.kind === 'text'"
v-model="selected[f.key]"
:maxlength="f.maxlength || 100"
:placeholder="f.placeholder"
@@ -333,17 +449,19 @@ defineExpose({ validate });
</div>
</div>
<!-- 添加区块抽屉 -->
<Drawer v-model="addOpen" title="添加区块" width="420" class="hle-drawer">
<!-- 添加区块抽屉(R-40:每类型配缩略示意图) -->
<Drawer v-model="addOpen" title="添加区块" width="440" class="hle-drawer">
<div
v-for="def in BLOCK_TYPES"
:key="def.type"
class="hle-type-card"
@click="addBlock(def.type)"
>
<Icon :type="def.icon" class="hle-type-icon" />
<div class="hle-type-thumb">
<BlockTypeThumb :type="def.type" />
</div>
<span class="hle-type-body">
<span class="hle-type-title">{{ def.title }}</span>
<span class="hle-type-title"><Icon :type="def.icon" /> {{ def.title }}</span>
<span class="hle-type-desc">{{ def.description }}</span>
</span>
<Icon type="ios-arrow-forward" class="hle-type-go" />
@@ -360,10 +478,6 @@ defineExpose({ validate });
align-items: center;
gap: 10px;
margin-bottom: 12px;
.hle-gen,
.hle-clear {
margin-left: 0;
}
.hle-clear {
color: #999;
&:hover {
@@ -392,20 +506,23 @@ defineExpose({ validate });
}
.hle-body {
display: flex;
gap: 14px;
align-items: flex-start;
gap: 12px;
align-items: stretch;
}
.hle-sequence {
flex: 1;
min-width: 0;
width: 262px;
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 640px;
overflow: auto;
}
.hle-row {
position: relative;
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 8px 12px;
padding: 8px 12px 8px 16px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
&.active {
@@ -415,6 +532,28 @@ defineExpose({ validate });
&.hidden {
opacity: 0.55;
}
.hle-row-vis {
position: absolute;
left: 5px;
top: 10px;
bottom: 10px;
width: 4px;
border-radius: 2px;
background: linear-gradient(180deg, #7fb2f0 0%, #4a7fd0 100%);
opacity: 0.75;
}
.hle-row-vis.vis-ticker,
.hle-row-vis.vis-notice_bar {
top: 45%;
background: #2d8cf0;
}
.hle-row-vis.vis-links,
.hle-row-vis.vis-quick_links {
top: 20%;
}
.hle-row-vis.vis-gallery {
top: 15%;
}
.hle-row-main {
display: flex;
align-items: center;
@@ -446,13 +585,53 @@ defineExpose({ validate });
}
}
}
.hle-preview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid #e8eaec;
border-radius: 6px;
background: #f6f8fb;
padding: 8px;
.hle-preview-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.hle-preview-frame {
position: relative;
flex: 1;
min-height: 480px;
overflow: hidden;
border: 1px solid #e2e8f2;
border-radius: 4px;
background: #fff;
iframe {
border: 0;
transform-origin: top left;
display: block;
}
}
.hle-preview-tip {
margin: 6px 2px 0;
color: #999;
font-size: 12px;
line-height: 1.5;
}
}
.hle-props {
width: 320px;
width: 300px;
flex: none;
border: 1px solid #e8eaec;
border-radius: 6px;
padding: 12px;
background: #fafbfc;
max-height: 640px;
overflow: auto;
.hle-props-head {
display: flex;
align-items: center;
@@ -491,6 +670,37 @@ defineExpose({ validate });
margin-top: 3px;
}
}
.hle-style-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.hle-style-card {
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 6px;
cursor: pointer;
background: #fff;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #5cadff;
}
&.active {
border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.12);
.hle-style-name {
color: #2d8cf0;
font-weight: 600;
}
}
.hle-style-name {
display: block;
margin-top: 4px;
font-size: 12px;
color: #515a6e;
text-align: center;
}
}
}
.hle-cats {
max-height: 220px;
overflow: auto;
@@ -540,7 +750,7 @@ defineExpose({ validate });
gap: 10px;
border: 1px solid #dcdee2;
border-radius: 6px;
padding: 12px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -548,9 +758,12 @@ defineExpose({ validate });
border-color: #2d8cf0;
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
}
.hle-type-icon {
font-size: 22px;
color: #2d8cf0;
.hle-type-thumb {
width: 116px;
flex: none;
border: 1px solid #edf1f7;
border-radius: 4px;
overflow: hidden;
}
.hle-type-body {
flex: 1;
@@ -559,6 +772,10 @@ defineExpose({ validate });
display: block;
font-weight: 600;
font-size: 13px;
.ivu-icon {
color: #2d8cf0;
margin-right: 4px;
}
}
.hle-type-desc {
display: block;
+3 -1
View File
@@ -19,6 +19,8 @@ export interface BlockPropField {
tip?: string;
/** 条件显示(返回 false 时隐藏且不校验) */
showIf?: (block: LayoutBlock) => boolean;
/** select 的可视化选项卡渲染(RadioCard + 缩略图,F2/R-40:形态选择不盲选) */
card?: boolean;
}
export interface BlockTypeDef {
@@ -44,7 +46,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
key: "categoryIds", label: "数据栏目", kind: "categories",
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
},
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES },
{ key: "style", label: "展示形态", kind: "select", options: LIST_STYLES, card: true, tip: "卡片缩略图为该形态的典型渲染示意,最终视觉以皮肤包实现为准" },
{
key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
showIf: b => b.style !== "image_wall",