营销:适配商城装修组件【热区】

This commit is contained in:
owen
2023-12-14 20:22:52 +08:00
parent edfdee8cc1
commit 41a404a52c
8 changed files with 577 additions and 18 deletions

View File

@ -0,0 +1,143 @@
import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
import { StyleValue } from 'vue'
// 热区的最小宽高
export const HOT_ZONE_MIN_SIZE = 100
// 控制的类型
export enum CONTROL_TYPE_ENUM {
LEFT,
TOP,
WIDTH,
HEIGHT
}
// 定义热区的控制点
export interface ControlDot {
position: string
types: CONTROL_TYPE_ENUM[]
style: StyleValue
}
// 热区的8个控制点
export const CONTROL_DOT_LIST = [
{
position: '左上角',
types: [
CONTROL_TYPE_ENUM.LEFT,
CONTROL_TYPE_ENUM.TOP,
CONTROL_TYPE_ENUM.WIDTH,
CONTROL_TYPE_ENUM.HEIGHT
],
style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' }
},
{
position: '上方中间',
types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT],
style: { left: '50%', top: '-5px', cursor: 'n-resize', transform: 'translateX(-50%)' }
},
{
position: '右上角',
types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' }
},
{
position: '右侧中间',
types: [CONTROL_TYPE_ENUM.WIDTH],
style: { right: '-5px', top: '50%', cursor: 'e-resize', transform: 'translateX(-50%)' }
},
{
position: '右下角',
types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' }
},
{
position: '下方中间',
types: [CONTROL_TYPE_ENUM.HEIGHT],
style: { left: '50%', bottom: '-5px', cursor: 's-resize', transform: 'translateX(-50%)' }
},
{
position: '左下角',
types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' }
},
{
position: '左侧中间',
types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH],
style: { left: '-5px', top: '50%', cursor: 'w-resize', transform: 'translateX(-50%)' }
}
] as ControlDot[]
//region 热区的缩放
// 热区的缩放比例
export const HOT_ZONE_SCALE_RATE = 2
// 缩小:缩回适合手机屏幕的大小
export const zoomOut = (list?: HotZoneItemProperty[]) => {
return (
list?.map((hotZone) => ({
...hotZone,
left: (hotZone.left /= HOT_ZONE_SCALE_RATE),
top: (hotZone.top /= HOT_ZONE_SCALE_RATE),
width: (hotZone.width /= HOT_ZONE_SCALE_RATE),
height: (hotZone.height /= HOT_ZONE_SCALE_RATE)
})) || []
)
}
// 放大:作用是为了方便在电脑屏幕上编辑
export const zoomIn = (list?: HotZoneItemProperty[]) => {
return (
list?.map((hotZone) => ({
...hotZone,
left: (hotZone.left *= HOT_ZONE_SCALE_RATE),
top: (hotZone.top *= HOT_ZONE_SCALE_RATE),
width: (hotZone.width *= HOT_ZONE_SCALE_RATE),
height: (hotZone.height *= HOT_ZONE_SCALE_RATE)
})) || []
)
}
//endregion
/**
* 封装热区拖拽
*
* 注为什么不使用vueuse的useDraggable。在本场景下其使用方式比较复杂
* @param hotZone 热区
* @param downEvent 鼠标按下事件
* @param callback 回调函数
*/
export const useDraggable = (
hotZone: HotZoneItemProperty,
downEvent: MouseEvent,
callback: (
left: number,
top: number,
width: number,
height: number,
moveWidth: number,
moveHeight: number
) => void
) => {
// 阻止事件冒泡
downEvent.stopPropagation()
// 移动前的鼠标坐标
const { clientX: startX, clientY: startY } = downEvent
// 移动前的热区坐标、大小
const { left, top, width, height } = hotZone
// 监听鼠标移动
document.onmousemove = (e) => {
// 移动宽度
const moveWidth = e.clientX - startX
// 移动高度
const moveHeight = e.clientY - startY
// 移动回调
callback(left, top, width, height, moveWidth, moveHeight)
}
// 松开鼠标后,结束拖拽
document.onmouseup = () => {
document.onmousemove = null
document.onmouseup = null
}
}

View File

@ -0,0 +1,236 @@
<template>
<Dialog v-model="dialogVisible" title="设置热区" width="780" @close="handleClose">
<div ref="container" class="relative h-full w-750px">
<el-image :src="imgUrl" class="pointer-events-none h-full w-750px select-none" />
<div
v-for="(item, hotZoneIndex) in formData"
:key="hotZoneIndex"
class="hot-zone"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`
}"
@mousedown="handleMove(item, $event)"
@dblclick="handleShowAppLinkDialog(item)"
>
<span class="pointer-events-none select-none">{{ item.name || '双击选择链接' }}</span>
<Icon icon="ep:close" class="delete" :size="14" @click="handleRemove(item)" />
<!-- 8个控制点 -->
<span
class="ctrl-dot"
v-for="(dot, dotIndex) in CONTROL_DOT_LIST"
:key="dotIndex"
:style="dot.style"
@mousedown="handleResize(item, dot, $event)"
></span>
</div>
</div>
<template #footer>
<el-button @click="handleAdd" type="primary" plain>
<Icon icon="ep:plus" class="mr-5px" />
添加热区
</el-button>
<el-button @click="handleSubmit" type="primary" plain>
<Icon icon="ep:check" class="mr-5px" />
确定
</el-button>
</template>
</Dialog>
<AppLinkSelectDialog ref="appLinkDialogRef" @app-link-change="handleAppLinkChange" />
</template>
<script setup lang="ts">
import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
import { array, string } from 'vue-types'
import {
CONTROL_DOT_LIST,
CONTROL_TYPE_ENUM,
ControlDot,
HOT_ZONE_MIN_SIZE,
useDraggable,
zoomIn,
zoomOut
} from './controller'
import { AppLink } from '@/components/AppLinkInput/data'
import { remove } from 'lodash-es'
/** 热区编辑对话框 */
defineOptions({ name: 'HotZoneEditDialog' })
// 定义属性
const props = defineProps({
modelValue: array<HotZoneItemProperty>(),
imgUrl: string().def('')
})
const emit = defineEmits(['update:modelValue'])
const formData = ref<HotZoneItemProperty[]>([])
// 弹窗的是否显示
const dialogVisible = ref(false)
// 打开弹窗
const open = () => {
// 放大
formData.value = zoomIn(props.modelValue)
dialogVisible.value = true
}
// 提供 open 方法,用于打开弹窗
defineExpose({ open })
// 热区容器
const container = ref<HTMLDivElement>()
// 增加热区
const handleAdd = () => {
formData.value.push({
width: HOT_ZONE_MIN_SIZE,
height: HOT_ZONE_MIN_SIZE,
top: 0,
left: 0
} as HotZoneItemProperty)
}
// 删除热区
const handleRemove = (hotZone: HotZoneItemProperty) => {
remove(formData.value, hotZone)
}
// 移动热区
const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => {
useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => {
setLeft(item, left + moveWidth)
setTop(item, top + moveHeight)
})
}
// 调整热区大小、位置
const handleResize = (item: HotZoneItemProperty, ctrlDot: ControlDot, e: MouseEvent) => {
useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => {
ctrlDot.types.forEach((type) => {
switch (type) {
case CONTROL_TYPE_ENUM.LEFT:
setLeft(item, left + moveWidth)
break
case CONTROL_TYPE_ENUM.TOP:
setTop(item, top + moveHeight)
break
case CONTROL_TYPE_ENUM.WIDTH:
{
// 上移时,高度为减少
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT) ? -1 : 1
setWidth(item, width + moveWidth * direction)
}
break
case CONTROL_TYPE_ENUM.HEIGHT:
{
// 左移时,宽度为减少
const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP) ? -1 : 1
setHeight(item, height + moveHeight * direction)
}
break
}
})
})
}
// 设置X轴坐标
const setLeft = (item: HotZoneItemProperty, left: number) => {
// 不能超出容器
if (left >= 0 && left <= container.value!.offsetWidth - item.width) {
item.left = left
}
}
// 设置Y轴坐标
const setTop = (item: HotZoneItemProperty, top: number) => {
// 不能超出容器
if (top >= 0 && top <= container.value!.offsetHeight - item.height) {
item.top = top
}
}
// 设置宽度
const setWidth = (item: HotZoneItemProperty, width: number) => {
// 不能小于最小宽度 && 不能超出容器右边
if (width >= HOT_ZONE_MIN_SIZE && item.left + width <= container.value!.offsetWidth) {
item.width = width
}
}
// 设置高度
const setHeight = (item: HotZoneItemProperty, height: number) => {
// 不能小于最小高度 && 不能超出容器底部
if (height >= HOT_ZONE_MIN_SIZE && item.top + height <= container.value!.offsetHeight) {
item.height = height
}
}
// 处理对话框关闭
const handleSubmit = () => {
// 会自动触发handleClose
dialogVisible.value = false
}
// 处理对话框关闭
const handleClose = () => {
// 缩小
const list = zoomOut(formData.value)
emit('update:modelValue', list)
}
const activeHotZone = ref<HotZoneItemProperty>()
const appLinkDialogRef = ref()
const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => {
activeHotZone.value = hotZone
appLinkDialogRef.value.open(hotZone.url)
}
const handleAppLinkChange = (appLink: AppLink) => {
if (!appLink || !activeHotZone.value) return
activeHotZone.value.name = appLink.name
activeHotZone.value.url = appLink.path
}
</script>
<style scoped lang="scss">
.hot-zone {
position: absolute;
background: var(--el-color-primary-light-7);
opacity: 0.8;
border: 1px solid var(--el-color-primary);
color: var(--el-color-primary);
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
z-index: 10;
/* 控制点 */
.ctrl-dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
border: inherit;
background-color: #fff;
z-index: 11;
}
.delete {
display: none;
position: absolute;
top: 0;
right: 0;
padding: 2px 2px 6px 6px;
background-color: var(--el-color-primary);
border-radius: 0 0 0 80%;
cursor: pointer;
color: #fff;
text-align: right;
}
&:hover {
.delete {
display: block;
}
}
}
</style>

View File

@ -0,0 +1,42 @@
import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
/** 热区属性 */
export interface HotZoneProperty {
// 图片地址
imgUrl: string
// 导航菜单列表
list: HotZoneItemProperty[]
// 组件样式
style: ComponentStyle
}
/** 热区项目属性 */
export interface HotZoneItemProperty {
// 链接的名称
name: string
// 链接
url: string
// 宽
width: number
// 高
height: number
// 上
top: number
// 左
left: number
}
// 定义组件
export const component = {
id: 'HotZone',
name: '热区',
icon: 'tabler:hand-click',
property: {
imgUrl: '',
list: [] as HotZoneItemProperty[],
style: {
bgType: 'color',
bgColor: '#fff',
marginBottom: 8
} as ComponentStyle
}
} as DiyComponent<HotZoneProperty>

View File

@ -0,0 +1,42 @@
<template>
<div class="relative h-full min-h-30px w-full">
<el-image :src="property.imgUrl" class="pointer-events-none h-full w-full select-none" />
<div
v-for="(item, index) in property.list"
:key="index"
class="hot-zone"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
top: `${item.top}px`,
left: `${item.left}px`
}"
>
{{ item.name }}
</div>
</div>
</template>
<script setup lang="ts">
import { HotZoneProperty } from './config'
/** 热区 */
defineOptions({ name: 'HotZone' })
const props = defineProps<{ property: HotZoneProperty }>()
</script>
<style scoped lang="scss">
.hot-zone {
position: absolute;
background: var(--el-color-primary-light-7);
opacity: 0.8;
border: 1px solid var(--el-color-primary);
color: var(--el-color-primary);
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
z-index: 10;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<ComponentContainerProperty v-model="formData.style">
<!-- 表单 -->
<el-form label-width="80px" :model="formData" class="m-t-8px">
<el-form-item label="上传图片" prop="imgUrl">
<UploadImg v-model="formData.imgUrl" height="50px" width="auto" class="min-w-80px">
<template #tip>
<el-text type="info" size="small"> 推荐宽度 750</el-text>
</template>
</UploadImg>
</el-form-item>
</el-form>
<el-button type="primary" plain class="w-full" @click="handleOpenEditDialog">
设置热区
</el-button>
</ComponentContainerProperty>
<!-- 热区编辑对话框 -->
<HotZoneEditDialog ref="editDialogRef" v-model="formData.list" :img-url="formData.imgUrl" />
</template>
<script setup lang="ts">
import { usePropertyForm } from '@/components/DiyEditor/util'
import { HotZoneProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
import HotZoneEditDialog from './components/HotZoneEditDialog/index.vue'
/** 热区属性面板 */
defineOptions({ name: 'HotZoneProperty' })
const props = defineProps<{ modelValue: HotZoneProperty }>()
const emit = defineEmits(['update:modelValue'])
const { formData } = usePropertyForm(props.modelValue, emit)
// 热区编辑对话框
const editDialogRef = ref()
// 打开热区编辑对话框
const handleOpenEditDialog = () => {
editDialogRef.value.open()
}
</script>
<style scoped lang="scss">
.hot-zone {
position: absolute;
background: #409effbf;
border: 1px solid var(--el-color-primary);
color: #fff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
/* 控制点 */
.ctrl-dot {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #fff;
}
}
</style>

View File

@ -124,7 +124,15 @@ export const PAGE_LIBS = [
{
name: '图文组件',
extended: true,
components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
components: [
'ImageBar',
'Carousel',
'TitleBar',
'VideoPlayer',
'Divider',
'MagicCube',
'HotZone'
]
},
{ name: '商品组件', extended: true, components: ['ProductCard', 'ProductList'] },
{