热区配置小程序--再也不用苦等审核了!!

95 阅读7分钟

背景

开发小程序(尤其是电商类小程序)的过程中,总是伴随着很多活动弹框的开发。有些弹框会存在时效性,比如双十一活动,需要在双十一前上架,双十一后下架。但是小程序的上线流程繁琐:提审-审核通过-发布。甚至有时候还会莫名其妙审核不通过。如果可以配置化,那就完全不需要这套流程,直接后台更改配置即可。

方案

后台配置

  1. HotZone 组件:实现对图片热区的绘制。

    • 传入:

      image.png

    • 传出:热区的位置信息,通过 onZoneChange

  2. 配置页面:根据 HotZone 组件通过 onZoneChange 传出的热区位置信息进行回显,并且设置不同热区的行为。

    • 热区行为主要包括:关闭和跳转两种类型
    • 热区回显:根据热区信息,在配置页面中回显,方便定义热区行为时做参考
    • 配置页面如下: image.png

小程序展示

  • 进入首页时获取配置,根据热区位置信息和配置信息,结合absolute定位绘制热区,并根据行为类型实现跳转或者关闭功能。

实现

HotZone 组件

实现思路

元素布局

首先是定义页面,img用于展示弹框图片,canvas 用于绘制热区,结构如图:

image.png

需要注意:

  1. 两个canvas,一个用于绘制历史热区,一个用于绘制当前正在创建的热区
  2. canvas要完全覆盖在img上,确保可绘制区域覆盖满图片当然实现so easy~~

image.png

区分热区的创建、删除、缩放和拖动

通过onMouseMove事件:

image.png

  1. 首先判断鼠标是否在关闭图标的区域,并设置相应的状态:————由此识别是否是删除

image.png

  1. 判断鼠标是否在热区的边线上,并设置状态:————由此识别是否是缩放

image.png

  1. 判断鼠标是否在热区内,并设置状态:————由此识别是否是拖拽

image.png

处理热区的创建、删除、缩放和拖动

通过监听 mouseDown 事件,根据前边设置的状态,调用相应的处理函数:

image.png

缩放
  1. 找到操作的热区对象,并确定操作的位置(哪条边)记录到 resizeSide
  2. 记录鼠标开始的位置到 startMouse

image.png

鼠标点击后移动,触发 onMouseMove 事件,调用 resizeRect:要做的就是更新 rectHistory 的热区信息,因为后续用了 useEffect 监听 rectHistory 变化,当变化的时候会进行绘制。

image.png

拖拽

拖拽和缩放差不多,都是根据状态去更新 rectHistory 的热区信息。

image.png

创建

通过 onMouseDown 触发 startDraw 记录鼠标开始位置信息作为热区开始坐标到 rectInfo

image.png

鼠标点击后移动,触发 onMouseMove 事件,调用 drawRect:通过 canvas 绘制热区,通过 drawRectSymbol 绘制序号和关闭图标,记录热区关闭图标的位置,以便后续实现删除操作。

image.png

drawRectSymbol函数:

image.png

记录的关闭按钮信息如下:

image.png

删除

通过监听 onClick 事件:借助鼠标位置信息和之前记录的关闭图标位置信息,判断点击区域是否在关闭图标上,从而更新 rectHistory

image.png

重置状态

需要在创建、缩放、拖动后重置状态:

image.png

image.png

image.png

image.png

完整代码

此部分已经发布为公共包,欢迎大家下载尝试:点我尝试

配置页面

回显热区

此部分已经考虑到公共组件中(完整代码部分有示例),添加两个props即可轻松回显,欢迎大家下载尝试:点我尝试

配置信息的设置

这块就根据业务而定了:

image.png

然后将数据提交到数据库:这里要注意计算百分比,方便小程序中保证热区针对图片的相对位置不变。百分比的计算中也需要关注缩放,不然计算出来会有问题

image.png

页面配置:

image.png

提交的数据结构(当然这里也可以根据业务随意更改):

image.png

完整代码

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 都通过百分比定义,这样保证每个热区相对图片的位置不会变

其实应该将图片地址也存到数据库,跟随配置获取下来,因为是方案调研,所以难免有不足

image.png

响应事件

根据定义的配置,实现行为:

image.png

完整代码

<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() {

    }
})