0
0
0

单商户H5商城搭建:UNIAPP+小程序源码开源

职场区 3253582893 1月前
1404

源码获取:ym7k.com/11784/

单商户 H5 商城搭建:UniApp + 小程序开源系统与首页个性化排版的工程实现

引子:个性化排版是单商户系统的"取舍点"

单商户商城系统与多商户 SaaS 系统有一个根本区别:多商户系统必须支持任意商户的任意需求,因此功能要尽可能通用、可配置;单商户系统只需要服务一个商家,理论上可以"定制开发"。

但"定制开发"在工程上是一个陷阱。每次商家想调整首页布局,开发者都要改代码、重新编译、重新发布。这个成本在长期运营中会迅速累积。一个理性的单商户系统,应该在"通用性"和"灵活性"之间找到平衡点:核心业务逻辑固定,但页面布局可配置。

这就是首页个性化排版设计的价值所在。它让商家可以在后台拖拽组件、调整顺序、修改参数,实时预览效果,发布后前端自动更新,不需要开发者介入。

本文以 UniApp + 小程序的开源单商户商城为例,从个性化排版的数据模型、组件注册与动态渲染、实时预览的实现、多端适配策略,到开源系统的搭建流程,展开这类系统的完整技术分析。

一、个性化排版的核心设计

1.1 排版的本质:把页面结构数据化

个性化排版的本质,是把页面的结构从代码中抽离出来,变成可编辑的数据。

传统开发方式下,首页的布局写在 .vue 文件里:

vue
<template>
  <view class="home">
    <swiper>...</swiper>
    <nav-grid>...</nav-grid>
    <goods-list>...</goods-list>
  </view>
</template>

这种方式的问题是:布局是"硬编码"的。想换一个轮播图,要改代码;想调整商品列表的位置,要改代码;想增加一个公告栏,要改代码。

个性化排版的方式下,布局存在数据库中:

json
{
  "components": [
    {"type": "banner", "sort": 1, "config": {...}},
    {"type": "nav_grid", "sort": 2, "config": {...}},
    {"type": "goods_list", "sort": 3, "config": {...}}
  ]
}

前端根据这个 JSON 动态渲染。想换轮播图,只改 config;想调整顺序,只改 sort;想增加公告栏,只加一个组件。不需要改代码,不需要重新编译。

1.2 数据模型

sql
CREATE TABLE `page_config` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `page_key` varchar(50) NOT NULL COMMENT '页面标识:home/category/user',
  `name` varchar(100) NOT NULL COMMENT '页面名称',
  `components` longtext NOT NULL COMMENT '组件配置JSON',
  `background` varchar(50) DEFAULT '#f5f5f5' COMMENT '页面背景色',
  `version` int(11) NOT NULL DEFAULT '1' COMMENT '版本号',
  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '0=草稿 1=已发布',
  `published_at` datetime DEFAULT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_page_key` (`page_key`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 页面版本历史表(用于回滚)
CREATE TABLE `page_config_history` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `page_key` varchar(50) NOT NULL,
  `components` longtext NOT NULL,
  `version` int(11) NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_page_version` (`page_key`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

page_config 表存储当前生效的页面配置,page_config_history 存储历史版本。当商家修改配置并保存时,系统先将旧配置写入历史表,再更新当前配置。

二、组件注册与动态渲染

2.1 组件注册表

前端需要知道每种组件的渲染方式。这通过组件注册表实现。

javascript
// components/diy/registry.js

import Banner from './components/Banner.vue';
import NavGrid from './components/NavGrid.vue';
import GoodsList from './components/GoodsList.vue';
import Notice from './components/Notice.vue';
import Coupon from './components/Coupon.vue';
import ImageAd from './components/ImageAd.vue';
import Search from './components/Search.vue';
import VideoFeed from './components/VideoFeed.vue';

export const componentRegistry = {
  search: {
    name: '搜索栏',
    icon: 'search',
    component: Search,
    defaultConfig: {
      placeholder: '搜索商品',
      style: 'rounded',
      bgColor: '#ffffff',
    },
  },
  banner: {
    name: '轮播图',
    icon: 'image',
    component: Banner,
    defaultConfig: {
      height: 320,
      radius: 12,
      autoplay: true,
      interval: 3000,
      items: [],
    },
  },
  nav_grid: {
    name: '导航宫格',
    icon: 'grid',
    component: NavGrid,
    defaultConfig: {
      columns: 5,
      rows: 2,
      items: [],
    },
  },
  goods_list: {
    name: '商品列表',
    icon: 'goods',
    component: GoodsList,
    defaultConfig: {
      title: '精选商品',
      source: 'recommend',
      limit: 10,
      layout: 'grid',
      columns: 2,
      showPrice: true,
      showSales: true,
    },
  },
  notice: {
    name: '公告栏',
    icon: 'notice',
    component: Notice,
    defaultConfig: {
      text: '欢迎光临本店',
      scroll: true,
      icon: 'notice',
    },
  },
  coupon: {
    name: '优惠券',
    icon: 'coupon',
    component: Coupon,
    defaultConfig: {
      couponIds: [],
      layout: 'horizontal',
    },
  },
  image_ad: {
    name: '图片广告',
    icon: 'image',
    component: ImageAd,
    defaultConfig: {
      image: '',
      link: '',
      height: 200,
      radius: 8,
    },
  },
  video_feed: {
    name: '视频流',
    icon: 'video',
    component: VideoFeed,
    defaultConfig: {
      title: '种草视频',
      limit: 6,
      layout: 'double_column',
    },
  },
};

2.2 动态渲染器

前端页面遍历配置中的组件列表,根据 type 从注册表查找对应组件,传入 config。

vue
<!-- pages/index/index.vue -->
<template>
  <view class="diy-page" :style="{ background: pageConfig.background }">
    <block v-for="comp in sortedComponents" :key="comp.id">
      <component
        :is="getComponent(comp.type)"
        v-if="getComponent(comp.type)"
        :config="comp.config"
        :comp-id="comp.id"
        @navigate="onNavigate"
      />
    </block>
  </view>
</template>

<script>
import { componentRegistry } from '@/components/diy/registry';
import { adaptComponents, detectTerminal } from '@/components/diy/adapter';

export default {
  data() {
    return {
      pageConfig: {
        background: '#f5f5f5',
        components: [],
      },
    };
  },
  computed: {
    sortedComponents() {
      const terminal = detectTerminal();
      const adapted = adaptComponents(
        this.pageConfig.components || [],
        terminal
      );
      return adapted.sort((a, b) => a.sort - b.sort);
    },
  },
  onLoad() {
    this.loadPageConfig();
  },
  onPullDownRefresh() {
    this.loadPageConfig().finally(() => uni.stopPullDownRefresh());
  },
  methods: {
    async loadPageConfig() {
      try {
        const res = await this.$api.page.getConfig({ page_key: 'home' });
        this.pageConfig = res.data || this.pageConfig;
      } catch (e) {
        console.error('加载页面配置失败', e);
      }
    },
    getComponent(type) {
      const entry = componentRegistry[type];
      return entry ? entry.component : null;
    },
    onNavigate(url) {
      if (!url) return;
      // 处理不同类型的链接
      if (url.startsWith('http')) {
        // #ifdef H5
        window.location.href = url;
        // #endif
      } else {
        uni.navigateTo({ url });
      }
    },
  },
};
</script>

这个渲染器的核心设计是配置驱动 + 组件注册表。页面不硬编码任何组件,完全由配置决定。新增组件类型只需要在注册表中注册,不需要修改页面代码。

2.3 组件的统一接口

每个 DIY 组件都遵循统一的接口约定:接收 config 和 compId 作为 props,通过 $emit('navigate', url) 处理跳转。

vue
<!-- components/diy/components/Banner.vue -->
<template>
  <view class="diy-banner" :style="{ height: config.height + 'rpx' }">
    <swiper
      :autoplay="config.autoplay"
      :interval="config.interval"
      :circular="true"
      indicator-dots
      indicator-color="rgba(255,255,255,0.5)"
      indicator-active-color="#ffffff"
    >
      <swiper-item
        v-for="(item, idx) in config.items"
        :key="idx"
        @click="handleClick(item)"
      >
        <image
          :src="item.image"
          mode="aspectFill"
          class="banner-image"
          :style="{ borderRadius: config.radius + 'rpx' }"
        />
      </swiper-item>
    </swiper>
  </view>
</template>

<script>
export default {
  name: 'DiyBanner',
  props: {
    config: { type: Object, required: true },
    compId: { type: String, default: '' },
  },
  methods: {
    handleClick(item) {
      if (item.link) {
        this.$emit('navigate', item.link);
      }
    },
  },
};
</script>

<style scoped>
.diy-banner {
  width: 100%;
  padding: 20rpx;
  box-sizing: border-box;
}
.banner-image {
  width: 100%;
  height: 100%;
}
</style>

2.4 后台拖拽编辑器

商家在后台通过拖拽编辑器修改页面配置。编辑器由三部分组成:组件库(左侧,可拖拽的组件列表)、画布(中间,实时预览当前配置)、属性面板(右侧,编辑选中组件的参数)。

vue
<!-- admin/components/PageEditor.vue -->
<template>
  <view class="page-editor">
    <!-- 组件库 -->
    <view class="component-library">
      <view class="library-title">组件库</view>
      <view
        v-for="(entry, type) in registry"
        :key="type"
        class="library-item"
        draggable="true"
        @dragstart="onDragStart(type)"
        @click="addComponent(type)"
      >
        <text class="icon">{{ entry.icon }}</text>
        <text class="name">{{ entry.name }}</text>
      </view>
    </view>

    <!-- 画布 -->
    <view class="canvas" @drop="onDrop" @dragover.prevent>
      <view class="canvas-title">页面预览</view>
      <view
        v-for="(comp, index) in components"
        :key="comp.id"
        class="canvas-item"
        :class="{ active: selectedIndex === index }"
        @click="selectComponent(index)"
      >
        <view class="item-header">
          <text>{{ registry[comp.type]?.name || comp.type }}</text>
          <view class="item-actions">
            <text @click.stop="moveUp(index)">↑</text>
            <text @click.stop="moveDown(index)">↓</text>
            <text @click.stop="removeComponent(index)">×</text>
          </view>
        </view>
        <view class="item-preview">
          <!-- 简化预览,实际渲染对应的组件 -->
          <text class="preview-text">{{ comp.type }}</text>
        </view>
      </view>
    </view>

    <!-- 属性面板 -->
    <view class="property-panel">
      <view class="panel-title">属性配置</view>
      <view v-if="selectedComponent" class="property-form">
        <view
          v-for="(value, key) in selectedComponent.config"
          :key="key"
          class="property-item"
        >
          <text class="label">{{ key }}</text>
          <input
            v-if="typeof value === 'string' || typeof value === 'number'"
            :value="value"
            @input="updateConfig(key, $event.detail.value)"
          />
          <switch
            v-else-if="typeof value === 'boolean'"
            :checked="value"
            @change="updateConfig(key, $event.detail.value)"
          />
        </view>
      </view>
      <view v-else class="empty-tip">请选择一个组件</view>
    </view>
  </view>
</template>

<script>
import { componentRegistry } from '@/components/diy/registry';

export default {
  data() {
    return {
      registry: componentRegistry,
      components: [],
      selectedIndex: -1,
      dragType: null,
    };
  },
  computed: {
    selectedComponent() {
      return this.selectedIndex >= 0
        ? this.components[this.selectedIndex]
        : null;
    },
  },
  methods: {
    addComponent(type) {
      const entry = componentRegistry[type];
      if (!entry) return;

      this.components.push({
        id: `comp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
        type,
        sort: this.components.length + 1,
        config: JSON.parse(JSON.stringify(entry.defaultConfig)),
      });
    },
    onDragStart(type) {
      this.dragType = type;
    },
    onDrop() {
      if (this.dragType) {
        this.addComponent(this.dragType);
        this.dragType = null;
      }
    },
    selectComponent(index) {
      this.selectedIndex = index;
    },
    moveUp(index) {
      if (index <= 0) return;
      const temp = this.components[index];
      this.components.splice(index, 1);
      this.components.splice(index - 1, 0, temp);
      this.reindex();
    },
    moveDown(index) {
      if (index >= this.components.length - 1) return;
      const temp = this.components[index];
      this.components.splice(index, 1);
      this.components.splice(index + 1, 0, temp);
      this.reindex();
    },
    removeComponent(index) {
      this.components.splice(index, 1);
      this.selectedIndex = -1;
      this.reindex();
    },
    reindex() {
      this.components.forEach((c, i) => { c.sort = i + 1; });
    },
    updateConfig(key, value) {
      if (this.selectedIndex >= 0) {
        this.components[this.selectedIndex].config[key] = value;
      }
    },
    async save() {
      await this.$api.page.saveConfig({
        page_key: 'home',
        components: this.components,
      });
      uni.showToast({ title: '保存成功' });
    },
  },
};
</script>

这个编辑器的核心设计是三段式布局 + 拖拽交互。组件库、画布、属性面板各自独立,通过 components 数组共享数据。拖拽组件到画布时,系统从注册表获取默认配置,生成新的组件实例。

三、实时预览与发布

3.1 实时预览的实现

商家在编辑器中修改配置后,需要实时看到效果。这有两种实现方式。

方式一:编辑器内置渲染。 编辑器直接在画布中渲染真实组件,与前端使用相同的组件库。优点是所见即所得;缺点是编辑器的包体积会包含所有组件,且组件的交互行为在编辑器中需要被禁用。

方式二:预览链接。 编辑器保存草稿后,生成一个带 preview_token 的预览链接,商家在手机或浏览器中打开链接查看效果。优点是编辑器轻量;缺点是预览不是实时的,需要手动刷新。

对于单商户系统,方式一更适合,因为商家通常是在 PC 后台编辑,希望所见即所得。

javascript
// 编辑器画布中渲染真实组件
import { componentRegistry } from '@/components/diy/registry';

export default {
  components: {
    // 动态注册所有 DIY 组件
    ...Object.values(componentRegistry).reduce((acc, entry) => {
      acc[entry.component.name] = entry.component;
      return acc;
    }, {}),
  },
  // ...
};

3.2 草稿与发布机制

商家编辑页面时,修改的是草稿,不影响线上用户。只有点击"发布"后,草稿才生效。

php
<?php
// app/admin/controller/PageController.php

namespace app\admin\controller;

use think\facade\Db;

class PageController
{
    /**
     * 保存草稿
     */
    public function saveDraft()
    {
        $pageKey = input('page_key');
        $components = input('components/a');

        $page = Db::name('page_config')->where('page_key', $pageKey)->find();

        if ($page) {
            Db::name('page_config')
                ->where('id', $page['id'])
                ->update([
                    'components' => json_encode($components),
                    'status' => 0, // 草稿状态
                    'updated_at' => date('Y-m-d H:i:s'),
                ]);
        } else {
            Db::name('page_config')->insert([
                'page_key' => $pageKey,
                'name' => '首页',
                'components' => json_encode($components),
                'status' => 0,
                'created_at' => date('Y-m-d H:i:s'),
            ]);
        }

        return json(['code' => 200, 'msg' => '草稿保存成功']);
    }

    /**
     * 发布页面
     */
    public function publish()
    {
        $pageKey = input('page_key');

        Db::startTrans();
        try {
            $page = Db::name('page_config')
                ->where('page_key', $pageKey)
                ->lock(true)
                ->find();

            if (!$page) {
                throw new \Exception('页面不存在');
            }

            // 保存历史版本
            $newVersion = $page['version'] + 1;
            Db::name('page_config_history')->insert([
                'page_key' => $pageKey,
                'components' => $page['components'],
                'version' => $page['version'],
                'created_at' => date('Y-m-d H:i:s'),
            ]);

            // 更新当前版本
            Db::name('page_config')
                ->where('id', $page['id'])
                ->update([
                    'version' => $newVersion,
                    'status' => 1,
                    'published_at' => date('Y-m-d H:i:s'),
                ]);

            // 清除缓存
            \think\facade\Cache::delete("page_config:{$pageKey}");

            Db::commit();
            return json(['code' => 200, 'msg' => '发布成功']);

        } catch (\Exception $e) {
            Db::rollback();
            return json(['code' => 500, 'msg' => $e->getMessage()]);
        }
    }

    /**
     * 回滚到指定版本
     */
    public function rollback()
    {
        $pageKey = input('page_key');
        $version = input('version/d');

        $history = Db::name('page_config_history')
            ->where('page_key', $pageKey)
            ->where('version', $version)
            ->find();

        if (!$history) {
            return json(['code' => 404, 'msg' => '版本不存在']);
        }

        Db::name('page_config')
            ->where('page_key', $pageKey)
            ->update([
                'components' => $history['components'],
                'status' => 1,
                'updated_at' => date('Y-m-d H:i:s'),
            ]);

        \think\facade\Cache::delete("page_config:{$pageKey}");

        return json(['code' => 200, 'msg' => '回滚成功']);
    }
}

草稿与发布的分离是关键的工程设计。 如果商家修改后立即生效,任何误操作都会影响线上用户。草稿机制让商家可以反复编辑、预览,确认无误后再发布。

四、多端适配策略

4.1 终端差异的处理

同一个页面配置在不同终端上可能需要不同的渲染方式。适配层处理这些差异。

javascript
// components/diy/adapter.js

export function detectTerminal() {
  // #ifdef MP-WEIXIN
  return 'mp';
  // #endif

  // #ifdef H5
  const ua = navigator.userAgent;
  if (/MicroMessenger/i.test(ua)) return 'oa';
  if (!/Android|iPhone|iPad|Mobile/i.test(ua)) return 'pc';
  return 'h5';
  // #endif

  // #ifdef APP-PLUS
  return 'app';
  // #endif

  return 'unknown';
}

export function adaptComponents(components, terminal) {
  return components.map(comp => {
    const adapted = JSON.parse(JSON.stringify(comp));

    switch (comp.type) {
      case 'nav_grid':
        if (terminal === 'pc') {
          adapted.config.columns = Math.min(comp.config.columns || 5, 4);
        }
        break;

      case 'banner':
        if (terminal === 'mp') {
          adapted.config.height = Math.min(comp.config.height || 320, 400);
        }
        if (terminal === 'pc') {
          adapted.config.height = Math.min(comp.config.height || 320, 500);
        }
        break;

      case 'goods_list':
        if (terminal === 'pc') {
          adapted.config.columns = 4;
        } else if (terminal === 'h5' || terminal === 'oa') {
          adapted.config.columns = comp.config.columns || 2;
        }
        break;

      case 'video_feed':
        if (terminal === 'pc') {
          adapted.config.layout = 'grid';
          adapted.config.columns = 4;
        }
        break;
    }

    return adapted;
  });
}

4.2 样式单位的选择

UniApp 中,rpx 在移动端按屏幕宽度缩放,在 PC 端会导致元素巨大。因此 PC 端需要使用 px。

scss
/* 移动端使用 rpx */
.diy-page {
  padding: 20rpx;
}

/* PC 端使用 px */
@media screen and (min-width: 1024px) {
  .diy-page {
    padding: 20px;
    max-width: 1200px;
    margin: 0 auto;
  }
}

五、开源系统搭建流程

5.1 环境要求

text
操作系统:CentOS 7+ / Ubuntu 20.04+
Web 服务器:Nginx 1.18+
PHP:7.2+(需 pdo_mysql、curl、gd、fileinfo 扩展)
数据库:MySQL 5.7+
Node.js:14+

5.2 后端部署

bash
# 1. 拉取源码
cd /var/www
git clone https://github.com/your-repo/single-mall.git
cd single-mall

# 2. 安装依赖
composer install --no-dev

# 3. 配置环境
cp .env.example .env
vim .env

# 4. 导入数据库
mysql -u root -p single_mall < install/single_mall.sql

# 5. 设置权限
chmod -R 755 runtime public/uploads
chown -R www:www runtime public/uploads

5.3 前端编译

bash
cd uni-app

# 安装依赖
npm install

# 配置 API 地址
vim config/index.js
# 设置 BASE_URL 为后端地址

# 编译 H5
npm run build:h5

# 编译微信小程序
npm run build:mp-weixin

# 复制 H5 产物到后端
cp -r dist/build/h5/* ../public/h5/

5.4 Nginx 配置

nginx
server {
    listen 80;
    server_name your-domain.com;
    root /var/www/single-mall/public;
    index index.php index.html;

    location / {
        if (!-e $request_filename) {
            rewrite ^(.*)$ /index.php?s=$1 last;
        }
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php-fpm/www.sock;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ ^/(runtime|vendor|\.env) {
        deny all;
    }

    client_max_body_size 20m;
}

5.5 配置定时任务

bash
crontab -e

# 订单自动确认
0 * * * * cd /var/www/single-mall && php think order:auto_confirm

# 拼团超时检查
* * * * * cd /var/www/single-mall && php think group:check_expire

# 数据清理
0 3 * * * cd /var/www/single-mall && php think system:cleanup

5.6 初始化商城

访问 http://your-domain.com/admin 进入管理后台,完成以下初始化:

  1. 设置商城基本信息(名称、Logo、联系方式)

  2. 配置支付方式(微信支付、支付宝)

  3. 添加商品分类和商品

  4. 进入"页面装修"模块,编辑首页布局

  5. 发布页面,访问 H5 或小程序查看效果

六、边界与张力

单商户 H5 商城的个性化排版在工程实践中,有几个需要清醒认识的边界。

第一,组件的通用性与定制性的矛盾。 通用组件(轮播、导航、商品列表)能覆盖 80% 的场景,但商家的特殊需求(如"限时抢购倒计时+进度条")需要定制组件。系统需要在"提供通用组件"和"支持自定义组件"之间找到平衡。

第二,预览与线上的差异。 编辑器中的预览与真实终端上的渲染可能存在差异,尤其是涉及 API 数据的组件(如商品列表)。预览时可能显示的是模拟数据,实际渲染时依赖后端返回。系统需要提供"真机预览"功能,让商家在手机上看真实效果。

第三,多端一致性的维护。 同一个组件在 H5、小程序、App 上的渲染可能有差异。每次修改组件都需要在多个终端上验证。这增加了开发和测试的工作量。

第四,开源系统的安全维护。 开源意味着代码公开,安全漏洞容易被发现和利用。系统需要定期更新依赖、修复安全漏洞,并提供安全加固的文档。

第五,页面配置的性能影响。 如果页面配置过于复杂(如包含 20 个组件),前端的渲染性能会下降。系统需要限制组件的数量和复杂度,或者引入虚拟滚动等优化手段。

结语:个性化排版的工程本质是"配置驱动渲染"

单商户 H5 商城的个性化排版设计,其工程本质可以归结为一句话:把页面的结构从代码中抽离出来,变成可编辑、可预览、可发布的配置。

这个设计的关键组件包括:组件注册表(定义有哪些组件)、动态渲染器(根据配置渲染组件)、拖拽编辑器(让商家可视化编辑)、草稿发布机制(保护线上用户)、多端适配层(处理终端差异)。

这套设计的价值,最终体现在一个朴素的判断上:商家想调整首页的轮播图,需要多长时间? 如果答案是"打开后台,拖拽调整,点击发布,1 分钟",那它就是一个合格的个性化排版系统;如果答案是"联系开发者,改代码,重新编译,1 天",那它只是一个传统商城。开源系统的价值,正在于把"配置驱动渲染"这个能力免费提供给商家,让他们真正掌握自己商城的页面。

最新回复 (0)

    暂无评论

请先登录后发表评论!

返回
请先登录后发表评论!