[CMS-VISUAL] F2: 布局编辑器三栏化——中间 iframe 实时预览(500ms debounce 注入+手动刷新兜底+三档视口)、类型卡 SVG 缩略图(R-40)、展示形态 RadioCard、首用自动编排、序列高度示意(171 测试绿)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user