专栏首页前端技术江湖可视化搭建移动端店铺解决方案

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

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

前言

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

PC端界面如下:

PC端界面

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

技术方案

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

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

/*
 * @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 数据进行解析。数据格式如下:

// 图片广告
{
    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组件联动。

/*
 * @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

本文分享自微信公众号 - 前端技术江湖(bigerfe),作者:_sky_

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2021-07-07

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

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

    经过许久的深思熟虑与探索,同时也借鉴了行业内不错的产品(如:有赞,H5-Dooring等),但跟列举的产品还是有区别的(先卖个关子,后面再讲有哪些区别)。其实这...

    徐小夕
  • 化妆品行业电商平台系统解决方案

    爱美是人的本性,在全民直播、网红当道的移动互联网时代推波助澜下,这种自然需求得到了前所未有的释放,中国人对美的追求也不再含蓄。随着人们对多元文化的日益接受,美不...

    数商云
  • OA系统助力连锁商超行业,实现人、财、物统一管控

    随着连锁行业门店的迅速扩张,人工处理数据的速度跟不上巨大的工作量,包括合同、人员、营运、店铺、招商等,需要一整套系统进行统一管理:

    泛微移动办公
  • 解决方案丨智慧零售:如何构建视频监控方案实现商铺实时监控?

    市场经济的快速发展促进了商业店铺规模的扩大,在连锁商铺为消费者带来品质信赖、购物便利的同时,经营过程中的安全隐患、安全漏洞却常常给连锁商铺投资人带来不可预见的损...

    TSINGSEE青犀视频
  • 手淘店铺全链路性能优化

    店铺是导购中重要的一环,承接来自商品详情页、主分会场、主搜等数十亿的流量,店铺的性能体验就显得尤为重要。店铺作为流量大,架构复杂,形态多样,稳定性要求高的典型场...

    winty
  • 服装服饰类电商系统业务框架搭建(图文)

    服装电商零售线上线下一体化帮助解决企业单渠道运营难题,融合线上线下全渠道销售体系,实现全渠道商品信息、价格、服务等环节一体化。

    数商云网络科技
  • Salesforce Commerce Cloud简介

    Commerce Cloud是组成Salesforce智能客户成功平台的八个云产品之一。 除了销售云,服务云,市场营销云,Salesforce物联网以及其他几朵...

    臭豆腐
  • WiFi雷达—近场立体定位服务平台的建设历程

    ? 本文作者:hugoma,腾讯 CSIG 开发工程师 终端厂商在发力培养负一屏的流量新入口。近场服务,是vivo、OPPO等厂商在2018年下半年推出的新招...

    腾讯技术工程官方号
  • 有赞移动端商品模块的架构演变之路

    商品作为电商SaaS业务中的核心模块,提供了最基础的功能,用户从进入商家主页开始预览、查看商详、到下单完成交易,都离不开商品这个最小单元。商品不仅需要提供最基础...

    有赞coder
  • 一种室内定位免采集室内店铺Wi-Fi指纹填充算法

    对于目前最主流的室内Wi-Fi指纹定位技术而言,采集Wi-Fi指纹的覆盖度和新鲜度是决定定位精度最重要的因素。受到成本和导航需求等因素的限制,腾讯地图定位平台目...

    腾讯位置服务
  • 一套完整的直播带货系统包含有哪些功能?

    直播带货系统,是一款依托于直播平台或者短视频平台,集成了商铺建设和商城管理的综合性电商应用系统。它的诞生,是为了满足主播在带货时的一系列功能支持。其目的是在展现...

    就爱吃小笼包
  • 大数据24小时 | 腾讯将用大数据构建互联网+医疗连接器 奥美健康想做运动大数据领域的“BAT”

    腾讯启动“疼爱医疗”战略 用大数据构建互联网+医疗连接器 ? 近日,腾讯公司副总裁丁珂在“互联网+慢病管理”发布会上宣布正式启动“腾爱医疗”战略,将利用腾讯...

    数据猿
  • 有赞移动权限体系建设

    权限管理是一个几乎所有大中型 B 端系统都会涉及的重要组成部分,其目的是对整个系统进行权限控制,避免造成误操作及数据泄露等风险问题。在充分调研了商家的经营需求后...

    用户6021891
  • 技术解析 | 线下门店消费场景中的感知和互动

    随着技术的快速发展和人们生活水平的不断提升,传统的零售模式已经难以满足消费者的需求,而且传统的运营模式需要进行重构。京东提出了无界零售的概念,对于前端门店用户体...

    京东技术
  • 别错过!微信智慧论坛精华汇总

    今天,蓝天碧海的博鳌,微信团队及其合作伙伴集体亮相微信智慧论坛,210分钟,18位嘉宾分享了哪些最干货。 关于微信公众平台,曾鸣说,我们希望更多第三方在微信提供...

    腾讯大讲堂
  • SaaS如何解决好标准产品与个性化需求之间的平衡?

    ? 来源:小飞哥笔记|作者:丰宪飞 ---- 我们知道,做SaaS产品和做定制化项目之间最大不同是: 做定制化项目,可以根据客户的需求,考虑其业务的特...

    腾讯SaaS加速器
  • 「镁客·请讲」周全:想做世界上没有的、比较酷的东西

    镁客网
  • 有赞与法大大合作升级,帮助商家解决线上签约难题|腾讯SaaS加速器·案例库

    ? 来源|腾讯SaaS加速器一期项目-法大大&二期项目-有赞 ---- 电商发展如火如荼的同时,纠纷维权数量也在节节攀升。当发生商品纠纷时,商家和用户往往因...

    腾讯SaaS加速器
  • 有赞美业店铺装修前端解决方案

    做过电商项目的同学都知道,店铺装修是电商系统必备的一个功能,在某些场景下,可能是广告页制作、活动页制作、微页面制作,但基本功能都是类似的。所谓店铺装修,就是用户...

    有赞coder

扫码关注云+社区

领取腾讯云代金券