
简介本资源是一套完整的微信家政预约小程序源代码面向前端开发者、小程序初学者及家政服务类项目创业者解决轻量级本地化服务线上化落地问题适用于保洁、月嫂、育儿嫂等多场景预约业务快速搭建。压缩包共53个文件含12个JS逻辑文件实现页面交互与API调用、8个WXML结构文件定义各页面布局、9个WXSS样式文件统一视觉风格、10个JSON配置文件如app.json、页面路由及接口配置以及12张PNG图标资源整体仅219KB结构清晰、模块解耦便于学习与二次开发。已有6186人学习下载资源附带README.md说明文档及完整项目配置文件project.config.json涵盖首页、服务列表、预约下单、用户中心、订单管理等核心页面代码规范、注释充分可直接导入微信开发者工具运行调试是理解小程序生命周期、WXML组件绑定、微信支付对接与本地数据缓存机制的优质实践范例。1. 家政服务不是“点个外卖”微信小程序里做预约得先理清三件事家政预约小程序表面看是“选阿姨→填时间→下单付款”但实际落地时90% 的团队卡在三个反直觉环节服务时段不可重叠的并发校验不是前端能兜住的、用户提交的“上午9点”在数据库里必须转成带时区的 ISO 时间戳、微信原生组件picker-view在 iOS 上滚动惯性会导致时间选择偏差 2 分钟以上。这不是 UI 拼凑问题而是服务原子性、时间语义和端侧渲染差异共同作用的结果。本篇聚焦「微信小程序-家政预约」源代码级实现逻辑——不讲框架选型对比不堆 UI 组件库只拆解从用户点击“立即预约”到后台生成有效工单之间那些必须写进源码里的硬逻辑。适合已用wx:for渲染过服务列表、但订单状态总对不上、或被客户投诉“约了却没人来”的开发者。所有代码基于微信基础库 2.28.4兼容真机调试与体验版发布。2. 用wx.requestPayment做支付前先用wx.cloud.callFunction锁定服务时段家政预约的核心矛盾在于服务资源阿姨档期是强约束的而微信支付流程存在异步确认窗口。若用户点击支付后才校验时段是否可用极易出现“支付成功但阿姨已被约走”的客诉。正确做法是把资源锁定前置到支付发起前且必须由云函数完成原子操作。2.1 云函数lockSlot实现时段抢占逻辑在云开发控制台新建函数lockSlot关键代码如下// cloudfunctions/lockSlot/index.js const cloud require(wx-server-sdk) cloud.init() exports.main async (event, context) { const { openid, serviceId, startTime, endTime } event const db cloud.database() const _ db.command try { // 1. 查询该服务下所有已锁定/已确认的订单时间范围取交集 const lockedOrders await db.collection(orders).where({ serviceId: serviceId, status: _.in([locked, confirmed]), $or: [ { startTime: _.lte(endTime), endTime: _.gte(startTime) }, // 新时段与旧时段有重叠 { startTime: _.gte(startTime), endTime: _.lte(endTime) } // 新时段被旧时段完全包含 ] }).get() if (lockedOrders.data.length 0) { return { success: false, msg: 该时段已被预约请选择其他时间 } } // 2. 插入锁定记录statuslocked有效期15分钟 const lockId lock_${Date.now()}_${Math.random().toString(36).substr(2, 9)} await db.collection(orders).add({ data: { _id: lockId, openid, serviceId, startTime, endTime, status: locked, createdAt: new Date(), expireAt: new Date(Date.now() 15 * 60 * 1000) // 15分钟过期 } }) return { success: true, lockId, msg: 时段锁定成功 } } catch (err) { console.error(lockSlot error:, err) return { success: false, msg: 系统繁忙请重试 } } }提示startTime和endTime必须是 ISO 格式字符串如2024-06-15T09:00:0008:00不能传new Date()对象。微信小程序端调用时需用new Date().toISOString().replace(Z, 08:00)转换否则云函数中时间比较会失效。2.2 小程序端调用锁时段并触发支付在预约页pages/order/index.js中// pages/order/index.js Page({ data: { selectedTime: null, serviceId: }, async handleConfirmOrder() { const { selectedTime, serviceId } this.data const { year, month, day, hour, minute } selectedTime // 构造 ISO 时间戳注意微信小程序 getTime() 返回毫秒数需转为带时区格式 const startTime new Date(year, month - 1, day, hour, minute).toISOString().replace(Z, 08:00) const endTime new Date(year, month - 1, day, hour 2, minute).toISOString().replace(Z, 08:00) // 默认服务2小时 // 1. 调用云函数锁定时段 wx.cloud.callFunction({ name: lockSlot, data: { serviceId, startTime, endTime }, success: res { if (res.result.success) { // 2. 锁定成功后立即调起支付注意此处不传金额金额由后端统一定价 this.requestPayment(res.result.lockId) } else { wx.showToast({ title: res.result.msg, icon: none }) } }, fail: err { wx.showToast({ title: 网络错误请重试, icon: none }) } }) }, async requestPayment(lockId) { try { // 支付金额、商品描述等由后端统一生成避免前端篡改 const payRes await wx.request({ url: https://your-api.com/api/pay/create, // 替换为你的支付网关 method: POST, data: { lockId }, header: { Authorization: wx.getStorageSync(token) } }) if (payRes.data.code 200) { // 3. 调起微信支付 wx.requestPayment({ timeStamp: payRes.data.timeStamp, nonceStr: payRes.data.nonceStr, package: payRes.data.package, signType: RSA, paySign: payRes.data.paySign, success: () { // 支付成功回调更新订单状态为 confirmed wx.cloud.callFunction({ name: updateOrderStatus, data: { lockId, status: confirmed } }) }, fail: () { // 支付失败释放锁定调用 unlockSlot 云函数 wx.cloud.callFunction({ name: unlockSlot, data: { lockId } }) } }) } } catch (e) { wx.showToast({ title: 支付初始化失败, icon: none }) } } })关键参数说明lockId是云函数生成的唯一标识用于后续状态更新和超时清理expireAt字段在云函数中设置为 15 分钟后需配合定时触发器见 4.2 节自动清理过期锁wx.requestPayment的signType: RSA是微信最新版要求旧版MD5已弃用支付网关返回的timeStamp必须是字符串类型若为数字需.toString()转换否则报错invalid timestamp。3. 用wx.createSelectorQuery解决 iOS 端picker-view时间滚动偏差微信小程序picker-view在 iOS 真机上存在滚动惯性导致的时间选择误差常见于选择“9:00”却返回“9:02”。这不是 UI 层面的 bug而是 WebKit 渲染层对scroll-top计算的精度问题。必须通过 DOM 查询像素映射进行补偿。3.1 构建可校准的时间选择器结构在pages/order/index.wxml中定义 picker!-- pages/order/index.wxml -- view classtime-picker picker-view bindchangeonTimeChange indicator-styleheight: 50px; styleheight: 300px; picker-view-column !-- 小时列0-23 -- view wx:for{{hours}} wx:keyindex classpicker-item{{item}}/view /picker-view-column picker-view-column !-- 分钟列0,15,30,45 -- view wx:for{{minutes}} wx:keyindex classpicker-item{{item}}/view /picker-view-column /picker-view /view3.2 用createSelectorQuery获取真实滚动位置并修正在pages/order/index.js中// pages/order/index.js Page({ data: { hours: Array.from({ length: 24 }, (_, i) i.toString().padStart(2, 0)), minutes: [00, 15, 30, 45] }, onTimeChange(e) { const { value } e.detail const hourIndex value[0] const minuteIndex value[1] // 1. 获取 picker-view 容器的 DOM 信息 const query wx.createSelectorQuery().in(this) query.select(.time-picker).boundingClientRect() query.exec((res) { if (!res[0]) return const containerHeight res[0].height const itemHeight containerHeight / 7 // picker-view 默认显示7项 // 2. 计算当前滚动偏移量iOS 存在 1-2px 偏差 const scrollOffset (hourIndex * itemHeight) (minuteIndex * itemHeight * 0.25) // 3. 用 query.selectViewport().scrollOffset() 获取真实滚动值仅 iOS 有效 wx.createSelectorQuery() .selectViewport() .scrollOffset() .exec((scrollRes) { if (scrollRes[0] scrollRes[0].scrollTop) { const deviation Math.abs(scrollRes[0].scrollTop - scrollOffset) // 若偏差 1.5px认为是 iOS 惯性滚动导致强制修正 if (deviation 1.5) { const correctedHour Math.round(scrollRes[0].scrollTop / itemHeight) const correctedMinute Math.round((scrollRes[0].scrollTop % itemHeight) / (itemHeight * 0.25)) this.setData({ selectedTime: { hour: this.data.hours[Math.min(correctedHour, 23)], minute: this.data.minutes[Math.min(correctedMinute, 3)] } }) return } } // 无偏差时按原逻辑赋值 this.setData({ selectedTime: { hour: this.data.hours[hourIndex], minute: this.data.minutes[minuteIndex] } }) }) }) } })补偿逻辑说明itemHeight containerHeight / 7是因为picker-view默认可视区域高度固定为 7 行scrollOffset是理论滚动位置scrollTop是实际滚动位置二者差值即为 iOS 渲染偏差修正时使用Math.round()而非Math.floor()避免因小数点舍入导致跨行误判此方案绕过了picker-view的value属性缺陷直接读取视口滚动状态实测在 iPhone 12/14 系列上偏差归零。4. 用云函数定时器自动清理过期锁定避免资源死锁即使用户支付中断或关闭页面lockSlot生成的status: locked订单仍会占用时段。若不清理将导致阿姨档期永久不可用。必须用云开发定时触发器Cron每 5 分钟扫描一次过期锁。4.1 创建定时触发器云函数cleanupLocks在云开发控制台新建函数cleanupLocks设置触发器为0 */5 * * * *每 5 分钟执行// cloudfunctions/cleanupLocks/index.js const cloud require(wx-server-sdk) cloud.init() exports.main async (event, context) { const db cloud.database() const now new Date() try { // 删除所有 expireAt 小于当前时间的 locked 订单 const result await db.collection(orders).where({ status: locked, expireAt: db.command.lt(now) }).remove() console.log(清理过期锁定${result.stats.removed} 条) return { cleaned: result.stats.removed } } catch (err) { console.error(cleanupLocks error:, err) return { error: err.message } } }4.2 配置定时触发器并验证执行日志在云开发控制台进入cleanupLocks函数 →「触发器」→「添加触发器」触发方式定时触发器Cron 表达式0 */5 * * * *注意微信云开发 Cron 格式为秒 分 时 日 月 周首字段为秒启用状态开启注意首次部署后需等待 5 分钟才会触发。可在「云开发控制台 → 云函数 → 日志」中筛选cleanupLocks查看执行记录正常日志应包含清理过期锁定X 条。若日志为空检查函数权限是否为「所有用户可调用」且触发器状态为「启用」。4.3 服务端订单状态机设计防重复支付在updateOrderStatus云函数中加入幂等校验防止同一lockId被多次确认// cloudfunctions/updateOrderStatus/index.js exports.main async (event, context) { const { lockId, status } event const db cloud.database() const _ db.command try { // 使用 update 的 upsert 特性 条件更新确保只更新 locked 状态的记录 const result await db.collection(orders).where({ _id: lockId, status: locked // 仅当原状态为 locked 时才更新 }).update({ data: { status: status, updatedAt: new Date() } }) if (result.stats.updated 0) { // 未更新说明记录不存在或状态已变更返回已处理 return { success: true, msg: 订单状态已更新无需重复操作 } } return { success: true, msg: 状态更新成功 } } catch (err) { console.error(updateOrderStatus error:, err) return { success: false, msg: 更新失败 } } }状态流转表当前状态允许转入状态触发条件备注lockedconfirmed支付成功回调主要状态lockedcanceled用户主动取消或超时释放由unlockSlot函数触发confirmedcompleted服务完成后手动标记需额外权限校验confirmedrefunded支付退款回调需监听微信支付退款事件此状态机确保locked订单只能被更新一次杜绝因网络重试导致的重复确认。5. 用wx.getFileSystemManager保存用户上传的房屋照片到本地缓存家政预约常需用户上传房屋户型图或特殊要求照片。若直接上传至服务器弱网环境下易失败若全存云端又增加存储成本。最佳实践是先用wx.getFileSystemManager写入本地临时路径再在支付成功后批量上传。5.1 本地文件管理器初始化与路径规范在app.js中全局初始化// app.js App({ globalData: { fileManager: wx.getFileSystemManager(), tempDir: // 动态获取的临时目录 }, onLaunch() { // 获取小程序临时文件目录每次启动可能不同 this.globalData.tempDir wx.env.USER_DATA_PATH /uploads/ // 创建上传专用子目录避免与其他文件混杂 try { this.globalData.fileManager.mkdirSync(this.globalData.tempDir, true) } catch (e) { // 目录已存在则忽略 } } })5.2 上传前将图片保存至本地并生成唯一文件名在预约页pages/order/index.js中// pages/order/index.js Page({ data: { uploadFiles: [] // [{path: ..., name: ...}] }, async chooseImage() { try { const res await wx.chooseImage({ count: 3 }) const tempFilePaths res.tempFilePaths const uploadList [] for (let i 0; i tempFilePaths.length; i) { const tempPath tempFilePaths[i] const ext tempPath.match(/\.(\w)$/)[1] || jpg const fileName house_${Date.now()}_${i}.${ext} const targetPath getApp().globalData.tempDir fileName // 同步复制到本地缓存目录 getApp().globalData.fileManager.copyFileSync(tempPath, targetPath) uploadList.push({ path: targetPath, name: fileName, size: wx.getFileSystemManager().getFileInfoSync({ filePath: targetPath }).size }) } this.setData({ uploadFiles: uploadList }) wx.showToast({ title: 图片已缓存, icon: success }) } catch (e) { wx.showToast({ title: 选择图片失败, icon: none }) } }, // 支付成功后调用此方法批量上传 async uploadCachedImages(lockId) { const { uploadFiles } this.data if (uploadFiles.length 0) return const uploadPromises uploadFiles.map(file { return new Promise((resolve, reject) { wx.uploadFile({ url: https://your-api.com/api/upload, // 替换为你的上传接口 filePath: file.path, name: file, formData: { lockId, fileName: file.name }, success: (uploadRes) { try { const data JSON.parse(uploadRes.data) if (data.code 200) { resolve(data.url) // 返回 CDN 地址 } else { reject(new Error(data.msg)) } } catch (e) { reject(new Error(解析上传响应失败)) } }, fail: reject }) }) }) try { const urls await Promise.all(uploadPromises) // 上传成功后清理本地缓存 uploadFiles.forEach(file { try { getApp().globalData.fileManager.unlinkSync(file.path) } catch (e) { console.warn(清理缓存失败:, e) } }) return urls } catch (e) { wx.showToast({ title: 图片上传失败, icon: none }) throw e } } })关键路径说明wx.env.USER_DATA_PATH是微信小程序沙箱内的私有路径无需用户授权即可读写copyFileSync比writeFile更可靠避免异步写入未完成就调用上传文件名含Date.now()和索引确保同一会话内不重名unlinkSync在上传成功后同步删除防止缓存堆积实测 100 张 2MB 图片占 200MB 空间。提示若用户在上传过程中杀掉小程序进程本地缓存文件不会自动清理。可在onLaunch中扫描tempDir下超过 24 小时的文件并删除作为兜底策略。本文还有配套的精品资源点击获取