前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >可视化搭建移动端店铺解决方案

可视化搭建移动端店铺解决方案

作者头像
zz_jesse
发布2021-07-12 10:40:22
1.3K0
发布2021-07-12 10:40:22
举报
文章被收录于专栏:前端技术江湖前端技术江湖

原文地址:https://juejin.cn/post/6979410699453726727

前言

经过许久的深思熟虑与探索,同时也借鉴了行业内不错的产品(如:有赞H5-Dooring等),但跟列举的产品还是有区别的(先卖个关子,后面再讲有哪些区别)。其实这种功能在零售系统(目前我所在公司是零售行业的领头羊)和电商系统应该很常见,很多应用场景都会用到,像产品营销页面、企业/个人微官网、H5活动页面等移动端页面,通过可视化配置快速搭建H5页面,且提供丰富的页面组件,更方便的为使用者搭建更强大的H5页面。

PC端界面如下:

PC端界面

移动端(H5和小程序)界面如下:

技术方案

PC端 React 技术栈,移动端 UniApp 跨平台框架,功能的设计结构图如下:

装修页面前端设计模式.png

代码语言:javascript
复制
/*
 * @description: DecoratePage Context交互
 * @version: 分支号 20210629
 * @author: xuchao
 */
import React, { PureComponent } from 'react';
import { withRouter, router } from 'umi';
import { Layout, Modal, Button } from 'antd';
import { isEmpty, findIndex, isArray, find, every, cloneDeep } from 'lodash';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { showMsg } from '@/global';
import Component from './components/Component';
import Preview from './components/Preview';
import Compiler from './components/Compiler';
import { DecorateContext, components } from './utilities';
import './style.less';

const { Header } = Layout;

export default class Decorate extends PureComponent {
    state = {
        compiler: 'PageSetting',
        pagename: '页面标题',
        selectIndex: 0,
        previewData: [],
    };

    getChildContext() {
        return {
            ...this.state,
            ...this.props,
            setState: state => this.setState(state),
        };
    }

    render() {
        return (
            <DecorateContext.Provider value={this.getChildContext()}>
                <Layout className="decorate">
                    <Header className="header">
                        <span className="hand">
                            返回首页装修
                        </span>
                        <Button type="primary" className="fr">
                            发布
                        </Button>
                        <Button type="primary" className="fr mr10">
                            保存
                        </Button>
                        <Button className="fr mr10">
                            预览
                        </Button>
                    </Header>
                    <DndProvider backend={HTML5Backend}>
                        <Layout className="container">
                            <Component />
                            <Preview />
                            <Compiler />
                        </Layout>
                    </DndProvider>
                </Layout>
            </DecorateContext.Provider>
        );
    }
} 

数据

前面说到与列举的产品有哪些区别,区别在于PC端与移动端的数据交互,它们都是通过 iframe 嵌套 H5 的页面,通过 postmessage API 来做数据交互,而是我没有这样做,原因是项目特别紧,加上人员分配问题,所以采用数据定义模式。

通过上面的设计结构图可以看出PC端最后会生成一份 schema 数据存储服务端,移动端从服务端获取到 schema 数据进行解析。数据格式如下:

代码语言:javascript
复制
// 图片广告
{
    component: 'ImageTextAd',
    options: {
        template: 'image', // image:一行一个 carousel:轮播海报 slide:大图横向滑动 zone:绘制热区
        image: [
            {
                id: '',
                url: '',
                title: '',
                linkCode: '',
                linkName: '',
                // 热区
                zones: [
                    {
                        x: 178,
                        y: 91,
                        width: 158,
                        height: 132,
                        code: '123',
                        text: '测试链接2',
                    }
                ],
            },
            {
                id: '',
                url: '',
                title: '',
                linkCode: '',
                linkName: '',
                // 热区
                zones: [
                    {
                        x: 436,
                        y: 97,
                        width: 170,
                        height: 168,
                        code: '',
                        text: '',
                    }
                ],
            },
        ],
        indicator: 'dotted', // 指示器
        style: {
            boxShadow: 'none',
            borderRadius: 'none',
            padding: '0',
        },
    },
},
// 公告
{
    component: 'Notice',
    options: {
        content: '公告内容',
        style: {
            background: 'rgb(255, 248, 233)',
            color: 'rgb(100, 101, 102)',
        },
    },
},
// 图文导航
{
    component: 'ImageTextNav',
    options: {
        template: 'image-nav', // image-nav:图片导航 text-nav:文字导航
        images: [{
            url: '',
            title: '',
            link: '',
        }],
        style: {
            background: 'rgb(255, 248, 233)',
            color: 'rgb(100, 101, 102)',
        },
    },
},
// 标题栏
{
    component: 'Title',
    options: {
        style: {
            textAlign: 'left',
            background: '#FFFFFF',
        },
        title: {
            text: '',
            style: {
                fontSize: '16px',
                fontWeight: 'bold',
                color: '#323233',
            },
        },
        content: {
            text: '',
            style: {
                fontSize: '12px',
                fontWeight: '400',
                color: '#969799',
            },
        },
    },
},
// 文本模块
{
    component: 'RichText',
    options: {
        content: '<html></html>',
        style: {
            backgroundColor: '#F9F9F9',
            padding: '10px 10px 0',
        },
    },
},
// 辅助分割
{
    component: 'DivideLine',
    options: {
        template: 'block', // block:辅助空白 line:辅助线
        style: {
            height: 30,
            // borderTopWidth: '1px',
            // borderTopStyle: 'dashed',
            // borderTopColor: '#EBEDF0',
            // margin: '10px 0 0',
        },
    },
},
// 商品搜索
{
    component: 'GoodSearch',
    options: {
        style: {
            backgroundColor: '#FFFFFF',
        },
        box: {
            style: {
                borderRadius: 'none',
                textAlign: 'left',
                height: 28,
                backgroundColor: '#F7F8FA',
                color: '#c8c9cc',
            },
        },
    },
},
// 左右图文
{
    component: 'LRImageText',
    options: {
        template: 'lr', // lr:左图右文 rl:左文右图
        content: '', // 内容
        image: {
            url: '', // 图片地址
            linkCode: '', // 跳转页面code
            linkName: '', // 跳转页面name
            style: {
                boxShadow: 'none',
                borderRadius: 'none',
            },
        },
    },
},
// 图文导航
{
    component: 'ImageTextNav',
    options: {
        template: 'image', // image:图片导航 text:文字导航
        image: [
            {
                url: '',
                title: '导航一',
                linkCode: '',
                linkName: '',
            },
            {
                url: '',
                title: '导航二',
                linkCode: '',
                linkName: '',
            },
            {
                url: '',
                title: '导航三',
                linkCode: '',
                linkName: '',
            },
            {
                url: '',
                title: '导航四',
                linkCode: '',
                linkName: '',
            },
            {
                id: uuid(),
                url: '',
                title: '导航五',
                linkCode: '',
                linkName: '',
            },
        ],
        style: {
            backgroundColor: '#FFFFFF',
            color: '#333333',
        },
    },
},
// 魔方
{
    component: 'Cube',
    options: {
        template: 'row-one', // row-one:一行一个 row-two:一行两个 row-four:一行四个 row-col:一大两小
        image: [
            {
                url: '',
                linkType: '',
                linkName: '',
            },
        ],
        imageMargin: 0,
        layoutMargin: 0,
    },
},
// 定位菜单
{
    component: 'PositionMenu',
    data: [], // 分组信息
    options: {
        template: 'tab-style-one', // tab-style-one:样式1 tab-style-two:样式2 tab-style-three:样式3
        data: [
            {
                id: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
                code: '',
                name: '',
                menuName: '',
                comsize: 6,
            },
            {
                id: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6c',
                code: '',
                name: '',
                menuName: '',
                comsize: 6,
            },
        ],
        style: {
            borderRadius: 'none',
            fontWeight: '400',
            paddingLeft: '5px',
            paddingRight: '5px',
        },
        listStyle: 'row-one', // row-one:大图模式 row-two:一行两个 row-three:一行三个 row-col:详细列表
        commodityStyle: 'no-border', // no-border:无边白底 shadow:卡片投影 stroke:描边白底 transparent:无边透明底
        commodityName: true, // 商品名称
        commodityDesc: true, // 商品描述
        commodityPrice: true, // 商品价格
        originalPrice: true, // 划线价格
        buyButton: true, // 购买按钮
        buyButtonStyle: 'style-1', // 购买按钮样式
        buyButtonText: '马上抢', // 购买按钮文字
        commoditySubscript: true, // 商品角标
        commoditySubscriptStyle: 'new', // 商品角标样式
    },
},
// 普通商品
{
    component: 'Goods',
    data: [], // 商品信息
    options: {
        template: 'large', // large:大图模式 small:一行两个 three:一行三个 list:详细列表
        data: [], // 商品信息
        style: {
            borderRadius: 'none',
            fontWeight: '400',
            paddingLeft: '5px',
            paddingRight: '5px',
        },
        listStyle: 'row-one', // row-one:大图模式 row-two:一行两个 row-three:一行三个 row-col:详细列表
        commodityStyle: 'no-border', // no-border:无边白底 shadow:卡片投影 stroke:描边白底 transparent:无边透明底
        commodityName: true, // 商品名称
        commodityDesc: true, // 商品描述
        commodityPrice: true, // 商品价格
        originalPrice: true, // 划线价格
        buyButton: true, // 购买按钮
        buyButtonStyle: 'style-1', // 购买按钮样式
        buyButtonText: '马上抢', // 购买按钮文字
        commoditySubscript: true, // 商品角标
        commoditySubscriptStyle: 'new', // 商品角标样式
    },
},
// 限时折扣
{
    template: 'row-one',
    data: [],
    style: {
        borderRadius: 'none',
        fontWeight: '400',
        padding: '0',
        margin: '0',
    },
    comsize: 10,
    tag: '限时折扣',
    commodityStyle: 'no-border',
    commodityName: true,
    commodityDesc: false,
    commodityPrice: true,
    originalPrice: true,
    lastStock: true,
    countdown: true,
    progressBar: true,
    buyButton: true,
    buyButtonStyle: 'style-1',
    buyButtonText: '即将开抢',
} 

拖拽

拖拽依赖第三方库react-dnd,提供的Hooks Api特别方便,上面的设计结构图 Component组件(DragSource) 和 Preview组件(DropTarget) 用到了拖拽,Preview组件不仅要支持上下拖拽,而且需要配合Compiler组件联动。

代码语言:javascript
复制
/*
 * @description: DragSource 拖动组件
 * @version: 分支号 20210629
 * @author: xuchao
 */
import React, { useContext } from 'react';
import { useDrag } from 'react-dnd';
import { findIndex, some, isUndefined, filter } from 'lodash';
import { v1 as uuid } from 'uuid';
import { DecorateContext } from '../../utilities';
import schema from '../Materials/schema';

export default ({ component, name, icon, max, componentType, fixedIndex }) => {
    const { previewData = [], setState } = useContext(DecorateContext);
    const number = filter(previewData, { component }).length;

    const [, drag] = useDrag(
        () => ({
            type: 'component',
            options: {
                dropEffect: 'copy',
            },
            item: {
                type: 'add',
                component,
                name,
                max,
                componentType,
                fixedIndex,
            },
            end: (item, monitor) => {
                const hasPh = some(previewData, { component: 'placeholder' });
                const phIndex = findIndex(previewData, { component: 'placeholder' });

                if (!hasPh) return;

                // 组件放置已达上限
                if (number === max) {
                    previewData.splice(phIndex, 1);

                    setState({ previewData: [...previewData] });

                    return;
                }

                if (monitor.didDrop()) {
                    // 判断拖拽放入Preview组件中,占位元素替换成组件元素
                    previewData.splice(phIndex, 1, {
                        id: uuid(),
                        component: item.component,
                        options: schema[component].defaultOptions,
                    });
                } else {
                    // 判断拖拽没有放入Preview组件中,删除占位元素
                    previewData.splice(phIndex, 1);
                }

                setState({
                    previewData: [...previewData],
                    selectIndex: phIndex,
                    compiler: item.component,
                });
            },
        }),
        [previewData],
    );

    /**
     * @description: 新增组件
     * @author: xuchao
     */
    const handleClick = () => {
        if (number === max) return;

        previewData.splice(!isUndefined(fixedIndex) ? fixedIndex : previewData.length, 0, {
            id: uuid(),
            component,
            options: schema[component].defaultOptions,
        });

        setState({
            previewData: [...previewData],
            selectIndex: !isUndefined(fixedIndex) ? fixedIndex : previewData.length - 1,
            compiler: component,
        });
    };

    return (
        <div ref={drag} className="item" onClick={handleClick}>
            <i className={icon}></i>
            <div className="name">{name}</div>
            <div className="number">
                {number}/{max}
            </div>
        </div>
    );
}; 

/*
 * @description: DropTarget 放置组件
 * @version: 分支号 20210629
 * @author: xuchao
 */
import React, { useContext, useCallback } from 'react';
import { useDrop } from 'react-dnd';
import { findIndex, some, isUndefined, filter } from 'lodash';
import update from 'immutability-helper';
import { DecorateContext } from '../../utilities';
import Item from './Item';

export default () => {
    const { previewData = [], selectIndex, setState } = useContext(DecorateContext);

    const [, drop] = useDrop(
        () => ({
            accept: 'component',
            hover: item => {
                const limit = filter(previewData, { component: item.component }).length;
                const hasPh = some(previewData, { component: 'placeholder' });
                const spliceIndex = !isUndefined(item.fixedIndex)
                    ? item.fixedIndex
                    : previewData.length;

                if (item.type === 'add' && !hasPh) {
                    // 判断占位符是否已经存在,若悬停空白处,插入占位符
                    previewData.splice(spliceIndex, 0, {
                        component: 'placeholder',
                        limit: item.max === limit ? true : false,
                    });

                    setState({ previewData: [...previewData] });
                }
            },
        }),
        [previewData],
    );

    /**
     * @description: move callback
     * @param {number} dragIndex
     * @param {number} hoverIndex
     * @param {object} item
     * @author: xuchao
     */
    const handleMove = useCallback(
        (dragIndex, hoverIndex, item) => {
            if (item.type === 'add' && !dragIndex) {
                // 判断拖拽是 Component 的组件,则 dragIndex 为 undefined,修改占位符的位置即可
                const limit = filter(previewData, { component: item.component }).length;
                const hasPh = some(previewData, { component: 'placeholder' });
                const spliceIndex = !isUndefined(item.fixedIndex) ? item.fixedIndex : hoverIndex;

                // 判断占位符是否已经存在,不再重复插入
                if (hasPh) {
                    const phIndex = findIndex(previewData, {
                        component: 'placeholder',
                    });

                    setState({
                        previewData: update(previewData, {
                            $splice: [
                                [phIndex, 1],
                                [
                                    spliceIndex,
                                    0,
                                    {
                                        component: 'placeholder',
                                        limit: item.max === limit ? true : false,
                                    },
                                ],
                            ],
                        }),
                    });

                    return;
                }

                setState({
                    previewData: update(previewData, {
                        $splice: [
                            [
                                spliceIndex,
                                0,
                                {
                                    component: 'placeholder',
                                    limit: item.max === limit ? true : false,
                                },
                            ],
                        ],
                    }),
                });
            } else {
                // 判断拖拽是 Preview 的组件,则 dragIndex 不为 undefined,替换 dragIndex 和 hoverIndex 位置的元素即可
                setState({
                    previewData: update(previewData, {
                        $splice: [
                            [dragIndex, 1],
                            [hoverIndex, 0, previewData[dragIndex]],
                        ],
                    }),
                    selectIndex: dragIndex === selectIndex ? hoverIndex : dragIndex,
                });
            }
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [previewData],
    );

    /**
     * description: delete callback
     * param {object} event
     * param {number} index
     * author: xuchao
     */
    const handleDelete = (event, index) => {
        event.stopPropagation();

        previewData.splice(index, 1);

        setState({
            previewData: [...previewData],
            compiler: selectIndex === previewData.length ? undefined : previewData[index].compiler,
        });
    };

    return (
        <div ref={drop} className="content">
            {previewData.map((item, index) => {
                return (
                    <Item
                        key={item.id}
                        index={index}
                        selectIndex={selectIndex}
                        {...item}
                        onClick={() => setState({ selectIndex: index, compiler: item.component })}
                        onMove={handleMove}
                        onDelete={handleDelete}
                    />
                );
            })}
        </div>
    );
}; 

总结

开发耗费时间比较长的地方是怎么设计与移动端同步数据和拖拽功能,最后还是迎刃而解。如果大家有什么疑问可以交流一下?

The End

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-07-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端技术江湖 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 技术方案
    • 数据
      • 拖拽
      • 总结
      相关产品与服务
      数据保险箱
      数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档