feat(pc-decorate): 优化PC楼层装修组件自适应高度、支持可视化热区图绘制及编辑器交互修复

- fix(editor): 修复点击返回页面列表 autoSaveTimer is not defined 运行时错误,优化回退逻辑
- feat(hotzone): PC 端热区图接入可视化组件,支持拖拽画框、拉伸缩放与百分比自适应
- fix(floor-height): 商品宫格、宫格楼层、横幅楼层、商品条等默认行高改为自适应撑开,防止商品溢出截断
- fix(video): 修正视频组件默认行高与播放器高度对齐为 450px,避免底部遮挡
- fix(freelayout): 统一自由排版默认高度与后台画布高度为 450px,消除底部空白
- feat(adaptive): 频道宫格、品牌墙、优惠券等集合类组件支持动态行高与 autoCompTypes 弹性包裹
- refactor(preset): 优化双端 double11Preset 预设模板,清理遗留固定行高
This commit is contained in:
pikachu1995@126.com
2026-10-30 09:27:21 +08:00
parent ccf243f31a
commit ce11f50942
10 changed files with 210 additions and 71 deletions

View File

@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="flag"
width="800px"
:width="width"
:close-on-click-modal="false"
title="绘制热区"
top="120px"
@@ -26,6 +26,13 @@
import { ref } from "vue";
import hotzone from "./components/Hotzone.vue";
const props = defineProps({
width: {
type: String,
default: "800px",
},
});
const emit = defineEmits(["changeZone", "closeFlag"]);
const flag = ref(false);

View File

@@ -29,6 +29,10 @@
<div class="hotzone-preview-box" v-if="options.imageUrl">
<img :src="options.imageUrl" alt="hotzone" class="img-preview" />
<div class="preview-actions-bar">
<el-button size="small" type="primary" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon>
绘制热区
</el-button>
<el-button size="small" type="primary" plain @click="handleSelectImage">
<el-icon class="mr-4"><Picture /></el-icon>
更换底图
@@ -46,6 +50,9 @@
<div class="section-title flex-between">
<span>热区列表 ({{ options.zones?.length || 0 }})</span>
<div class="header-action-group">
<el-button type="primary" plain size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 绘制热区
</el-button>
<el-button link type="primary" size="small" @click="handleAddZone">
<el-icon><Plus /></el-icon> 添加热区
</el-button>
@@ -204,16 +211,25 @@
</div>
<div class="empty-zones-box" v-else>
<span>暂无热区,请点击右上角“添加热区”</span>
<span>暂无热区,可点击“绘制热区”在底图上拖拽画热区</span>
<div class="mt-8">
<el-button type="primary" size="small" @click="handleOpenHotzone">
<el-icon class="mr-4"><Aim /></el-icon> 立即绘制热区
</el-button>
</div>
</div>
</div>
</div>
<!-- 绘制热区弹窗(复用移动端热区绘制器) -->
<hotzone ref="hotzoneDialogRef" width="960px" @changeZone="handleChangeZone" />
</div>
</template>
<script setup>
import { inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import { ref, inject } from 'vue';
import { Plus, Delete, Link, Picture, Close, Top, Bottom, Aim } from '@element-plus/icons-vue';
import { ElMessageBox, ElMessage } from 'element-plus';
import hotzone from '@/components/hotzone';
const props = defineProps({
component: {
@@ -298,6 +314,68 @@ function handleAddZone() {
height: 35
});
}
const hotzoneDialogRef = ref(null);
// 绘制热区弹窗打开
function handleOpenHotzone() {
const imgUrl = props.options.imageUrl || '';
if (!imgUrl) {
ElMessage.warning('请先输入或选择热区底图');
return;
}
const zoneInfo = (props.options.zones || []).map((z, idx) => {
const leftPer = z.leftPer !== undefined ? z.leftPer : (Number(z.left) || 0) / 100;
const topPer = z.topPer !== undefined ? z.topPer : (Number(z.top) || 0) / 100;
const widthPer = z.widthPer !== undefined ? z.widthPer : (Number(z.width) || 20) / 100;
const heightPer = z.heightPer !== undefined ? z.heightPer : (Number(z.height) || 20) / 100;
const link = z.url || z.link || '';
return {
...z,
leftPer,
topPer,
widthPer,
heightPer,
link,
title: z.title || `热区 ${idx + 1}`,
type: z.type || 'other'
};
});
if (hotzoneDialogRef.value) {
hotzoneDialogRef.value.open({
img: imgUrl,
zoneInfo
});
}
}
// 热区绘制器变更回调
function handleChangeZone(zoneList) {
if (!Array.isArray(zoneList)) return;
props.options.zones = zoneList.map((z, idx) => {
const leftVal = z.leftPer !== undefined ? z.leftPer * 100 : (z.left !== undefined ? z.left : 0);
const topVal = z.topPer !== undefined ? z.topPer * 100 : (z.top !== undefined ? z.top : 0);
const widthVal = z.widthPer !== undefined ? z.widthPer * 100 : (z.width !== undefined ? z.width : 20);
const heightVal = z.heightPer !== undefined ? z.heightPer * 100 : (z.height !== undefined ? z.height : 20);
const linkVal = z.link || z.url || '';
const titleVal = z.title || `热区 ${idx + 1}`;
return {
...z,
title: titleVal,
url: linkVal,
link: linkVal,
left: Math.round(leftVal * 10) / 10,
top: Math.round(topVal * 10) / 10,
width: Math.round(widthVal * 10) / 10,
height: Math.round(heightVal * 10) / 10,
type: z.type || 'other'
};
});
}
</script>
<style lang="scss" scoped>

View File

@@ -299,7 +299,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'channelGrid',
defaultRowHeight: 120,
defaultRowHeight: '',
name: '频道宫格',
group: 'navigation',
icon: 'Grid',
@@ -431,7 +431,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'brandWall',
defaultRowHeight: 220,
defaultRowHeight: '',
name: '品牌图',
group: 'media',
icon: 'Medal',
@@ -513,7 +513,7 @@ export const COMPONENT_DEFINITIONS = [
defaultOptions: {
height: 320,
height: 450,
elements: []
@@ -532,7 +532,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'promoGroup',
defaultRowHeight: 180,
defaultRowHeight: 220,
name: '活动卡组',
@@ -567,13 +567,14 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'video',
defaultRowHeight: 360,
defaultRowHeight: 450,
name: '视频',
group: 'media',
icon: 'VideoCamera',
description: '品牌宣传与商品视频播放',
defaultOptions: {
videoUrl: '',
height: 450,
coverUrl: 'https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png',
url: '',
autoPlay: false,
@@ -597,7 +598,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsList',
defaultRowHeight: 980,
defaultRowHeight: '',
name: '商品列表',
@@ -727,7 +728,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'gridFloor',
defaultRowHeight: 560,
defaultRowHeight: '',
name: '宫格楼层',
@@ -843,7 +844,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsGrid',
defaultRowHeight: 420,
defaultRowHeight: '',
name: '商品宫格',
group: 'commerce',
icon: 'Menu',
@@ -922,7 +923,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'bannerFloor',
defaultRowHeight: 560,
defaultRowHeight: '',
name: '横幅楼层',
@@ -964,7 +965,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'goodsBar',
defaultRowHeight: 280,
defaultRowHeight: '',
name: '商品条',
@@ -1001,7 +1002,7 @@ export const COMPONENT_DEFINITIONS = [
},
{type: 'channelFloor',
defaultRowHeight: 620,
defaultRowHeight: '',
name: '频道楼层',
@@ -1045,7 +1046,7 @@ export const COMPONENT_DEFINITIONS = [
{
type: 'recommendFeed',
defaultRowHeight: 1270,
defaultRowHeight: '',
name: '推荐商品',
@@ -1120,7 +1121,7 @@ export const COMPONENT_DEFINITIONS = [
},
{
type: 'couponList',
defaultRowHeight: 160,
defaultRowHeight: '',
name: '优惠券',

View File

@@ -225,7 +225,7 @@
:class="{ 'is-row-selected': isCurrentRowSelected(row.id) }"
:style="getRowWrapperStyle(row.rowStyle)"
:style="getRowWrapperStyle(row.rowStyle, row.components)"
@click.stop="handleSelectRow(row, rIndex)"
@@ -289,14 +289,14 @@
group="pc-row-components"
item-key="id"
class="row-components-flex"
:style="getRowFlexStyle(row.rowStyle)"
:style="getRowFlexStyle(row.rowStyle, row.components)"
@change="markDirty"
>
<template #item="{ element: comp, index: cIndex }">
<div
class="canvas-comp-wrap"
:class="{ 'is-selected': selectedCompId === comp.id }"
:style="getCompWrapperStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1)"
:style="getCompWrapperStyle(comp.componentStyle, row.rowStyle, row.components ? row.components.length : 1, comp.type)"
@click.stop="handleSelectComp(row, rIndex, comp)"
>
<!-- 选中的组件左上角名称标识徽标(万米魔方设计) -->
@@ -800,11 +800,19 @@ function clearSelection() {
function normalizeRows(list) {
if (!Array.isArray(list)) return [];
const autoCompTypes = ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'];
const legacyHeights = [40, '40', 280, '280', 420, '420', 560, '560', 620, '620', 980, '980', 1270, '1270'];
const normalized = [];
list.forEach(r => {
if (!r) return;
if (r.rowStyle && (r.rowStyle.height === 40 || r.rowStyle.height === "40")) {
r.rowStyle.height = "";
if (r.rowStyle) {
if (r.rowStyle.height === 40 || r.rowStyle.height === "40") {
r.rowStyle.height = "";
}
const hasAutoComp = Array.isArray(r.components) && r.components.some(c => autoCompTypes.includes(c?.type));
if (hasAutoComp && legacyHeights.includes(r.rowStyle.height)) {
r.rowStyle.height = "";
}
}
normalized.push(r);
});
@@ -1252,7 +1260,8 @@ function getCompIcon(type) {
// 计算行样式
function getRowWrapperStyle(style) {
function getRowWrapperStyle(style, components = []) {
const hasAutoComp = Array.isArray(components) && components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
if (!style) return { width: '100%' };
const s = {};
@@ -1280,8 +1289,12 @@ function getRowWrapperStyle(style) {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
if (hasAutoComp) {
s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
}
}
@@ -1318,7 +1331,8 @@ function getRowWrapperStyle(style) {
return s;
}
function getRowFlexStyle(style) {
function getRowFlexStyle(style, components = []) {
const hasAutoComp = Array.isArray(components) && components.some(c => ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(c?.type));
if (!style) return { display: 'flex', flexDirection: 'row', flexWrap: 'nowrap', width: '100%', height: '100%' };
const s = {
display: 'flex',
@@ -1333,8 +1347,12 @@ function getRowFlexStyle(style) {
const h = String(style.height).trim();
if (h && Number(h) > 0) {
const hVal = /^\d+$/.test(h) ? `${h}px` : h;
s.height = hVal;
s.minHeight = hVal;
if (hasAutoComp) {
s.minHeight = hVal;
} else {
s.height = hVal;
s.minHeight = hVal;
}
}
}
if (style.gap !== undefined && style.gap !== '') {
@@ -1348,7 +1366,8 @@ function getRowFlexStyle(style) {
// 计算组件外层样式
function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) {
function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1, compType = '') {
const isAutoHeightComp = compType && ['goodsGrid', 'gridFloor', 'bannerFloor', 'goodsBar', 'channelFloor', 'goodsList', 'recommendFeed', 'articleContent', 'brandWall', 'channelGrid', 'couponList'].includes(compType);
if (!style) return {};
@@ -1358,8 +1377,12 @@ function getCompWrapperStyle(style, rowStyle = {}, totalComps = 1) {
const rh = String(rowStyle.height).trim();
if (rh && Number(rh) > 0) {
const rhVal = /^\d+$/.test(rh) ? `${rh}px` : rh;
s.height = rhVal;
s.minHeight = rhVal;
if (isAutoHeightComp) {
s.minHeight = rhVal;
} else {
s.height = rhVal;
s.minHeight = rhVal;
}
}
}
const gap = Number(rowStyle?.gap) || 0;
@@ -1612,7 +1635,11 @@ function goBack() {
} else {
router.push('/pcFloor');
if (window.history.state?.back) {
router.go(-1);
} else {
window.close();
}
}
@@ -1643,6 +1670,8 @@ function goBack() {
}
let autoSaveTimer = null;
onMounted(() => {
const id = route.query.id;
@@ -1671,7 +1700,10 @@ onMounted(() => {
onUnmounted(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer);
if (autoSaveTimer) {
clearInterval(autoSaveTimer);
autoSaveTimer = null;
}
});

View File

@@ -724,7 +724,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
0,
0
],
"height": 180
"height": 220
},
"components": [
{
@@ -901,6 +901,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
"backgroundColor": "transparent"
},
"options": {
"height": 450,
"elements": [
{
"type": "badge",
@@ -979,7 +980,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 560
"height": ""
},
"components": [
{
@@ -1061,7 +1062,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 560
"height": ""
},
"components": [
{
@@ -1140,7 +1141,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 620
"height": ""
},
"components": [
{
@@ -1249,7 +1250,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 420
"height": ""
},
"components": [
{
@@ -1404,7 +1405,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 360
"height": 450
},
"components": [
{
@@ -1417,6 +1418,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
},
"options": {
"videoUrl": "https://vjs.zencdn.net/v/oceans.mp4",
"height": 450,
"coverUrl": "https://lilishop-pc.oss-cn-beijing.aliyuncs.com/static/video/video_cover.png",
"url": "/goodsList?brand=1",
"title": "双11全球狂欢发布盛典 · 年度旗舰好物真实开箱测评",
@@ -1481,7 +1483,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 220
"height": ""
},
"components": [
{
@@ -1656,7 +1658,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 980
"height": ""
},
"components": [
{
@@ -1810,7 +1812,7 @@ export const DOUBLE11_PAGE_DOCUMENT = {
16
],
"borderRadius": "8px",
"height": 1270
"height": ""
},
"components": [
{

View File

@@ -23,7 +23,7 @@ const props = defineProps({
const customStyle = computed(() => {
const s = props.componentStyle || {};
return {
height: `${props.options?.height || 260}px`,
height: `${props.options?.height || 450}px`,
backgroundColor: s.backgroundColor || '#f8fafc',
borderRadius: Array.isArray(s.radius)
? `${s.radius[0]}px ${s.radius[1]}px ${s.radius[2]}px ${s.radius[3]}px`

View File

@@ -7,10 +7,10 @@
:key="idx"
class="zone-mask"
:style="{
top: `${zone.top || 0}%`,
left: `${zone.left || 0}%`,
width: `${zone.width || 10}%`,
height: `${zone.height || 10}%`
top: `${zone.top !== undefined ? zone.top : ((zone.topPer || 0) * 100)}%`,
left: `${zone.left !== undefined ? zone.left : ((zone.leftPer || 0) * 100)}%`,
width: `${zone.width !== undefined ? zone.width : ((zone.widthPer || 0.1) * 100)}%`,
height: `${zone.height !== undefined ? zone.height : ((zone.heightPer || 0.1) * 100)}%`
}"
>
<span class="zone-label">{{ zone.title || `热区 ${idx + 1}` }}</span>