登录与用户系统
本页关键词:ElementPlus 表单校验(
rules/validate/自定义validator)、Pinia 管理用户数据、pinia-plugin-persistedstate持久化、Token 全流程(请求拦截器携带 / 401 响应拦截清除)、购物车双模式(本地 + 接口)、登录合并与退出清空、Checkout 结算流程、支付倒计时 composable、会员中心嵌套路由、字符映射优化
一、登录模块
1. ElementPlus 表单校验
整体思路:el-form 提供声明式的校验机制 — 通过 :rules 定义每个字段的校验规则,el-form-item 的 prop 属性指定该字段对应 rules 中的哪个 key。用户 blur 输入框时自动触发校验,也可以通过 ref 手动调用 validate() 做统一校验。
const form = ref({
account: '',
password: '',
agree: true
})
const rules = {
account: [
{ required: true, message: '用户名不能为空', trigger: 'blur' }
],
password: [
{ required: true, message: '密码不能为空', trigger: 'blur' },
{ min: 6, max: 14, message: '密码长度为6-14个字符', trigger: 'blur' },
],
agree: [{
validator: (rule, val, callback) => {
return val ? callback() : new Error('请先同意协议')
}
}]
}自定义校验器:内置规则(required、min、max 等)无法满足时使用 validator 函数。参数中 callback() 表示校验通过,new Error(msg) 表示校验失败。上面的 agree 字段就用自定义 validator 判断是否勾选了协议。
2. 统一校验 + 登录逻辑
点击登录按钮时,不是直接调接口,而是先通过 formRef.value.validate() 统一校验所有字段。只有全部通过后才执行登录操作:
const formRef = ref(null)
const router = useRouter()
const doLogin = () => {
const { account, password } = form.value
formRef.value.validate(async (valid) => {
if (valid) {
await userStore.getUserInfo({ account, password })
ElMessage({ type: 'success', message: '登录成功' })
router.replace({ path: '/' }) // replace 而非 push,防止用户后退回登录页
}
})
}<el-form ref="formRef" :model="form" :rules="rules">
<el-form-item prop="account" label="账户">
<el-input v-model="form.account" />
</el-form-item>
<el-form-item prop="password" label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item prop="agree">
<el-checkbox v-model="form.agree">我已同意隐私条款和服务条款</el-checkbox>
</el-form-item>
<el-button @click="doLogin">点击登录</el-button>
</el-form>面试要点:
el-form的validate(callback)遍历所有声明了prop的el-form-item,依次执行对应 rules。router.replace和router.push的区别:replace 不会在浏览器历史中留下记录,用户无法通过后退按钮回到登录页。
二、用户认证体系
1. Pinia 管理用户数据 + 持久化
设计思路:用户信息(包括 token)属于全局数据,多个组件需要读取(导航栏显示用户名、请求拦截器携带 token、判断登录状态等)。用 Pinia Store 集中管理,并通过 pinia-plugin-persistedstate 自动持久化到 localStorage,刷新页面不丢失。
// stores/user.ts
import { loginAPI } from '@/apis/user'
import { mergeCartAPI } from '@/apis/cart'
export const useUserStore = defineStore('user', () => {
const cartStore = useCartStore()
const userInfo = ref({})
const getUserInfo = async ({ account, password }) => {
const res = await loginAPI({ account, password })
userInfo.value = res.result // 存储用户信息(含 token)
// 登录成功后,将本地购物车数据合并到服务端
await mergeCartAPI(cartStore.cartList.map(item => ({
skuId: item.skuId,
selected: item.selected,
count: item.count
})))
cartStore.updateNewList() // 合并后拉取最新购物车
}
const clearUserInfo = () => {
userInfo.value = {}
cartStore.clearCart() // 退出时同步清空购物车
}
return { userInfo, getUserInfo, clearUserInfo }
}, {
persist: true // 整个 store 的 state 自动存入 localStorage
})2. Token 全流程
Token 认证的完整链路:登录获取 → 持久化存储 → 每次请求自动携带 → 失效时清除并跳转登录页。
请求拦截器 — 自动携带 Token:
// utils/http.js
httpInstance.interceptors.request.use(config => {
const userStore = useUserStore()
const token = userStore.userInfo.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})为什么在拦截器内部调用 useUserStore():拦截器是在请求发送时才执行的函数(不是在模块加载时),此时 Pinia 已经初始化完成,可以安全调用。如果在模块顶层调用 useUserStore(),可能在 Pinia 初始化之前执行,导致报错。
401 响应拦截 — Token 失效处理:
当后端返回 401(Unauthorized)时,说明 token 已过期或无效。此时需要清除本地用户信息并跳转到登录页重新认证:
httpInstance.interceptors.response.use(
res => res.data,
error => {
ElMessage({
type: 'warning',
message: error.response?.data?.message || '请求失败'
})
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.clearUserInfo() // 清除过期的用户信息
router.push('/login') // 跳转登录页
}
return Promise.reject(error)
}
)3. 退出登录
退出的逻辑很简单:清除用户信息(内部会连带清空购物车)+ 跳转登录页:
const userStore = useUserStore()
userStore.clearUserInfo()
router.push('/login')4. Token 控制导航模板切换
根据 token 是否存在,使用 v-if/v-else 切换顶部导航的显示状态 — 有 token 显示用户名和退出按钮,无 token 显示登录/注册链接。这样用户登录后界面会自动切换,因为 userInfo.token 是响应式数据。
面试要点:Token 全流程 — 登录获取 →
persist:true存 localStorage → 请求拦截器携带Authorization: Bearer xxx→ 401 时清除 token 并跳登录页。持久化插件底层使用localStorage.setItem/getItem,在 store 数据变化时自动写入,初始化时自动读取恢复。
三、购物车
1. Cart Store 设计 — 本地/接口双模式
设计思路:未登录时使用本地 Store(持久化到 localStorage),登录后使用服务端接口。通过 isLogin computed 动态判断走哪条路径。这样未登录用户也能正常使用购物车,登录后数据会合并到服务端。
// stores/cartStore.ts
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore()
const isLogin = computed(() => userStore.userInfo.token)
const cartList = ref([])
// 添加购物车 — 双模式
const addCart = async (goods) => {
if (isLogin.value) {
// 已登录 → 调接口 → 刷新列表
await insertCartAPI({ skuId: goods.skuId, count: goods.count })
updateNewList()
} else {
// 未登录 → 本地操作(通过 skuId 判断是否已存在)
const item = cartList.value.find(i => goods.skuId === i.skuId)
if (item) {
item.count++ // 已有 → 数量+1
} else {
cartList.value.push(goods) // 没有 → 加入列表
}
}
}
// 删除购物车 — 同样双模式
const delCart = async (skuId) => {
if (isLogin.value) {
await delCartAPI([skuId])
await updateNewList()
} else {
const idx = cartList.value.findIndex(i => skuId === i.skuId)
cartList.value.splice(idx, 1)
}
}
// 单选 / 全选
const singleCheck = (skuId, selected) => {
const item = cartList.value.find(i => i.skuId === skuId)
item.selected = selected
}
const allCheck = (selected) => {
cartList.value.forEach(i => i.selected = selected)
}
// computed 统计量 — 自动随 cartList 变化更新
const allCount = computed(() =>
cartList.value.reduce((a, c) => a + c.count, 0))
const allPrice = computed(() =>
cartList.value.reduce((a, c) => a + c.count * c.price, 0))
const selectedCount = computed(() =>
cartList.value.filter(i => i.selected).reduce((a, c) => a + c.count, 0))
const selectedPrice = computed(() =>
cartList.value.filter(i => i.selected).reduce((a, c) => a + c.count * c.price, 0))
const isAll = computed(() =>
cartList.value.every(i => i.selected))
const clearCart = () => { cartList.value = [] }
const updateNewList = async () => {
const res = await findNewCarListAPI()
cartList.value = res.result
}
return {
cartList, addCart, delCart, clearCart,
singleCheck, allCheck, isAll,
allCount, allPrice, selectedCount, selectedPrice,
updateNewList
}
}, { persist: true })全选逻辑的思路:isAll 用 every 判断是否所有商品都选中(返回 boolean)。全选按钮的 change 事件调用 allCheck(selected) 批量设置所有商品的 selected 状态。由于 isAll 是 computed,当某个商品取消选中时会自动变为 false,全选按钮自动取消。
2. 登录时合并购物车
在 getUserInfo action 中处理:登录成功后,把本地购物车数据(skuId、selected、count)发送到服务端合并接口,然后拉取最新列表覆盖本地数据。
3. 退出时清空购物车
clearUserInfo 中调用 cartStore.clearCart(),确保退出后不会保留上一个用户的购物车数据。
面试要点:购物车双模式设计是电商项目的常见方案 — 未登录用 localStorage 保证体验,登录后与服务端同步保证数据一致。
computed派生的统计量(allCount、allPrice、isAll)通过reduce累加实现,会在 cartList 任何变化时自动重新计算。Store 之间可以互相调用(userStore 调 cartStore),但要注意避免循环依赖。
四、结算与订单
1. Checkout 结算页
路由配置:{ path: 'checkout', component: Checkout }
核心流程:进入结算页 → 获取结算信息(收货地址列表 + 商品列表) → 用户选择/切换收货地址 → 点击提交订单 → 调用创建订单接口 → 跳转支付页。
// 地址切换交互 — 临时变量 + 确认按钮
const curAddress = ref({}) // 当前使用的地址
const activeAddress = ref({}) // 弹窗中暂时选中的地址
const switchAddress = (item) => {
activeAddress.value = item // 点击某个地址 → 暂存
}
const confirm = () => {
curAddress.value = activeAddress.value // 确认 → 更新实际地址
toggleFlag.value = false
}2. 创建订单
提交订单时,需要组装后端要求的数据格式(商品列表只需 skuId 和 count):
const createOrder = async () => {
const res = await createOrderAPI({
deliveryTimeType: 1,
payType: 1,
payChannel: 1,
buyerMessage: '',
goods: checkInfo.value.goods.map(item => ({
skuId: item.skuId,
count: item.count
})),
addressId: curAddress.value.id
})
const orderId = res.result.id
router.push({ path: '/pay', query: { id: orderId } })
cartStore.updateNewList() // 下单后刷新购物车(已购商品应从购物车移除)
}五、支付
1. 支付功能
通过 route.query.id 获取订单 ID,拼接支付宝支付链接。点击「支付」按钮跳转到支付宝页面,支付完成后支付宝回调到指定的 redirect URL:
const route = useRoute()
const payInfo = ref({})
const getPayInfo = async () => {
const res = await getOrderAPI(route.query.id)
payInfo.value = res.result
}
const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net'
const backURL = 'http://127.0.0.1:5173/paycallback'
const redirectUrl = encodeURIComponent(backURL)
const payUrl = `${baseURL}/pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}`2. useCountDown 倒计时 composable
支付页需要显示订单倒计时(超时未支付则自动取消)。将倒计时逻辑封装为 composable,实现逻辑复用和关注点分离:
// composables/useCountDown.js
import { computed, onUnmounted, ref } from 'vue'
import dayjs from 'dayjs'
export const useCountDown = () => {
let timer = null
const time = ref(0)
const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
const start = (currentTime) => {
time.value = currentTime
timer = setInterval(() => { time.value-- }, 1000)
}
// 组件销毁时清除定时器,防止内存泄漏
onUnmounted(() => {
timer && clearInterval(timer)
})
return { formatTime, start }
}使用:
const { formatTime, start } = useCountDown()
// 获取订单数据后启动倒计时
start(payInfo.value.countdown)面试要点:composable 中使用
onUnmounted清除定时器是防止内存泄漏的标准做法。dayjs.unix()接收秒级时间戳,format()格式化为可读文本。setInterval在组件销毁后如果不清除,回调函数仍会执行,且引用了已销毁组件的响应式数据,可能导致意外行为。
六、会员中心
1. 三级嵌套路由
会员中心是 Layout 的子路由,自身又包含两个子页面(个人中心 / 我的订单),形成三级路由嵌套:
{
path: 'member/',
component: Member,
children: [
{ path: '', component: MemberInfo }, // /member/ → 个人中心
{ path: 'order', component: MemberOrder } // /member/order → 我的订单
]
}Member 组件作为子路由的外壳,包含侧边导航和 <RouterView />:
2. 订单列表 Tab 切换
通过 el-tabs 切换订单状态。tabTypes 数组定义了所有状态,切换时修改请求参数中的 orderState 并重新请求:
const tabTypes = [
{ name: "all", label: "全部订单" },
{ name: "unpay", label: "待付款" },
{ name: "deliver", label: "待发货" },
{ name: "receive", label: "待收货" },
{ name: "comment", label: "待评价" },
{ name: "complete", label: "已完成" },
{ name: "cancel", label: "已取消" }
]
const params = ref({
orderState: 0,
page: 1,
pageSize: 2
})
const tabChange = (type) => {
params.value.orderState = type
getOrderList()
}3. 字符映射优化
将数字状态码转为可读中文,使用对象映射替代 if-else 链。这种模式在实际项目中非常常见,代码更简洁、可扩展性更好:
const fomartPayState = (payState) => {
const stateMap = {
1: '待付款', 2: '待发货', 3: '待收货',
4: '待评价', 5: '已完成', 6: '已取消'
}
return stateMap[payState]
}面试要点:字符映射(Object lookup)替代 if-else/switch 是常见的代码优化手法 — O(1) 查找、可读性好、易于新增状态。三级路由通过嵌套
children实现,每一级都需要对应的<RouterView />作为子路由挂载点。