微信小程序家政预约核心实现:时段锁定与时间精度校准
2026/9/13 12:31:12 网站建设 项目流程

简介:本资源是一套完整的微信家政预约小程序源代码,面向前端开发者、小程序初学者及家政服务类项目创业者,解决轻量级本地化服务线上化落地问题,适用于保洁、月嫂、育儿嫂等多场景预约业务快速搭建。压缩包共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. 插入锁定记录(status=locked,有效期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: '系统繁忙,请重试' } } }

提示startTimeendTime必须是 ISO 格式字符串(如'2024-06-15T09:00:00+08: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.requestPaymentsignType: '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 class="time-picker"> <picker-view bindchange="onTimeChange" indicator-style="height: 50px;" style="height: 300px;" > <picker-view-column> <!-- 小时列:0-23 --> <view wx:for="{{hours}}" wx:key="index" class="picker-item">{{item}}</view> </picker-view-column> <picker-view-column> <!-- 分钟列:0,15,30,45 --> <view wx:for="{{minutes}}" wx:key="index" class="picker-item">{{item}}</view> </picker-view-column> </picker-view> </view>

3.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-viewvalue属性缺陷,直接读取视口滚动状态,实测在 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是微信小程序沙箱内的私有路径,无需用户授权即可读写;
  • copyFileSyncwriteFile更可靠,避免异步写入未完成就调用上传;
  • 文件名含Date.now()和索引,确保同一会话内不重名;
  • unlinkSync在上传成功后同步删除,防止缓存堆积(实测 100 张 2MB 图片占 200MB 空间)。

提示:若用户在上传过程中杀掉小程序进程,本地缓存文件不会自动清理。可在onLaunch中扫描tempDir下超过 24 小时的文件并删除,作为兜底策略。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询