简介:一款面向微信小程序开发者的商城分销系统演示项目,既适合刚接触小程序的初学者理解电商与分销业务,也适合进阶者参考完整的代码结构。压缩包共包含194个文件,整体约8MB,内有139张图片和30张图片素材,用于商品、横幅、头像等界面;另有8个脚本文件处理页面逻辑,6个样式文件控制界面外观,5个页面结构文件搭建页面框架,5个配置数据文件完成项目配置,目录层次分明,方便按模块查找。目前已有117人学习。示例覆盖商品展示、商品详情、购物车、订单管理、微信支付等商城核心功能,同时内置分销模式、多级等级、佣金自动计算、推广数据统计与提现申请流程,并借助云数据库、开放接口和本地缓存优化用户体验。开发者可通过该实例掌握页面生命周期、数据绑定、组件通信、支付对接以及分销返佣的设计思路,为自主开发电商类小程序提供可复用的参考。
1. 微信小程序商城分销demo的资源结构:从切图反推页面角色
把“微信小程序demo:商城分销系统.rar”解开之后,你会发现里面没有现成的源码工程,而是一组命名直白的切图:t1.jpg、t2.jpg、uc-head.jpg、sch-banner.jpg、offline-banner.jpg、fxbanner.jpg。第一次拿到这类资源的人多半会愣一下,觉得被放了鸽子,但换个角度看,这恰恰是商城分销项目里最容易被反复返工的界面层已经替你定稿了——首页头图、搜索横幅、个人中心头部、分销中心入口、商品占位图全都齐了。
这篇文章就沿着这堆切图,把一个小程序商城分销从页面骨架一直推到分销佣金结算,告诉你哪些地方可以直接抄,哪些地方必须自己补齐。适合刚学小程序页面拆分的人,也适合已经写过商城、想快速补上分销模块的开发者。素材包里没有后端代码,所以数据层我会按微信小程序云开发最常见的方式补全,同时给出切回PHP或Java后端的边界。
2. 从素材归类到页面骨架:WXML布局与tabBar导航配置
项目要跑起来,先做两件事:把图片归位到对应页面,把app.json里的路由和tabBar配好。微信开发者工具打开项目目录后,默认先读app.json的pages数组,数组第一项就是冷启动页面。这一步不需要写业务逻辑,但决定了后面所有页面的入口位置,属于典型的搭建微信小程序流程里最先要落地的动作。
2.1 先给切图做一次“页面测绘”
把图片文件当设计稿来读,比照着文档写页面要快得多。很多这类demo把图片命名为t1、t2、uc-head、sch-banner,命名虽然随意,但角色是清晰的。按常见商城分销页面结构,可以整理成一张素材映射表,后续写WXML时直接对号入座。
| 素材文件 | 对应页面 | 界面里的角色 |
|---|---|---|
| t1.jpg / t2.jpg | 首页 / 商品列表 | 头图、金刚区图标或商品占位图 |
| sch-banner.jpg | 首页顶部 / 搜索页 | 搜索横幅背景 |
| uc-head.jpg | 个人中心 | 用户信息卡片的背景头图 |
| hometit1.jpg / hometit3.jpg | 首页 | 楼层标题(今日上新、热卖推荐) |
| fxbanner.jpg | 分销中心 | 分销规则或提成入口横幅 |
| offline-banner.jpg | 全局 | 门店下线 / 活动结束提示横幅 |
| t5.jpg / t6.jpg | 商品详情 / 购物车 | 商品图、优惠券展示位 |
要注意横版banner和竖版商品图的宽高比差异很大,放到image标签后必须配合mode裁剪,否则真机上会出现大面积拉伸。另外,这类素材包本身就是对“微信小程序图片提取工具”产出结果的一种简化,图片文件名不建议再改,因为WXML里每改一处引用都要同步替换,收益很低。
2.2 app.json:注册页面路由与底部导航
新建项目后,在根目录创建app.json,把页面路由和tabBar一次性配好。这里给出一个能直接跑的配置:
{ "pages": [ "pages/index/index", "pages/category/category", "pages/cart/cart", "pages/mine/mine", "pages/goods/detail", "pages/distribute/center" ], "window": { "navigationBarTitleText": "商城分销", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black" }, "tabBar": { "color": "#999999", "selectedColor": "#e64340", "backgroundColor": "#ffffff", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/cart/cart", "text": "购物车" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "lazyCodeLoading": "requiredComponents", "style": "v2" }pages数组第一项决定首次进入时加载哪个页面,index必须排在前面。tabBar的list最多5项,且只能引用pages里已经注册过的页面,所以商品详情和分销中心不放进tabBar,通过导航跳转进入。tabBar可以不配iconPath,素材包里没有tab图标就先用纯文字,不影响功能。lazyCodeLoading是官方接口,按需注入组件代码,能明显压缩首包体积,建议保留。上传版本时开发者工具会把代码编译成wxapkg包,线上包是混淆压缩后的,抓包拿到的文件已经不具备源码结构,排查问题要在真机调试里看,反编译工具只适合分析自己项目产出的包,拿别人的线上包做商业用途不在讨论范围内。
2.3 在WXML里把切图落到首屏
路由配好之后,页面文件还是空的。先在pages/index/index.wxml里放一版静态结构,验证图片路径和模式是否正确:
<view class="page"> <image class="search-banner" src="/images/sch-banner.jpg" mode="aspectFill" lazy-load="{{true}}" /> <view class="goods-grid"> <image wx:for="{{goodsList}}" wx:key="id" src="{{item.cover}}" mode="widthFix" /> </view> </view>这里的src用的是绝对路径,以斜杠开头指向项目根目录的images文件夹。相对路径在分包多的时候容易飞线,所以一律用绝对路径最稳妥。mode="aspectFill"会等比缩放并居中裁剪,适合搜索横幅这类固定高度的区域;商品流图建议用mode="widthFix",宽度跟随容器、高度按比例自适应,瀑布流场景下不会出现扁图。lazy-load只对image组件生效,配合列表滚动能减少首屏网络请求。
小程序主包限制2MB,这套切图全部塞进包内容易超限。常见做法是把大尺寸banner传CDN,本地只放占位图,运营后台返回图片URL后,前端再用wx.downloadFile把zip包下载下来,解压到wx.env.user_data_path目录做缓存,目录里存放的是用户设备上的附件和临时文件,不会污染代码包。这样既绕开包体积限制,也方便后续动态换图。静态页能跑通后,再进入商品和购物车的逻辑链路。
3. 商品展示、详情传参与购物车:生命周期与setData键路径串起主链路
骨架搭好后,整个商城主链路是“首页商品流 → 详情 → 购物车 → 下单”。这条链路里有两件事决定了代码质量:页面生命周期该在哪个阶段取数,以及setData怎么更新嵌套对象。这两件事看起来基础,实际写的时候踩坑率极高。
3.1 首页列表用onLoad拉数据,loading态单独拆出来
首页数据加载在微信小程序里没有悬念,就是onLoad里调接口,然后setData渲染。但“刚进入时的加载页面”长什么样,很多demo都忽略了。先看一段能跑的首页逻辑:
Page({ data: { loading: true, goodsList: [] }, onLoad() { this.loadGoods() }, loadGoods() { // 真实项目这里换成 wx.cloud.callFunction 或 wx.request const mockList = [ { id: 1, name: '燕麦礼盒', price: 99, cover: '/images/t5.jpg' }, { id: 2, name: '坚果组合', price: 129, cover: '/images/t6.jpg' } ] setTimeout(() => { this.setData({ goodsList: mockList, loading: false }) }, 300) } })onLoad只在页面首次创建时触发一次,onShow是每次页面展示都会触发。商品列表这种冷启动数据放在onLoad,购物车角标数量这种需要实时刷新的数据放在onShow,这是最常规的分工。loading字段配合WXML里的wx:if控制骨架屏,要改刚进入时的加载页面效果,就调整这段骨架屏结构,而不是在page.json里配导航栏loading文案,后者在真机上基本不显示。mock数据这里的setTimeout是为了模拟网络延迟,真实项目里替换成wx.request或云函数调用即可。setTimeout结束后必须把loading置为false,否则骨架屏会一直转,这是个很容易漏掉的细节。
生命周期在商城里的用途可以简单归纳成一张表,写页面时对照着选:
| 生命周期 | 触发时机 | 商城里的用途 |
|---|---|---|
| onLoad | 页面首次创建 | 拉商品列表、商品详情 |
| onShow | 页面每次展示 | 刷新购物车角标、分销业绩 |
| onHide | 页面退到后台 | 暂停轮播图定时器 |
| onUnload | 页面销毁 | 清理定时器、释放资源 |
3.2 列表跳详情:dataset传参与navigateTo页面栈
首页列表渲染出来后,点击某个商品要跳到详情页。跳转代码看起来简单,但传参方式直接影响后续接口查询。商品项的绑定事件和数据是这样写的:
<view class="goods-item" wx:for="{{goodsList}}" wx:key="id" bindtap="goDetail">goDetail(e) { const id = e.currentTarget.dataset.id wx.navigateTo({ url: `/pages/goods/detail?id=${id}` }) }事件对象里通过e.currentTarget.dataset拿到data-id的值。注意dataset的key会做驼峰转换,data-goods-id要写成dataset.goodsId,容易写错。url上的参数有长度限制,复杂对象序列化后塞进去很容易截断,所以只传id,详情页拿到id再去请求完整信息。
详情页接收参数的代码:
Page({ onLoad(options) { this.setData({ id: options.id, loading: true }) // 用 this.data.id 请求商品详情接口 } })navigateTo的页面栈上限是10层,用户连续跳10个详情页后跳转会静默失败,所以详情页返回列表一定要用wx.navigateBack而不是再navigateTo一次。商品规格选择这里,如果用原生radio-group,样式在小程序里很难调好看,我一般会用一个view加activeClass模拟单选框,绑定选中规格的specId,代码更可控。详情页大图预览用wx.previewImage,长按可以保存图片;如果素材图本身是旋转过的,先用CSS的transform: rotate修正展示角度,不要在canvas里重绘,省内存也省事。
3.3 购物车数量调整:setData键路径写法与本地缓存
购物车列表的加减数量是最典型的嵌套数据更新场景。很多新人会先取整个cartList,改完再整体setData,数据量小还行,商品一多就会卡。用键路径更新能省掉整数组setData的开销:
changeCount(e) { const { index, op } = e.currentTarget.dataset const key = `cartList[${index}].count` const count = this.data.cartList[index].count + op if (count < 1) return this.setData({ [key]: count }) wx.setStorageSync('cart', this.data.cartList) }setData支持数组下标和对象字段的局部更新,key写成cartList[0].count这种字符串就能只更新购物车里某一项的count字段,不需要把整个数组发回视图层。这里注意不要在setData回调里读this.data做后续判断,因为data更新是异步的,回调里拿到的可能是旧值。
网上常见到类似this.setData({ 'userInfo.nickname': that.data.nickname })的写法,键路径本身没问题,问题在于nickname必须提前从输入框事件里取出来,不能在回调里用that.data同步,那样取到的永远是上一次的值。购物车数据用wx.setStorageSync落本地,用户下次进来先从缓存恢复购物车,再在onShow里请求接口校库存,这样秒开购物车的同时不会出现下单才发现库存不足的情况。
4. 分销关系、佣金计算与推广统计:云开发聚合把demo数据层推进到可用
商城主链路能跑之后,分销是这个demo真正有信息量的部分。分销系统不是多一张分享海报,核心是用户关系链、佣金结算和统计报表三件事。素材包里的fxbanner.jpg只是分销中心入口的视觉,数据层怎么做才是上线前要面对的真实问题。
4.1 用云开发集合设计核心表
微信小程序云开发自带数据库,集合对应关系型数据库里的表,不用单独租服务器。按商城分销最常见的做法,设计成5个集合,字段和职责如下:
| 集合 | 关键字段 | 职责 |
|---|---|---|
| users | _openid, nickname, avatar, distribute_level, parent_openid | 用户资料与分销上下级关系 |
| goods | _id, name, price, stock, cover, sales | 商品主数据 |
| orders | _id, buyer_openid, share_openid, goods_id, amount, commission_status, create_time | 订单与成交归属 |
| commission_logs | _id, order_id, to_user, from_user, level, amount, status | 佣金流水 |
| code_logs | _id, openid, scene, create_time | 分享二维码与渠道追踪 |
这里最关键的是orders表里同时存在buyer_openid和share_openid。buyer_openid是实际付款人,share_openid是这笔成交归属的推广员,二者不能混。share_openid不一定等于buyer的parent_openid,因为用户可能点了别人的活动链接,临时绑定了推荐关系,所以每个订单必须单独记录归属。users里的parent_openid只代表分销绑定关系,是推广员体系的地基。这套表结构你换成PHP或Java后端时可以直接平移成MySQL表,小程序端把wx.cloud.callFunction换成wx.request,业务逻辑不变。
4.2 三级佣金分成怎么算才不重复
佣金结算是分销系统的核心,也是出错率最高的地方。最常见的问题是一个订单被结算多次,或者分销层级往上多算了一层。用云函数做结算时,从购买者开始往上查三级,每一级按比例生成一条pending状态的佣金流水,最后给订单打上settled标记:
const LEVEL_RATES = { 1: 0.10, 2: 0.05, 3: 0.03 } async function settleCommission(order) { const db = wx.cloud.database() const buyerRes = await db.collection('users') .where({ _openid: order.buyer_openid }).get() if (!buyerRes.data.length) return let current = buyerRes.data[0].parent_openid let level = 1 while (current && level <= 3) { const rate = LEVEL_RATES[level] if (rate && rate > 0) { const amount = Number((order.amount * rate).toFixed(2)) await db.collection('commission_logs').add({ data: { order_id: order._id, to_user: current, from_user: order.buyer_openid, level, amount, status: 'pending' } }) } const parentRes = await db.collection('users') .where({ _openid: current }).get() current = parentRes.data[0]?.parent_openid || '' level += 1 } await db.collection('orders').doc(order._id) .update({ data: { commission_status: 'settled' } }) }佣金比例放在LEVEL_RATES配置里,一级10%、二级5%、三级3%,按订单实付金额计算,toFixed(2)保证金额只保留两位小数。每级生成一条status为pending的流水,等订单过了售后期再改成valid,这样客户退款时可以直接撤销pending流水,不会污染已结算数据。最后给订单写commission_status: settled是防重入口,即使云函数被意外调用两次,第二次也会因为订单已结算而跳过。性能上每级一次查询在三级以内没问题,如果有五级以上分销,就要在订单里冗余一条完整的分销路径,一次读出来循环计算,少打几次数据库。如果是后端用PHP实现,这段逻辑等价于在事务里执行三次SELECT和三次INSERT,同样要加订单状态锁。
4.3 用聚合分组做推广员业绩报表
推广员打开分销中心要看到自己带来了多少订单、多少交易额,这就需要对订单集合做分组统计。云数据库聚合就是为这个场景设计的:
const $ = db.command.aggregate const report = await db.collection('orders') .aggregate() .match({ share_openid: openid, commission_status: 'settled' }) .group({ _id: '$goods_id', orderCount: $.sum(1), totalAmount: $.sum('$amount'), totalPv: $.sum('$pv') }) .sort({ orderCount: -1 }) .limit(20) .end()match先把当前推广员名下且已经结算的订单过滤出来,group按商品ID分组,$.sum(1)统计订单数量,$.sum('$amount')累加交易额。pv字段是在分享页面埋点写入的浏览量,通过这个字段能看出哪个商品值得继续推广。聚合结果可以直接渲染成分销中心里的商品业绩排行,也可以传给后端生成CSV,用户点导出时用wx.openDocument打开,满足运营人员的导出需求。注意聚合这一步消耗的是云函数侧的数据库性能,不要在客户端直接对全表聚合,数据量一大就会超时。
4.4 支付与后端对接边界
真实微信支付在小程序里是wx.requestPayment,这个接口要求后端先调用统一下单拿到支付参数,而且支付回调必须验签。demo阶段用模拟支付按钮把订单状态置为paid即可,但接口边界要留好:支付参数由云函数或后端返回,前端只负责调起支付;从cloud.getWXContext().OPENID拿用户身份,不要自己拼接openid。
调试阶段想确认请求是否到达后端,开发者工具的Network面板能看wx.request的进出,抓包工具也能看到小程序到服务端的HTTPS请求,但云函数内部逻辑只能去云开发控制台的日志里查。上传体验版时,记得在小程序管理后台把版本设为体验版,加体验成员,支付回调域名要提前配到后台白名单,否则真机上一调支付就报url not in domain list。
5. 进阶调试:导航栏高度适配与列表长按拖拽的滚动冲突
这个demo的页面基本成型后,真正的体力活往往在看不到的地方,比如自定义导航栏在不同机型上的表现,还有运营要求“商品长按拖拽排序”时列表滚动和手势的冲突。这两块问题在真机上尤其明显,模拟器里反而都正常。
5.1 用getMenuButtonBoundingClientRect算自定义导航栏高度
小程序右上角的三点圆圈胶囊菜单位置不固定,尤其全面屏和刘海屏差异很大,自定义导航栏不能写死px。正确做法是拿胶囊坐标反推导航栏高度:
const menu = wx.getMenuButtonBoundingClientRect() const system = wx.getSystemInfoSync() const navBarHeight = (menu.top - system.statusBarHeight) * 2 + menu.heightmenu.top减状态栏高度得到导航栏上下留白的单边值,乘以2后加上胶囊高度就是整个导航栏高度。这套算法在绝大多数机型上都成立。基础库2.20.1之后wx.getSystemInfoSync有替代接口wx.getWindowInfo,但胶囊坐标始终以getMenuButtonBoundingClientRect为准,两个接口配合使用。
5.2 长按拖拽排序与scroll-view滚动互斥
长按拖拽排序在商品管理、楼层排序里用得多,和scroll-view天生冲突,手指一动页面就跟着滚。核心思路是长按进入拖拽态后,禁止列表滚动,通过touchmove的pageY偏移计算目标索引:
onLongPress(e) { this.setData({ dragging: true, dragIndex: e.currentTarget.dataset.index }) }, onTouchMove(e) { if (!this.data.dragging) return const itemHeight = 110 const offset = e.touches[0].pageY - this.data.startY const target = Math.min( this.data.goodsList.length - 1, Math.max(0, this.data.dragIndex + Math.round(offset / itemHeight)) ) if (target !== this.data.dragIndex) { this.setData({ dragIndex: target }) } }拖拽开始时记录起始pageY和dragIndex,touchmove里按itemHeight估算目标位置,Math.max和Math.min把索引夹在合法范围里。scroll-view要同步设置scroll-y="{{!dragging}}"或者把move事件改为catchtouchmove阻止冒泡,否则排序还没落下来,页面先滚走了。iOS上scroll-view的渲染机制比较特殊,如果页面里同时有uni-datetime-picker这类浮层组件,浮层放在scroll-view内部会出现点不到picker的问题,把picker移出滚动区域用fixed定位,是成本最低的解法。如果你打算用uniapp重建这套demo,WXML不能直接塞进vue模板,但页面拆分和分销聚合逻辑完全可以照搬,HBuilderX把uni-app编译成微信小程序后跑的仍然是同一套底层接口。
本文还有配套的精品资源,点击获取