ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

微信小程序扫码点餐:从wx.request封装到订单状态机的完整实践

2026/9/14 5:29:09 拓冰建站 浏览量
微信小程序扫码点餐:从wx.request封装到订单状态机的完整实践 简介面向小程序开发者的扫码点餐项目源码包完整实现从菜品浏览、加入购物车、确认订单、提交支付到历史订单查看的点餐闭环。项目基于微信小程序原生框架开发适用于餐饮门店扫码点餐场景前后端交互逻辑清晰适合需快速搭建小程序点餐原型或学习小程序开发流程的读者。压缩包共69个文件以js逻辑、json配置、wxml页面、wxss样式及png图标素材为主整体仅1.3MB方便阅读与修改。目录按功能划分包含菜品列表、菜品详情、购物车、订单详情、历史订单等页面模块并封装了request、cart、util等公共工具图片素材覆盖扫码、菜品、下单结算等交互状态。已有5400余人学习下载。通过源码可掌握小程序页面数据绑定、组件化拆分、本地缓存和网络请求等核心写法同时理解多人同步点餐、购物车逻辑及服务员免支付下单等业务设计思路对实际项目开发具有较强参考价值。1. 扫码点餐小程序的页面链路与原生实现边界这个项目是一套原生微信小程序扫码点餐源码pages 目录里已经从 index、dishes、dish-detail 排到 pre-order、people-number、order-success、history-orders、order-detail完整覆盖“扫桌台码 - 进店 - 选菜 - 加购物车 - 确认人数 - 下单支付 - 查历史订单”的闭环。和 uniapp 生成的跨端包不同原生 WXML/WXSS 页面在小程序里的启动和渲染链路更短点餐这类依赖微信登录、微信支付、小程序码的工具型应用用原生写法维护反而最直接。但这个项目不是开箱即用的商城模板README 已经把多人同步点餐、服务员免支付下单、推荐菜品数据结构列为待办。想看明白这些 TODO 为什么存在就得先把请求层、购物车层和订单状态机完整过一遍。2. 请求层与通用工具把 wx.request 封装成可维护的 API 客户端2.1 为什么点餐项目要先封装请求层扫码点餐项目的页面数量多每个页面至少会用到“菜品列表、菜品详情、创建订单、查询订单、购物车操作”中的两到三个接口。如果把 wx.request 直接写在 Page 里每个页面都要重复判断 HTTP statusCode、业务 code、登录失效和 loading 状态等到要加服务员点餐改动会散落到 pre-order、dishes、history-orders 多个页面。所以项目里的 utils/request.js 单独抽出来不只是少写几行代码而是把所有接口的公共规则收口到一个文件里。点餐系统的接口约定通常是HTTP 状态码表示传输层结果业务 code 表示业务层结果data 只放业务数据。前端只要在这一层统一处理后续页面只需要 await request(...) 就能拿到干净的 data不需要再关心 toasting 和 loading 状态。2.2 request.js 的 Promise 化封装与统一错误码// utils/request.js const BASE_URL https://api.example.com // 部署后替换为实际网关地址 function request({ url, method GET, data {}, header {}, showLoading true }) { if (showLoading) { wx.showLoading({ title: 加载中, mask: true }) } return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${url}, method, data, header: { content-type: application/json, ...header }, success(res) { const body res.data || {} if (res.statusCode 200 res.statusCode 300 body.code 0) { resolve(body.data) } else if (body.code 401) { wx.removeStorageSync(token) wx.navigateTo({ url: /pages/login/login }) reject(body) } else { wx.showToast({ title: body.message || 请求失败, icon: none }) reject(body) } }, fail(err) { wx.showToast({ title: 网络异常, icon: none }) reject(err) }, complete() { if (showLoading) wx.hideLoading() } }) }) } module.exports { request }这里把成功回调里的 body.data 直接 resolve 出去页面里拿到的就不再是包裹结构。参数 showLoading 默认打开用于列表请求下单这类需要防止重复点击的按钮建议把 showLoading 设为 true同时配合按钮的 disabled 状态。401 分支里 wx.removeStorageSync 是处理登录态过期的常见做法token 由 wx.login 换取 code、再由后端换得小程序端只存 token 和 openid不直接参与敏感校验。业务 code场景前端动作0成功resolve(body.data)401登录失效清 token 并跳登录1001菜品已下架刷新菜品列表500服务端异常toast 展示 messagecode 具体数字取决于后端设计但建议保留 0 成功和 401 两个全局值点餐场景里可以把 1001 换成桌台状态变化例如“桌台已换台”或“菜品估清”。2.3 util.js 的金额处理与克制的前端计算// utils/util.js function formatPrice(priceInCents) { if (priceInCents undefined || priceInCents null) return 0.00 return (priceInCents / 100).toFixed(2) } function calcTotal(cartList) { return cartList.reduce((total, item) { return total item.price * item.count (item.specPrice || 0) * item.count }, 0) } module.exports { formatPrice, calcTotal }这段代码背后是点餐系统的金额约定价格全部以“分”为单位前端展示时再除以 100。原因是 JavaScript 的浮点数在累加计算小数时会出误差购物车有二十来个明细时尤其明显后端也只接受整数分便于微信支付统一。calcTotal 里 specPrice 是规格加价字段比如“酸菜鱼”中份 68 元、大份加 8 元大份的 price 仍是 6800specPrice 存 800。前端用它做购物车角标和预点餐页展示但最终下单金额必须以后端重算为准防止用户篡改内存数据。3. 购物车与页面状态多人点餐场景下的数据合并策略3.1 cart.js 的数据结构与本地缓存策略项目把购物车放在 utils/cart.js这是原生小程序很明确的本地数据结构把购物车数组写入 storage页面不直接操作 storage而是通过 add、remove、getCart 这类方法读写。点餐购物车和电商购物车差异在于它通常没有 SKU 维度那么复杂但有“规格加价”和“点餐人”两个字段。对于一桌多人点餐cart 的每一项都应该记录 chooser包含 openid、nickName、avatarUrl。// utils/cart.js 中的核心结构 const CART_KEY cart function getCart() { return wx.getStorageSync(CART_KEY) || [] } function addToCart(cart, dish) { const key dish.dishId - (dish.spec || 默认) const index cart.findIndex( item item.dishId - (item.spec || 默认) key ) if (index -1) { cart[index].count dish.count || 1 cart[index].chooser dish.chooser || cart[index].chooser } else { cart.push({ dishId: dish.dishId, name: dish.name, price: dish.price, spec: dish.spec || 默认, specPrice: dish.specPrice || 0, count: dish.count || 1, chooser: dish.chooser || null, checked: true }) } wx.setStorageSync(CART_KEY, cart) return cart } module.exports { getCart, addToCart }key 由 dishId 拼接 spec避免“大份酸菜鱼”和“小份酸菜鱼”错误合并。发现同 key 时累加 count 并更新 chooser不同 key 时 push 新对象。参数 dish 来自 dish-detail 页加购按钮后端返回的菜品一般带 id、name、price、specList前端需要自己把规格对象转成 spec 和 specPrice 两个字段再交给购物车。count 要转成整数不能直接使用 input 的原始值否则会出现小数购物车。checked 字段用于 pre-order 页“部分下单”功能普通扫码点餐默认全选即可。多人同时点同一道菜时如果要区分“张三点的”和“李四点的”更稳妥的做法是拆成两条购物车记录而不是把 count 累加到同一条里。这也是 README 里把多人同步点餐列为 TODO 的原因本地 storage 只能覆盖单机数据真正要同步得依赖后端但先把数据结构带上 chooser 是正确的前提。3.2 菜品详情页与菜单页之间的参数传递在页面间传参常见做法是 wx.navigateTo 的 url 上带 id而不是把菜品对象塞进 globalData。原生小程序的 Page 实例在冷启动、分享进入时不会重新执行页面上次的内存状态塞对象很容易拿不到。这个项目里 dishes 是列表页dish-detail 是详情页列表页点击菜品时执行// pages/dishes/dishes.js goDishDetail(e) { const { id } e.currentTarget.dataset wx.navigateTo({ url: /pages/dish-detail/dish-detail?dishId${id} }) }在 dish-detail 的 onLoad 里使用 options.dishId 重新请求详情。e.currentTarget.dataset 来自 wxml 中>// pages/index/index.js onLoad(options) { const query {} if (options.scene) { const scene decodeURIComponent(options.scene) scene.split().forEach((pair) { const [k, v] pair.split() query[k] decodeURIComponent(v) }) } else { Object.assign(query, options) } this.setData({ shopId: query.shopId || , tableNo: query.tableNo || 0 }) } goDishes() { wx.navigateTo({ url: /pages/dishes/dishes?shopId${this.data.shopId}tableNo${this.data.tableNo} }) }scene 通常用于小程序码开发者工具里测试时可以在“添加编译模式”的启动参数中填写 scene 的 URL 编码值。普通二维码路径带 query 的场景options 里直接就是 shopId 和 tableNo所以代码里同时兼容两种来源。把解析结果存进 data 后选菜页、pre-order 页通过同样的 query 传递桌台号便于下单时定位桌台。people-number 页独立记录人数通过 wx.setStorageSync 暂存下单完成后清除避免下一桌客人共享人数。4. 订单数据模型与下单状态机从 pre-order 到 order-success4.1 菜品返回数据里的 type 字段与冗余层级项目的 README 里专门提到 type 02 时data 属性没有必要type 03 是推荐菜品。这说明后端在返回菜品列表时用了 type 区分普通菜、套餐和推荐组合但结构上出现了嵌套冗余。要减少前端适配代码应把菜品列表统一成扁平协议type 为 01 是普通菜品02 是套餐03 是推荐套餐和推荐都把包含的菜品 id 列表放在同一层而不是再包 data。{ code: 0, data: [ { type: 01, dishId: D001, name: 拍黄瓜, price: 1800 }, { type: 02, dishId: P001, name: 双人套餐, price: 6800, dishIds: [D001, D002] }, { type: 03, dishId: R001, title: 今日推荐, dishIds: [D003, D004] } ] }推荐菜品 type03 在系统里只能配置一个所以前端看到它时直接渲染一个推荐模块不需要遍历推荐位推荐内容用 dishIds 去详情接口逐个加载避免一次性返回完整菜品信息导致 payload 过大。嵌套 data 的问题是前端拿到 type02 的 item还要判断 item.data 是否存在、再在 item.data.dishList 里循环两个 type 的渲染分支会越写越长最终只好在 util.js 里做数据清洗。与其让前端清洗不如后端返回时把套餐内容直接上提一层。4.2 预点餐页的订单参数组装与支付前置校验pre-order 页面是下单的汇总页它读取 cart.js 的缓存、people-number 页的人数、起始页带进来的 shopId 和 tableNo然后组装成后端订单参数。需要强调items 里通常不传单价传 dishId、spec、count、chooser 即可服务端以菜品表里的价格为准商品价格和名称不能信任前端。// pages/pre-order/pre-order.js const cart require(../../utils/cart.js) const { request } require(../../utils/request.js) Page({ data: { shopId: , tableNo: , peopleNumber: 1, cart: [], remark: , total: 0 }, onLoad(options) { this.setData({ shopId: options.shopId, tableNo: options.tableNo, peopleNumber: wx.getStorageSync(peopleNumber) || 1, cart: cart.getCart().filter(item item.checked) }) }, async submitOrder() { const order { shopId: this.data.shopId, tableNo: this.data.tableNo, peopleNumber: this.data.peopleNumber, remark: this.data.remark.trim(), items: this.data.cart.map(item ({ dishId: item.dishId, spec: item.spec, count: item.count, chooser: item.chooser })) } const orderId await request({ url: /order/create, method: POST, data: order }) wx.redirectTo({ url: /pages/order-success/order-success?orderId${orderId} }) } })调用 wx.redirectTo 而不是 navigateTo是为了避免用户从下单成功页返回到 pre-order 重复提交order-success 页里再用 orderId 查询订单详情。peopleNumber 是从 storage 读取如果放在 onLoad 时直接读冷启动会读到旧值所以下单成功后要记得 removeStorageSync。这里没有放 wx.requestPayment是因为支付动作在默认顾客流程里应该是先创建订单、再发起支付两步中间需要后端确认订单金额没有变化避免一次请求里既建单又支付否则服务员免支付和顾客支付两种角色无法复用。4.3 history-orders 与 order-detail 的状态展示规则订单状态机是扫码点餐后端的核心。前端 history-orders 只承担展示和二次操作。常见 status 定义如下。status语义前端可操作10待支付继续支付、取消订单20已支付/制作中查看详情、申请退菜30已完成再来一单、评价40已取消删除本地记录order-detail 页面通过订单详情接口查询菜品明细和状态。如果状态是 10页面底部应显示“去支付”点击后请求 /pay/prepay 拿到 wx.requestPayment 必要参数。这里有个细节微信支付结果回调不一定可靠页面在 requestPayment 的 success 里不能马上把订单改成“已支付”需要调一次 /order/detail 确认 status 已变为 20如果还是 10再轮询两次每次间隔 1000ms。history-orders 的下拉刷新用 wx.stopPullDownRefresh 收尾避免页面一直处于 loading。5. 服务员免支付点餐与推荐菜品的可维护化改造5.1 用登录角色分流顾客下单与服务员下单README 里说“系统稍作改造可以支持服务员点餐”要点是后端先指定服务员 openid登录后返回角色前端在 pre-order 提交时按角色区分。先创建订单再判断角色// pages/pre-order/pre-order.js async submitOrder() { const orderId await this.createOrder() const role wx.getStorageSync(userRole) if (role waiter) { // 服务员下单不发起支付直接确认后端打印小票 await request({ url: /order/confirm, method: POST, data: { orderId } }) wx.redirectTo({ url: /pages/order-success/order-success?orderId${orderId}fromwaiter }) return } const payParams await request({ url: /pay/prepay, method: POST, data: { orderId } }) wx.requestPayment({ ...payParams, success() { wx.redirectTo({ url: /pages/order-success/order-success?orderId${orderId} }) } }) }createOrder 和支付分两步后在 role 判断处拆分支服务员确认接口实际是状态机里的“接单/确认制作”后端收到后触发打印任务。小票打印应在服务端接打印机驱动小程序端不直接使用蓝牙串口。role 可能存在缓存旧值提交前需要确认当前 openid 对应的角色仍是服务员。5.2 推荐菜品接口的收敛与前端验证从 type03 的语义看推荐菜品只需要一个推荐位返回结构收敛成 title dishIds 后前端在 dishes 页面用 find 找到 type03 的项再单独渲染推荐模块不需要再处理 item.data 的分支。这样后续加“第二推荐位”时只需要后端在推荐位置表里加配置前端仍按同一规则渲染。验证这套逻辑是否通顺可以在微信开发者工具里添加编译模式启动页面选 pages/index/index启动参数填 sceneshopId%3D10086%26tableNo%3D12模拟扫桌台码。确认解析到 shopId 和 tableNo 后进入选菜把购物车加满到 pre-order 提交。默认顾客身份下请求序列里应该有 /order/create 和 /pay/prepay不能直接出现 /order/confirm把 storage 里的 userRole 改成 waiter 后重新提交请求序列变成 /order/create 和 /order/confirm再切换回顾客身份做对照改动就完成了。本文还有配套的精品资源点击获取