[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
+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",