【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

1. 概述

魔方组件是店铺装修平台的常见组件,旨在帮助商家展示商品和活动海报,支持多样化布局选择和自定义模板。

开发说明:通过扩展阿里低代码引擎提供的示例项目,扩展魔方组件物料和魔方组件设置器。

2. 效果演示

【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

3. 功能特点

  1. 多样化的布局选择,包括一行两个、一行三个、一行四个、两左两右、一左两右、一上二下、一左三右和自定义模板。
    1. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    2. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    3. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    4. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    5. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    6. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    7. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    8. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
  1. 支持上传商品图片和活动海报,并为图片添加链接。
  2. 可调整图片间隙和尺寸要求,适配不同的页面布局和设备。
  3. 自定义模板支持移动鼠标选定布局区域大小,满足商家的个性化需求。
    1. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
    2. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

4. 数据格式

{
    "model": "custom",
    "row": 5,
    "col": 5,
    "list": [
        {
            "y": 0,
            "x": 0,
            "height": 3,
            "width": 3,
            "image": "http://localhost:3006/1710684266282.jpg",
            "targetUrl": ""
        },
        {
            "y": 3,
            "x": 0,
            "height": 2,
            "width": 3,
            "image": "http://localhost:3006/1704280623065.jpeg",
            "targetUrl": ""
        },
        {
            "y": 0,
            "x": 3,
            "height": 3,
            "width": 2,
            "image": "http://localhost:3006/1704280618340.jpeg",
            "targetUrl": ""
        },
        {
            "y": 3,
            "x": 3,
            "height": 2,
            "width": 2,
            "image": "http://localhost:3006/1704280628992.png",
            "targetUrl": ""
        }
    ]
}

cube (object): 魔方配置对象,包含以下属性:

  • model (string): 模板类型
  • row (number): 魔方的行数。
  • col (number): 魔方的列数。
  • list (array): 魔方的数据数组,每个元素包含以下属性:
    • x (number): 小块的横向位置。
    • y (number): 小块的纵向位置。
    • width (number): 小块的宽度占比。
    • height (number): 小块的高度占比。
    • image (string): 小块的背景图片 URL。
    • targetUrl (string, optional): 点击小块时跳转的目标 URL。

5. 思路

多样化的布局选择,包括一行两个、一行三个、一行四个、两左两右、一左两右、一上二下、一左三右和自定义模板。

一行两个、一行三个、一行四个、两左两右、一左两右、一上二下、一左三右 都可以从自定义模版中演化而来。

5.1. 自定义模板

首先介绍自定义模板的设计思路(以魔方密度 6×6 为例):

  1. 把整个魔方布局区域看成一个直角坐标系区域
  2. 把一个区域划分为 n×n 等份,比如这里 n=6
    1. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现
  1. 外层容器绝对定位,内层每一块区域相对定位

  2. 每一个区域由 x, y 定位到位置,width, height 分别决定区域的宽高

    1. 转换成样式,x->left,y->top,width->width,height->height
    2. 【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

5.2. 数据格式

export interface Model {
  x: number;
  y: number;
  width: number;
  height: number;
  image: string;
}

export interface InitialModels {
  [key: string]: Model[];
}

export const initialModels: InitialModels = {
  magicCube1: [
    {
      x: 0,
      y: 0,
      height: 1,
      width: 1,
      image: defaultImage
    },
    {
      x: 1,
      y: 0,
      height: 1,
      width: 1,
      image: defaultImage
    }
  ],
  magicCube2: [
    {
      x: 0,
      y: 0,
      height: 1,
      width: 1,
      image: defaultImage
    },
    {
      x: 1,
      y: 0,
      height: 1,
      width: 1,
      image: defaultImage
    },
    {
      x: 2,
      y: 0,
      height: 1,
      width: 1,
      image: defaultImage
    }
  ]
};

export interface ModelOption {
  label: string;
  value: string;
  row: number;
  col: number;
}

export const modelOptions: ModelOption[] = [
  {
    label: "一行两个",
    value: "magicCube1",
    row: 1,
    col: 2
  },
  {
    label: "一行三个",
    value: "magicCube2",
    row: 1,
    col: 3
  },
  {
    label: "自定义",
    value: "custom",
    row: 5,
    col: 5
  }
];

5.3. 一行两个

比如一行两个可以看作是一行两列(row: 1, col: 2)的自定义区域

【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

{
    "model": "magicCube1",
    "row": 1,
    "col": 2,
    "list": [
        {
            "x": 0,
            "y": 0,
            "height": 1,
            "width": 1,
            "image": ""
        },
        {
            "x": 1,
            "y": 0,
            "height": 1,
            "width": 1,
            "image": ""
        }
    ]
}

6. 运行态

【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

6.1. 代码实现

6.2. 代码说明

  1. 在组件内部,通过解构赋值获取 cube、imgMargin 和 imgRadius。
  2. 定义一系列辅助函数,如 getContainerWidth、getItemWidth、getItemHeight 等,用于计算容器和元素的尺寸、位置等样式属性。
  3. getWrapStyle 函数根据 cube.list 中的元素数量来设置容器高度,并设置背景样式或默认高度。
  4. getMainStyle 函数根据传入的样式参数计算并返回每个元素的位置、尺寸等样式属性。
  5. getItemStyle 函数根据传入的图片 URL 返回对应的样式对象,设置背景图片和圆角等样式。
  6. 最后通过遍历渲染 cube.list 中的每个元素,并根据其样式设置位置、背景图片等。

7. 设置器

【可视化搭建平台 | 店铺装修】魔方组件的设计与实现

7.1. 功能说明

  1. 容器的行数和列数由 props 传入,通过下拉框设置魔方密度来实现。
  2. 点击某个空白方块会触发编辑模式,此时可以选择多个方块来创建一个新的容器块。会避免选择的方块与已存在的容器块重叠。
  3. 已有的容器块可以被点击,进入编辑状态,可以对容器块的位置和大小进行调整,并可以上传图片、添加链接等操作。
  4. 可以删除已有的容器块。

7.2. 代码实现

结构

7.2.1. magic-cube-setter/index.tsx -> 父组件 MagicCubeSetter

定义一个父组件 MagicCubeSetter,其主要功能包括:

  • 根据选择的模板进行布局设置,支持自定义布局和预设模板选择。
  • 在自定义布局模式下,可以调整魔方的密度(行数和列数)。
  • 提供一个自定义布局组件CustomLayout,用于展示和操作布局区域,并在布局区域中添加图片。
import React, { useState, useRef, useEffect } from 'react';
import { Select } from '@alifd/next';
import { event } from '@alilc/lowcode-engine';
import { cloneDeep } from 'lodash';
import { useLatest } from 'ahooks';
import CustomLayout from './CustomLayout';
import { cubeRowsList, initialModels, modelOptions } from './helper';
import './index.scss';
interface CubeValue {
list?: any[];
row?: number;
col?: number;
model?: string;
}
interface MagicCubeSetterProps {
type: string;
name: string;
initialValue?: CubeValue;
defaultValue?: CubeValue;
value: CubeValue,
onChange: (val: object) => void;
}
const MagicCubeSetter: React.FC<MagicCubeSetterProps> = (props) => {
const { value: cubeValue, initialValue: defaultValue, onChange } = props;
const [activeItem, setActiveItem] = useState(0);
const cubeValueRef = useLatest(cubeValue);
const activeItemRef = useLatest(activeItem);
const layoutRef = useRef<any>(null);
useEffect(() => {
if (cubeValue === undefined && defaultValue) {
onChange(defaultValue);
}
const bindEvent = (value: string) => {
console.log("common:magic-cube-setter.bindEvent-on", value);
let newValue = cloneDeep(cubeValueRef.current);
const currentIdx = activeItemRef.current;
if (newValue?.list?.[currentIdx]) {
newValue.list[currentIdx].image = value;
}
onChange(newValue);
};
event.on(`common:magic-cube-setter.bindEvent`, bindEvent);
return () => {
// setter 是以实例为单位的,每个 setter 注销的时候需要把事件也注销掉,避免事件池过多
event.off(`common:magic-cube-setter.bindEvent`, bindEvent);
}
}, []);
const changeModel = (model: string) => {
if (model) {
let target = modelOptions.find((m) => m.value === model);
// 重置模板
layoutRef.current?.reset();
let newValue: CubeValue = {
list: [],
row: target?.row || 1,
col: target?.col || 1,
model,
};
// 设置模板对应初始数据
if (model === 'custom') {
newValue.list = [];
} else {
newValue.list = JSON.parse(JSON.stringify(initialModels[model]));
}
onChange(newValue);
}
};
const handleChangeRow = (val: string) => {
const value = parseInt(val || '5');
const newValue: CubeValue = {
model: 'custom',
list: [],
row: value,
col: value,
};
onChange(newValue);
layoutRef.current?.reset();
};
const onCurIndex = (item: number) => {
setActiveItem(item);
const activeImageUrl = cubeValueRef.current.list?.[item]?.['image'];
event.emit('magic-cube-setter.changeSelectValue', activeImageUrl)
};
const onCustomChange = (newList: []) => {
const { model, row, col } = cubeValueRef.current;
const newValue: CubeValue = {
model,
row,
col,
list: newList
};
onChange(newValue);
}
return (
<div className="magic-cube-setter">
{cubeValue.model === 'custom' && (
<div className="common">
<label>魔方密度</label>
<Select value={cubeValue.row} onChange={handleChangeRow}>
{cubeRowsList.map((key) => (
<Select.Option key={key} value={key}>
{key}×{key}
</Select.Option>
))}
</Select>
</div>
)}
{/* <div>魔方布局</div> */}
<div className="custom-design-tips">
{cubeValue.model === 'custom' ? '移动鼠标选定布局区域大小' : '选定布局区域,在下方添加图片'}
</div>
<CustomLayout
ref={layoutRef}
row={cubeValue.row || 1}
col={cubeValue.col || 2}
model={cubeValue.model || 'magicCube1'}
list={cubeValue.list || []}
onCurIndex={onCurIndex}
onCustomChange={onCustomChange}
{...props}
/>
<div className="common">
<label>模板选择</label>
<Select value={cubeValue.model} onChange={(val) => changeModel(val)}>
{modelOptions.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</div>
</div>
);
};
export default MagicCubeSetter;

7.2.2. magic-cube-setter/CustomLayout.tsx -> 子组件 CustomLayout

定义一个子组件 CustomLayout,用于展示一个自定义布局的网格容器,并允许用户点击和移动来选择和编辑其中的块。其主要功能包括:

  1. 渲染网格容器:根据 row 和 col 的值,生成相应数量的 ul 和 li 元素,形成一个网格布局。
  2. 处理点击事件:当用户点击某个网格块时,根据当前的编辑状态(edit)来执行不同的操作。如果当前不处于编辑状态,则记录点击的块的 key 值,并进入编辑状态。如果当前处于编辑状态,则根据记录的起始 key 值和结束 key 值,创建一个新的块,并将其添加到 list 数组中。
  3. 处理移动事件:当用户在编辑状态下移动鼠标时,根据起始 key 值和当前鼠标所在的块的 key 值,计算出需要更新的块的 key 值,并将其记录在 editKeys 数组中。
  4. 更新布局:根据 list 数组中的数据,渲染编辑容器块。用户可以通过点击和选择块来切换当前的编辑状态,并在编辑状态下删除选定的块。
import React, { useState, useEffect, useImperativeHandle, ForwardRefRenderFunction, forwardRef, Ref, MouseEvent } from 'react';
import { cloneDeep, sortBy } from 'lodash';
import { cubeWrapWidth, customLayoutWidth, isRectangleOverlap } from './helper';
import defaultImage from './defaultImage.png';
interface CustomLayoutProps {
list: any[];
model: string;
row: number;
col: number;
onCurIndex: (index: number) => void;
onCustomChange: (val: []) => void;
selectedNodeId: number;
ref?: React.Ref<HTMLDivElement>;
}
interface CustomDivRef extends HTMLDivElement {
reset: () => void;
}
interface SplitKey {
y: number;
x: number;
}
const CustomLayout: ForwardRefRenderFunction<CustomDivRef, CustomLayoutProps> = forwardRef<HTMLDivElement, CustomLayoutProps>(
(props: CustomLayoutProps, ref: Ref<HTMLDivElement>) => {
const { list, model, row, col, onCurIndex, onCustomChange } = props;
const [startKey, setStartKey] = useState<number>(0);
const [curIndex, setCurIndex] = useState<number>(-1);
const [edit, setEdit] = useState<boolean>(false);
const [ys, setYs] = useState<number[]>([]);
const [xs, setXs] = useState<number[]>([]);
const [editKeys, setEditKeys] = useState<number[]>([]);
const getBaseW = (): number => {
return parseInt(customLayoutWidth / col);
};
useEffect(() => {
setTimeout(() => {
updateCurIndex(0);
}, 500);
}, []);
useEffect(() => {
setYs([...Array(row).keys()]);
setXs([...Array(col).keys()]);
}, [row, col]);
// 将 reset 方法暴露给父组件
useImperativeHandle(ref, () => ({
reset
}));
const updateCurIndex = (index: number) => {
setCurIndex(index);
onCurIndex(index);
};
const updateList = (updatedValue: number[]) => {
onCustomChange(updatedValue);
};
const reset = () => {
setStartKey(0);
updateCurIndex(-1);
setEdit(false);
setEditKeys([]);
};
const clickWrap = (e: MouseEvent<HTMLDivElement>) => {
if (!edit) {
const key = Number((e.target as HTMLDivElement).dataset.key);
setEditKeys([...editKeys, key]);
setStartKey(key);
setEdit(true);
} else {
let keys = cloneDeep(sortBy(editKeys));
const start = splitKey(keys[0]);
const end = splitKey(keys.pop());
const temp = {
x: start.x,
y: start.y,
height: end.y - start.y + 1,
width: end.x - start.x + 1,
image: defaultImage,
targetUrl: ''
};
const updatedValue = [...list, temp];
onCustomChange(updatedValue);
updateCurIndex(updatedValue.length - 1);
setEditKeys([]);
setEdit(false);
}
};
const move = (e: MouseEvent<HTMLDivElement>) => {
if (!edit) {
return;
}
const keys = [];
const start = splitKey(startKey);
const end = splitKey(Number((e.target as HTMLDivElement).dataset.key));
const ys = sortBy([start.y, end.y]);
const xs = sortBy([start.x, end.x]);
if (antiCollision(start, end)) {
return;
}
for (let i = ys[0]; i <= ys[1]; i++) {
for (let j = xs[0]; j <= xs[1]; j++) {
keys.push(mergeKey(i, j));
}
}
setEditKeys(keys);
};
const antiCollision = (start: { x: number, y: number }, end: { x: number, y: number }) => {
const rec1 = [start.x, start.y, end.x, end.y];
for (let i = 0; i < list.length; i++) {
const item = list[i];
const rec2 = [item.x, item.y, item.x + item.width, item.y + item.height];
const isRectangleOverlapRes = isRectangleOverlap(rec1, rec2);
if (isRectangleOverlapRes) {
return true;
}
}
return false;
};
const mergeKey = (y: number, x: number) => {
return Number(x + (y * 10));
};
const splitKey = (key: number) => {
if (key >= 10) {
return { y: parseInt((key % 100) / 10), x: key % 10 };
} else {
return { y: 0, x: Number(key) };
}
};
const getWidth = () => {
return parseInt(cubeWrapWidth / col);
};
const getStyle = (style) => {
const { x, y, width, height } = style;
const result = {
left: `${x * getWidth() - 1}px`,
top: `${y * getWidth() - 1}px`,
width: `${width * getWidth() + 1}px`,
height: `${height * getWidth() + 1}px`,
};
return result;
};
const deleteEditWrap = (index: number) => {
const updatedValue = [...list];
updatedValue.splice(index, 1);
updateList(updatedValue);
updateCurIndex(updatedValue.length - 1);
};
return (
<div className="custom-layout" style={{ width: `${cubeWrapWidth}px` }}>
{ys.map((y) => (
<ul key={y} className="custom-layout-ul">
{xs.map((x) => {
const key = mergeKey(y, x);
const dataKey = key.toString();
const dataY = y.toString();
const dataX = x.toString();
const isActive = editKeys.includes(key);
const width = getWidth();
return (
<li
key={key}
data-key={dataKey}
data-y={dataY}
data-x={dataX}
style={{
width,
height: width,
textAlign: 'center'
}}
className={`wrap-item flex-center ${isActive ? 'move-wrap' : ''}`}
onClick={clickWrap}
onMouseOver={move}
>
<i style={{ lineHeight: `${width}px` }} className={`gscm-designer-font icon-jia1`} />
</li>
);
})}
</ul>
))}
{/* 编辑容器块 */}
{list.map((item, index) => {
const isActive = curIndex === index;
const style = getStyle(item);
const isImageEmpty = item.image === defaultImage || !item.image;
const backgroundImage = isImageEmpty ? 'none' : `url(${item.image})`;
return (
<div
key={index}
className={`edit-wrap flex-column flex-center ${isActive ? 'edit-wrap-active' : ''}`}
style={style}
onClick={() => updateCurIndex(index)}
>
{model === 'custom' && (
<div className="edit-wrap-close" onClick={() => deleteEditWrap(index)}>
<i className="gscm-designer-font icon-guanbi"></i>
</div>
)}
<div className='edit-warp-text' style={{ backgroundImage }}>
{isImageEmpty && <div>{`${parseInt(item.width * getBaseW())}x${parseInt(item.height * getBaseW())}`}</div>}
{/* {item.width > 1 && <div>或同等比例</div>} */}
</div>
</div>
);
})}
</div>
);
}
);
export default CustomLayout;

7.2.3. magic-cube-setter/helper.ts

  1. cubeRowsList: 这是一个包含数字的数组,表示了一些魔方的行数。
  2. cubeWrapWidth: 表示魔方的包裹宽度。
  3. customLayoutWidth: 表示自定义布局的宽度。
  4. Model 接口定义了一个模型对象的结构,包括 x、y 坐标、宽度、高度和图片路径。
  5. InitialModels 接口定义了一个初始模型对象的结构,是一个 key 值为字符串,值为 Model 数组的对象。
  6. initialModels: 是一个包含多个魔方初始模型的对象,每个魔方有不同数量的模型组成。
  7. ModelOption 接口定义了一个模型选项的结构,包括标签、值、行数和列数等信息。
  8. modelOptions: 是一个包含多个模型选项的数组,用于描述不同类型的模型布局选项。
  9. rectangleFormat 函数用于格式化矩形的坐标信息,确保左上角坐标值小于右下角坐标值。根据 temp 参数的设置,可以返回不同的格式化结果。
  10. isRectangleOverlap 函数用于判断两个矩形是否重叠,内部调用了 rectangleFormat 函数来格式化矩形坐标信息,并进行比较判断是否重叠。
import { sortBy } from 'lodash';
import defaultImage from './defaultImage.png';
export const cubeRowsList: number[] = [4, 5, 6, 7];
export const cubeWrapWidth: number = 220;
export const customLayoutWidth: number = 750;
export interface Model {
x: number;
y: number;
width: number;
height: number;
image: string;
}
export interface InitialModels {
[key: string]: Model[];
}
export const initialModels: InitialModels = {
magicCube1: [
{
x: 0,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 0,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube2: [
{
x: 0,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 2,
y: 0,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube3: [
{
x: 0,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 2,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 3,
y: 0,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube4: [
{
x: 0,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 0,
y: 1,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 1,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube5: [
{
x: 0,
y: 0,
height: 2,
width: 1,
image: defaultImage
},
{
x: 1,
y: 0,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 1,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube6: [
{
x: 0,
y: 0,
height: 1,
width: 2,
image: defaultImage
},
{
x: 0,
y: 1,
height: 1,
width: 1,
image: defaultImage
},
{
x: 1,
y: 1,
height: 1,
width: 1,
image: defaultImage
}
],
magicCube7: [
{
x: 0,
y: 0,
height: 4,
width: 2,
image: defaultImage
},
{
x: 2,
y: 0,
height: 2,
width: 2,
image: defaultImage
},
{
x: 2,
y: 2,
height: 2,
width: 1,
image: defaultImage
},
{
x: 3,
y: 2,
height: 2,
width: 1,
image: defaultImage
}
]
};
export interface ModelOption {
label: string;
value: string;
row: number;
col: number;
}
export const modelOptions: ModelOption[] = [
{
label: "一行两个",
value: "magicCube1",
row: 1,
col: 2
},
{
label: "一行三个",
value: "magicCube2",
row: 1,
col: 3
},
{
label: "一行四个",
value: "magicCube3",
row: 1,
col: 4
},
{
label: "两左两右",
value: "magicCube4",
row: 2,
col: 2
},
{
label: "一左两右",
value: "magicCube5",
row: 2,
col: 2
},
{
label: "一上二下",
value: "magicCube6",
row: 2,
col: 2
},
{
label: "一左三右",
value: "magicCube7",
row: 4,
col: 4
},
{
label: "自定义",
value: "custom",
row: 5,
col: 5
}
];
const rectangleFormat = (rec, temp) => {
const xs = sortBy([rec[0], rec[2]]);
const ys = sortBy([rec[1], rec[3]]);
if (temp) {
return [xs[0], ys[0], xs[1] + 1, ys[1] + 1];
} else {
return [xs[0], ys[0], xs[1], ys[1]];
}
};
export const isRectangleOverlap = function (rec1, rec2) {
const rectangle1 = rectangleFormat(rec1, true);
const rectangle2 = rectangleFormat(rec2);
return rectangle2[0] < rectangle1[2] && rectangle2[1] < rectangle1[3] && rectangle2[2] > rectangle1[0] && rectangle2[3] > rectangle1[1];
};

8. 小结

魔方组件基本可以满足商家对于产品展示和活动海报的灵活需求。它具有以下特点:

  1. 提供多样化的布局选择,包括预设模板和自定义模板。
  2. 支持上传商品图片和活动海报,并为图片添加链接。
  3. 可调整图片间隙和尺寸要求,适配不同的页面布局和设备。
  4. 自定义模板支持移动鼠标选定布局区域大小,满足商家的个性化需求。
  5. 设置器组件用于配置魔方的布局和属性。
    • 可以选择不同的模板和布局密度,同时支持自定义布局。
    • 在布局区域中,用户可以选择图片并进行编辑操作,以满足不同的展示需求。

原文链接:https://juejin.cn/post/7346959888009248802 作者:窗边的anini

(0)
上一篇 2024年3月18日 下午4:49
下一篇 2024年3月18日 下午5:00

相关推荐

发表回复

登录后才能评论