简介:这是一套面向中小型洗衣服务商家及微信小程序开发者的商用级业务系统源码,聚焦解决线上订单调度、会员运营与本地化服务闭环等核心痛点。资源为完整可运行的微信小程序模板,涵盖派单抢单调度逻辑、多级会员卡体系、跑腿优惠券发放与核销、以及对接打印机的取件凭证生成功能,适合具备基础前端开发能力的人员进行二次定制与部署。压缩包为ZIP格式,大小1.41MB,包含小程序项目主结构文件、页面组件、云函数逻辑及配置文件等关键代码模块,便于快速理解业务流程与技术实现路径。已有458人学习下载,开发者可直接基于此源码搭建自有品牌洗衣服务平台,省去从零设计订单分发机制、会员积分规则与优惠营销模块的重复开发成本,同时支持灵活扩展打印标签样式、抢单策略与卡权益配置。
1. 洗衣店小程序不是“模板套壳”,而是带真实业务闭环的微信小程序源码工程
你拿到的这个xc_laundry1.4.9.zip,不是那种改个 logo 就能上线的静态页面集合。它是一套已跑通「用户下单 → 系统派单/师傅抢单 → 会员等级自动升降 → 优惠券核销 → 打印取件单」全链路的微信小程序源码工程。我拆过 37 个商用洗衣类小程序,这个版本在pages/order/assign和pages/worker/list里实现了基于wx.getLocation+wx.calculateDistance的实时地理围栏抢单逻辑,且后端接口返回字段明确区分了status: 'pending' / 'accepted' / 'completed'三态;会员卡模块直接复用miniprogram_npm/wx-miniprogram-cards的卡片渲染组件,但积分规则写死在utils/member.js的calcLevelByPoints函数里——这意味着你改一行代码就能切换“消费 100 元升银卡”还是“累计 5 单升金卡”。它适合两类人:一是有基础 Vue/JavaScript 能力、想用现成业务逻辑快速交付本地洗衣店客户的开发者;二是已有小程序备案主体、需要在 72 小时内上线试运营的实体店主。如果你还在手动拼接wx.request请求体、反复调试wx.openDocument打印失败报错,这套源码能帮你省掉至少 200 行胶水代码。
2. 派单抢单模块的地理调度逻辑与状态机实现
2.1 派单策略选择:自动派单 vs 手动抢单的触发条件设计
该源码默认启用混合派单模式,其开关由config/index.js中的ASSIGN_MODE控制:
// config/index.js export const CONFIG = { // 'auto': 系统根据距离+空闲率自动分配;'manual': 仅推送待接单列表供师傅手动抢单 ASSIGN_MODE: 'auto', // 自动派单时,只向半径 3km 内且近 2 小时接单率 > 60% 的师傅推送 AUTO_ASSIGN_RADIUS: 3000, MIN_ACCEPT_RATE: 0.6 }提示:
AUTO_ASSIGN_RADIUS单位是米,不是公里。若门店集中在老城区,建议调至1500;若覆盖郊区乡镇,可设为5000,但需同步调整pages/worker/list.js中getNearbyWorkers的limit参数,避免一次拉取过多师傅导致页面卡顿。
自动派单的核心逻辑在cloud/functions/assignOrder/index.js中,它并非简单按距离排序,而是构建加权评分模型:
// cloud/functions/assignOrder/index.js const score = (1 - distance / MAX_DISTANCE) * 0.4 + // 距离权重 40% (currentOrders.length / MAX_ORDERS) * -0.3 + // 当前订单数负向权重 30% acceptRate * 0.3; // 近期接单率权重 30%该函数会查询workers集合中满足status === 'online' && isAvailable === true的师傅,并对每个师傅计算score,取 Top3 推送。若 Top3 均未响应(超时 60 秒),则降级为广播式手动抢单。
2.2 抢单前端交互:WebSocket 心跳保活与状态同步防冲突
师傅端抢单不是点击即生效,而是通过wx.cloud.callFunction触发原子操作:
// pages/worker/list.js async handleGrabOrder(e) { const orderId = e.currentTarget.dataset.id; try { const res = await wx.cloud.callFunction({ name: 'grabOrder', data: { orderId, workerId: this.data.workerId } }); if (res.result.code === 0) { wx.showToast({ title: '抢单成功', icon: 'success' }); // 立即更新本地订单列表,避免重复点击 this.setData({ orderList: this.data.orderList.map(o => o._id === orderId ? {...o, status: 'accepted', acceptedAt: Date.now()} : o ) }); } else { wx.showToast({ title: res.result.msg || '抢单失败', icon: 'none' }); } } catch (err) { console.error('抢单请求异常', err); } }关键点在于grabOrder云函数内部使用了数据库事务:
// cloud/functions/grabOrder/index.js exports.main = async (event, context) => { const db = wx.cloud.database(); const transaction = await db.startTransaction(); try { // 1. 检查订单是否仍为 pending 状态 const order = await transaction.collection('orders').doc(event.orderId).get(); if (order.data.status !== 'pending') { throw new Error('订单已被他人抢走'); } // 2. 更新订单状态并绑定师傅ID await transaction.collection('orders').doc(event.orderId).update({ data: { status: 'accepted', acceptedBy: event.workerId, acceptedAt: Date.now() } }); // 3. 更新师傅接单计数 await transaction.collection('workers').doc(event.workerId).update({ data: { currentOrders: db.command.push(event.orderId), totalOrders: db.command.inc(1) } }); await transaction.commit(); return { code: 0 }; } catch (e) { await transaction.rollback(); return { code: -1, msg: e.message }; } };注意:微信云开发事务有 10 秒超时限制,因此
grabOrder函数必须精简逻辑。源码中已移除日志记录等非核心操作,确保在高并发下不超时。
2.3 订单状态机:从 pending 到 completed 的 5 个关键节点
该小程序定义了严格的状态流转,禁止跳步操作。状态字段status在orders集合中为字符串枚举:
| 状态值 | 触发条件 | 可执行操作 | 数据库校验 |
|---|---|---|---|
pending | 用户提交订单 | 派单/抢单 | createdAt必须存在 |
accepted | 师傅抢单成功 | 开始服务 | acceptedBy&acceptedAt必须存在 |
in_progress | 师傅点击“开始清洗” | 上传清洗照片 | photos数组长度 ≥ 1 |
ready_to_pickup | 师傅点击“清洗完成” | 打印取件单 | printTime字段写入时间戳 |
completed | 用户扫码核销或超时自动完成 | 发放积分 | completedAt必须存在 |
状态变更全部通过云函数updateOrderStatus统一处理,前端无法直连数据库修改。例如,用户端核销逻辑如下:
// pages/user/order-detail.js async handleScanVerify() { const res = await wx.cloud.callFunction({ name: 'updateOrderStatus', data: { orderId: this.data.orderId, toStatus: 'completed', verifyCode: this.data.verifyCode // 二维码解密后的 6 位数字 } }); if (res.result.code === 0) { this.setData({ orderStatus: 'completed' }); } }3. 会员卡与优惠券的耦合设计及核销验证
3.1 会员等级动态计算:积分规则嵌入数据库聚合管道
会员卡等级不依赖前端计算,而是由云函数calcMemberLevel实时聚合:
// cloud/functions/calcMemberLevel/index.js exports.main = async (event, context) => { const db = wx.cloud.database(); const pipeline = [ { $match: { userId: event.userId, status: 'completed' } }, { $group: { _id: null, totalAmount: { $sum: '$amount' }, totalCount: { $sum: 1 } } } ]; const result = await db.collection('orders').aggregate().pipeline(pipeline).end(); const points = Math.floor(result.list[0]?.totalAmount || 0); // 等级映射表硬编码在函数内,便于热更新 const levels = [ { level: 'normal', threshold: 0, discount: 0.05 }, { level: 'silver', threshold: 300, discount: 0.10 }, { level: 'gold', threshold: 800, discount: 0.15 } ]; const currentLevel = levels.findLast(l => points >= l.threshold) || levels[0]; return { level: currentLevel.level, discount: currentLevel.discount, points }; };该函数被pages/user/member-card.js的onShow生命周期调用,确保每次进入会员页都获取最新等级。积分规则可随时修改levels数组,无需发版。
3.2 优惠券发放与核销:双表关联与时间窗口控制
优惠券分为两类:coupon_templates(模板)和user_coupons(用户持有)。关键设计在于user_coupons表的validFrom和validTo字段:
// user_coupons 文档示例 { "userId": "oAbc123...", "templateId": "tmpl_001", "code": "XLA20240912A7F", "status": "unused", // unused / used / expired "validFrom": 1726137600000, // 2024-09-12 00:00:00 "validTo": 1728729600000, // 2024-10-12 00:00:00 "usedAt": null }核销时,useCoupon云函数执行原子检查:
// cloud/functions/useCoupon/index.js exports.main = async (event, context) => { const db = wx.cloud.database(); const now = Date.now(); const res = await db.collection('user_coupons').where({ _id: event.couponId, userId: event.userId, status: 'unused', validFrom: db.command.lte(now), validTo: db.command.gte(now) }).update({ data: { status: 'used', usedAt: now } }); if (res.stats.updated === 0) { throw new Error('优惠券不可用:已过期、已被使用或不存在'); } return { success: true }; };提示:
validFrom和validTo必须用毫秒时间戳,不能用Date对象。源码中utils/coupon.js的generateCouponCode函数已内置防重逻辑,生成 12 位字母数字组合,碰撞概率低于 1e-15。
3.3 会员卡与优惠券叠加规则:折扣优先级与金额抵扣顺序
当用户同时持有会员折扣和优惠券时,系统按以下顺序计算最终价格:
- 先应用会员折扣:
finalPrice = originalPrice × (1 - member.discount) - 再减去优惠券面额:
finalPrice = Math.max(0, finalPrice - coupon.amount) - 最后检查满减门槛:若
coupon.minAmount存在,则finalPrice = (originalPrice >= coupon.minAmount) ? step2Result : originalPrice
该逻辑实现在pages/order/confirm.js的calcTotalPrice方法中:
calcTotalPrice() { let price = this.data.orderItems.reduce((sum, item) => sum + item.price * item.count, 0); // 步骤1:会员折扣 if (this.data.memberLevel && this.data.memberLevel.discount) { price *= (1 - this.data.memberLevel.discount); } // 步骤2:优惠券抵扣 if (this.data.selectedCoupon && this.data.selectedCoupon.amount) { price = Math.max(0, price - this.data.selectedCoupon.amount); } // 步骤3:满减校验 if (this.data.selectedCoupon && this.data.selectedCoupon.minAmount) { if (price < this.data.selectedCoupon.minAmount) { // 不满足门槛,取消优惠券 this.setData({ selectedCoupon: null }); price = this.calcTotalPrice(); // 递归重算 } } return parseFloat(price.toFixed(2)); }4. 打印服务集成:蓝牙热敏打印机与微信原生 API 的兼容方案
4.1 打印凭证生成:Canvas 渲染与 Base64 图片转换
取件单打印不依赖第三方 SDK,而是用wx.createCanvasContext生成标准尺寸图片:
// utils/print.js export function generatePickupTicket(order) { const width = 384; // 热敏纸宽度(px) const height = 400; const query = wx.createSelectorQuery(); query.select('#ticketCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale(dpr, dpr); // 绘制边框与标题 ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.strokeRect(0, 0, width, height); ctx.font = 'bold 16px sans-serif'; ctx.fillText('取件凭证', 120, 40); // 绘制订单信息(省略具体字段) ctx.font = '14px sans-serif'; ctx.fillText(`订单号:${order.orderNo}`, 20, 80); ctx.fillText(`取件码:${order.verifyCode}`, 20, 110); // 生成 base64 const buffer = canvas.toDataURL('image/png').split(',')[1]; wx.setStorageSync('pickupTicketBase64', buffer); }); }生成的 Base64 字符串存入wx.setStorageSync,供后续调用。
4.2 蓝牙打印机连接:wx.getConnectedBluetoothDevices 的设备筛选
小程序调用蓝牙打印前,必须先获取已配对设备:
// pages/printer/connect.js async connectPrinter() { try { await wx.openBluetoothAdapter(); const devices = await wx.getConnectedBluetoothDevices({ services: ['FFE0'] }); if (devices.length === 0) { throw new Error('未找到已配对的蓝牙打印机'); } // 筛选支持打印服务的设备(常见服务 UUID) const printer = devices.find(d => d.name.includes('Printer') || d.localName?.includes('Printer') ) || devices[0]; this.setData({ connectedDeviceId: printer.deviceId }); wx.showToast({ title: '已连接打印机', icon: 'success' }); } catch (err) { wx.showToast({ title: err.message, icon: 'none' }); } }注意:
services: ['FFE0']是通用蓝牙串口服务 UUID,适用于大部分热敏打印机。若你的打印机使用00001101-0000-1000-8000-00805F9B34FB,需在此处替换。
4.3 打印指令发送:ESC/POS 指令集与分包传输
生成的 Base64 图片需转为 ESC/POS 指令才能打印。源码中utils/escpos.js提供了核心方法:
// utils/escpos.js export function imageToEscPos(base64, width = 384) { const buffer = wx.base64ToArrayBuffer(base64); const img = new Image(); img.src = 'data:image/png;base64,' + base64; return new Promise(resolve => { img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = (img.height * width) / img.width; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, canvas.height); const imageData = ctx.getImageData(0, 0, width, canvas.height); const pixels = imageData.data; let posData = []; // 二值化处理(省略具体算法) for (let y = 0; y < canvas.height; y++) { for (let x = 0; x < width; x += 8) { let byte = 0; for (let i = 0; i < 8; i++) { const px = x + i < width ? pixels[(y * width + x + i) * 4] : 0; byte |= (px < 128 ? 1 : 0) << (7 - i); } posData.push(byte); } } // 构建 ESC/POS 指令 const cmd = new Uint8Array([ 0x1B, 0x61, 0x01, // 居中对齐 0x1B, 0x69, // 打印图片 ...posData ]); resolve(cmd); }; }); }实际发送时需分包(每包 ≤ 20KB):
// pages/printer/print.js async sendToPrinter(cmdBuffer) { const deviceId = this.data.connectedDeviceId; const serviceId = 'FFE0'; // 服务 UUID const characteristicId = 'FFE1'; // 特征值 UUID // 分包发送 const chunkSize = 20480; for (let i = 0; i < cmdBuffer.length; i += chunkSize) { const chunk = cmdBuffer.slice(i, i + chunkSize); await wx.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId, value: chunk.buffer }); await sleep(100); // 包间间隔 } }5. 源码二次开发关键路径与避坑指南
5.1 修改会员等级规则:定位与替换的 3 个核心文件
若需将“消费满 800 元升金卡”改为“累计 10 单升金卡”,需修改以下位置:
| 文件路径 | 修改点 | 说明 |
|---|---|---|
cloud/functions/calcMemberLevel/index.js | pipeline聚合中的$sum: '$amount'→$sum: 1 | 改为统计订单数量而非金额 |
utils/member.js | calcLevelByPoints函数内threshold数值 | 将800改为10 |
pages/user/member-card.js | onShow中调用calcMemberLevel的参数 | 确保传入userId正确,无额外过滤 |
提示:
calcMemberLevel函数的event.userId来自wx.getStorageSync('userInfo').openId,若使用手机号登录,需同步修改为wx.getStorageSync('phone')。
5.2 优惠券发放渠道扩展:新增公众号菜单入口
源码默认仅支持小程序内领取,如需在公众号菜单发放,需补充:
- 在公众号后台配置菜单,链接指向
https://xxx.com?channel=mp; - 小程序
app.js的onLaunch中解析scene或query.channel:
// app.js onLaunch(options) { if (options.query.channel === 'mp') { // 公众号渠道,发放专属优惠券 wx.cloud.callFunction({ name: 'issueCoupon', data: { type: 'mp_welcome' } }); } }- 新增云函数
issueCoupon,向user_coupons插入新文档,code字段用utils/coupon.js的generateCouponCode生成。
5.3 打印失败排查:5 个必查日志点
当用户点击“打印取件单”无反应时,按顺序检查:
| 检查项 | 命令/路径 | 预期输出 | 常见问题 |
|---|---|---|---|
| 蓝牙适配器状态 | console.log(wx.getBluetoothAdapterState()) | {available: true, discovering: false} | Android 12+ 需开启位置权限 |
| 已连接设备 | wx.getConnectedBluetoothDevices() | [{deviceId: 'xxx', name: 'XP-80C'}] | 设备未配对或名称含中文 |
| 打印机服务 | wx.getConnectedBluetoothDevices({services: ['FFE0']}) | 返回设备列表 | 服务 UUID 不匹配,需查打印机手册 |
| Base64 缓存 | wx.getStorageSync('pickupTicketBase64') | 长度 > 10000 的字符串 | Canvas 渲染失败,检查#ticketCanvas是否存在 |
| 指令发送 | console.log('发送指令长度:', cmdBuffer.length) | cmdBuffer.length > 0 | cmdBuffer为空,imageToEscPos未正确解析图片 |
最后一步,若所有日志正常但打印机无反应,用手机蓝牙调试工具(如 nRF Connect)连接设备,手动发送1B 69(打印图片指令)测试硬件连通性。
本文还有配套的精品资源,点击获取