[CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿)
This commit is contained in:
@@ -1,21 +1,24 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
/**
|
/**
|
||||||
* 首页布局编辑器(v2 工作项 B / 手册 §2.8)挂载回归:T-B-09——
|
* 首页布局编辑器(v2 工作项 B → docs/20 F2 三栏升级)挂载回归:
|
||||||
* quick_links 的属性面板必须是「数组编辑器」(每行 name + url,可增删、受 12 项上限约束),
|
* T-B-09 quick_links/notice_bar 属性面板行为不变;
|
||||||
* notice_bar 必须是正文 + 可选跳转 + level 选择;序列行摘要走 blockSummary 新增分支。
|
* F2 新增:三栏结构(序列/预览/属性)、预览视口三档切换、类型卡 SVG 缩略图(R-40)、
|
||||||
* 沿用 siteSetting.spec.ts 的手写 createApp + vi.mock 桩风格。
|
* 展示形态 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 { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||||
import ViewUIPlus from "view-ui-plus";
|
import ViewUIPlus from "view-ui-plus";
|
||||||
|
|
||||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
|
||||||
|
|
||||||
|
const treeMock = vi.hoisted(() => ({ send: vi.fn() }));
|
||||||
|
|
||||||
vi.mock("@/api/module/cms", () => ({
|
vi.mock("@/api/module/cms", () => ({
|
||||||
categoryApi: { tree: () => ({ send: async () => [] }) },
|
categoryApi: { tree: () => treeMock },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
import HomeLayoutEditor from "@/views/cms/homeLayout/HomeLayoutEditor.vue";
|
||||||
|
import { CategoryTreeNode } from "@/types/cms";
|
||||||
|
|
||||||
const cleanups: (() => void)[] = [];
|
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");
|
const el = document.createElement("div");
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
const model = ref(configJson);
|
const model = ref(configJson);
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
defineComponent({
|
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);
|
app.use(ViewUIPlus);
|
||||||
@@ -74,11 +88,16 @@ function del(field: HTMLElement, index: number): HTMLElement {
|
|||||||
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
|
return field.querySelectorAll<HTMLElement>(".hle-link-del")[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
treeMock.send.mockReset();
|
||||||
|
treeMock.send.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanups.splice(0).forEach(fn => fn());
|
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 () => {
|
it("quick_links:入口行编辑器渲染 name+url 双输入,添加/删除可用且回写 items", async () => {
|
||||||
const { el, model } = await mountEditor(CONFIG);
|
const { el, model } = await mountEditor(CONFIG);
|
||||||
// 序列行摘要走 blockSummary 新增分支
|
// 序列行摘要走 blockSummary 新增分支
|
||||||
@@ -140,3 +159,80 @@ describe("HomeLayoutEditor - quick_links / notice_bar 属性面板(T-B-09)",
|
|||||||
expect(levelField.textContent).toContain("醒目");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -120,6 +120,10 @@ const portalPreviewUrl = computed(() =>
|
|||||||
? portalUrlOf(row.value)
|
? portalUrlOf(row.value)
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
/** F2 实时预览 iframe 地址(编辑模式 = serviceHost + 正式门户首页) */
|
||||||
|
const portalFrameUrl = computed(() =>
|
||||||
|
!isCreate.value && row.value ? `${config.serviceHost}${portalUrlOf(row.value)}` : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
code: [
|
code: [
|
||||||
@@ -268,6 +272,7 @@ async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
|
|||||||
seeding = true;
|
seeding = true;
|
||||||
if (tab === "layout") {
|
if (tab === "layout") {
|
||||||
layoutModel.value = lastSavedConfigJson.value;
|
layoutModel.value = lastSavedConfigJson.value;
|
||||||
|
layoutEditorRef.value?.refreshAfterSave?.();
|
||||||
} else if (tab === "css") {
|
} else if (tab === "css") {
|
||||||
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
||||||
}
|
}
|
||||||
@@ -530,7 +535,13 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
|||||||
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
|
<span class="card-subtitle">首页 = 可编排的区块序列(要闻/栏目文章/画廊/链接组)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
|
<Button type="primary" :loading="saving.layout" @click="saveLayout">保存首页布局</Button>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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 { categoryApi } from "@/api/module/cms";
|
||||||
import { CategoryTreeNode } from "@/types/cms";
|
import { CategoryTreeNode } from "@/types/cms";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import BlockTypeThumb from "@/components/BlockTypeThumb.vue";
|
||||||
import {
|
import {
|
||||||
BlockType,
|
BlockType,
|
||||||
HOME_LAYOUT_VERSION,
|
HOME_LAYOUT_VERSION,
|
||||||
@@ -19,16 +20,19 @@ import {
|
|||||||
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
|
import { BLOCK_TYPES, blockDefOf, blockSummary } from "./blockTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页布局编辑器(docs/16 主线B §4.3):嵌入站点设置「首页布局」分区。
|
* 首页布局编辑器(docs/16 主线B → docs/20 F2 三栏升级):
|
||||||
* v-model 绑定整页共享的 configJson(仅读写 home_layout 键,其余键无损回写)。
|
* 左=区块序列(类型小图标 + 高度示意骨架)· 中=实时预览 iframe(加载正式门户,
|
||||||
* 左侧区块序列(上移/下移/隐藏/删除)+ 右侧属性面板(注册表 schema 驱动);
|
* 编排变更 debounce 500ms postMessage 注入近似模拟,含手动刷新兜底,D-3 方案 B)· 右=属性面板(schema 驱动)。
|
||||||
* 添加抽屉列组件库类型卡;「从当前栏目一键生成」复刻现状渲染为初始序列(首用救生索)。
|
* 添加抽屉类型卡与「展示形态」RadioCard 均配 SVG 缩略示意图(R-40)。
|
||||||
* 保存由父分区发起(emit("save")),校验经 defineExpose.validate()。
|
* 首用自动编排(D-4):无 home_layout 键且站点有列表型栏目时进入即自动生成初始序列;
|
||||||
|
* 显式空布局(blocks=[]) 是用户选择,不再自动生成。v-model 仍只读写 home_layout 键。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
/** 布局数据源栏目树归属站点(编辑模式必传) */
|
/** 布局数据源栏目树归属站点(编辑模式必传) */
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
|
/** 实时预览 iframe 的正式门户地址(编辑模式传入;创建模式无站点则隐藏预览栏) */
|
||||||
|
portalUrl?: string;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:modelValue", v: string): void;
|
(e: "update:modelValue", v: string): void;
|
||||||
@@ -64,6 +68,7 @@ function emitUp() {
|
|||||||
: null;
|
: null;
|
||||||
lastEmitted = serializeHomeLayout(props.modelValue, layout);
|
lastEmitted = serializeHomeLayout(props.modelValue, layout);
|
||||||
emit("update:modelValue", lastEmitted);
|
emit("update:modelValue", lastEmitted);
|
||||||
|
schedulePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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) {
|
function addBlock(type: string) {
|
||||||
const def = blockDefOf(type);
|
const def = blockDefOf(type);
|
||||||
@@ -138,6 +214,7 @@ function clearLayout() {
|
|||||||
selectedId.value = null;
|
selectedId.value = null;
|
||||||
lastEmitted = serializeHomeLayout(props.modelValue, null);
|
lastEmitted = serializeHomeLayout(props.modelValue, null);
|
||||||
emit("update:modelValue", lastEmitted);
|
emit("update:modelValue", lastEmitted);
|
||||||
|
reloadPreview();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -202,7 +279,7 @@ function validate(): string | null {
|
|||||||
return errors.length ? errors.join(";") : null;
|
return errors.length ? errors.join(";") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ validate });
|
defineExpose({ validate, refreshAfterSave });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -222,8 +299,9 @@ defineExpose({ validate });
|
|||||||
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
|
<p class="hle-empty-sub">点「从当前栏目一键生成」得到与现状等价的初始序列,再删改微调;或从空白开始添加区块。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 序列 + 属性面板 -->
|
<!-- 三栏:序列 + 实时预览 + 属性面板 -->
|
||||||
<div v-else class="hle-body">
|
<div v-else class="hle-body">
|
||||||
|
<!-- 左:区块序列(图标 + 高度示意骨架 + 摘要) -->
|
||||||
<div class="hle-sequence">
|
<div class="hle-sequence">
|
||||||
<div
|
<div
|
||||||
v-for="(b, i) in blocks"
|
v-for="(b, i) in blocks"
|
||||||
@@ -232,6 +310,7 @@ defineExpose({ validate });
|
|||||||
:class="{ active: b.id === selectedId, hidden: !b.visible }"
|
:class="{ active: b.id === selectedId, hidden: !b.visible }"
|
||||||
@click="selectedId = b.id"
|
@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">
|
<span class="hle-row-main">
|
||||||
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" />
|
<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>
|
<span class="hle-row-title">{{ b.title || blockDefOf(b.type)?.title || b.type }}</span>
|
||||||
@@ -247,6 +326,30 @@ defineExpose({ validate });
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="hle-props">
|
||||||
<template v-if="selected && selectedDef">
|
<template v-if="selected && selectedDef">
|
||||||
<div class="hle-props-head">
|
<div class="hle-props-head">
|
||||||
@@ -256,8 +359,21 @@ defineExpose({ validate });
|
|||||||
</div>
|
</div>
|
||||||
<div v-for="f in visibleFields" :key="f.key" class="hle-field">
|
<div v-for="f in visibleFields" :key="f.key" class="hle-field">
|
||||||
<label class="hle-field-label">{{ f.label }}</label>
|
<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
|
<Input
|
||||||
v-if="f.kind === 'text'"
|
v-else-if="f.kind === 'text'"
|
||||||
v-model="selected[f.key]"
|
v-model="selected[f.key]"
|
||||||
:maxlength="f.maxlength || 100"
|
:maxlength="f.maxlength || 100"
|
||||||
:placeholder="f.placeholder"
|
:placeholder="f.placeholder"
|
||||||
@@ -333,17 +449,19 @@ defineExpose({ validate });
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加区块抽屉 -->
|
<!-- 添加区块抽屉(R-40:每类型配缩略示意图) -->
|
||||||
<Drawer v-model="addOpen" title="添加区块" width="420" class="hle-drawer">
|
<Drawer v-model="addOpen" title="添加区块" width="440" class="hle-drawer">
|
||||||
<div
|
<div
|
||||||
v-for="def in BLOCK_TYPES"
|
v-for="def in BLOCK_TYPES"
|
||||||
:key="def.type"
|
:key="def.type"
|
||||||
class="hle-type-card"
|
class="hle-type-card"
|
||||||
@click="addBlock(def.type)"
|
@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-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 class="hle-type-desc">{{ def.description }}</span>
|
||||||
</span>
|
</span>
|
||||||
<Icon type="ios-arrow-forward" class="hle-type-go" />
|
<Icon type="ios-arrow-forward" class="hle-type-go" />
|
||||||
@@ -360,10 +478,6 @@ defineExpose({ validate });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
.hle-gen,
|
|
||||||
.hle-clear {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.hle-clear {
|
.hle-clear {
|
||||||
color: #999;
|
color: #999;
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -392,20 +506,23 @@ defineExpose({ validate });
|
|||||||
}
|
}
|
||||||
.hle-body {
|
.hle-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.hle-sequence {
|
.hle-sequence {
|
||||||
flex: 1;
|
width: 262px;
|
||||||
min-width: 0;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
max-height: 640px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.hle-row {
|
.hle-row {
|
||||||
|
position: relative;
|
||||||
border: 1px solid #dcdee2;
|
border: 1px solid #dcdee2;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px 8px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
&.active {
|
&.active {
|
||||||
@@ -415,6 +532,28 @@ defineExpose({ validate });
|
|||||||
&.hidden {
|
&.hidden {
|
||||||
opacity: 0.55;
|
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 {
|
.hle-row-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.hle-props {
|
||||||
width: 320px;
|
width: 300px;
|
||||||
flex: none;
|
flex: none;
|
||||||
border: 1px solid #e8eaec;
|
border: 1px solid #e8eaec;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
|
max-height: 640px;
|
||||||
|
overflow: auto;
|
||||||
.hle-props-head {
|
.hle-props-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -491,6 +670,37 @@ defineExpose({ validate });
|
|||||||
margin-top: 3px;
|
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 {
|
.hle-cats {
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -540,7 +750,7 @@ defineExpose({ validate });
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
border: 1px solid #dcdee2;
|
border: 1px solid #dcdee2;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
@@ -548,9 +758,12 @@ defineExpose({ validate });
|
|||||||
border-color: #2d8cf0;
|
border-color: #2d8cf0;
|
||||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
|
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.1);
|
||||||
}
|
}
|
||||||
.hle-type-icon {
|
.hle-type-thumb {
|
||||||
font-size: 22px;
|
width: 116px;
|
||||||
color: #2d8cf0;
|
flex: none;
|
||||||
|
border: 1px solid #edf1f7;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.hle-type-body {
|
.hle-type-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -559,6 +772,10 @@ defineExpose({ validate });
|
|||||||
display: block;
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
.ivu-icon {
|
||||||
|
color: #2d8cf0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.hle-type-desc {
|
.hle-type-desc {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface BlockPropField {
|
|||||||
tip?: string;
|
tip?: string;
|
||||||
/** 条件显示(返回 false 时隐藏且不校验) */
|
/** 条件显示(返回 false 时隐藏且不校验) */
|
||||||
showIf?: (block: LayoutBlock) => boolean;
|
showIf?: (block: LayoutBlock) => boolean;
|
||||||
|
/** select 的可视化选项卡渲染(RadioCard + 缩略图,F2/R-40:形态选择不盲选) */
|
||||||
|
card?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockTypeDef {
|
export interface BlockTypeDef {
|
||||||
@@ -44,7 +46,7 @@ export const BLOCK_TYPES: BlockTypeDef[] = [
|
|||||||
key: "categoryIds", label: "数据栏目", kind: "categories",
|
key: "categoryIds", label: "数据栏目", kind: "categories",
|
||||||
tip: "可多选;渲染时聚合所选栏目的全部子孙栏目文章(文章通常挂叶子栏目)",
|
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,
|
key: "size", label: "文章条数", kind: "number", min: 1, max: 20,
|
||||||
showIf: b => b.style !== "image_wall",
|
showIf: b => b.style !== "image_wall",
|
||||||
|
|||||||
Reference in New Issue
Block a user