
去年接手这个项目的时候学校给我的需求只有一句话把校园卡充值和缴费做成学生能在手机上自己完成的样子。听起来就是一个高校学生饭卡在线缴费充值综合业务系统但真正动手以后才发现牵扯到的东西比想象中多得多。项目编号9805638最初只是一套应急用的极简页面可一旦开学选课、饭卡余额清零、宿舍水电费集中推送全校上万学生同时登录充值原实现直接被打回原形。这篇内容既是我们开发过程的一次完整复盘也是想给正在用vue3做后台管理、做业务系统的人一份能直接参考的实践经验。整个项目最值得说的不是某个炫酷的交互效果而是怎么把一个“天天在用但没人觉得复杂”的缴费系统拆成可靠的前端工程。下面按实际推进顺序来聊。1. 为什么要把校园卡缴费系统推到 Vue3场景、痛点与选型判断1.1 校园一卡通缴费绝对不是“一个充值页面”那么简单先说需求盘点。所谓“综合业务系统”核心其实是四个角色的闭环学生要充值和查询食堂和超市商户要收银和对账财务或卡务中心要审核和报表运维方要拿到日志和异常告警。单看学生端页面确实不多饭卡充值、余额查询、消费流水、挂失解挂、补卡缴费、补助领取。但把这些串起来之后你会发现它更像一个轻量级的账务系统而不只是“充个值”。这个场景里有很多容易被忽略的细节。比如饭卡余额存在两台不同的机器上一台是食堂刷卡机的本地钱包一台是后台账户系统两边靠定时任务对账。学生刚刷完卡本地钱包余额没同步前端却显示后台余额已经变了然后就会出现“我卡里有钱为什么刷不了”的投诉。前端必须清楚哪些数据以服务端为准哪些数据允许延时不能把页面渲染得“好像实时”最后被对账结果打脸。高峰并发也是绕不开的问题。新生入学报到那几天充值和缴费的请求量是平时的十倍以上。这时候页面的每个请求、每个组件加载时机、每段并发逻辑都会被放大。选 Vue3 的其中一个重要原因就是组合式 API 能把这类复杂业务逻辑拆成可复用的模块而不是在几百行的 methods 里互相纠缠。1.2 从 Vue2 切换到 Vue3 的决策依据项目组里有一部分人之前只写过 Vue2所以技术选型时不能只拍脑袋说“新项目就用新框架”得让团队明白换框架到底解决了什么问题我列过一张对比表比较有参考价值对比点Vue2 的典型表现Vue3 下的变化逻辑复用依赖 mixins命名冲突多来源不清晰Composition API 组合式函数按业务域切割响应式原理Object.defineProperty数组下标和新增属性容易丢响应式Proxy 拦截新增删除属性更自然TypeScript支持很费劲类型推导不友好全程支持配合 defineComponent 体验顺滑生态配套Vuex 4 很别扭UI 库以旧版为主Pinia Element Plus 基本是标配构建工具webpack 配置繁琐Vite 冷启动和热更新快很多真正打动我们的倒不是响应式原理那一层而是组合式函数带来的重构空间。充值、挂失、账单查询这些模块都需要处理“提交中、成功、失败、补偿操作”这几种状态用 Vue2 写要么复制粘贴一大段要么塞进 mixins。在 Vue3 里我们可以把支付提交逻辑统一抽到 usePaySubmit 里不同页面各自调用改一处逻辑所有页面同步生效。另一个考虑点是组件库。这个系统要覆盖学生端、商户端、管理端UI 组件量不小。Element Plus 的表格、表单、日期选择器、上传组件正好对得上与其自己反复造轮子不如直接站在组件库生态上。1.3 综合业务系统的模块划分与基础工程结构技术栈定下来以后我把前端工程切成了四个子模块student 学生端、merchant 商户端、admin 管理端、common 公共能力。四个子模块在同一个仓库里通过路由前缀和目录隔离没有强行拆成微前端因为当时的团队规模撑不起微前端的运维成本。src/ api/ # 接口层按后端模块拆分 components/ # 公共业务组件 composables/ # 组合式函数支付、导出、字典等 layouts/ # 多端布局 router/ # 路由与权限 stores/ # Pinia 状态 views/ student/ merchant/ admin/ utils/ # 加密、日期、金额格式化等这里想专门提醒一个工程化细节api 层一定要和后端接口文档的数据结构对齐不要写一堆 any 类型的接口函数。团队里有人图省事接口返回类型全是 any结果联调阶段出了问题光靠运行时错误根本定位不了。后来我们强制要求为每个接口定义 ResponseData 类型等于把大部分低级错误留在了编译阶段。2. 地基工程权限路由、状态管理和接口层的统一封装2.1 路由设计学生、商户、管理员三类身份的权限控制综合业务系统最典型的问题是多角色共用一套代码。学生登录后只能看到充值、账单、挂失商户登录后只能看到受理终端、交易流水、日结单管理员则要看到用户列表、订单管理、对账报表。如果把这些页面全部写在静态路由里权限校验会变得非常痛苦。我们采用的是“后端返回菜单权限 前端动态注册路由”的方式。登录成功之后接口会返回当前用户可访问的路由标识前端用 router.addRoute 动态挂载。这样有两个好处一是未授权页面即便被猜到路径也无法通过路由守卫二是菜单和路由天然一致后端调整权限时前端不用发版。路由守卫里的核心逻辑大致是这样// router/guard.ts router.beforeEach(async (to) { const userStore useUserStore() if (!userStore.token !to.meta.public) { return { path: /login, query: { redirect: to.fullPath } } } // 已有 token 但 userInfo 未加载时先拉用户信息 if (userStore.token !userStore.infoLoaded) { try { await userStore.fetchProfile() } catch (e) { userStore.reset() return { path: /login, query: { redirect: to.fullPath } } } } // 基础权限校验 const roles userStore.roles if (!hasPermission(to.meta?.roles ?? [], roles)) { return { path: /403 } } })这里有个容易踩的坑addRoute 是在路由守卫里异步完成的用户第一次点击菜单可能命中“匹配不到路由”的空白页最终被重定向到 404。要判断当前路径是否已经注册如果没注册可以尝试重新拉取菜单并再次跳转实在不行再重定向登录页。我们在测试环境就被这个问题坑过第一次登录后随意刷新一个二级页面直接掉线。2.2 状态管理为什么选了 Pinia 而不是 VuexVue3 项目里 Pinia 几乎已经是默认选项了它比 Vuex 简单没有 mutations 那层概念直接改 state 就行而且对 TypeScript 的支持接近零成本。我们的 store 拆成了四个userStore 管用户信息和角色权限payStore 管支付订单状态和当前处理步骤billStore 管账单查询条件与列表缓存noticeStore 管站内信和待办数量。有一点要说明不是所有数据都适合放 store。充值页的金额输入框、挂失页的表单内容这类组件私有的临时状态就放在组件里没必要全局共享。全局 store 一多维护成本和心智负担会指数级上升。我们约定只有被两个以上页面共用的状态才提升到 store 里。2.3 基于 Element Plus 封装业务组件Element Plus 在 Vue3 下配起来不复杂但直接裸用组件会产生大量重复代码。例如“选择日期范围 查询 重置”这个组合在账单页、流水审核页、报表页反复出现我封装成 FilterPanel 组件金额展示和正负号颜色封装成 AmountText 组件订单状态映射为标签颜色也做成一个专门组件。封装组件时另一个体会是组件要保持单纯。有些同事喜欢把查询逻辑也写进组件里结果是这个组件只能在一个页面用换个查询字段就得返工。我们后来提炼了规则公共组件只做展示和事件回调不管业务接口业务请求一律在页面里处理。2.4 接口层封装与防重复提交接口层直接决定联调效率。我们封装 axios 实例时做了四件事请求头统一加 token、时间戳、随机数 requestId响应拦截器统一处理后端返回的 code401 自动跳登录根据 header 里的 loading 配置自动控制全局 loading 开关请求错误统一弹出提示页面里不用到处写 try/catch接口层里最重要的其实是不起眼的 requestId。校园网环境不稳定学生充值页面偶尔会出现一次点击发了两次请求的情况如果后端没有幂等控制就会生成两条订单。我们每个请求带一个前端生成的 requestId后端拿到同一 requestId 时直接复用第一次的处理结果这个设计让我们在正式上线后少处理了大量重复订单投诉。3. 充值缴费核心链路的开发落地从下单到支付状态同步3.1 充值页面的实现细节充值页是整个系统使用频率最高的页面看起来简单细节却很多。金额选择区支持 10、20、50 的固定档位也支持自定义金额输入框需要过滤掉非数字字符。提交按钮必须同时做 loading 状态和 disabled 控制防止学生在网络慢的时候连点三次。下单接口返回的并不是“充值成功”而是一个待支付订单。前端拿到订单号后跳转到统一收银台。收银台里根据不同渠道展示微信、支付宝或校园卡钱包余额整个交互必须让用户明白当前处于“支付处理中”而不是卡死了。我们特意在页面上放了“支付结果确认中请勿关闭页面”的提示条其实后端可以后补通知但学生用户不理解必须有人性化提示。自定义金额这里有个新手容易忽略的场景如果学生输入 100.01后端计算时用浮点数直接加减可能会出现 0.01 的精度误差。虽然这不是前端负责的核心逻辑但前端提交的金额格式必须做校验只允许两位小数避免把脏数据传给后端。金额展示我们也统一用分做最小单位来格式化避免 0.1 0.2 的经典尴尬。3.2 支付状态轮询、WebSocket 与实时性取舍支付渠道的回调是异步的前端不能只在调用支付后坐等。我们最初想用 WebSocket 实时接收支付结果但学校内网环境复杂部分网络会断长连接重连机制做得不好反而增加问题。最终采用了“轮询为主、WebSocket 为辅”的方案支付中每 2 秒查询一次订单状态最多轮询 60 次同时监听 WebSocket 消息一旦有支付成功通知立即中断轮询并刷新余额。这里最重要的原则是前端绝对不能根据支付渠道的“返回支付成功”去更新余额。因为第三方渠道支付成功不代表学校账户系统已经入账入账动作可能还在异步队列里。前端必须等后端订单状态变成 success 或 confirmed才展示“充值成功”并把余额更新成服务端的最新值。前端展示的数据永远只是服务端状态的投影而不是支付链路里的某一个中间结果。3.3 消费明细与账单核对账单页要支持按日期筛选、按消费类型筛选、分页加载。数据量大了以后一次性渲染几千行 DOM 会明显卡顿。我们除了做分页还在页面里做了虚拟滚动用户快速滑动时也不会掉帧。账单列表里每笔金额都要区分“支出”“收入”“退款”我们约定支出显示绿色负数收入显示红色正数颜色统一用状态字典驱动。这里又出现一个真需求Excel 导出。学生想要带盖章的消费凭证找辅导员报销管理员想要按食堂维度导出流水所以前端调用了一个统一的导出接口导出任务在服务端异步生成前端轮询任务状态后提供下载链接。3.4 挂失、补卡、退费这些非支付类业务的前端实现挂失是一个高敏感操作误操作可能导致学生好几天吃不上饭。前端的第一步是让用户二次确认确认框里必须展示“挂失后实体卡立即失效”的后果。我还在这个页面里加了一个倒计时缓冲点击确认后 5 秒内允许撤销超过时间才真正提交。这算是从支付系统的“冲正”思路里抄过来的。补卡和退费则要和线下柜台联动。前端提交的是申请单而不是直接改卡状态。申请单的状态流是“待审核、已受理、已办结、已驳回”每个状态对应不同按钮。这提醒我们做这种系统前先画清楚业务状态机哪怕是画在纸上都比直接写代码强一百倍。4. 联调踩坑实录签名校验、表单规则与浏览器兼容性4.1 支付渠道签名规则的对齐与踩坑对接支付渠道时后端把签名逻辑封装好了前端只需要传参但我强烈建议前端也理解一遍规则否则遇到参数大小写问题会无从调起。常见做法是把请求参数按字典序排序拼接 keyvalue再用密钥做 HMAC-SHA256把所有字段转成字符串后计算签名。我们踩过一个典型的坑后端要求时间戳是秒级字符串前端却传了毫秒级数字。后端验签失败后不会告诉我们原因只返回“签名错误”排查了很久才发现是时间戳格式对不上。后来我们在接口文档里明确规定时间戳统一为秒级字符串并且在 api 层加了一个切面所有请求自动将 Date 类型转成标准字符串。4.2 Element Plus 表单校验里的日期规则坑日期校验看着简单真正写起来有不少门道。我们遇到的需求是充值补贴的有效期必须晚于当前时间账单查询的结束时间不能早于开始时间寒暑假补助的申请日期只能在本学期范围内。Vue3 里用 Element Plus 表单校验经常出现的问题是校验函数在异步数据到达后不自动重新触发。例如编辑页面回填数据后明明日期已经合法错误提示还挂着。解决方法是回填数据后调用 formRef.clearValidate()或者对依赖校验的字段用 watch 监听变化。另一个坑是时间边界的处理。日期范围如果精确到秒学生选了 2026-02-28 到 2026-03-01实际上跨越了三天和用户认知的“两天”不一致。我们统一把开始时间设置为 00:00:00结束时间设置为 23:59:59再用 validator 比较两个时间的差值是否大于等于 0。这里贴一段常用校验逻辑const validateDateRange (_rule: any, value: string, callback: any) { const { startDate, endDate } formData if (!startDate || !endDate) { callback() return } const start new Date(${startDate} 00:00:00).getTime() const end new Date(${endDate} 23:59:59).getTime() if (end start) { callback(new Error(结束日期不能早于开始日期)) } else { callback() } }看起来简单但真实环境里常常出问题的是日期组件回传的值可能是 Date 对象、字符串或数组必须统一格式化后再比较。4.3 浏览器兼容性与终端设备的怪问题学校环境里浏览器版本五花八门最让我们头疼的是行政楼老电脑上的旧版 Edge。有用户反馈“在页面里点了右上角最小化按钮没反应”一开始以为是我们代码问题排查之后发现只是浏览器扩展冲突导致的最小化事件被抢占。这类和业务无关的兼容性问题最好的处理方式是在部署文档里写清楚最低支持版本同时用 browserslist 限制构建目标避免引入过新的语法导致旧浏览器白屏。项目里还有一部分自助终端用的是 CefSharp 内嵌页面前端需要调本机读卡器通过 window.cefbridge 注册原生能力。遇到的经典问题是 bridge 对象在 Vue 应用挂载前还没有注册成功导致调用时 undefined。解决方式不是改 Vue 代码而是把调用时机延后到 onMounted 之后并且监听本地终端传上来的 ready 事件再执行绑定。这类跨端调试光靠 Chrome DevTools 是复现不了的必须有实机验证环境。4.4 并发和重复提交的兜底校园网延迟高学生着急容易反复点击。前端做防抖、按钮 loading 都只是缓解真正的兜底在接口层。我们每个下单请求必须携带 requestId后端收到相同 requestId 的请求直接返回第一次的订单数据不再创建新订单。前端还要考虑一个边缘情况用户在收银台支付成功但页面还没轮询到结果就关闭了然后立刻重新打开 App 查余额发现没变于是再充一次。这里必须加上引导流程登录后若存在“支付中”订单App 会自动弹窗提示并恢复查询状态而不是让学生重复下单。5. 性能优化与上线后的稳定性经验5.1 包体积优化首屏加载从 6 秒降到 2 秒以内Vue3 项目默认用 Vite 打包配置不优化的话一个包含 Element Plus 全量引入的管理系统首屏 JS 随随便便就上 1MB。我们做了三件事Element Plus 按需引入只注册实际用到的组件路由懒加载每个一级路由独立分包把 vue、vue-router、pinia 这些基础库单独拆 vendor 包利用浏览器强缓存做完之后首屏体积明显下降。建议在 vite.config.ts 里配置 manualChunks而不是把所有 node_modules 塞进一个 chunk否则核心代码更新时整个 vendor 缓存失效。5.2 骨架屏和 Loading 状态的一致性账务页面最怕两个问题一是用户以为页面没响应二是用户点击后页面闪一下空白。我们在所有列表页初始加载时使用骨架屏增删改操作使用顶部进度条支付页面则使用全屏遮罩式 loading。关键状态统一封装成 useLoading 组合式函数它会把“接口在途、成功、失败”转换成不同的 UI 状态。5.3 运行时错误监控页面一旦出 bug不能总是等用户截图反馈。我们在入口处注册了 Vue 的 app.config.errorHandler给 window 添加 error 和 unhandledrejection 监听把错误信息、路由、用户标识统一上报到后端日志。这个成本很低但帮助巨大。有一次线上问题就是支付页面某个组件在旧浏览器里没有实现某个方法靠错误上报十分钟内就定位到了学生侧还没形成规模投诉就已经修复。6. 项目验收后的复盘哪些设计让我觉得当初没白折腾6.1 把状态机画清楚再写代码做这套系统最大的经验是一切围绕订单状态转。学生充值、商户结算、挂失补卡、补助领取本质上都是状态流转。前端要做的不是把所有状态在页面里平铺出来而是定义好每个状态对应的界面用户处在 pending 时看到等待提示在 success 时看到结果页在 closed 时看到失败原因。状态机画清楚之后前端的实现就是“翻译”工作。6.2 前端不要相信中间结果只相信服务端最终状态这句话我想强调很多次。支付回调、本地缓存、页面残留数据这些都可能是错的。饭卡系统里唯一可靠的是服务端订单记录和账户流水。所有前端余额展示、充值成功提示都必须在拿到服务端 confirm 状态之后再更新。6.3 保留日志与对账能力就算前端逻辑做得再严密也挡不住后端异步任务偶尔丢消息。我们特意做了一个“消息补拉”按钮学生如果确认充值成功但余额未变页面会触发一次主动对账把第三方流水号、订单号、时间戳一起提交给后端重新核算。这个功能上线后学生投诉量明显下降因为大家觉得“至少系统能自己补救”。项目上线后的实际体会是这类校园综合缴费系统技术难度不在于某个框架的某个 API而在于把账、人、设备、渠道几件本来不太相关的事串成一份可靠的结果。如果手里正好有类似的 Vue3 业务系统要重构我建议先把支付状态机和幂等规则定下来再开始写页面前端永远不要根据支付渠道的返回页面去更新余额一切以服务端回调与订单状态为准。想明白这两点整个系统就不会出现“钱花了但饭卡没到账”这种最容易让人炸毛的问题。