背景
开发小程序(尤其是电商类小程序)的过程中,总是伴随着很多活动弹框的开发。有些弹框会存在时效性,比如双十一活动,需要在双十一前上架,双十一后下架。但是小程序的上线流程繁琐:提审-审核通过-发布。甚至有时候还会莫名其妙审核不通过。如果可以配置化,那就完全不需要这套流程,直接后台更改配置即可。
方案
后台配置
-
HotZone
组件:实现对图片热区的绘制。-
传入:
-
传出:热区的位置信息,通过
onZoneChange
-
-
配置页面:根据
HotZone
组件通过onZoneChange
传出的热区位置信息进行回显,并且设置不同热区的行为。- 热区行为主要包括:关闭和跳转两种类型
- 热区回显:根据热区信息,在配置页面中回显,方便定义热区行为时做参考
- 配置页面如下:
小程序展示
- 进入首页时获取配置,根据热区位置信息和配置信息,结合absolute定位绘制热区,并根据行为类型实现跳转或者关闭功能。
实现
HotZone
组件
实现思路
元素布局
首先是定义页面,img
用于展示弹框图片,canvas
用于绘制热区,结构如图:
需要注意:
- 两个
canvas
,一个用于绘制历史热区,一个用于绘制当前正在创建的热区 canvas
要完全覆盖在img
上,确保可绘制区域覆盖满图片当然实现so easy~~
区分热区的创建、删除、缩放和拖动
通过onMouseMove
事件:
- 首先判断鼠标是否在关闭图标的区域,并设置相应的状态:————由此识别是否是删除
- 判断鼠标是否在热区的边线上,并设置状态:————由此识别是否是缩放
- 判断鼠标是否在热区内,并设置状态:————由此识别是否是拖拽
处理热区的创建、删除、缩放和拖动
通过监听 mouseDown
事件,根据前边设置的状态,调用相应的处理函数:
缩放
- 找到操作的热区对象,并确定操作的位置(哪条边)记录到
resizeSide
- 记录鼠标开始的位置到
startMouse
鼠标点击后移动,触发 onMouseMove
事件,调用 resizeRect
:要做的就是更新 rectHistory
的热区信息,因为后续用了 useEffect
监听 rectHistory
变化,当变化的时候会进行绘制。
拖拽
拖拽和缩放差不多,都是根据状态去更新 rectHistory
的热区信息。
创建
通过 onMouseDown
触发 startDraw
记录鼠标开始位置信息作为热区开始坐标到 rectInfo
:
鼠标点击后移动,触发 onMouseMove
事件,调用 drawRect
:通过 canvas
绘制热区,通过 drawRectSymbol
绘制序号和关闭图标,记录热区关闭图标的位置,以便后续实现删除操作。
drawRectSymbol
函数:
记录的关闭按钮信息如下:
删除
通过监听 onClick
事件:借助鼠标位置信息和之前记录的关闭图标位置信息,判断点击区域是否在关闭图标上,从而更新 rectHistory
重置状态
需要在创建、缩放、拖动后重置状态:
完整代码
此部分已经发布为公共包,欢迎大家下载尝试:点我尝试
配置页面
回显热区
此部分已经考虑到公共组件中(完整代码部分有示例),添加两个props
即可轻松回显,欢迎大家下载尝试:点我尝试
配置信息的设置
这块就根据业务而定了:
然后将数据提交到数据库:这里要注意计算百分比,方便小程序中保证热区针对图片的相对位置不变。百分比的计算中也需要关注缩放,不然计算出来会有问题
页面配置:
提交的数据结构(当然这里也可以根据业务随意更改):
完整代码
import { useState, useRef, useEffect, ChangeEvent } from 'react';
import styles from './index.module.scss';
import HotZone, { RectInfoType } from 'react-hotzone';
import { CloseOutlined } from '@ant-design/icons';
import { Divider, Radio, Input, Card, Button, } from 'antd';
import type { RadioChangeEvent } from 'antd';
type extendsRectType = {
config?: {
actionType?: 1 | 2,//行为类型,1关闭,2跳转
jumpType?: 1 | 2,//跳转类型,1tab,2普通
jumpUrl?: string// 跳转的url
}
};
type ZoneType = RectInfoType & extendsRectType;
const phoneWidth = 375;
const hotZoneWidth = 500;
const CreatePopConfig = () => {
const phoneImage = 'http://img.netbian.com/file/2024/0920/194034Hwm4C.jpg';//页面图片
const imgRef = useRef<HTMLImageElement>(null);
const [isHotZoneVisible, setHotZoneVisible] = useState(false);
const [zones, setZones] = useState<ZoneType[]>([]);
const [zoneKey, setZoneKey] = useState(1);
const hotZoneRef = useRef<HTMLCanvasElement>(null);
const readonlyHotZoneRef = useRef<HTMLCanvasElement>(null);
const [hotzoneSize, setHotzoneSize] = useState<{
width: number,
height: number,
widthReadonly: number,
heightReadonly: number
}>({
width: 0,
height: 0,
widthReadonly: 0,
heightReadonly: 0
});
// 双击图片展示热区绘制
const openHotZone = () => {
setHotZoneVisible(true);
};
const closeHotZone = () => {
setHotZoneVisible(false);
};
const handleZoneChange = (zones: RectInfoType[]) => {
console.log('父组件接收数据', zones);
setZones(zones);
setZoneKey(zoneKey + 1);
if (hotZoneRef.current && readonlyHotZoneRef.current) {
const { offsetWidth, offsetHeight } = hotZoneRef.current;
const { offsetWidth: widthReadonly, offsetHeight: heightReadonly } = readonlyHotZoneRef.current;
setHotzoneSize({
width: offsetWidth,
height: offsetHeight,
widthReadonly,
heightReadonly
});
}
};
// 行为类型和跳转类型改变回调
const onConfigRadioChange = (e: RadioChangeEvent, id: string, key: 'actionType' | 'jumpType') => {
const zone = zones.find(item => item.id === id);
if (zone) {
zone['config'] = { ...zone['config'] };
zone['config'][key] = e.target.value;
};
setZones([...zones]);
console.log('radio checked', e.target.value, id, zone);
};
const onUrlChange = (e: ChangeEvent<HTMLInputElement>, id: string) => {
const zone = zones.find(item => item.id === id);
if (zone) {
zone['config'] = { ...zone['config'] };
zone['config']['jumpUrl'] = e.target.value;
};
setZones([...zones]);
console.log('url change', e.target.value, id);
};
const onSubmitConfig = () => {
const imgWidth = hotzoneSize.widthReadonly;
const imgHeight = hotzoneSize.heightReadonly;
const submitParams = zones.map(item => { //绘制的热区,
const rectWidth = Math.max(item.startX, item.endX) - Math.min(item.startX, item.endX);
const rectHeight = Math.max(item.startY, item.endY) - Math.min(item.startY, item.endY);
const scaleW = phoneWidth / hotZoneWidth;
const scaleH = hotzoneSize.heightReadonly / hotzoneSize.height;
return {
...item,
leftPercent: (Math.min(item.startX, item.endX) / imgWidth * scaleW * 100).toFixed(2),
topPercent: (Math.min(item.startY, item.endY) / imgHeight * scaleH * 100).toFixed(2),
widthPercent: (rectWidth / imgWidth * scaleW * 100).toFixed(2),
heightPercent: (rectHeight / imgHeight * scaleH * 100).toFixed(2),
width: rectWidth,
height: rectHeight
}
});
console.log('提交config', JSON.stringify(submitParams), '------------', submitParams);
};
return <div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<div className={styles.phone} style={{ width: `${phoneWidth}px` }}>
{phoneImage ? <div onDoubleClick={openHotZone}>
<HotZone
key={zoneKey}
dashBorder={[10, 5]}
width={`${phoneWidth}px`}
src="http://img.netbian.com/file/2024/0920/194034Hwm4C.jpg"
allowOverlap={false}
zones={zones}
readonly={true}
ref={readonlyHotZoneRef}
scale={[hotzoneSize?.widthReadonly / hotzoneSize?.width, hotzoneSize?.heightReadonly / hotzoneSize?.height]}
/>
</div> : <></>}
{
isHotZoneVisible ? <div className={`${styles.hotZone} mask`}>
<div className={styles.hotZone_header}>
<CloseOutlined onClick={closeHotZone} />
<span>热区绘制</span>
</div>
<div style={{ height: '100vh', overflowY: 'auto', display: 'flex', justifyContent: 'center' }}>
<HotZone ref={hotZoneRef} width={`${hotZoneWidth}px`} zones={zones} src={phoneImage} onZoneChange={handleZoneChange} />
</div>
</div> : <></>
}
</div>
</div>
<Divider type='vertical' />
<div className={styles.config}>
<h3>热区配置</h3>
{
zones && zones.length ? zones.map((rect, index) => {
return <Card key={rect.id} title={`热区-${index + 1}`} bordered={false} style={{ width: 350, marginTop: '15px' }}>
<div>
<div className={styles.form_line}>
<span>行为类型:</span>
<Radio.Group onChange={(e) => onConfigRadioChange(e, rect.id, 'actionType')} value={rect.config?.actionType || 1}>
<Radio value={1}>关闭</Radio>
<Radio value={2}>跳转</Radio>
</Radio.Group>
</div>
{
rect.config?.actionType === 2 ? (
<div className={styles.form_line}>
<span>跳转类型:</span>
<Radio.Group onChange={(e) => onConfigRadioChange(e, rect.id, 'jumpType')} value={rect.config?.jumpType || 1}>
<Radio value={1}>Tab页面</Radio>
<Radio value={2}>普通页面</Radio>
</Radio.Group>
</div>
) : <></>
}
{
rect.config?.actionType === 2 ? (
<div className={styles.form_line}>
<span>跳转链接:</span>
<Input onChange={(e) => onUrlChange(e, rect.id)} value={rect.config?.jumpUrl} style={{ width: '230px' }} placeholder="/pages/pageA/pageA" />
</div>
) : <></>
}
</div>
</Card>
}) : <></>
}
{zones && zones.length ? <Button onClick={onSubmitConfig} style={{ marginTop: '20px', width: 350 }} type='primary'>保存配置</Button> : <></>}
</div>
</div>
}
export default CreatePopConfig;
小程序
实现思路
小程序中主要是获取配置信息,然后创建热区,监听热区点击事件,根据热区配置的行为实现关闭或者跳转行为。 (这里先用一个普通页面做示例,弹框也是同理的。)
元素布局
父元素定义 relative
,然后循环遍历热区,为每个热区创建区域,每个热区定义absolute
,然后将 top、left、width、height
都通过百分比定义,这样保证每个热区相对图片的位置不会变。
其实应该将图片地址也存到数据库,跟随配置获取下来,因为是方案调研,所以难免有不足
响应事件
根据定义的配置,实现行为:
完整代码
<view style="position: relative;">
<image bindload="onImageLoad" style="width: 100%;display: block;" src="{{imageUrl}}" mode="widthFix" />
<view wx:for="{{hotZones}}" bindtap="onClickHotZone" data-zone="{{item.config}}" wx:key="index"
style="position: absolute;top: {{item.topPercent+'%'}};left: {{item.leftPercent+'%'}};background-color: rgba(241, 11, 11,0.4);width: {{item.widthPercent+'%'}};height: {{item.heightPercent+'%'}};">
</view>
</view>
const common = require('../../utils/util');
Page({
/**
* 页面的初始数据
*/
data: {
hotZones: [],
imageUrl: 'http://img.netbian.com/file/2024/0920/194034Hwm4C.jpg',
},
onClickHotZone(e) {
const {
zone
} = e.target.dataset;
const {
actionType,
jumpType,
jumpUrl,
} = zone;
switch (actionType) {
case 2: //跳转
switch (jumpType) {
case 2: //普通页面
wx.navigateTo({
url: jumpUrl,
});
break;
case 1: //tab页面
default: //tab页面
wx.switchTab({
url: jumpUrl,
});
break;
};
break;
case 1: // 关闭
default: // 关闭
this.closePop()
break;
}
},
closePop() {
common.showToast('关闭弹框成功');
},
onImageLoad: function (e) {
const {
width,
height
} = e.detail;
console.log('宽高', width, height, wx.getSystemInfoSync().windowWidth);
},
getInit() {
return new Promise((resolve) => {
const data =[{"id":"e43a3be8-c227-437e-9ad8-6838e066c83f","startX":2,"startY":383,"endX":283,"endY":601,"leftPercent":"0.40","topPercent":"34.47","widthPercent":"56.20","heightPercent":"19.62","width":281,"height":218},{"id":"fe63f743-983b-47f6-b58c-fb2ed26a90cc","startX":3,"startY":606,"endX":288,"endY":836,"config":{"actionType":2,"jumpUrl":"/pages/userindex/userindex"},"leftPercent":"0.60","topPercent":"54.55","widthPercent":"57.00","heightPercent":"20.70","width":285,"height":230},{"id":"90d3d87e-97e6-46b5-acea-b3f579be52ba","startX":390,"startY":257,"endX":500,"endY":380,"config":{"actionType":2,"jumpType":2,"jumpUrl":"/pages/order/Moreorders/Moreorders"},"leftPercent":"78.00","topPercent":"23.13","widthPercent":"22.00","heightPercent":"11.07","width":110,"height":123}];
setTimeout(() => {
resolve(data);
}, 1000);
})
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
this.getInit().then(res => {
this.setData({
hotZones: res,
})
});
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})