微信小程序电商实战 08:用户中心——微信授权登录、收货地址与订单历史

微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 | 06 购物车与订单 | 07 微信支付 | 08 用户中心 ← 本文 | 09 性能优化 | 10 上线部署


# 用户中心 ## 微信登录体系 ### 静默登录(wx.login) - code → openid(云函数) - 无感知,首次进入自动完成 ### 手机号授权 - Button open-type="getPhoneNumber" - 仅在需要时触发(下单/支付前) ### userStore 状态 - openid / nickName / phone - isLogin 登录态 ## 收货地址管理 ### 地址列表 - 默认地址标记 ### 新增/编辑地址 - 省市区选择器 - 设为默认 ### 地址选择模式 - 订单确认页调用 ## 订单历史 ### Tab 切换(5个状态) - 全部/待付款/待发货/待收货/已完成 ### 订单卡片 - 商品缩略图 - 金额/状态/操作按钮 ### 操作 - 去支付(待付款) - 确认收货(待收货) - 查看物流(待收货/已完成)

一、微信登录流程

微信小程序不能直接获取用户信息,需要通过以下步骤建立用户身份:

sequenceDiagram participant U as 小程序 participant CF as 云函数 participant WX as 微信服务器 U->>WX: wx.login() WX-->>U: code(5分钟有效) U->>CF: login(code) CF->>WX: code2Session(code + appSecret) WX-->>CF: openid + session_key CF->>CF: 查询/创建 users 记录 CF-->>U: 用户信息(openid已在云端) Note over U,CF: 云函数中可直接获取 openid<br/>无需 code2Session

使用云开发时,云函数通过 cloud.getWXContext() 直接获得 OPENID无需 appSecret,安全且简单。


二、用户 Store

// src/stores/user.ts
import { defineStore } from 'pinia'
import Taro from '@tarojs/taro'
import { callCloud } from '@/api/cloud'

interface Address {
  id: string
  name: string
  phone: string
  province: string
  city: string
  district: string
  detail: string
  isDefault: boolean
}

interface UserState {
  isLogin: boolean
  openid: string
  nickName: string
  avatarUrl: string
  phone: string
  addresses: Address[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    isLogin: false,
    openid: '',
    nickName: '',
    avatarUrl: '',
    phone: '',
    addresses: [],
  }),
  getters: {
    defaultAddress: (state): Address | null =>
      state.addresses.find(a => a.isDefault) ?? state.addresses[0] ?? null,
  },
  actions: {
    async silentLogin() {
      if (this.isLogin) return
      try {
        const user = await callCloud<UserState>('user', { action: 'login' })
        Object.assign(this, user, { isLogin: true })
      } catch {
        // 静默失败不打扰用户
      }
    },
    async bindPhone(encryptedData: string, iv: string) {
      const result = await callCloud<{ phone: string }>('user', {
        action: 'bindPhone',
        encryptedData,
        iv,
      })
      this.phone = result.phone
    },
    setAddresses(addresses: Address[]) {
      this.addresses = addresses
    },
  },
})

三、登录云函数

// cloudfunctions/user/index.js
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()

exports.main = async (event, context) => {
  const { OPENID } = cloud.getWXContext()
  const { action } = event

  if (action === 'login') {
    // 查询用户,不存在则创建
    const userQuery = await db.collection('users')
      .where({ _openid: OPENID })
      .limit(1)
      .get()

    if (userQuery.data.length === 0) {
      await db.collection('users').add({
        data: {
          _openid: OPENID,
          nickName: '',
          avatarUrl: '',
          phone: '',
          addresses: [],
          createdAt: db.serverDate(),
        },
      })
      return { openid: OPENID, nickName: '', avatarUrl: '', phone: '', addresses: [] }
    }

    const user = userQuery.data[0]
    return {
      openid: OPENID,
      nickName: user.nickName,
      avatarUrl: user.avatarUrl,
      phone: user.phone,
      addresses: user.addresses || [],
    }
  }

  if (action === 'bindPhone') {
    // 解密手机号(需 session_key,云函数统一处理)
    const phone = decryptPhone(event.encryptedData, event.iv)  // 见下方说明
    await db.collection('users').where({ _openid: OPENID }).update({
      data: { phone },
    })
    return { phone }
  }

  if (action === 'updateAddress') {
    const { addresses } = event
    await db.collection('users').where({ _openid: OPENID }).update({
      data: { addresses },
    })
    return { success: true }
  }
}

四、用户中心页面

<!-- src/pages/user/index.vue -->
<template>
  <view class="user-page">
    <!-- 用户信息头部 -->
    <view class="user-header">
      <nut-avatar :url="userStore.avatarUrl || defaultAvatar" size="large" />
      <view class="user-info">
        <text class="nick-name">{{ userStore.nickName || '微信用户' }}</text>
        <text v-if="userStore.phone" class="phone">{{ maskPhone(userStore.phone) }}</text>
        <button v-else class="get-phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">
          绑定手机号
        </button>
      </view>
    </view>

    <!-- 订单入口 -->
    <view class="order-nav">
      <view class="order-nav-title">
        <text>我的订单</text>
        <text class="view-all" @tap="goOrders()">全部订单 ›</text>
      </view>
      <view class="order-tabs">
        <view v-for="tab in orderTabs" :key="tab.status" class="order-tab" @tap="goOrders(tab.status)">
          <text class="order-tab-icon">{{ tab.icon }}</text>
          <text class="order-tab-name">{{ tab.name }}</text>
          <nut-badge v-if="tab.count > 0" :value="tab.count" />
        </view>
      </view>
    </view>

    <!-- 功能列表 -->
    <view class="menu-list">
      <view class="menu-item" @tap="goAddress">
        <text>收货地址</text>
        <text class="menu-arrow">›</text>
      </view>
      <view class="menu-item" @tap="goAbout">
        <text>关于我们</text>
        <text class="menu-arrow">›</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { Avatar as NutAvatar, Badge as NutBadge, showToast } from '@nutui/nutui-taro'
import { useUserStore } from '@/stores/user'
import { getOrderCounts } from '@/api/order'

const userStore = useUserStore()
const defaultAvatar = '/assets/images/default-avatar.png'

const orderTabs = ref([
  { status: 1, name: '待付款', icon: '💳', count: 0 },
  { status: 2, name: '待发货', icon: '📦', count: 0 },
  { status: 3, name: '待收货', icon: '🚚', count: 0 },
  { status: 4, name: '已完成', icon: '✅', count: 0 },
])

onMounted(async () => {
  await userStore.silentLogin()
  const counts = await getOrderCounts()
  orderTabs.value.forEach(tab => {
    tab.count = counts[tab.status] ?? 0
  })
})

const onGetPhone = async (e: any) => {
  if (!e.detail.code) return
  try {
    // 新版手机号授权使用 code 方式
    await userStore.bindPhone(e.detail.code, '')
    showToast.success('绑定成功')
  } catch {
    showToast.fail('绑定失败,请重试')
  }
}

const maskPhone = (phone: string) =>
  phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')

const goOrders = (status?: number) => {
  const url = status ? `/pages/order/list?status=${status}` : '/pages/order/list'
  Taro.navigateTo({ url })
}

const goAddress = () => Taro.navigateTo({ url: '/pages/user/address' })
const goAbout = () => Taro.navigateTo({ url: '/pages/user/about' })
</script>

五、收货地址页面

<!-- src/pages/user/address.vue -->
<template>
  <view class="address-page">
    <view
      v-for="addr in userStore.addresses"
      :key="addr.id"
      class="address-item"
      @tap="selectOrEdit(addr)"
    >
      <view class="addr-header">
        <text class="addr-name">{{ addr.name }}</text>
        <text class="addr-phone">{{ addr.phone }}</text>
        <nut-tag v-if="addr.isDefault" type="primary" plain>默认</nut-tag>
      </view>
      <text class="addr-detail">
        {{ addr.province }}{{ addr.city }}{{ addr.district }}{{ addr.detail }}
      </text>
      <view class="addr-actions">
        <text @tap.stop="editAddress(addr)">编辑</text>
        <text @tap.stop="deleteAddress(addr.id)">删除</text>
        <text v-if="!addr.isDefault" @tap.stop="setDefault(addr.id)">设为默认</text>
      </view>
    </view>

    <nut-button block type="primary" @tap="addAddress">+ 新增收货地址</nut-button>
  </view>
</template>

<script setup lang="ts">
import Taro from '@tarojs/taro'
import { Button as NutButton, Tag as NutTag, showDialog, showToast } from '@nutui/nutui-taro'
import { useUserStore } from '@/stores/user'
import { updateAddresses } from '@/api/user'

const userStore = useUserStore()
const { mode } = Taro.getCurrentInstance().router?.params ?? {}

const selectOrEdit = (addr: any) => {
  if (mode === 'select') {
    // 订单确认页选择地址模式
    Taro.navigateBack()
    // 通过 eventBus 或 Pinia 传递选中地址
  }
}

const setDefault = async (id: string) => {
  const newAddresses = userStore.addresses.map(a => ({ ...a, isDefault: a.id === id }))
  await updateAddresses(newAddresses)
  userStore.setAddresses(newAddresses)
}

const deleteAddress = (id: string) => {
  showDialog({
    title: '删除地址',
    content: '确认删除该收货地址?',
    onOk: async () => {
      const newAddresses = userStore.addresses.filter(a => a.id !== id)
      await updateAddresses(newAddresses)
      userStore.setAddresses(newAddresses)
      showToast.success('已删除')
    },
  })
}

const addAddress = () => Taro.navigateTo({ url: '/pages/user/address-edit' })
const editAddress = (addr: any) =>
  Taro.navigateTo({ url: `/pages/user/address-edit?id=${addr.id}` })
</script>

微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 | 06 购物车与订单 | 07 微信支付 | 08 用户中心 ← 本文 | 09 性能优化 | 10 上线部署

实操清单

  • 创建 cloudfunctions/user/index.js,实现 login / bindPhone / updateAddress action
  • 部署 user 云函数,在小程序端调用 silentLogin(),验证 users 集合写入了一条记录
  • 完善 useUserStore,测试 defaultAddress getter 返回默认地址
  • 创建 src/pages/user/index.vue,验证用户头像、昵称正确显示
  • 在真机上测试「绑定手机号」按钮(open-type="getPhoneNumber"),模拟器无法测试
  • 创建 src/pages/user/address.vue,实现地址列表展示、设为默认、删除
  • 创建 src/pages/user/address-edit.vue(省市区选择器 + 表单),实现新增/编辑地址
  • 在订单确认页(第 06 篇)接入地址选择:跳转到 address?mode=select,选中后回传
  • 验证手机号脱敏显示(138****5678
  • app.tsonLaunch 中加入 userStore.silentLogin(),实现无感知登录