微信小程序电商实战 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,测试defaultAddressgetter 返回默认地址 - 创建
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.ts的onLaunch中加入userStore.silentLogin(),实现无感知登录