本文以一个真实交付的「到店扫码点餐」微信小程序项目为主线,从目录设计、请求封装、全局状态、Mock 调试到对接后端,完整复盘一套 UniApp(Vue3) 前端的工程化实践。无论你是刚接触 uni-app 的新手,还是想参考一套可落地的点餐类项目结构,都值得花几分钟读完。
一、项目背景
最近完成了一个纯前端的点餐小程序项目,名字叫「阳光点餐」。它面向餐饮门店顾客,实现到店扫码点餐的完整流程:浏览门店与菜品 → 多规格选菜 → 本地购物车 → 优惠券自动最优抵扣 → 下单挂单 → 餐后收银台统一结账。
几个关键定位:
- 纯前端交付:只交付小程序前端源码 + 一份《接口规范》,不包含任何后端代码;
- 前端可独立运行:内置一套本地 Mock 数据,
mock: true时不需要后端,整个项目离线就能跑起来,方便甲方验收 UI 和交互; - 对接极简:后端就绪后,只需改
config/env.js里的域名、appid 和一个开关,业务代码零改动。
这种「先交付可运行的前端,后端按规范实现」的模式,特别适合外包 / 插件市场交付和甲方团队并行开发的场景。
二、功能与技术栈
功能清单
| 模块 | 能力 |
|---|---|
| 门店 | 门店信息、营业状态、分类、菜品列表、多规格、套餐 |
| 购物车 | 本地持久化、跨页不丢失、多规格区分、全选/删改 |
| 优惠券 | 领取、未使用/已使用/已过期、下单自动匹配最优券 |
| 订单 | 到店挂单、订单列表状态筛选、详情、取消、评价(多图上传) |
| 用户 | 手机号验证码登录、收货地址管理、收藏 |
| 点餐入口 | 扫码桌号、手动输入桌号 |
| 商家后台(附带) | 营业统计、营业额趋势、菜品销量排行、结算对账(Web 端用) |
技术栈
- 框架:UniApp + Vue3 +
<script setup>组合式 API - 样式:SCSS + 全局 CSS 变量(Design Tokens)
- 状态:Vue3
reactive+computed+watch自建轻量 store(未引入 Pinia) - 网络:
uni.request/uni.uploadFile全局封装 - 组件:easycom 自动注册,8 个公共业务组件
- UI 库:uni-ui 基础组件
三、目录结构:把"对接层"和"业务层"彻底分开
一个点餐项目文件不少,目录划分的核心思路是:让"将来要改的地方"高度集中。
order-food/
├── api/ # 接口封装(对接层,所有网络请求都在这)
│ ├── base.js # 请求/响应拦截器 + mock 分发
│ ├── shop.js # 门店、菜品
│ ├── cart.js # 购物车(云端同步)
│ ├── order.js # 订单
│ ├── coupon.js # 优惠券
│ ├── user.js # 用户、地址、评价、收藏
│ └── statistics.js # 商家端统计
├── components/ # 8 个公共组件(GoodsItem/CartFloat/CouponItem...)
├── config/
│ └── env.js # ★ 唯一需要改的对接配置
├── mock/ # 本地模拟数据(开关控制)
├── pages/ # 11 个业务页面
├── store/ # 全局状态(cart 持久化 / user / shop)
├── utils/ # 金额分转元、时间格式化
└── App.vue # 全局样式 + Design Tokens
为什么这样设计? 因为项目是"纯前端交付",甲方后端团队一定会问一句话:"我们后端好了之后,前端要改多少?"。答案在这套结构里被压缩到极致——只改 config/env.js 一个文件,并把 mock 开关关掉。其余业务代码与后端约定解耦,这是外包/交付类项目最核心的工程体验。
四、贯穿全项目的两个"硬规范"
这类项目最容易在对接时扯皮,所以在动手写第一行页面之前,先和后端约定死两条规则:
1. 统一返回结构 + 统一错误码
所有接口,无论成败,必须返回同一结构:
{
"code": 0, // 0=成功;非0=错误码
"msg": "提示文案",
"data": {}, // 业务数据,无数据返回 null
"requestId": "随机唯一请求ID(排查日志用)"
}
错误码是前后端沟通的"共同语言":
| 错误码 | 含义 | 前端处理 |
|---|---|---|
| 0 | 成功 | 正常渲染 |
| 401 | Token 失效 | 清缓存 + 跳登录页 |
| 403 | 无权限 | Toast 提示 |
| 2001 | 门店未营业 | 弹窗 + 禁用下单 |
| 3001 | 库存不足 | 弹窗 + 刷新库存 |
2. 金额一律用"分"(整数)
后端所有价格单位是分,前端展示时 ÷100 转成"元"。这样彻底规避了浮点运算精度丢失的问题。工具函数放 utils/money.js:
// 后端返回 2990 → 前端展示 29.90 元
export function fenToYuan(fen) {
const n = Number(fen)
if (!isFinite(n)) return '0.00'
return (n / 100).toFixed(2)
}
// 列表合计(单位:分)
export function sumFen(list, getPrice, getNum) {
return list.reduce((acc, it) => acc + Number(getPrice(it)) * Number(getNum(it)), 0)
}
这两条规范在《前端开发文档》里用一章写死,后端照着实现即可,前端不用写任何"防御性分叉代码"。
五、核心:全局请求封装 + Mock 开关(api/base.js)
这是整套前端的"血管"。它统一做了几件事:
- 自动拼接 baseUrl;
- 自动从缓存读 token 塞进请求头;
- 统一 loading、超时(10s);
- 按错误码分流处理(401 跳登录、2001 提示门店未营业等);
- 命中 Mock 直接返回本地数据,不发真实请求。
export function request(url, method = 'GET', data = {}, options = {}) {
const { loading = true } = options
// Mock 模式:命中本地模拟数据则直接返回,不再请求真实后端
if (env.mock) {
const mockData = getMock(url, method, data)
if (mockData !== undefined) {
return new Promise((resolve) => setTimeout(() => resolve(mockData), 200))
}
}
return new Promise((resolve, reject) => {
if (loading) uni.showLoading({ title: '加载中', mask: true })
uni.request({
url: env.baseUrl.replace(/\/$/, '') + url,
method,
data,
header: {
[env.tokenKey]: env.tokenPrefix + getToken(),
'content-type': 'application/json'
},
timeout: 10000,
success: (res) => handleSuccess(res, resolve, reject),
fail: () => {
uni.hideLoading()
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' })
reject(new Error('network error'))
}
})
})
}
export function get(url, params = {}, options) {
return request(url, 'GET', params, options)
}
export function post(url, data = {}, options) {
return request(url, 'POST', data, options)
}
这里有个很关键的设计:拦截器在 code === 0 时直接 resolve(data),把业务数据吐给页面;页面拿到的就是纯粹的 data,而不是整个响应体。这让业务代码非常干净:
// api/shop.js —— 页面拿到的直接是门店数据
export function getShopInfo() {
return get('/api/shop/info')
}
而响应拦截器对错误码的统一处理(见 handleSuccess),保证了"门店未营业""库存不足"这类业务错误,在任何页面都能给出统一提示,不用每个页面重复写。
Mock 是这套交付模式的灵魂。mock/index.js 根据 url + method + 入参 分发本地数据:
export function getMock(url, method, data = {}) {
switch (url) {
case '/api/shop/goodsList': {
const cid = data.categoryId
const list = cid && cid !== 0 ? goodsAll.filter((g) => g.categoryId === cid) : goodsAll
return clone(list)
}
case '/api/shop/goodsDetail': {
// 注意:URL 参数是字符串,goodsAll 里是数字,需转数字再严格比较
const g = goodsAll.find((x) => x.goodsId === Number(data.goodsId)) || goodsAll[0]
return clone(g)
}
// ... 其余接口同理
}
}
甚至连"优惠券自动计算最优券"这种业务逻辑,Mock 里都实现了完整版(满减校验 + 有效期判断),这样前端可以把下单结算全流程都调通,真正实现"无后端可验收"。
六、状态管理:不引 Pinia,一个文件搞定购物车持久化
购物车是这个项目数据模型里最典型的场景。用 Vue3 的 reactive + watch 自建了一个轻量 store,并通过 watch 深监听自动写入 uniStorage,实现切换页面不丢失:
import { reactive, computed, watch } from 'vue'
const STORAGE_KEY = 'local_cart'
export const state = reactive({
list: uni.getStorageSync(STORAGE_KEY) || [] // 启动即从缓存恢复
})
// 深监听,购物车一变就写缓存
watch(
() => state.list,
(v) => uni.setStorageSync(STORAGE_KEY, v),
{ deep: true }
)
function findIndex(goodsId, specId) {
return state.list.findIndex((i) => i.goodsId === goodsId && i.specId === specId)
}
export function addItem(goods, spec, num = 1) {
const specId = spec ? spec.specId : ''
const idx = findIndex(goods.goodsId, specId)
if (idx > -1) {
state.list[idx].num += num // 同商品同规格 → 累加数量
} else {
state.list.push({ ...goods, specId, specName: spec ? spec.specName : '', num })
}
}
export const totalCount = computed(() => state.list.reduce((a, i) => a + i.num, 0))
export const totalPrice = computed(() => state.list.reduce((a, i) => a + i.price * i.num, 0))
关键细节:购物车去重键是 goodsId + specId 的组合——同一个奶茶,大杯和小杯是两条不同的购物车记录。这个组合键在后续"勾选结算"时也用 goodsId:specId 字符串传参,全项目保持了一致。
页面里直接响应式消费:
<script setup>
import { addItem, totalCount, totalPrice } from '@/store/cart.js'
function addToCart(g) {
addItem(g, null, 1)
uni.showToast({ title: '已加入', icon: 'none' })
}
</script>
<template>
<!-- 底部悬浮购物车 -->
<CartFloat :count="totalCount" :totalPrice="totalPrice" tabbar @tap="goConfirm" />
</template>
七、结算页实战:优惠券自动匹配最优券
结算页(confirm)是这个项目交互最重的页面。它只做一件事:帮顾客算出最划算的实付金额。
流程是:
- 进入页面自动请求"我的可用优惠券";
- 调用后端
/api/coupon/calc自动匹配抵扣最多的券; - 顾客也可以手动点开券面板切换/放弃优惠券;
- 实付 = 商品小计 - 券抵扣。
const payMoney = computed(() => Math.max(0, subTotal.value - reduce.value))
onMounted(async () => {
try {
couponList.value = await getCouponList(1) // 1=未使用
} catch (e) {}
try {
const r = await calcCoupon(subTotal.value) // 后端返回最优券
coupon.value = r.coupon
reduce.value = r.reduce || 0
} catch (e) {}
})
// 满减门槛判断(手动切换券时实时校验)
function canUse(c) {
return c.fullMoney <= subTotal.value
}
payMoney 用 Math.max(0, ...) 兜底,防止券抵扣超过小计出现负数金额——这种边界虽然少见,但做支付相关 UI 时一定要防。
下单动作比较特别,因为是到店挂单、餐后统一结账的模型,所以 createOrder 只建单不支付:
const cartList = list.value.map((i) => ({ goodsId: i.goodsId, specId: i.specId, num: i.num }))
createOrder({
deliveryType: 1,
tableNo: shop.tableNo,
cartList,
couponId: coupon.value ? coupon.value.couponId : '',
remark: remark.value,
totalMoney: subTotal.value,
payMoney: payMoney.value
}).then(() => {
clearCart() // 下单成功清空本地购物车
uni.redirectTo({ url: '/pages/order/pay' }) // 跳到"提交成功"页
})
这里体现了业务模型的重要差异:到店点餐 ≠ 外卖,支付环节由收银台完成,所以
wx.requestPayment不在顾客端实现。做业务项目,先想清楚"钱从哪收",再决定代码结构,能少走很多弯路。
八、扫码桌号:条件编译 + 正则兜底
到店场景的入口是扫码。uni.scanCode 扫出来的结果可能是二维码链接(可能带参数),也可能是不带协议的纯桌号,所以要做正则兜底,把结果里的数字抠出来:
function scanCode() {
// #ifdef MP-WEIXIN || APP-PLUS
uni.scanCode({
success: (res) => {
const m = (res.result || '').match(/\d{1,4}/)
if (m) {
shop.setTableNo(m[0])
uni.showToast({ title: '已入座桌号' + m[0], icon: 'none' })
}
}
})
// #endif
}
同时首页 onLoad 支持从链接参数里直接带桌号进店(?tableNo=12),适合把带参的小程序码贴到餐桌上。桌号信息放在全局 store/shop.js,保证首页、结算页、订单页数据一致。
九、Design Tokens:一套 CSS 变量,全站视觉统一
这个项目没有引 UI 框架的主题系统,而是自己在 App.vue 里定义了一套设计令牌(CSS 变量),组件内统一 var(--xxx) 引用:
page {
--primary: #FF7A1A; /* 品牌橙 */
--primary-dark: #F2600A;
--price: #FF4D2D; /* 价格红 */
--bg: #FFF7EF; /* 暖色页面底 */
--card: #FFFFFF;
--text-main: #2B2B2B;
--text-sub: #666666;
--text-tip: #999999;
--border: #F0E6DC;
--success: #2BBF7A;
--disable: #CCCCCC;
--r-card: 16rpx; /* 圆角 */
--r-btn: 40rpx;
--r-tag: 8rpx;
--pad-page: 24rpx;
--gap: 16rpx;
}
好处很明显:
- 改主题只动一处:想换品牌色,改
--primary一个变量,全站同步; - 组件零魔法数字:所有页面用
var(--gap)、var(--r-card),间距圆角高度统一; - 配合 easycom:组件开箱即用,视觉天然一致。
再配合 .btn-primary、.price、.safe-bottom 这几个全局工具类,页面样式代码大幅瘦身。
十、对接真实后端:到底要改什么?
这是甲方最关心的问题,也是这篇文章想让你学会的"交付思维"。
前端侧,只改 config/env.js:
export default {
baseUrl: 'https://your-api-domain.com', // ① 换成备案过的 HTTPS 域名
uploadUrl: 'https://your-api-domain.com/api/upload',
tokenKey: 'Authorization',
tokenPrefix: 'Bearer ',
mpAppId: 'wxxxxxxxxxxxxxxxxx', // ② 换真实 appid
mock: false // ③ 关掉 Mock 开关
}
后端侧,照着《接口规范》第 7 章实现 30 多个接口即可,前端不感知。上线的硬性要求也要提前说清楚:
- 后端域名必须 ICP 备案 + HTTPS(443 端口),小程序强制要求;
- 小程序后台「服务器域名」要加 request / uploadFile / downloadFile 白名单;
- 不支持本地 IP、localhost、内网地址线上使用。
十一、踩坑与经验总结
最后分享几个这个项目里真实踩过、值得记住的点:
- URL 参数类型坑:
uni.navigateTo传参拿到的是字符串,而 mock 数据里goodsId是数字,不Number()强转会匹配失败返回错误数据。跨页面传参后一定要校验类型。 toISOString()时区坑:判断优惠券有效期时,如果用了new Date().toISOString()取"今天",会比北京时间慢 8 小时,晚间会误判券已过期。正确做法是用本地日期拼接YYYY-MM-DD字符串比较。- appid 泄露风险:开发用真实 appid,但发布到插件市场 / 交付源码前,一定要把
manifest.json和project.config.json里的 appid 替换成占位符,避免泄露。建议用构建脚本或 git 分支处理,别在源码里硬编码两套。 - 购物车键要用
goodsId + specId:同一商品不同规格必须分开记账,这是点餐类项目最容易漏的点。 - Mock 优先让"完整业务"能跑通:不要只 mock 列表数据,把"优惠券最优匹配""下单建单"这种核心逻辑也 mock 出来,前端才能真正离线验收全流程,这对交付谈判非常有利。
十二、写在最后
一套点餐小程序前端,难点从来不在某个页面,而在如何把"对接后端"这件事变得可预测、可验收、可维护。这套项目的核心经验可以浓缩成三句话:
- 规范先行:返回结构、错误码、金额单位,开工前就写死,避免对接扯皮;
- Mock 兜底:让前端在无后端时也能完整跑通,降低并行开发的阻塞;
- 改造成本聚焦:所有对接配置收敛到一个文件,后端就绪后一行配置切换。
如果你也在做 uni-app 业务项目,或准备交付一套可复用的点餐类小程序,希望这篇文章的目录设计和几个核心思路能给你参考。
十三、项目开源地址
本项目完整源码已发布到 DCloud 插件市场,可直接下载运行、二次开发:
📦 阳光点餐 · UniApp 点餐小程序前端源码 插件地址:ext.dcloud.net.cn/plugin?id=2…
下载后按 README 说明:用 HBuilderX 打开工程,填入真实 appid 即可在微信开发者工具中运行;对接后端只需修改 config/env.js 一个文件。也欢迎在插件市场评论区交流使用心得。
如果你对文中的某个模块(比如 Mock 完整实现、优惠券计算、订单状态机)感兴趣,欢迎评论区留言,我可以单独拆一篇展开讲。
- URL 参数类型坑:
uni.navigateTo传参拿到的是字符串,而 mock 数据里goodsId是数字,不Number()强转会匹配失败返回错误数据。跨页面传参后一定要校验类型。 toISOString()时区坑:判断优惠券有效期时,如果用了new Date().toISOString()取"今天",会比北京时间慢 8 小时,晚间会误判券已过期。正确做法是用本地日期拼接YYYY-MM-DD字符串比较。- appid 泄露风险:开发用真实 appid,但发布到插件市场 / 交付源码前,一定要把
manifest.json和project.config.json里的 appid 替换成占位符,避免泄露。建议用构建脚本或 git 分支处理,别在源码里硬编码两套。 - 购物车键要用
goodsId + specId:同一商品不同规格必须分开记账,这是点餐类项目最容易漏的点。 - Mock 优先让"完整业务"能跑通:不要只 mock 列表数据,把"优惠券最优匹配""下单建单"这种核心逻辑也 mock 出来,前端才能真正离线验收全流程,这对交付谈判非常有利。
十二、写在最后
一套点餐小程序前端,难点从来不在某个页面,而在如何把"对接后端"这件事变得可预测、可验收、可维护。这套项目的核心经验可以浓缩成三句话:
- 规范先行:返回结构、错误码、金额单位,开工前就写死,避免对接扯皮;
- Mock 兜底:让前端在无后端时也能完整跑通,降低并行开发的阻塞;
- 改造成本聚焦:所有对接配置收敛到一个文件,后端就绪后一行配置切换。
如果你也在做 uni-app 业务项目,或准备交付一套可复用的点餐类小程序,希望这篇文章的目录设计和几个核心思路能给你参考。
如果你对文中的某个模块(比如 Mock 完整实现、优惠券计算、订单状态机)感兴趣,欢迎评论区留言,我可以单独拆一篇展开讲。