Skip to content

登录与用户系统

本页关键词:ElementPlus 表单校验(rules/validate/自定义 validator)、Pinia 管理用户数据、pinia-plugin-persistedstate 持久化、Token 全流程(请求拦截器携带 / 401 响应拦截清除)、购物车双模式(本地 + 接口)、登录合并与退出清空、Checkout 结算流程、支付倒计时 composable、会员中心嵌套路由、字符映射优化


一、登录模块

1. ElementPlus 表单校验

整体思路el-form 提供声明式的校验机制 — 通过 :rules 定义每个字段的校验规则,el-form-itemprop 属性指定该字段对应 rules 中的哪个 key。用户 blur 输入框时自动触发校验,也可以通过 ref 手动调用 validate() 做统一校验。

js
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() 统一校验所有字段。只有全部通过后才执行登录操作:

js
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,防止用户后退回登录页
    }
  })
}
vue
<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-formvalidate(callback) 遍历所有声明了 propel-form-item,依次执行对应 rules。router.replacerouter.push 的区别:replace 不会在浏览器历史中留下记录,用户无法通过后退按钮回到登录页。


二、用户认证体系

1. Pinia 管理用户数据 + 持久化

设计思路:用户信息(包括 token)属于全局数据,多个组件需要读取(导航栏显示用户名、请求拦截器携带 token、判断登录状态等)。用 Pinia Store 集中管理,并通过 pinia-plugin-persistedstate 自动持久化到 localStorage,刷新页面不丢失。

js
// 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

js
// 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 已过期或无效。此时需要清除本地用户信息并跳转到登录页重新认证:

js
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. 退出登录

退出的逻辑很简单:清除用户信息(内部会连带清空购物车)+ 跳转登录页:

js
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 动态判断走哪条路径。这样未登录用户也能正常使用购物车,登录后数据会合并到服务端。

js
// 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 })

全选逻辑的思路isAllevery 判断是否所有商品都选中(返回 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 }

核心流程:进入结算页 → 获取结算信息(收货地址列表 + 商品列表) → 用户选择/切换收货地址 → 点击提交订单 → 调用创建订单接口 → 跳转支付页。

js
// 地址切换交互 — 临时变量 + 确认按钮
const curAddress = ref({})       // 当前使用的地址
const activeAddress = ref({})    // 弹窗中暂时选中的地址

const switchAddress = (item) => {
  activeAddress.value = item     // 点击某个地址 → 暂存
}
const confirm = () => {
  curAddress.value = activeAddress.value  // 确认 → 更新实际地址
  toggleFlag.value = false
}

2. 创建订单

提交订单时,需要组装后端要求的数据格式(商品列表只需 skuId 和 count):

js
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:

js
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,实现逻辑复用和关注点分离:

js
// 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 }
}

使用:

js
const { formatTime, start } = useCountDown()
// 获取订单数据后启动倒计时
start(payInfo.value.countdown)

面试要点:composable 中使用 onUnmounted 清除定时器是防止内存泄漏的标准做法。dayjs.unix() 接收秒级时间戳,format() 格式化为可读文本。setInterval 在组件销毁后如果不清除,回调函数仍会执行,且引用了已销毁组件的响应式数据,可能导致意外行为。


六、会员中心

1. 三级嵌套路由

会员中心是 Layout 的子路由,自身又包含两个子页面(个人中心 / 我的订单),形成三级路由嵌套:

ts
{
  path: 'member/',
  component: Member,
  children: [
    { path: '', component: MemberInfo },       // /member/ → 个人中心
    { path: 'order', component: MemberOrder }  // /member/order → 我的订单
  ]
}

Member 组件作为子路由的外壳,包含侧边导航和 <RouterView />

2. 订单列表 Tab 切换

通过 el-tabs 切换订单状态。tabTypes 数组定义了所有状态,切换时修改请求参数中的 orderState 并重新请求:

js
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 链。这种模式在实际项目中非常常见,代码更简洁、可扩展性更好:

js
const fomartPayState = (payState) => {
  const stateMap = {
    1: '待付款', 2: '待发货', 3: '待收货',
    4: '待评价', 5: '已完成', 6: '已取消'
  }
  return stateMap[payState]
}

面试要点:字符映射(Object lookup)替代 if-else/switch 是常见的代码优化手法 — O(1) 查找、可读性好、易于新增状态。三级路由通过嵌套 children 实现,每一级都需要对应的 <RouterView /> 作为子路由挂载点。