ARTICLE DETAIL

资讯详情

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

Vue3会员积分商城实战:积分经济闭环与前端工程落地

Vue3会员积分商城实战:积分经济闭环与前端工程落地 把网上商城和会员积分放在一起做最难的不是写代码而是想清楚积分到底怎么发行、怎么回收。我去年接手一个真实项目做一个带会员积分的网上商城核心需求只有一句话——用户买东西能赚积分赚到的积分能在商城里换东西。听起来很简单真正动手才发现这几乎等于设计一套微型经济体。前端技术栈我最终选了 Vue3原因很直接组合式 API 能把用户、积分、订单这几条复杂业务线的代码收拢在一起不再像 Vue2 时代那样散落一堆 options。这篇文章就聊聊这个会员积分购物系统从需求梳理到前端落地的完整过程包括关键代码、踩过的坑以及一些我做完之后后悔没早点想清楚的设计。1. 商城积分系统不是“会员plus”而是一套完整的经济循环很多团队接到“商城加积分”的需求时第一反应是在用户表加一个points字段下单时加几分兑换时减几分。这种思路做出来的东西上线必出问题。积分会过期、会冻结、会因退款回收用户对余额的感知非常敏感真金白银的抵扣规则稍微不一致客服电话马上被打爆。所以我拿到需求后先把积分当成一套独立的经济系统来设计。1.1 需求整理角色、流程和状态整个系统涉及三类角色普通用户、商城运营、系统管理员。用户端核心流程是“逛商品 → 用积分/钱下单 → 获得积分 → 查看流水 → 兑换商品”。运营端要配置积分规则、上下架积分商品、处理异常订单。管理员要能看到全局的积分发放和回收数据。前端页面按这个流程拆成几个模块商城首页、商品列表、商品详情、购物车、结算页、会员中心、积分商城、积分流水、个人设置。路由设计上我用了两级结构一级是面向访客的商城路由一级是登录后的会员路由通过路由守卫控制访问权限。状态上必须重点区分三类积分余额、积分流水、积分状态。流水状态又分“待入账、已入账、已冻结、已过期、已作废”后端返回的不是一个简单数字而是一个带状态机的事件流。1.2 积分从哪里来、到哪里去积分来源我列了几类注册奖励新用户首次注册送固定积分。每日签到连续签到有额外加成。下单返还按订单实付金额一定比例返还。商品评价评价后返积分带图评价额外加。运营活动节日活动、分享邀请等。积分去向则是积分商城兑换实物或虚拟券。订单结算时按比例抵扣现金。过期回收维护积分池健康。这里最关键的是“发行-消耗-回收”的闭环。比如每天签到送出 1000 分但兑换商品库存和过期回收机制跟不上积分就会贬值用户手里的积分越多失去购买力的感觉越强。所以前端不只要展示“你有多少分”还要展示“你本月会过期多少分”引导用户及时消耗。这个设计直接影响了会员中心的 UI 结构我把“即将过期”单独做了卡片用倒计时条提醒。1.3 为什么用 Vue3 而不是继续用 Vue2这个项目如果放在两年前我大概率会继续用 Vue2。但这次团队要新起一套代码而且后续要接 TypeScript所以 Vue3 基本是必然选择。我整理过一张对比表用来说服同事对比项Vue2Vue3响应式原理Object.defineProperty数组和新增属性有局限Proxy动态增删属性都能拦截代码组织Options API逻辑按 data/methods/computed 分散Composition API逻辑按业务功能聚合TypeScript支持弱复杂泛型容易崩底层重写类型推导更顺畅性能虚拟 DOM 重渲染优化有限静态树提升、静态属性提升、更细粒度 patch生态现状维护期官方不再新增功能Vite、Pinia、Element Plus 都在全面适配实际开发中感受最明显的是“同一块业务逻辑终于能写在一起了”。Vue2 里一个商品列表要处理 loading、数据、筛选、分页你得把 data 写一处、methods 写一处、computed 写一处改需求时四处跳。Vue3 的 setup 里我可以把一个商品筛选功能相关的响应式变量、计算属性、函数全部放在一个区块里甚至可以抽成useProductList这样的组合式函数多个页面复用。对商城这种业务线长的项目来说这个优势比性能提升更值钱。2. 项目脚手架与目录架构先把地基打牢技术选型定下来后我没有直接在浏览器里敲npm create vue而是先花半天把目录结构和依赖规划清楚。商城项目页面多、模块多如果按“views、components、utils”这样的传统三个文件夹硬堆后面维护成本会失控。这次我采用了按业务域划分的方式配合 Vite 的工程化能力开发体验好了很多。2.1 Vite搭建Vue3项目需要哪些依赖项目使用 Vite 作为构建工具创建命令如下npm create vuelatest # 选择TypeScript、Vue Router、Pinia、ESLint、Prettier创建完成后再安装 UI 库和工具库npm install element-plus npm install element-plus/icons-vue npm install axios npm install dayjs npm install pinia-plugin-persistedstatepinia-plugin-persistedstate用来做登录态本地持久化比手动 localStorage 方便。Vue3 项目里状态管理我选 Pinia不用 Vuex因为 Pinia 对 TypeScript 支持更好而且去掉了 mutations 那一层写起来更直接。package.json 里的核心依赖大概长这样{ dependencies: { vue: ^3.4.0, vue-router: ^4.3.0, pinia: ^2.1.0, axios: ^1.6.0, element-plus: ^2.6.0, dayjs: ^1.11.10 }, devDependencies: { vite: ^5.0.0, vitejs/plugin-vue: ^4.5.0, typescript: ^5.2.0, unplugin-auto-import: ^0.17.0, unplugin-vue-components: ^0.26.0 } }2.2 目录按业务模块划分而不是按文件类型我最终采用的目录结构是这样的src/ ├── api/ # 接口请求层 │ ├── product.ts │ ├── cart.ts │ ├── order.ts │ └── points.ts ├── assets/ # 静态资源 ├── components/ # 跨模块通用组件 │ ├── PointsTag.vue │ ├── CountUp.vue │ └── EmptyState.vue ├── composables/ # 组合式函数 │ ├── useProductList.ts │ ├── usePointsBalance.ts │ └── useCountDown.ts ├── layouts/ # 布局组件 │ ├── DefaultLayout.vue │ └── AdminLayout.vue ├── router/ # 路由配置 ├── stores/ # Pinia store │ ├── user.ts │ ├── cart.ts │ └── points.ts ├── styles/ # 全局样式 ├── types/ # TypeScript 类型定义 │ ├── product.ts │ └── points.ts └── views/ # 页面 ├── home/ ├── product/ ├── cart/ ├── checkout/ ├── member/ └── points-mall/这个结构的核心逻辑是composables放“可复用的业务逻辑”stores放“全局共享状态”api负责所有请求页面里只做视图编排。比如积分余额卡片在会员中心和结算页都要展示我就把“获取余额、格式化显示、倒计时刷新”逻辑全部写进usePointsBalance组件里只传一个 userId。2.3 环境变量与接口代理配置商城项目开发时前后端分离接口跨域问题必须提前处理。我在vite.config.ts里配置了代理import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ], server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })环境变量区分开发和生产。.env.development里写VITE_API_BASE_URL/api.env.production里写真实网关地址。代码里统一用import.meta.env.VITE_API_BASE_URL作为 axios 的 baseURL。这样切换环境时不需要改业务代码。3. 会员中心与积分流水的Vue3实现会员中心是积分系统的门面用户最关心的就是“我还有多少分”“我的分花到哪里去了”。这块我用了 Pinia 管理登录用户用组合式函数管理积分流水列表整体写下来非常顺手。3.1 登录态持久化与会员信息store用户登录后后端会返回一个 token 和用户基础信息。Vue3 里我用 Pinia 的 store 来保存并开启持久化插件// stores/user.ts import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: , userInfo: null as UserInfo | null, }), actions: { setLogin(data: { token: string; userInfo: UserInfo }) { this.token data.token this.userInfo data.userInfo }, logout() { this.token this.userInfo null } }, persist: { key: mall_user, storage: localStorage } })登录后会员中心直接取userStore.userInfo.pointsBalance展示。但这里有个容易踩的坑用户积分余额是会变的如果只存在 userInfo 里下单成功或兑换成功后必须立刻更新 store否则会员中心显示的还是旧值。我在项目里的做法是单独维护一个pointsstore余额和流水分开userInfo 只放基础信息。// stores/points.ts import { defineStore } from pinia import { getPointsBalance } from /api/points export const usePointsStore defineStore(points, { state: () ({ balance: 0, expiredToday: 0, }), actions: { async refreshBalance() { const res await getPointsBalance() this.balance res.balance this.expiredToday res.expiredToday } } })这样每次进入需要展示积分的页面时调用一次refreshBalance()数据源统一不会出现多个页面各存一份积分数字的问题。3.2 积分流水的分页加载与状态映射积分流水是一个只增不改的明细列表适合用分页加载。Vue3 里我用ref管理列表数据reactive管理分页条件逻辑清晰// views/member/PointsFlow.vue script setup langts import { ref, reactive, onMounted } from vue import { getPointsFlow } from /api/points import dayjs from dayjs const flowList refPointsFlowItem[]([]) const total ref(0) const loading ref(false) const query reactive({ page: 1, pageSize: 10, type: // 空为全部 }) const statusMap: Recordnumber, { text: string; type: string } { 1: { text: 待入账, type: warning }, 2: { text: 已入账, type: success }, 3: { text: 已冻结, type: info }, 4: { text: 已过期, type: danger }, 5: { text: 已作废, type: danger } } async function loadFlow() { loading.value true try { const res await getPointsFlow(query) flowList.value res.list total.value res.total } finally { loading.value false } } function formatTime(time: string) { return dayjs(time).format(YYYY-MM-DD HH:mm:ss) } onMounted(loadFlow) /script模板里根据statusMap渲染标签。这里有个 Vue3 的细节statusMap是普通对象不需要做成reactive因为它是纯静态映射不会改变响应式的只有flowList、loading这些数据。很多新手把所有对象都塞进 reactive反而增加了性能开销。3.3 积分余额展示的响应式细节积分数字长位数比较多时直接显示1234567不够直观。我封装了一个CountUp组件在数字变化时做滚动动画同时把千分位格式化放在计算属性里import { computed } from vue const formattedBalance computed(() { const num store.balance || 0 return num.toLocaleString(zh-CN) })倒计时刷新余额的定时器也要注意清理。我在组合式函数里用onScopeDispose代替 Vue2 的beforeDestroyfunction usePointsBalance() { let timer: number | undefined function startAutoRefresh(interval 60000) { timer window.setInterval(() { pointsStore.refreshBalance() }, interval) } onScopeDispose(() { if (timer) window.clearInterval(timer) }) return { formattedBalance, startAutoRefresh } }onScopeDispose是 Vue3 新增的 API当组件卸载时它会自动调用清理函数不用再手动绑onUnmounted这个小细节让代码干净不少。4. 积分商城购物流程从商品列表到积分兑换积分商城的购物流程和普通商城最大的区别是结算货币不是人民币而是积分。这意味着前端要额外处理“积分余额是否足够”“库存是否充足”“兑换后积分变动是否即时反馈”这三件事。4.1 商品列表和筛选Composition API让逻辑更聚合商品列表页有分类筛选、价格/积分排序、关键词搜索、分页加载逻辑非常多。Vue2 时代这些逻辑散落在 data、methods、watch 里Vue3 我直接抽成组合式函数。// composables/useProductList.ts import { ref, reactive, computed } from vue import { getProductList } from /api/product export function useProductList() { const list refProductItem[]([]) const total ref(0) const loading ref(false) const query reactive({ page: 1, pageSize: 12, categoryId: , keyword: , sortBy: default }) const sortOptions computed(() [ { label: 默认, value: default }, { label: 所需积分从低到高, value: points_asc }, { label: 所需积分从高到低, value: points_desc } ]) async function loadList() { loading.value true try { const res await getProductList(query) list.value res.list total.value res.total } finally { loading.value false } } function handleSearch() { query.page 1 loadList() } function handlePageChange(page: number) { query.page page loadList() } return { list, total, loading, query, sortOptions, loadList, handleSearch, handlePageChange } }在商品列表页组件里只需要const { list, total, loading, query, sortOptions, handleSearch, handlePageChange } useProductList() onMounted(loadList)筛选条件变了调用handleSearch重渲染自然发生。如果商品详情页也需要类似能力可以直接复用这个函数不必重写一份。4.2 动态表单在积分兑换地址填写中的坑积分兑换实物时经常要填多个收货地址比如一次兑换多件商品发给不同的人。我做了动态添加/删除表单行热词里提到的“vue3动态添加删除form表单一行数据”就在这里派上用场。关键代码是维护一个addressList数组每一行是一个对象const addressList ref([ { name: , phone: , address: } ]) function addRow() { addressList.value.push({ name: , phone: , address: }) } function removeRow(index: number) { if (addressList.value.length 1) { ElMessage.warning(至少保留一条地址) return } addressList.value.splice(index, 1) }模板中用v-for渲染注意必须绑定唯一 key。这里最大的坑是如果删除中间一行Vue 复用 DOM 时可能会保留旧行输入框的值。我一开始用index作为 key发现问题后改用uid字段let uid 0 function addRow() { addressList.value.push({ id: uid, name: , phone: , address: }) }同时表单校验规则也要绑定到对应行上。Element Plus 的动态表单校验prop要写成addressList.${index}.name这种路径格式。删除行后还要重新校验否则校验状态会错位function removeRow(index: number) { addressList.value.splice(index, 1) formRef.value.validate().catch(() {}) }4.3 购物车与库存扣减的联动问题积分商品的库存一般比普通商品少用户加购后可能犹豫库存就被别人换走了。所以购物车数量修改时要同时校验库存和积分余额。function updateCount(item: CartItem, count: number) { if (count item.stock) { ElMessage.warning(库存仅剩 ${item.stock} 件) item.count item.stock return } if (count * item.points pointsStore.balance) { ElMessage.warning(积分余额不足) item.count Math.max(1, Math.floor(pointsStore.balance / item.points)) return } item.count count }这里还有一个并发问题用户停留在购物车页时库存可能已经被别人扣完了。我的方案是结算前重新请求一次库存和最新积分余额接口返回失败就让用户刷新购物车。前端不能只相信本地状态因为本地购物车的数据只是一份快照。5. 积分规则与风控容易被忽略但必须提前设计很多项目把积分规则写在业务代码里今天加一个签到送积分明天改一个兑换折扣前端也跟着改版本。这个项目我做了一个简单的配置化思路规则尽量由后端配置前端只负责展示和按接口返回值计算。即使如此前端在展示层依然要注意几个容易出问题的点。5.1 积分获取规则的正向逻辑常见的积分获取规则包括行为规则示例前端展示方式注册注册成功送 100 积分会员中心显示“新人礼包”卡片每日签到连续 1/3/7 天分别送 1/3/7 积分签到页日历组件标记连续天数下单实付金额满 10 元送 10 积分以此类推订单详情显示预计到账积分评价评价返 20 积分带图额外加 10订单列表“评价有礼”按钮前端要做的不是硬编码这些数值而是从后端拉规则配置然后渲染到页面。比如签到页const signRules refSignRule[]([]) async function getSignRules() { const res await api.getSignRules() signRules.value res.rules }这样运营后台调整数值时前端不用发版。5.2 积分过期与冻结的倒计时展示积分过期是最容易引发客诉的环节。用户前三个月没看积分明细突然发现几千分没了第一反应就是投诉。为了缓解这个问题我在积分流水和会员中心都加了“即将过期”提示并用 dayjs 计算剩余天数function getExpireCountdown(expireTime: string) { const now dayjs() const expire dayjs(expireTime) const diffDays expire.diff(now, day) if (diffDays 0) return { text: 已过期, type: danger } if (diffDays 7) return { text: 剩 ${diffDays} 天过期, type: warning } return { text: 剩 ${diffDays} 天, type: info } }积分冻结一般发生在订单退款或违规操作时。冻结的积分不能使用但还在余额明细里。前端展示余额时要明确区分“可用积分”和“冻结积分”否则用户看到余额但下单时提示余额不足体验很糟糕。我在会员中心用两个卡片分别展示并在结算页只取可用积分。5.3 后端接口设计对前端的影响这里必须强调一下积分系统的核心约束在数据库层前端只是展示器。我踩过最大的坑是后端积分流水接口一开始没有分页字段导致前端一页加载全部流水几千条记录直接在页面上渲染卡成幻灯片。后来和后端约定统一的列表接口格式{ code: 0, data: { list: [], total: 200, page: 1, pageSize: 10 } }同时积分变更必须是原子操作。比如下单获取积分后端要在同一个事务里完成“订单状态修改”和“积分流水插入”否则用户提交订单后积分没到账前端不断轮询也查不到。我建议前端在订单详情接口里增加orderPointsStatus字段展示“待入账/已入账”而不是自己根据时间瞎猜。6. 性能优化与踩坑实录真实项目里那些Vue3问题这个项目上线前我集中做了一轮性能优化也遇到不少 Vue3 特有的问题。这里挑几个有代表性的记录下来每个都是从定位到解决的全过程不是直接贴结论。6.1 路由级和组件级懒加载商城页面多首屏如果打包全部路由JS 体积会很大。Vue3 配合 Vite 使用动态 import 非常方便const routes [ { path: /, component: () import(/layouts/DefaultLayout.vue), children: [ { path: , component: () import(/views/home/index.vue) }, { path: product/:id, component: () import(/views/product/detail.vue) } ] }, { path: /member, component: () import(/layouts/MemberLayout.vue), meta: { requiresAuth: true }, children: [ { path: points-flow, component: () import(/views/member/PointsFlow.vue) }, { path: points-mall, component: () import(/views/points-mall/index.vue) } ] } ]组件级懒加载针对大弹窗和低频组件比如积分规则弹窗点击时才加载component :isshowRules ? defineAsyncComponent(() import(/components/PointsRulesDialog.vue)) : null /6.2 Element Plus按需引入与主题定制Element Plus 全量引入会让打包体积多出几百 KB我用了unplugin-vue-components按需自动引入。这个插件有个坑如果项目的组件名恰好和 Element Plus 组件名冲突比如自定义了ElButton的包装组件自动解析器可能把自定义组件也当成 Element Plus 去处理导致样式异常。我的解决办法是在Components插件配置里加上dirs自定义组件目录并设置解析器Components({ resolvers: [ ElementPlusResolver({ importStyle: sass, }) ], dirs: [src/components] })主题定制也需要提前规划。团队约定积分主色是橙色我用 SCSS 变量覆写 Element Plus 默认主题forward element-plus/theme-chalk/src/common/var.scss with ( $colors: ( primary: ( base: #ff6b00, ), ) );然后在 vite.config 里配置scss.additionalData导入变量文件。6.3 我在这个项目里遇到且解决掉的几个Vue3坑第一个坑ref嵌套在reactive中会被自动解包但解包只发生在访问时。比如const state reactive({ list: refProduct[]([]) }) state.list [] // 直接覆盖 ref导致响应丢失这种写法一开始让我很困惑。我的建议是同一个数据要么用ref管要么用reactive管不要混用。业务列表我统一用ref表单模型我统一用reactive减少心智负担。第二个坑动态表单校验的日期规则。“vue3 rules 日期检验”这个热搜词说明很多人卡在日期校验上。积分活动经常有开始时间和结束时间Element Plus 的日期选择器绑定的是 Date 对象校验规则如果设了type: date必须保证绑定值确实是 Date而不是字符串。我后来统一把后端返回的日期字符串在后端转成时间戳前端再格式化成 Date 传给校验器。第三个坑Vue3 项目在 Edge 浏览器中偶尔出现页面右上角最小化按钮无法关闭的异常。这个排查了很久最后发现是某个第三方组件库在beforeunload里弹了确认框阻塞了浏览器原生关闭事件。不是 Vue 本身的问题但 Vue3 项目里如果嵌入了外部脚本要小心这类全局事件污染。第四个坑vue3引入所有的ui框架都不生效。这个热词很像新手刚上手时的状态。实际原因大概率是 main.ts 里没有正确createApp或者组件没有注册。Vue3 没有全局注册的旧习惯用插件方式引入后组件会自动注册但如果你用 Options API 的components选项却把变量写成了全名ElButton而不是引入的局部名称模板里就找不到。// 正确写法 import { ElButton } from element-plus export default { components: { ElButton } }还有一点按需自动引入时模板中直接写el-button解析器会自动导入但如果组件选项中又手动 import 了同名的ElButton会出现重复导入提示。这类问题不需要硬记理解“自动引入是编译期插件帮你注入”的原理后就豁然开朗了。真到上线后我才意识到Vue3 在这类业务系统里的优势不只是“新”而是把复杂业务逻辑拆成组合式函数后团队协作冲突变少了代码 review 更高效。如果再让我做一次我会在第一周就把积分规则配置化方案定死而不是先把页面写完再回头补规则引擎前端层面我会坚持所有列表页统一封装usePagination组合式函数避免每个页面各写一套分页逻辑。这些经验都是在真实项目里一遍遍踩出来的希望看到这篇文章的人能少走几步弯路。
返回列表