尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Vue的多门店收银系统:架构、状态与权限实战

基于Vue的多门店收银系统:架构、状态与权限实战 简介这套基于Vue.js的多门店连锁收银系统源码面向需要多门店统一管理的零售、餐饮企业以及有一定Vue基础的前端开发者覆盖会员管理、门店管理、商品库存等核心业务已稳定上线运行适合快速部署或作为二次开发基座。压缩包共462个文件以267个js脚本、116个vue组件为主辅以scss样式、json配置、png图标及少量html、ts等文件整体分层清晰模块化程度较高符合前端工程化标准。已有133人学习下载。读者可获得完整项目源码、package.json与.gitignore等工程化配置并通过目录结构理解组件复用、状态管理及多门店数据交互方式同时可参考移动端H5页面与基础样式模块掌握收银系统从页面搭建到接口联调的完整流程对提升Vue实际项目开发能力和连锁业务改造能力均有直接帮助。1. 基于Vue的多门店收银系统难点不在收银台上连锁门店的收银需求和单店收银完全是两回事。单店只需要把商品、购物车、结算三件套做顺一旦加上“多门店”问题就变成同一套Vue代码要跑在几十家店的前台收银机上门店A的订单不能出现在门店B的报表里不同门店的支付通道、会员折扣、小票模板还得各自生效。捷盈这套“多门店连锁收银系统”的源码核心价值不在收银交互多花哨而在门店维度的数据隔离与状态切换怎么组织。正在做管理后台、想理解连锁业务怎么落地的人以及Vue状态管理还停在组件通信层面、需要补一课工程化的同学都能从这套设计里找到对应方案。下面按我接手这类项目时最关心的顺序把架构、交易链路和后端协作的关键点拆开讲。2. Vue多门店架构设计状态管理里的门店上下文与路由守卫多门店收银系统的第一行设计不是商品列表而是“当前门店是谁”。想不清楚这个问题后面所有页面都会在切换门店时出乱子。最常见的做法是在Vuex里维护一个门店上下文把storeId存在本地持久化存储里所有请求带上storeId前端不做跨门店数据缓存。这样一个门店一套配置报表和订单接口天然按门店过滤。2.1 按门店分片的store模块切换门店必须清掉哪些状态我一般会把门店上下文放进独立的session模块而不是散落在各个业务模块里。它负责三件事记住当前storeId、拉取门店配置、在切换时广播清理信号。// store/modules/session.js import { getStoreId, setStoreId } from /utils/storeContext const session { namespaced: true, state: () ({ storeId: getStoreId() || , storeName: , storeConfig: {}, }), mutations: { SET_STORE(state, store) { state.storeId store.id state.storeName store.name setStoreId(store.id) }, SET_STORE_CONFIG(state, config) { state.storeConfig config }, }, actions: { async switchStore({ commit }, store) { // 关键点换店先清购物车和订单态避免上一家店的商品带到新门店 commit(cart/CLEAR_CART, null, { root: true }) commit(order/RESET_ORDER, null, { root: true }) commit(SET_STORE, store) const res await api.getStoreConfig(store.id) commit(SET_STORE_CONFIG, res.data) return res.data }, }, }逻辑说明state里的storeId从utils/storeContext读取这个工具函数封装了localStorage的读写刷新页面后门店仍能保持。switchStore这个action是切换门店的唯一入口它在换店之前先commit了两个root级的清理mutation购物车和进行中的订单都不允许跨门店存活。为什么用commit而不是直接在组件里改state因为清购物车、重置订单、拉配置这三个操作必须在同一事件循环里按顺序执行任何一步落下都会出现“门店显示A、订单记录却是B的”这类脏状态。参数说明getStoreConfig是后端接口返回门店的钱箱、小票打印机、税率、支付渠道配置实际项目中这个接口还可能返回门店的营业时间前端据此控制下单按钮的可点状态。setStoreId的入参是门店编码建议用后端持久化的store_code而不是自增id因为连锁场景下门店可能合并或重排业务编码比数字主键稳定。2.2 路由守卫把好第一道门未选门店先强制去选店数据隔离不止在store里做路由层也要卡一道。进入收银页、报表页时如果当前没有storeId直接跳到门店选择页而不是渲染一个空内容这是vue-router在换店场景下最常用的接入方式。// router/index.js router.beforeEach((to, from, next) { const storeId getStoreId() if (to.meta.requiresStore !storeId) { // 未选门店先强制走选择页redirect参数保留原目标路径 next({ name: store-select, query: { redirect: to.fullPath } }) return } next() })逻辑说明to.meta.requiresStore是路由配置里的一个标记。收银、订单、库存这些强门店相关的路由在meta里标上requiresStore: true门店选择页和登录页不标。守卫读的是同一个storeContext工具函数而不是Vuex里的state。原因Vuex的state在页面刷新后会重置而localStorage不会用本地存储做判断更可靠。redirect参数保证用户选完门店后能回到原页面这里用到的就是vue-router里最常见的query参数传递。参数说明这段代码在Vue 2和Vue 3的vue-router下写法一致区别只在new Vue和createApp的挂载方式。路由守卫里不要引入api调用只做同步判断否则首屏会因为异步请求产生空白闪烁。另外门店选择页提交后要调用上文的switchStore再通过router.replace可以确保浏览器返回键不会退回选店页面。2.3 axios请求层统一注入门店参数不要每个组件手动传组件里手动传storeId的做法在收银源码里很常见但维护成本高。新增一个页面容易漏传后端就会收到空的门店号。我更推荐在axios拦截器里统一注入。// utils/request.js service.interceptors.request.use(config { const storeId getStoreId() if (storeId) { config.params { ...config.params, storeId } config.headers[X-Store-Id] storeId } return config })注入到params和header两个位置各有考虑params适合数据库按门店过滤用后端网关也能拿到header则是给全局中间件做鉴权判断比如总部角色可以不带storeId访问多门店汇总接口。这里有一个常见误用不要在每次请求里都用Vuex mapState取storeVuex的state不能跨页面持久化请求层用localStorage封装函数是更稳的做法。header和params双写时还要注意个别网关会限制header长度门店号尽量短编码不要整串塞JSON进去。从这三个环节可以看出门店上下文要贯穿store、路由、请求三层缺一层都会在换店或者刷新时露馅。接完这套底座才能放心往收银台这个高频页面上堆交互。3. 收银台核心链路购物车结算、订单号生成与重复提交防护的Vue实现收银台是所有门店的公共入口这个页面在源码里通常拆成三个组件左侧商品陈列支持扫码和关键词两种检索、右侧购物车、底部结算条。组件之间的通信不要用event bus收银动作非常频繁事件总线会把数据流搅乱。用Vuex的cart模块统一管购物车组件只做展示和派发action这是vue项目实战里最经得起返工的结构。3.1 购物车state设计skuId为主键qty可变subtotal一改全改购物车模块是整个收银页面里出bug概率最高的地方因为它的状态分散在商品列表、数量输入框、结算条三处。// store/modules/cart.js const cart { namespaced: true, state: () ({ items: [], }), mutations: { ADD_ITEM(state, product) { const exists state.items.find(i i.skuId product.skuId) if (exists) { // 同一种商品只加数量避免列表里出现两行同样的sku exists.qty product.qty exists.subtotal moneyRound(exists.price * exists.qty) } else { state.items.push({ skuId: product.skuId, name: product.name, price: product.price, qty: product.qty || 1, subtotal: moneyRound(product.price * (product.qty || 1)), maxQty: product.stock, }) } }, SET_QTY(state, { skuId, qty }) { const item state.items.find(i i.skuId skuId) if (!item || qty 0) return item.qty qty item.subtotal moneyRound(item.price * qty) }, CLEAR_CART(state) { state.items [] }, }, getters: { totalCount: state state.items.reduce((sum, i) sum i.qty, 0), totalAmount: state moneyRound(state.items.reduce((sum, i) sum Number(i.subtotal), 0)), }, }逻辑说明购物车以skuId为唯一主键而不是商品id。同一个商品可能有多个规格大杯/中杯、不同口味skuId能精确到一条可售卖的商品避免加购时规格冲突。ADD_ITEM里先find再决定是累加还是新增这段逻辑要写进mutation而不是在组件里判断因为扫码枪快速连扫时连续两次add如果并发走到组件层判断很容易把同一商品拆成两条。SET_QTY用于手动改数量它重新计算subtotal目的是让单行小计与总价永远基于同一套金额换算逻辑。参数说明getters里的moneyRound是金额四舍五入的公共函数收银场景大多保留两位小数但计算过程中先取Number再round避免字符串拼接导致的类型错乱。maxQty字段存库存余量加购时超过这个值要给出“库存不足”提示注意这里的stock是后端在进入结算页时下发的一次性快照不是实时库存实时扣减必须等下单接口返回才能确认。3.2 结算提交的幂等控制按钮loading与本地锁缺一不可结算按钮双击是收银场景最常见的事故源。顾客连刷两次付款码或者收银员鼠标连点都可能产生两笔相同金额的订单。前端要在提交那一刻拦住第二次请求。// 结算按钮的提交逻辑 async function submitPayment() { if (submitting) return submitting true this.$refs.submitBtn.loading true try { const orderNo genOrderNo(storeId) await api.createOrder({ storeId, orderNo, items: cart.items.map(i ({ skuId: i.skuId, qty: i.qty })), totalAmount: cart.totalAmount, }) this.$router.push({ name: pay-result, query: { orderNo } }) } catch (e) { // 网络或后端异常时保持购物车不销毁方便重试 this.$message.error(e.message || 下单失败) } finally { submitting false this.$refs.submitBtn.loading false } }逻辑说明submitting是个局部布尔锁配合按钮的loading属性双保险防重复提交。使用场景是顾客连刷两次付款码或收银员双击鼠标第一次请求没返回时第二次点击直接被if拦截。loading和局部锁是两个层面的保护loading防的是用户可见的操作反馈submitting防的是异步回调还没回来时的代码逻辑漏洞。二者缺一不可只做loading是有缝隙的。参数说明api.createOrder是后端下单接口传入的是orderNo而不是id让后端做幂等判断后端只要发现orderNo已存在就直接返回已有订单不重复扣库存。storeId直接从session中取这里用了作用域上的storeId实际项目里一般来自computed里的mapState。catch里特意保留购物车数据是不让收银员在故障时重新扫码录入一遍商品这对门店高峰期很关键。3.3 订单号生成门店号日期当日序列的前端实现与边界订单号在收银前端生成是为了在弱网环境下先把流水落本地但前端生成有天然的并发边界。// utils/orderNo.js const seqMap {} export function genOrderNo(storeId) { const d new Date() const pad n String(n).padStart(2, 0) const datePart ${d.getFullYear()}${pad(d.getMonth() 1)}${pad(d.getDate())} seqMap[datePart] (seqMap[datePart] || 0) 1 const seq String(seqMap[datePart]).padStart(4, 0) return ${storeId}${datePart}${seq} }逻辑说明这个单号格式的好处有两个。一是可读性强运营人员从orderNo里能直接读出是哪个门店、哪天、第几单。二是按天自然归零序列号4位足够覆盖普通门店一天三千单的峰值容量。seqMap是一个模块级内存对象按日期分片计数日期变了序列自动归零。注意这里有一个明显的工程边界前端内存计数只在单机单页面有效。收银软件如果一台电脑开两个标签页或者换设备重开浏览器序列会从1重新开始产生重复单号。所以这个函数只能当显示用的草稿单号真正入库时后端必须用自己的序列源通常是数据库序列或Redis自增来替换或补全。做vue项目实战或毕业设计时用前端生成完全没问题上生产则要在后端再算一次。4. 连锁场景的角色权限与门店参数下发动态路由、指令权限与配置表连锁收银系统一定有总部和门店两个角色。总部能看全部门店的汇总报表门店只能看自己同一个操作员调到另外一家店菜单和权限要保持一致还是缩小取决于角色设计。这套逻辑在Vue前端落地时核心是三个点菜单数据由后端下发而非前端写死、按钮级权限由指令统一控制、门店专属参数集中配置。4.1 菜单权限的动态路由后端返回菜单树前端addRoute注册页面前端写死菜单的缺点是门店收银员也能看到总部的报表入口只是点了报错。更稳的做法是登录成功后请求一次菜单接口把返回的结果动态注册成路由。// 登录成功后加载菜单并动态注册路由 async function loadMenus(storeId) { const { data } await api.getMenus(storeId) // data形如[{ path: /cashier, name: cashier, component: CashierView, children: [] }] const routes data.map(n { const component () import(/views/${n.component}) return { path: n.path, name: n.name, component, meta: n.meta } }) routes.forEach(r router.addRoute(root, r)) }逻辑说明菜单由后端根据当前登录人角色和门店编号动态组装前端只负责把路径和views目录下的组件名对应起来。import函数是webpack的动态import语法只有被返回的菜单才会被打进运行时的代码块这样前端不能通过路由直接访问未授权的页面。addRoute的第二个参数是父路由的name主布局路由通常挂载在名为root的路由下子页面都成为它的children。参数说明getMenus的入参storeId即门店上下文总部的storeId可以约定为all后端看到这个值就返回全部门店的汇总菜单。这里要注意一个坑Vue Router 4里对同一个name重复调用addRoute会告警二次登录或切换账号时要先遍历router.getRoutes()把旧路由按name删掉否则菜单会叠加或者跳转异常。注意Vue Router 4 中 addRoute 对同一个 name 重复注册会抛警告切换账号前需要先清理旧路由。4.2 按钮级权限v-permission指令与权限点命名页面级权限用路由控制后像“作废订单”“修改价格”这种操作按钮还要再细一层。收银源码里常见的做法是一个权限点对应一个操作指令挂在按钮上权限点由后端随用户信息下发。// directives/permission.js const permission { mounted(el, binding) { const { value } binding const perms useStore().state.user.permissions || [] if (value !perms.includes(value)) { el.parentNode el.parentNode.removeChild(el) } }, }el-button v-permissionorder:void作废/el-button el-button v-permissionprice:modify改价/el-button逻辑说明mounted时拿当前用户的权限点数组如果按钮上标注的权限点不在数组里直接移除DOM。用removeChild而不是v-if或者disabled是为了把无权限的元素彻底从页面上删除避免灰掉后用户仍能通过开发者工具触发事件。权限点命名推荐格式模块:动作例如order:void、order:refund、goods:edit这样一个角色授权时一眼能看出能力范围。参数说明permissions数组在登录时随用户信息一起缓存到Vuex和localStorage。这里要注意一个细节指令在mounted阶段判断时权限数据必须已经就绪如果页面渲染早于权限拉取指令会误删按钮。解决办法是把权限拉取放在路由守卫或者应用启动阶段保证进页面前数据已落地。4.3 门店差异配置一张配置表管住收款方式、打印机与税率门店之间最常见的差异不是代码差异而是收款方式和小票格式。这些差异应该全部走配置下发而不是每开一家店改一次代码。配置项字段名示例值作用范围门店编码store_codeS0102所有请求的过滤维度收款方式pay_channels[wechat,cash,alipay]收银台结算区域显示哪些支付图标小票打印机printer_codeprinter_03下单后调用云打印服务的设备号税率模式tax_rate0.06 / 0总价是含税还是不含税展示会员折扣开关member_discounttrue/false结算时是否显示会员价这份配置在门店上下文里拉取存在storeConfig中。收银台的支付按钮区域根据pay_channels里的值条件渲染而不是写死三四个按钮。接这部分时最容易出现的失误是把门店配置写在环境变量里。env是部署环境维度不是门店维度不同门店共用同一套部署时env根本分不开必须走接口。门店配置下发后还要考虑版本接口最好返回一个configVersion前端发现版本变化时提示刷新避免收银台长时间挂机后拿到过期配置。5. 源码排查与验证金额精度、重复提交和切店残留的正确姿势最后放三个我在联调中翻过车、但特别值得当验证点的细节。它们不会让页面崩掉但会让门店财务对账时暴跳如雷。5.1 金额统一到分再计算杜绝JS浮点误差JavaScript里0.1加0.2不等于0.3。收银系统有折扣、抹零、优惠券数字经过多轮运算之后浮点误差会在财务报表上累出对不上的几毛钱。最稳的做法是所有金额以分为单位进行加减只有在展示时再除以100。// utils/money.js export const fenToYuan fen (fen / 100).toFixed(2) export const yuanToFen yuan Math.round(Number(yuan) * 100)逻辑说明toFixed返回的是字符串计算时要先Number转回。String和Number混用的类型错乱是收银金额类bug的最高发区域。建议在项目里规定一条土规矩前端展示用分转元的函数后端存储按分接口字段名直接叫amountFen减少歧义。如果后端已经用元前端就统一乘100后再入购物车。5.2 用订单号做请求唯一键活捉重复提交页面按钮锁只解决当前页面的重复点击解决不了网络层重试导致的重复提交。更硬的防线是请求唯一键。let lastSubmitKey async function submitWithGuard(orderNo) { if (lastSubmitKey orderNo) return lastSubmitKey orderNo try { await api.createOrder({ orderNo }) } finally { setTimeout(() { lastSubmitKey }, 3000) } }逻辑说明这里和3.2的不同在于不依赖按钮状态而是用orderNo本身作为请求唯一键三秒内相同单号的请求直接丢弃。配合后端对orderNo的唯一约束能挡住绝大多数重复扣款场景。最后把lastSubmitKey清空是防止三秒后用户手动重试时被旧的key挡住。建议在联调时用浏览器开发者工具的慢网速模拟连点十次结算看后端日志里createOrder被调了几次这个用例能同时验出3.2和5.2两道防线是否生效。5.3 切店残留的验证手工压一遍“加购-切店-回单”收银机上先加购商品随后切换门店。预期结果是购物车空、订单状态重置、付款方式变更为新门店配置、顶部门店名称刷新。如果列表里残留上一家店的商品优先查switchStore里有没有commit对应的CLEAR mutation。这一条在连锁场景里体验提升最明显值得在联调期每天压一遍。本文还有配套的精品资源点击获取
返回列表