很多同学在写 Vue3 电商项目时,经常卡在同一个地方:登录能跳转,商品能加购,但到了“生成订单、拉起支付、确认支付结果”这条链路上就会断掉。后端返回了一个code_url,前端不知道该怎么用;H5 里调不起微信支付;支付成功后订单状态又迟迟不刷新。其实从登录到支付并不是多个孤立功能,而是一条完整的数据流转链路。
本文以一套微型电商前端为主线,从登录鉴权开始讲,经过商品浏览、购物车、结算下单,最后实现支付二维码展示与支付状态轮询闭环。文章会给出完整可运行的 Vue3 代码片段、接口设计约定和常见坑点。无论是准备 Vue3 面试、做毕业设计,还是第一次接触真实电商业务,都可以按这条链路把项目跑通。
1. 需求分析与整体设计
1.1 电商核心闭环拆解
一个标准电商应用的最小闭环,可以拆成以下环节:
- 用户登录并获取 token。
- 前端保存登录态,并通过请求拦截器自动携带 token。
- 用户浏览商品列表。
- 用户将商品加入购物车。
- 用户选择商品并提交订单。
- 后端创建未支付订单。
- 前端调用支付预下单接口,获取支付二维码链接。
- 用户扫码完成支付。
- 前端轮询或等待后端回调,更新订单状态。
很多教程只讲前两步,或者只讲商品管理,导致你跟着做出来后,支付环节依然不知道怎么接。本文会围绕第 5 到第 9 步展开,电商项目中真正容易出错的就是这一段。
1.2 技术选型说明
本文前端选择 Vue3 + Vite + Vue Router + Pinia + Axios + Element Plus。这套组合是目前国内 Vue3 项目的主流组合,也基本是 Vue3 面试中的常客。
| 模块 | 选型 | 作用 |
|---|---|---|
| 构建工具 | Vite | 本地开发与打包 |
| 框架 | Vue3 组合式 API | 页面与组件开发 |
| 路由 | Vue Router 4 | 页面跳转与路由守卫 |
| 状态管理 | Pinia | 保存用户 token、购物车数据 |
| HTTP 请求 | Axios | 请求接口、处理拦截器 |
| UI 组件 | Element Plus | 快速搭建表单、表格、弹窗 |
| 二维码 | qrcode | 将 code_url 转为二维码图片 |
这里要特别说明,当前是 Vue3 的技术生态,选项式 API 依然能用,但新项目和企业实际开发中,组合式 API +<script setup>已经成为主流写法。本文统一使用<script setup>。
1.3 支付方式如何选择
国内常见的支付方式有:
- 微信 JSAPI 支付:只能在微信内置浏览器中调起。
- 微信 Native 支付:适合 PC Web,后端生成
code_url,前端展示二维码,用户用手机微信扫码。 - 支付宝当面付:适合 PC Web 扫码,后端返回二维码字符串。
- 支付宝手机网站支付:适合 H5 浏览器中跳转支付宝。
本文以“扫码支付”为主线,因为这种模式最容易在本地开发环境中验证,不依赖微信内置浏览器等特殊环境。无论后端对接微信支付还是支付宝沙箱,前端处理逻辑都类似:先通过接口拿到支付二维码内容,再展示二维码并轮询订单状态。
2. 环境准备与项目初始化
2.1 版本环境说明
本文使用的核心版本如下:
- Node.js:建议使用 18 或 20 LTS 版本。
- npm:随 Node.js 自带,建议 9 以上。
- Vue:3.4 或更高版本。
- Vite:5 或更高版本。
- Pinia:2.x。
- Vue Router:4.x。
由于不同版本之间 API 差异不大,你的本地版本不需要完全一致,但尽量使用 Node 18+,避免 Vite 启动时报错。
2.2 创建 Vue3 项目
使用 Vite 创建项目时,可以执行以下命令:
npm create vite@latest vue3-shop-demo -- --template vue创建完成后进入项目目录:
cd vue3-shop-demo然后安装项目基础依赖:
npm install再安装后续需要使用的依赖:
npm install vue-router@4 pinia axios element-plus qrcode其中qrcode用于将支付链接转换为二维码图片,element-plus用于快速搭建后台风格页面。
2.3 项目目录结构规划
建议把业务代码按模块拆分,不要全部堆在App.vue或某个页面组件中。
src/ ├── api/ │ ├── auth.js │ ├── order.js │ └── product.js ├── assets/ ├── components/ ├── router/ │ └── index.js ├── stores/ │ ├── cart.js │ └── user.js ├── utils/ │ └── request.js ├── views/ │ ├── LoginView.vue │ ├── ProductListView.vue │ ├── CartView.vue │ ├── OrderConfirmView.vue │ ├── PayView.vue │ └── OrderDetailView.vue ├── App.vue └── main.js这里的api目录负责集中管理接口请求,stores目录管理全局状态,utils/request.js封装 Axios 实例和拦截器。这种分层方式也是中型前端项目比较标准的写法。
3. 后端接口设计约定
本文重点面向 Vue3 前端实战,不展开完整的后端实现,但前后端接口需要约定清楚。真实开发中,你通常是照着后端 Swagger 文档写前端请求。为了方便演示,这里假设后端提供以下接口。
3.1 登录鉴权接口
POST /api/auth/login Content-Type: application/json { "mobile": "13800138000", "password": "123456" }响应格式:
{ "code": 0, "message": "success", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "userInfo": { "userId": 10001, "nickname": "张三" } } }后续所有业务接口都需要在请求头中携带:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...3.2 商品、购物车与订单接口
为了便于演示,商品数据可以先使用静态列表或 Mock 数据。
订单提交接口示例:
POST /api/orders Authorization: Bearer <token> Content-Type: application/json { "items": [ { "productId": 1001, "quantity": 2 } ], "receiver": { "name": "张三", "mobile": "13800138000", "address": "北京市海淀区某街道 1 号" } }响应:
{ "code": 0, "message": "success", "data": { "orderNo": "SN20250101120000123", "payAmount": 19900 } }这里注意payAmount单位是“分”,不是“元”。金额的单位问题在电商项目中非常关键,前端不要在后端计算金额,更不要自己把元转成分后传回后端,容易造成金额不一致。
3.3 支付预下单与状态查询接口
创建订单成功后,前端需要调用支付预下单接口:
POST /api/payments/native Authorization: Bearer <token> Content-Type: application/json { "orderNo": "SN20250101120000123" }响应:
{ "code": 0, "message": "success", "data": { "codeUrl": "weixin://wxpay/bizpayurl?pr=xxxxx", "payOrderNo": "PAY20250101120000123" } }这里的codeUrl是给二维码插件使用的原始字符串。对于支付宝当面付,后端返回的通常是一长串 URL,前端同样把它变成二维码。
支付状态查询接口:
GET /api/orders/SN20250101120000123/pay-status Authorization: Bearer <token>响应:
{ "code": 0, "message": "success", "data": { "orderNo": "SN20250101120000123", "orderStatus": "PAID" } }orderStatus常见取值有PENDING、PAID、CANCELLED等。前端轮询时只需要关心是否变为PAID。
4. 登录模块与前端鉴权实现
4.1 Axios 封装与请求拦截器
在真实项目中,我们不会在每个组件里直接调用axios.get,而是封装一个统一的request.js,统一处理基础 URL、token、超时、错误码。这样当后端告诉你登录过期时,你只改一处就能全局生效。
// 文件路径:src/utils/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '@/router'; import { useUserStore } from '@/stores/user'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); // 请求拦截器:自动携带 token request.interceptors.request.use( (config) => { const userStore = useUserStore(); if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}`; } return config; }, (error) => { return Promise.reject(error); } ); // 响应拦截器:统一处理业务错误 request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || 'Error')); } return res.data; }, (error) => { const status = error.response?.status; if (status === 401) { const userStore = useUserStore(); userStore.clearLoginState(); router.push('/login'); ElMessage.error('登录状态已过期,请重新登录'); } else { ElMessage.error(error.response?.data?.message || '网络异常'); } return Promise.reject(error); } ); export default request;在响应拦截器中,我们做了两件很重要的事情:
- 当后端返回
code !== 0时,直接弹出错误提示,业务代码不需要再重复处理错误分支。 - 当 HTTP 状态码为 401 时,说明 token 失效,自动清除登录状态并跳回登录页。
这里要注意:ElMessage和router的导入路径取决于你的项目结构,如果request.js与router之间存在循环引用,可以考虑把跳转逻辑放在登录页统一处理,或使用window.location.href做兜底。
4.2 Pinia 管理用户登录态
用户登录后,token 和用户信息属于全局状态,适合放在 Pinia 中。同时我们需要实现 token 的持久化,否则刷新页面后登录态会丢失。
// 文件路径:src/stores/user.js import { defineStore } from 'pinia'; import { loginApi } from '@/api/auth'; export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null') }), getters: { isLoggedIn: (state) => !!state.token }, actions: { async login(loginForm) { const data = await loginApi(loginForm); this.token = data.token; this.userInfo = data.userInfo; localStorage.setItem('token', data.token); localStorage.setItem('userInfo', JSON.stringify(data.userInfo)); }, clearLoginState() { this.token = ''; this.userInfo = null; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); } } });这里将 token 直接存放在localStorage,是小型项目的常见做法。生产级项目也可以使用 cookie 并设置httpOnly来防 XSS,但鉴权流程思路是类似的。
对应的登录接口api/auth.js可以这样写:
// 文件路径:src/api/auth.js import request from '@/utils/request'; export function loginApi(data) { return request.post('/auth/login', data); } export function getUserInfoApi() { return request.get('/user/info'); }4.3 登录页面实现
登录页的核心逻辑是调用userStore.login,登录成功后跳转到 redirect 参数指定的页面,否则跳转首页。
<!-- 文件路径:src/views/LoginView.vue --> <template> <div class="login-page"> <el-card class="login-card"> <h2>电商系统登录</h2> <el-form ref="formRef" :model="loginForm" :rules="rules" label-width="80px" > <el-form-item label="手机号" prop="mobile"> <el-input v-model="loginForm.mobile" placeholder="请输入手机号" /> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" show-password /> </el-form-item> <el-form-item> <el-button type="primary" :loading="loading" @click="handleLogin"> 登录 </el-button> </el-form-item> </el-form> </el-card> </div> </template> <script setup> import { ref, reactive } from 'vue'; import { useRouter, useRoute } from 'vue-router'; import { ElMessage } from 'element-plus'; import { useUserStore } from '@/stores/user'; const router = useRouter(); const route = useRoute(); const userStore = useUserStore(); const formRef = ref(null); const loading = ref(false); const loginForm = reactive({ mobile: '', password: '' }); const rules = { mobile: [{ required: true, message: '请输入手机号', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] }; async function handleLogin() { await formRef.value.validate(); loading.value = true; try { await userStore.login(loginForm); ElMessage.success('登录成功'); const redirect = route.query.redirect; router.replace(redirect || '/'); } finally { loading.value = false; } } </script> <style scoped> .login-page { display: flex; justify-content: center; align-items: center; height: 100vh; background: #f5f7fa; } .login-card { width: 420px; } </style>4.4 路由守卫控制页面访问
登录页完成后,还需要通过路由守卫控制哪些页面必须登录才能访问。这里使用的是 Vue Router 4 的全局前置守卫。
// 文件路径:src/router/index.js import { createRouter, createWebHistory } from 'vue-router'; import { useUserStore } from '@/stores/user'; const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/LoginView.vue') }, { path: '/', component: () => import('@/views/ProductListView.vue') }, { path: '/cart', name: 'Cart', component: () => import('@/views/CartView.vue'), meta: { requiresAuth: true } }, { path: '/order/confirm', name: 'OrderConfirm', component: () => import('@/views/OrderConfirmView.vue'), meta: { requiresAuth: true } }, { path: '/pay', name: 'Pay', component: () => import('@/views/PayView.vue'), meta: { requiresAuth: true } }, { path: '/order/detail', name: 'OrderDetail', component: () => import('@/views/OrderDetailView.vue'), meta: { requiresAuth: true } } ]; const router = createRouter({ history: createWebHistory(), routes }); router.beforeEach((to) => { const userStore = useUserStore(); if (to.meta.requiresAuth && !userStore.isLoggedIn) { return { path: '/login', query: { redirect: to.fullPath } }; } return true; }); export default router;这里使用meta.requiresAuth标识需要登录的页面。当用户未登录时,跳转到登录页并携带redirect参数,登录成功后回到原页面。这种实现方式在真实后台管理项目和电商项目中非常常用。
5. 商品浏览、购物车与订单提交
5.1 商品列表页基础实现
商品列表页不是本文核心,但为了让支付链路完整可见,这里给出一个最简实现思路:请求商品列表接口,渲染商品卡片,点击“加入购物车”后存放到 Pinia。
// 文件路径:src/stores/cart.js import { defineStore } from 'pinia'; export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0), totalAmount: (state) => state.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ) }, actions: { addItem(product) { const existing = this.items.find( (item) => item.productId === product.productId ); if (existing) { existing.quantity += 1; } else { this.items.push({ productId: product.productId, title: product.title, price: product.price, image: product.image, quantity: 1 }); } }, removeItem(productId) { this.items = this.items.filter( (item) => item.productId !== productId ); } } });这里的price字段同样建议以“分”为单位。如果后端返回的是元,前端可以在展示层用过滤器转换为元,但计算时不要使用浮点数累加。
5.2 订单提交流程
购物车页面点击“去结算”后,跳转到订单确认页。订单确认页需要展示商品清单、收货地址和应付金额,然后调用创建订单接口。
以订单确认页中的提交函数为例:
const submitLoading = ref(false); async function submitOrder() { submitLoading.value = true; try { const params = { items: cartStore.items.map((item) => ({ productId: item.productId, quantity: item.quantity })), receiver: { name: receiverForm.name, mobile: receiverForm.mobile, address: receiverForm.address } }; const data = await createOrderApi(params); cartStore.clearCart(); router.push({ path: '/pay', query: { orderNo: data.orderNo } }); } finally { submitLoading.value = false; } }创建订单后,前端不要本地修改订单状态,应该携带orderNo跳转到支付页。这一步在很多项目中会被忽略,导致用户直接刷新页面找不到订单号,支付流程无法继续。
api/order.js中定义:
// 文件路径:src/api/order.js import request from '@/utils/request'; export function createOrderApi(data) { return request.post('/orders', data); } export function getOrderDetailApi(orderNo) { return request.get(`/orders/${orderNo}`); } export function createNativePaymentApi(orderNo) { return request.post('/payments/native', { orderNo }); } export function getOrderPayStatusApi(orderNo) { return request.get(`/orders/${orderNo}/pay-status`); }6. 支付模块完整实战:从预下单到状态轮询
6.1 支付页核心逻辑
支付页是整个流程中最关键的一环。页面接收订单号后,需要完成以下操作:
- 根据
orderNo请求支付预下单接口,拿到codeUrl。 - 使用
qrcode库把codeUrl转为二维码图片。 - 展示“订单金额”和“二维码区域”。
- 启动定时器,每隔 1 到 3 秒查询一次订单支付状态。
- 当订单状态变为
PAID后,停止轮询并跳转到订单详情页。
完整代码如下:
<!-- 文件路径:src/views/PayView.vue --> <template> <div class="pay-page"> <el-card class="pay-card"> <template #header> <span>订单支付</span> </template> <div v-if="loading" class="pay-loading"> 正在获取支付信息... </div> <div v-else-if="qrCodeDataUrl" class="pay-content"> <div class="amount-text"> 应付金额: <span class="amount">¥ {{ (payAmount / 100).toFixed(2) }}</span> </div> <img class="qr-img" :src="qrCodeDataUrl" alt="支付二维码" /> <div class="tip-text"> 请使用手机扫码完成支付 </div> <div class="pay-status"> 当前状态:{{ orderStatusText }} </div> </div> </el-card> </div> </template> <script setup> import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { ElMessage } from 'element-plus'; import QRCode from 'qrcode'; import { createNativePaymentApi, getOrderDetailApi, getOrderPayStatusApi } from '@/api/order'; const route = useRoute(); const router = useRouter(); const orderNo = route.query.orderNo; const loading = ref(true); const qrCodeDataUrl = ref(''); const payAmount = ref(0); const orderStatus = ref('PENDING'); let timer = null; const orderStatusText = computed(() => { const map = { PENDING: '待支付', PAID: '已支付', CANCELLED: '已取消' }; return map[orderStatus.value] || orderStatus.value; }); onMounted(async () => { if (!orderNo) { ElMessage.error('缺少订单号'); return; } try { const detail = await getOrderDetailApi(orderNo); payAmount.value = detail.payAmount; const payData = await createNativePaymentApi(orderNo); qrCodeDataUrl.value = await QRCode.toDataURL(payData.codeUrl); timer = setInterval(pollPayStatus, 2000); } catch (error) { console.error('获取支付信息失败', error); } finally { loading.value = false; } }); async function pollPayStatus() { try { const res = await getOrderPayStatusApi(orderNo); orderStatus.value = res.orderStatus; if (res.orderStatus === 'PAID') { clearInterval(timer); timer = null; ElMessage.success('支付成功'); router.replace({ path: '/order/detail', query: { orderNo } }); } } catch (error) { console.error('查询支付状态失败', error); } } onBeforeUnmount(() => { if (timer) { clearInterval(timer); timer = null; } }); </script> <style scoped> .pay-page { display: flex; justify-content: center; padding: 40px 16px; background: #f5f7fa; min-height: 100vh; } .pay-card { width: 480px; text-align: center; } .amount-text { font-size: 16px; margin-bottom: 24px; } .amount { color: #e6a23c; font-size: 28px; font-weight: 600; } .qr-img { width: 240px; height: 240px; border: 1px solid #ebeef5; padding: 8px; background: #fff; } .tip-text { color: #909399; font-size: 14px; margin: 16px 0; } .pay-loading { padding: 40px 0; color: #909399; } </style>6.2 支付状态轮询的设计要点
支付页中轮询的设计有几个容易被忽略的地方。
第一,轮询必须在组件销毁时关闭。用户直接关闭浏览器标签页会导致组件进入beforeUnmount,所以要把clearInterval放在onBeforeUnmount中,避免后台继续发起无意义的请求。
第二,轮询不能只查询一次就结束。二维码支付的特点是用户扫码时间不可控,所以需要每隔一定时间发起查询。间隔建议设置为 2 到 3 秒,太短会增加服务端压力,太长会让用户感觉延迟明显。
第三,支付成功后要使用router.replace而不是router.push。原因是用户从支付页到达订单详情后,如果点击浏览器返回按钮,不应该再回到“正在支付”的页面,否则可能出现重复支付提示。
第四,真实收款成功必须以服务端收到的支付回调为准,前端轮询结果只能作为展示参考。比如后端服务在自己收到微信或支付宝的异步通知后,才将订单状态更新为已支付。前端不能因为收到“PAID”就直接发货,否则可能造成刷单漏洞。
7. 订单详情页与支付结果的最终展示
当支付状态轮询到PAID后,页面跳转到订单详情页。订单详情页应该展示订单号、商品信息、实付金额、订单状态等信息。
这个页面相对简单,核心是调用订单详情接口,把后端数据回显到页面上。如果支付成功后订单状态仍然没有更新,可以增加一个手动“刷新状态”按钮,让用户主动查询。
<!-- 文件路径:src/views/OrderDetailView.vue --> <template> <div class="order-detail"> <el-card v-if="order" class="detail-card"> <template #header> <span>订单详情</span> </template> <el-descriptions :column="1" border> <el-descriptions-item label="订单号"> {{ order.orderNo }} </el-descriptions-item> <el-descriptions-item label="订单状态"> {{ statusText }} </el-descriptions-item> <el-descriptions-item label="支付金额"> ¥ {{ (order.payAmount / 100).toFixed(2) }} </el-descriptions-item> <el-descriptions-item label="收货信息"> {{ order.receiverName }} {{ order.receiverMobile }} </el-descriptions-item> <el-descriptions-item label="收货地址"> {{ order.receiverAddress }} </el-descriptions-item> </el-descriptions> <el-button v-if="order.orderStatus !== 'PAID'" type="primary" class="refresh-btn" @click="handleRefresh" > 刷新状态 </el-button> </el-card> </div> </template> <script setup> import { ref, computed, onMounted } from 'vue'; import { useRoute } from 'vue-router'; import { ElMessage } from 'element-plus'; import { getOrderDetailApi } from '@/api/order'; const route = useRoute(); const order = ref(null); const orderNo = route.query.orderNo; const statusText = computed(() => { const map = { PENDING: '待支付', PAID: '已支付', CANCELLED: '已取消' }; return map[order.value?.orderStatus] || '未知'; }); onMounted(async () => { await loadDetail(); }); async function loadDetail() { try { order.value = await getOrderDetailApi(orderNo); } catch (error) { console.error('获取订单详情失败', error); } } async function handleRefresh() { await loadDetail(); if (order.value.orderStatus === 'PAID') { ElMessage.success('订单已支付'); } } </script> <style scoped> .order-detail { padding: 24px; max-width: 800px; margin: 0 auto; } .refresh-btn { margin-top: 16px; } </style>至此,一条从登录、加购、下单到支付的完整前端闭环就打通了。
8. 常见问题排查
8.1 高频率问题速查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 登录成功后跳转正常,刷新后登录态丢失 | token 只存在 Pinia,没有持久化 | 登录后写入 localStorage |
| 请求接口返回 401 | token 不存在、过期或格式错误 | 检查拦截器 Authorization 头,登录后重新获取 token |
| 后端返回了 code_url,但二维码图片不显示 | 二维码内容未正确传递,或 qrcode 依赖未安装 | 检查 codeUrl 是否为空,安装并引入 qrcode |
| 支付二维码扫了但订单状态一直是待支付 | 轮询间隔太长,或后端状态更新依赖回调 | 检查订单状态查询接口,确认服务端是否收到支付回调 |
| 页面提示缺少订单号 | 创建订单后跳转支付页时没有拼接 orderNo | 使用 route.query.orderNo 接参,跳转时携带 query |
| 支付页面返回上一页仍可重复支付 | 没有在支付成功后清理页面缓存 | 使用 router.replace 跳转 |
| 金额计算出现小数误差 | 前端直接使用浮点数计算金额 | 金额统一使用分,展示时再转换为元 |
8.2 登录失败如何排查
如果你在本地联调时遇到登录失败,可以先确认几个位置:
- 请求地址是否正确。如果使用 Vite 开发代理,检查
vite.config.js中的 proxy 配置。 - 后端返回的字段名是否与前端一致。有的后端返回
token,有的返回accessToken,需要统一。 - 密码是否在传输前需要加密。真实项目中密码通常不会明文传输,可能需要先做 RSA 加密或使用 HTTPS。
Vite 开发代理参考配置:
// 文件路径:vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });8.3 二维码不显示但接口正常
如果后端返回正常,codeUrl也有值,但二维码区域空白,可以检查安装的依赖是qrcode还是qrcode.vue。本文使用的是qrcode,通过QRCode.toDataURL生成 base64 图片。
如果你更习惯组件式写法,也可以使用qrcode.vue,用法如下:
<template> <qrcode-vue :value="codeUrl" :size="240" /> </template> <script setup> import QrcodeVue from 'qrcode.vue'; import { ref } from 'vue'; const codeUrl = ref('https://example.com'); </script>两种方式都可以,重点是根据项目习惯选择一种并保持一致。
9. 工程化与安全最佳实践
9.1 token 安全与权限控制
在真实电商项目中,token 的存储位置需要认真考虑。localStorage容易受到 XSS 攻击,cookie中的httpOnly可以降低风险,但要同时处理 CSRF 问题。
建议:
- 不要把用户密码明文保存在前端任何位置。
- 请求拦截器统一带上
Authorization头。 - 退出登录时清除本地 token 和用户信息。
- 路由守卫只做页面级控制,真正的权限校验必须在后端接口完成。
9.2 支付安全注意事项
支付功能涉及资金安全,以下原则必须遵守:
- 订单金额以后端计算为准,前端绝不能自行修改金额。
- 支付预下单接口必须有登录鉴权,不接收前端传入的金额和商品明细。
- 订单状态更新以后端支付回调为准,不能只信任前端轮询结果。
- 测试环境使用支付宝沙箱或微信支付沙箱商户号,不要使用真实商户信息调试。
- 生产环境密钥由服务端保管,前端不直接接触商户私钥和签名密钥。
- 页面销毁时清除支付状态轮询定时器。
9.3 代码规范与可维护性
电商项目通常会持续迭代,建议从第一天就保持清晰的代码分层。
接口请求全部放入src/api目录,按模块拆分文件。每次后端接口变更时,只需要修改对应的 api 文件,不影响页面代码。
全局状态放入 Pinia,不要在多个组件中各自保存购物车副本,否则会出现各页面数据不同步的问题。
金额计算封装成通用方法。例如:
// 文件路径:src/utils/money.js export function formatPrice(cents) { return `¥ ${(cents / 100).toFixed(2)}`; }所有金额展示统一走这个方法,可以避免大量重复的toFixed(2)代码。
10. 总结与下一步建议
本文从零梳理了一套 Vue3 电商应用从登录到支付的完整流程,重点代码集中在登录鉴权、Axios 拦截器、路由守卫、订单创建、支付二维码展示和支付状态轮询这几个阶段。
从登录到支付,最核心的思维不是分别写登录页和支付页,而是理解接口数据如何流动:登录拿到 token,token 通过拦截器自动注入后续请求;下单拿到 orderNo,orderNo 再用于支付预下单和状态轮询。只要这条链路通了,电商前端的主流程就完成了一大半。
如果你打算继续深入,可以从以下方向扩展:
- 接入真实后端,把 Mock 换成 Swagger 接口。
- 增加商品 SKU 和库存逻辑。
- 学习微信小程序中的
wx.requestPayment,对比 H5 扫码支付与小程序支付的区别。 - 增加订单超时自动取消逻辑。
- 完善异常情况,例如支付成功回调延迟、退款流程、订单关闭流程。
第一次实现支付流程时,建议先用支付宝沙箱或微信 Native 支付跑通扫码流程,不要一上来就挑战复杂的 H5 内支付。扫码支付的链路最简单,也能让你更清晰地理解“预下单、展示二维码、轮询结果”这套后端与前端协作模型。等你理解以后,再切换到 JSAPI 或 App 支付,就会发现所有支付渠道的前端逻辑本质上是相同的。