ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue零食商城:数据流驱动的SKU与购物车状态管理实践

Vue零食商城:数据流驱动的SKU与购物车状态管理实践 简介这是一份基于Vue框架构建的在线零食销售系统设计源码面向需要快速上手前后端分离电商项目的开发者、前端学习者及毕业设计人群适合用来理解Vue组件化开发、Java后端接口交互及完整的商城交易流程。压缩包共357个文件整体约10.14MB包含95个JavaScript业务逻辑文件、76个Vue组件文件、69个Java后端服务文件以及JSON配置、CSS样式、Excel数据、图片与Markdown文档等辅助资源目录按front-admin、backend、doc等模块合理划分便于检索和二次开发。目前已有120人学习下载。借助该项目源码读者可掌握一套结构完整、界面美观的在线零食商城前后端实现涵盖商品浏览、购物车、订单管理、后台管理等典型电商模块通过配置文件和SQL脚本可快速搭建本地运行环境也可作为课程设计、实训项目或中小型商城系统开发的基础模板。1. Vue 零食商城的前端骨架数据流先于组件在线零食销售系统的源码工程表面上看是商品展示加增删改查真正卡住开发进度的往往是三件事零食 SKU 的多规格组合、购物车金额的精度、订单状态从待支付到已完成的过程推进。基于 Vue 框架的在线零食销售系统设计源码核心价值就是把这三条数据流在一个工程里串成闭环。它适合两类人需要交付毕业设计或课程作业的在校学生以及想快速搭一个可演示商城原型的独立前端。下面按源码开发和前后端联调时的常规顺序逐步拆解 store 怎么设计、组件怎么切、订单怎么走、代理怎么配。2. Pinia 里的零食 SKU 与购物车建模store 不是收纳箱2.1 零食品类为什么必须拆 SPU 和 SKU 两层零食品类的规格维度比一般商品更碎口味有原味、番茄、烧烤包装有单包、整箱净含量有 60g、90g、500g。同款薯片经常同时展示三到四个可下单单元。如果在源码里把不同规格塞进同一条商品记录购物车和订单就丢失了“用户具体买了哪包”的信息后端发货时对着订单明细也没法确定该发哪个口味。常规做法是复刻电商领域的 SPU/SKU 模型SPU 是“手撕面包”这个抽象商品SKU 是“原味 500g、19.9 元”这个具体单元。商品详情接口返回的结构也应该按这个模型组织// GET /api/products/1001 响应示例 const product { productId: 1001, title: 手撕面包混合装, cover: https://cdn.example.com/bread-cover.jpg, skus: [ { skuId: 500001, spec: 原味 500g, price: 1990, stock: 120 }, { skuId: 500002, spec: 抹茶味 500g, price: 2190, stock: 43 }, { skuId: 500003, spec: 原味抹茶 1kg, price: 3580, stock: 0 }, ], description: 独立小包装常温避光保存。, };price 字段以“分”为单位存储这是与列表页、购物车页联动时不出现 0.1 0.2 这种浮点误差的基础。页面初始化时默认选中第一个有库存的 SKU用户切换规格后价格、库存、可加购数量全部由这个 skuId 派生。如果接口返回的价格是字符串形式的 19.90前端要用Math.round(parseFloat(price) * 100)转成整数再做计算。2.2 购物车 store 的最小实现与金额精度源码阶段最常见的错误是把所有接口数据都塞进 Vuex 或 Pinia商品详情也放、订单列表也放、地址簿也放最后 state 越来越大没人分得清数据从哪里来。我一般只把两类数据放全局 store一类是跨页面共享且会被多处改写的比如购物车另一类是贯穿整个应用的身份信息比如 token。商品详情、订单列表留在组件内部用 ref 或 reactive 持有即可。以 Pinia 为例购物车 store 的最小骨架如下// stores/cart.js import { defineStore } from pinia export const useCartStore defineStore(cart, { state: () ({ // 购物车行元素含 skuId/spec/price/stock/count/selected items: [], }), getters: { selectedItems: (state) state.items.filter((item) item.selected), totalQuantity: (state) state.items.reduce((acc, item) acc (item.selected ? item.count : 0), 0), totalAmount: (state) { // 价格以分为单位累加最后统一除以 100 返回元 const cents state.items.reduce( (acc, item) acc (item.selected ? item.price * item.count : 0), 0, ) return cents / 100 }, // 提交订单时的载荷只带 skuId 和 count不把金额传给后端 checkoutPayload: (state) state.items .filter((item) item.selected) .map((item) ({ skuId: item.skuId, count: item.count })), }, actions: { addItem(sku, count 1) { const existing this.items.find((item) item.skuId sku.skuId) if (existing) { existing.count Math.min(existing.count count, sku.stock) } else { this.items.push({ ...sku, count }) } }, removeItem(skuId) { this.items this.items.filter((item) item.skuId ! skuId) }, toggleSelected(skuId, selected) { const item this.items.find((i) i.skuId skuId) if (item) item.selected selected }, }, })totalAmount 用整数分做累加规避了 JavaScript 浮点数在连续乘法后精度漂移的风险。checkoutPayload 把客户端算出的金额完全剥离下单金额由后端按当前最新库存价重新计算这个约定能避免用户通过修改请求 Body 来改价。购物车页面渲染时用storeToRefs(cartStore)解构 items直接在模板里读取不要在页面里再维护一份本地副本。2.3 商品、购物车行、订单项的数据对照数据对象核心字段是否需要持久化产生方ProductSPUproductId, title, cover, description后端商品表商品管理后台SkuskuId, spec, price, stock后端 sku 表商品管理后台CartItemskuId, count, selected本地或后端购物车表用户前端操作OrderItemskuId, skuName, snapshotPrice, count后端订单明细表下单接口生成注意 OrderItem 里的价格字段写的是 snapshotPrice 而不是直接引用 Sku 表。零食品类经常做限时折扣、满减商品原价会变但订单一旦生成成交单价必须固定。后端在创建订单时把当时的成交价写进订单明细三个月后查历史订单金额才对得上。前端 store 里不需要备份价格快照因为下单成功后购物车会清空历史价格以订单查询接口返回为准。提示购物车的 selected 字段只存活在前端页面状态里提交订单时后端只接收 checkoutPayload 中的 skuId 和 count勾选状态不要放进请求 Body。3. 商品列表与规格弹窗三块可以少写的重复代码3.1 列表筛选条件同步到 URL 参数商品列表页的筛选器搜索关键词、分类、价格排序如果只存组件 data刷新页面条件就丢了。源码里常见的正确做法是把筛选条件同步到 Vue Router 的 query 上组件加载时先从 route 读初始值之后每次变更写回 URL后退前进也能恢复状态。// views/ProductList.vue import { ref, watch, onMounted } from vue import { useRoute, useRouter } from vue-router const route useRoute() const router useRouter() // 初始化数据直接读 query配合刷新页面保持筛选状态 const keyword ref(route.query.k ?? ) const categoryId ref(route.query.c ?? ) const page ref(Number(route.query.p ?? 1)) async function fetchList() { const params { keyword: keyword.value || undefined, categoryId: categoryId.value || undefined, page: page.value, pageSize: 12, } // const { data } await http.get(/api/products, { params }) // productList.value data.records // total.value data.total } // 条件变化后重查列表并把条件写回 URL watch([keyword, categoryId, page], () { router.replace({ query: { ...(keyword.value { k: keyword.value }), ...(categoryId.value { c: categoryId.value }), ...(page.value ! 1 { p: page.value }), }, }) fetchList() }) onMounted(fetchList)这里用router.replace而不是push是为了避免一次次筛选堆出十几条历史记录用户按返回键要按半天。query 键名用k、c、p这类短参数请求 URL 更干净接口契约里不做限制的话也可以直接用英文全称。keyword 和 categoryId 为空时把对应 query 字段剔除避免 URL 里挂一串?kc的空值。3.2 规格弹窗只负责规格选择零食详情页的规格弹窗是事件传递最容易写乱的地方。弹窗内常有规格标签、数量选择、库存展示、加入购物车按钮如果每个状态都在弹窗内部维护父组件想根据选购结果做跳转时就得层层监听子组件事件。我一般把规格弹窗的职责范围压缩到最小只维护当前选中的 skuId、数量并抛出一个 confirm 事件。加入购物车、立即购买这些后续动作交给父组件处理!-- components/SkuSelectDialog.vue -- script setup import { computed, ref } from vue import { ElInputNumber } from element-plus const props defineProps({ skus: { type: Array, required: true }, }) const emit defineEmits([confirm]) // 默认选中第一个有库存的 SKU const selectedSkuId ref(props.skus.find((s) s.stock 0)?.skuId ?? ) const count ref(1) const currentSku computed(() props.skus.find((s) s.skuId selectedSkuId.value), ) // 库存不足或未选规格时加购按钮置灰 const maxCount computed(() Math.min(currentSku.value?.stock ?? 0, 50)) function confirm() { if (!currentSku.value) return emit(confirm, { sku: currentSku.value, count: count.value }) } /script template div classsku-dialog span v-forsku in skus :keysku.skuId :class{ active: sku.skuId selectedSkuId, disabled: sku.stock 0 } clicksku.stock 0 (selectedSkuId sku.skuId) {{ sku.spec }} small v-ifsku.stock 0缺货/small /span el-input-number v-modelcount :min1 :maxmaxCount / button :disabled!currentSku clickconfirm加入购物车/button /div /template父组件里这样接function onSkuConfirm({ sku, count }) { cartStore.addItem(sku, count) ElMessage.success(已加入购物车 ${count} 件) }弹窗不 import store、不关心购物车逻辑这让 SkuSelectDialog 可以复用到“立即购买”场景——同一个 confirm 事件父组件换成调下单接口弹窗组件本身不用改动。maxCount 里的50是一个硬上限避免用户把数量加到 999 而库存只有 80 的边界情况。3.3 数量选择与库存联动数量选择器在零食系统里常见的 bug 是先选了原味 12 件再切到抹茶味而抹茶只有 5 件库存输入框仍显示旧数量。处理方式是在 SKU 切换时重置 countwatch(selectedSkuId, () { count.value 1 })切换规格后数量回到 1用户再自行调整这样的交互行为比自动截断到当前库存更有预期。el-input-number的 max 属性只限制点击按钮不能完全阻挡手动输入大数字所以 confirm 提交前还要再校验一次count.value maxCount.value。两侧双重校验加购数量与库存对齐这件事才算完整。4. 结算提交与订单状态机业务规则放对侧4.1 结算页直接读 store而不是复制数据从购物车跳转结算页时常见传参方式有几种路由 query 传商品 ID 数组、sessionStorage 存整个购物车、props 一层层往下传。这些方式在零食系统里都不够好query 会超出 URL 长度限制sessionStorage 在多个标签页同时操作时会互相覆盖。推荐做法是结算页直接读同一个 cartStore 里的 selectedItems。因为用户在前一步操作的就是购物车页两个页面共享同一个 store 实例数据天然同步// views/Checkout.vue const cartStore useCartStore() const items computed(() cartStore.selectedItems) const totalAmount computed(() cartStore.totalAmount)这时不要在 store 初始化时做 localStorage 持久化零食项目一般单会话内完成下单会话结束购物车清空即可。只有当你明确做了“下次再结算”的交互设计时才需要手动把 items 序列化到 localStorage 并做合并逻辑。4.2 重复提交双保险submitting 锁与幂等键用户双击“提交订单”按钮是零食促销场景里最常见的重复下单来源。前端防重复是一层保险真正的兜底要靠后端做幂等。前端侧的做法const submitting ref(false) async function createOrder() { if (submitting.value) return if (!deliveryAddress.value) { ElMessage.warning(请先选择收货地址) return } submitting.value true // 生成一次性的客户端幂等键同一个订单号后端只允许创建一次 const clientToken ${Date.now()}-${Math.random().toString(36).slice(2)} try { const { data } await http.post(/api/orders, { items: cartStore.checkoutPayload, addressId: deliveryAddress.value.id, couponId: couponId.value ?? null, clientToken, }) // data.orderId 返回后跳转收银台 router.push({ path: /payment, query: { orderId: data.orderId } }) } finally { submitting.value false } }这里的关键参数是 clientToken。submitting 锁只对当前页面有效用户快速双击、或支付页回退后再次提交都需要 token 让后端做唯一性校验。后端拿到 token 后往订单表写入时通过唯一索引或查重逻辑拒绝第二次插入。碰到 409 冲突响应时前端应该跳转到已存在的订单页而不是把报错直接抛给用户。4.3 订单按钮渲染用状态配置表订单状态一多“已取消还显示去支付”“已完成还能申请退款”这类问题就冒出来了。原因是按钮显隐逻辑散落在各个模板的 v-if 里。更好的做法是先定义一张订单状态配置表// constants/orderStatus.js export const ORDER_STATUS { PENDING_PAYMENT: 0, PAID: 1, SHIPPED: 2, COMPLETED: 3, CANCELLED: 4, AFTER_SALE: 5, } export const ORDER_ACTIONS { [ORDER_STATUS.PENDING_PAYMENT]: [ { label: 去支付, type: primary, action: pay }, { label: 取消订单, type: default, action: cancel }, ], [ORDER_STATUS.PAID]: [{ label: 申请退款, type: danger, action: refund }], [ORDER_STATUS.SHIPPED]: [{ label: 确认收货, type: primary, action: confirm }], [ORDER_STATUS.COMPLETED]: [{ label: 申请售后, type: default, action: afterSale }], [ORDER_STATUS.CANCELLED]: [], [ORDER_STATUS.AFTER_SALE]: [{ label: 查看售后进度, type: default, action: progress }], }页面模板渲染时就一行循环el-button v-foraction in ORDER_ACTIONS[order.orderStatus] ?? [] :keyaction.action :typeaction.type clickhandleOrderAction(action.action, order) {{ action.label }} /el-button状态值与后端接口的数字代码一一对应前端不做任何字符串比对。想让某个状态多一个按钮只需改 ORDER_ACTIONS 一处。同时后端在收到“确认收货”请求时也要校验当前订单状态是否为 2只有已发货才能推进到已完成把状态机规则放在两侧都守住。提示接口传输用数字状态码文案“待支付”“已发货”只出现在前端展示层。不要在接口里传中文状态名否则后续做订单筛选和统计报表时字符串匹配会让 SQL 和索引都失去效率。5. Vite 联调代理与构建验证源码可交付的第一步5.1 开发代理解决端口不一致前后端分离项目里Vue 开发服务器默认跑在 5173 端口后端接口跑在 8080 端口。浏览器直接请求 5173 的/api/products会 404因为开发服务器没有这个静态资源。最常见做法是在 Vite 配置里加一层代理把/api前缀的请求转发到后端// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8080, // 后端服务地址 changeOrigin: true, // 如果后端接口本身没有 /api 前缀打开这行 // rewrite: (path) path.replace(/^\/api/, ), }, }, }, })changeOrigin: true会把请求头里的 Host 字段改写成 target 地址后端如果做了域名白名单这个开关必须打开。若后端所有路由都是根路径不带/api就启用 rewrite 把前缀剥掉。联调时先确认“最终请求落在哪个 URL”再决定 rewrite 开不开否则会出现前后端都以为自己在对接实际上请求根本没到达的假象。5.2 环境变量切接口地址部署到测试服或生产环境时接口地址一般不再走 Vite 代理而是直接请求独立域名。把这类地址放进环境变量文件构建时按环境自动替换# .env.development VITE_API_BASE_URL/api# .env.production VITE_API_BASE_URLhttps://api.example.comaxios 封装里读取// utils/http.js import axios from axios const http axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }) export default http只有VITE_前缀的变量会出现在构建产物中其他自定义变量一律排除。以后后端迁移域名改下.env.production重新 build 即可源码文件不动。5.3 交到别人手上之前的冒烟验证源码给出去之前我一般会做一轮五步验证每步都是一分钟内可判断结果npm install npm run dev能正常启动浏览器控制台无红色报错。商品列表能加载切换分类时 URL 的 query 跟着变刷新页面后筛选状态还在。加入购物车后在结算页能看到数量与金额一致提交订单接口能正确返回 orderId。重复点两次“提交订单”后端只生成一条订单记录。npm run build后 dist 目录能在任意静态服务器跑通接口前缀自动指向生产环境地址。这五项里最容易卡住的是最后一项很多人本机能跑build 完却因为绝对路径写死而白屏。源码里统一用相对路径引用静态资源接口地址交给环境变量构建产物就不挑部署位置。联调时常见的代理路径不对、跨域没开、订单状态值对不上前端 console 与后端日志对齐后逐一排查即可。本文还有配套的精品资源点击获取
返回列表