单商户H5商城搭建:UNIAPP+小程序源码开源
源码获取:ym7k.com/11784/
单商户 H5 商城搭建:UniApp + 小程序开源系统与首页个性化排版的工程实现


引子:个性化排版是单商户系统的"取舍点"
单商户商城系统与多商户 SaaS 系统有一个根本区别:多商户系统必须支持任意商户的任意需求,因此功能要尽可能通用、可配置;单商户系统只需要服务一个商家,理论上可以"定制开发"。
但"定制开发"在工程上是一个陷阱。每次商家想调整首页布局,开发者都要改代码、重新编译、重新发布。这个成本在长期运营中会迅速累积。一个理性的单商户系统,应该在"通用性"和"灵活性"之间找到平衡点:核心业务逻辑固定,但页面布局可配置。
这就是首页个性化排版设计的价值所在。它让商家可以在后台拖拽组件、调整顺序、修改参数,实时预览效果,发布后前端自动更新,不需要开发者介入。
本文以 UniApp + 小程序的开源单商户商城为例,从个性化排版的数据模型、组件注册与动态渲染、实时预览的实现、多端适配策略,到开源系统的搭建流程,展开这类系统的完整技术分析。
一、个性化排版的核心设计
1.1 排版的本质:把页面结构数据化
个性化排版的本质,是把页面的结构从代码中抽离出来,变成可编辑的数据。
传统开发方式下,首页的布局写在 .vue 文件里:
<template> <view class="home"> <swiper>...</swiper> <nav-grid>...</nav-grid> <goods-list>...</goods-list> </view> </template>
这种方式的问题是:布局是"硬编码"的。想换一个轮播图,要改代码;想调整商品列表的位置,要改代码;想增加一个公告栏,要改代码。
个性化排版的方式下,布局存在数据库中:
{ "components": [ {"type": "banner", "sort": 1, "config": {...}}, {"type": "nav_grid", "sort": 2, "config": {...}}, {"type": "goods_list", "sort": 3, "config": {...}} ] }
前端根据这个 JSON 动态渲染。想换轮播图,只改 config;想调整顺序,只改 sort;想增加公告栏,只加一个组件。不需要改代码,不需要重新编译。
1.2 数据模型
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 组件注册表
前端需要知道每种组件的渲染方式。这通过组件注册表实现。
// 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。
<!-- 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) 处理跳转。
<!-- 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 后台拖拽编辑器
商家在后台通过拖拽编辑器修改页面配置。编辑器由三部分组成:组件库(左侧,可拖拽的组件列表)、画布(中间,实时预览当前配置)、属性面板(右侧,编辑选中组件的参数)。
<!-- 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 后台编辑,希望所见即所得。
// 编辑器画布中渲染真实组件 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 // 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 终端差异的处理
同一个页面配置在不同终端上可能需要不同的渲染方式。适配层处理这些差异。
// 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。
/* 移动端使用 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 环境要求
操作系统: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 后端部署
# 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 前端编译
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 配置
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 配置定时任务
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 进入管理后台,完成以下初始化:
-
设置商城基本信息(名称、Logo、联系方式)
-
配置支付方式(微信支付、支付宝)
-
添加商品分类和商品
-
进入"页面装修"模块,编辑首页布局
-
发布页面,访问 H5 或小程序查看效果
六、边界与张力
单商户 H5 商城的个性化排版在工程实践中,有几个需要清醒认识的边界。
第一,组件的通用性与定制性的矛盾。 通用组件(轮播、导航、商品列表)能覆盖 80% 的场景,但商家的特殊需求(如"限时抢购倒计时+进度条")需要定制组件。系统需要在"提供通用组件"和"支持自定义组件"之间找到平衡。
第二,预览与线上的差异。 编辑器中的预览与真实终端上的渲染可能存在差异,尤其是涉及 API 数据的组件(如商品列表)。预览时可能显示的是模拟数据,实际渲染时依赖后端返回。系统需要提供"真机预览"功能,让商家在手机上看真实效果。
第三,多端一致性的维护。 同一个组件在 H5、小程序、App 上的渲染可能有差异。每次修改组件都需要在多个终端上验证。这增加了开发和测试的工作量。
第四,开源系统的安全维护。 开源意味着代码公开,安全漏洞容易被发现和利用。系统需要定期更新依赖、修复安全漏洞,并提供安全加固的文档。
第五,页面配置的性能影响。 如果页面配置过于复杂(如包含 20 个组件),前端的渲染性能会下降。系统需要限制组件的数量和复杂度,或者引入虚拟滚动等优化手段。
结语:个性化排版的工程本质是"配置驱动渲染"
单商户 H5 商城的个性化排版设计,其工程本质可以归结为一句话:把页面的结构从代码中抽离出来,变成可编辑、可预览、可发布的配置。
这个设计的关键组件包括:组件注册表(定义有哪些组件)、动态渲染器(根据配置渲染组件)、拖拽编辑器(让商家可视化编辑)、草稿发布机制(保护线上用户)、多端适配层(处理终端差异)。
这套设计的价值,最终体现在一个朴素的判断上:商家想调整首页的轮播图,需要多长时间? 如果答案是"打开后台,拖拽调整,点击发布,1 分钟",那它就是一个合格的个性化排版系统;如果答案是"联系开发者,改代码,重新编译,1 天",那它只是一个传统商城。开源系统的价值,正在于把"配置驱动渲染"这个能力免费提供给商家,让他们真正掌握自己商城的页面。
#免责声明#
本站不存储任何实质资源,该帖为网盘用户发布的网盘链接介绍帖,本文内所有链接指向的云盘网盘资源,其版权归版权方所有!其实际管理权为帖子发布者所有,本站无法操作相关资源。如您认为本站任何介绍帖侵犯了您的合法版权,请发送邮件 linkshow2025@163.com 进行投诉,我们将在确认本文链接指向的资源存在侵权后,立即删除相关介绍帖子!
盘主最近主题
- 1《秘书工作手记》套装共3册 职场心法 公文写作 助你在办公室混得风生水起[pdf]
- 2清华大学:DeepSeek从入门到精通 & 职场赋能【高清版PDF】- 15.9MB
- 3《B站 - 职场生存图鉴:拿下令你心动的 offer》【2.90 GB】
- 4《职场高手的12堂工作汇报术(1) 》【5.00 GB】
- 5《职场新人必修课 职场新人路径 》【8.85 GB】
- 6《职场人性沟通术学会再学说话(1) 》【3.01 GB】
- 7《职场生存拿下令你心动的offer 》【3.02 GB】
- 8《黄先生的职场独家秘笈·求职篇 》 【10.72 GB】
- 9职场蔡老师人性沟通课【3.01 GB】
- 10《职场高手的12堂工作汇报术》【5.15 GB】
职场区
暂无评论