uni-app 全局容器实战系列(五):登录鉴权模块设计
一、整体架构
1.1 模块定位
登录鉴权模块负责四大核心职责:
| 职责 | 说明 |
|---|---|
| 用户身份认证 | 登录、注册、登出 |
| 页面访问控制 | 路由拦截,防止未授权访问 |
| 请求身份标识 | API Token 自动携带 |
| 用户状态管理 | 统一管理用户信息 |
1.2 三层拦截体系
| 层级 | 触发时机 | 作用 | Token 无效后果 |
|---|---|---|---|
| App.vue 初始化 | 应用启动/恢复 | 验证登录态,决定能否进入当前页面 | 跳转登录页 |
| permission.js 路由拦截 | 每次路由跳转 | 防止未授权访问受限页面 | 弹框提示 |
| request.js 请求拦截 | 每次 API 调用 | 携带凭证、处理 401 | 弹框提示 |
二、存储层 (utils/auth.js)
Token 持久化是整个鉴权体系的基础:
javascript
// utils/auth.js
const TOKEN_KEY = 'App-Token'
const CHANNEL_ID = 'CHANNEL_ID'
export function getToken() {
return uni.getStorageSync(TOKEN_KEY)
}
export function setToken(token) {
uni.setStorageSync(TOKEN_KEY, token)
}
export function removeToken() {
uni.removeStorageSync(TOKEN_KEY)
}
export function getChannelId() {
return uni.getStorageSync(CHANNEL_ID)
}
export function setChannelId(channelId) {
uni.setStorageSync(CHANNEL_ID, channelId)
}注意:uni.getStorageSync 直接返回存储的值,无需额外 .data 属性。
三、状态管理层 (store/modules/user.js)
使用 Pinia 统一管理用户状态:
3.1 State 结构
| 字段 | 类型 | 说明 |
|---|---|---|
| token | string | 登录凭证 |
| channelId | string | 渠道标识 |
| userInfo | object | 用户信息 |
| abilities | array | 能力列表 |
| grades | array | 年级列表 |
| subjects | array | 科目列表 |
3.2 Actions
| 方法 | 说明 | 平台限制 |
|---|---|---|
login(phone, password) | 密码登录 | - |
register(phone, password, smsCode) | 注册 | - |
logout() | 退出登录 | - |
getInfo() | 获取用户信息 | - |
bindPhone(newPhone, smsCode) | 绑定手机号 | - |
updateUserInfo(newUserInfo) | 更新用户信息 | - |
wxH5Login() | 微信授权登录 | 仅 H5 |
3.3 核心实现
javascript
// store/modules/user.js
import { defineStore } from 'pinia'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { login as loginApi, register as registerApi, logout as logoutApi, member as memberApi } from '@/api/login'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken() || '',
channelId: '',
userInfo: null,
abilities: [],
grades: [],
subjects: [],
}),
actions: {
// 登录
async login(phone, password) {
const res = await loginApi({ phone, password })
this.token = res.token
setToken(res.token)
await this.getInfo()
return res
},
// 获取用户信息
async getInfo() {
try {
const res = await memberApi()
this.userInfo = res.data
this.abilities = res.data.abilities || []
this.grades = res.data.grades || []
this.subjects = res.data.subjects || []
} catch (e) {
// Token 失效时保留 token,弹框提示让用户重新登录
console.error('获取用户信息失败', e)
throw e
}
},
// 登出
async logout() {
try {
await logoutApi(this.token)
} catch (e) {
// 忽略接口调用失败
} finally {
this.token = ''
this.userInfo = null
removeToken()
}
},
},
})3.4 微信登录(仅 H5 支持)
平台限制
微信授权登录仅支持 H5 平台使用,其他平台(App、小程序)需使用各自的登录方案。
H5 微信登录流程:
前端实现:
javascript
// #ifdef H5
import { getWechatLoginUrl, getQueryString } from '@/utils/wechat'
actions: {
// 微信 H5 登录
async wxH5Login() {
// 检查是否在微信浏览器中
const isWechatBrowser = navigator.userAgent.toLowerCase().includes('micromessenger')
if (!isWechatBrowser) {
uni.showToast({ title: '请在微信浏览器中打开', icon: 'none' })
return
}
// 获取 URL 中的 code 参数(回调页携带的)
const code = getQueryString('code')
if (!code) {
// 触发微信授权,跳转到授权页面
const redirectUri = encodeURIComponent(window.location.href)
const appId = import.meta.env.VITE_WECHAT_APPID
const wxLoginUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base#wechat_redirect`
window.location.href = wxLoginUrl
return
}
// 有 code,调用后端接口完成登录
const res = await wxLoginApi({ code })
this.token = res.token
setToken(res.token)
await this.getInfo()
return res
},
}
// #endif后端交互接口:
| 接口 | 参数 | 说明 |
|---|---|---|
wxLoginApi(code) | 微信授权码 | H5 专用,后端通过 code 换取 openid 并返回 token |
getWechatLoginUrl() | redirectUri | 获取微信授权跳转 URL |
getQueryString(name) | 参数名 | 从 URL 中解析查询参数 |
微信登录路由守卫:
javascript
// permission.js
// 微信授权回调页需要加入白名单,否则会触发未登录提示
const whiteList = [
loginPage,
'/pages/register/index',
'/pages/protocol/index',
'/pages/privacy/index',
'/pages/login/wx-callback', // 微信授权回调页
]四、路由拦截层 (permission.js)
4.1 拦截时机
| 跳转方式 | 说明 | 是否拦截 |
|---|---|---|
navigateTo | 普通页面跳转 | ✅ |
redirectTo | 关闭当前页跳转 | ✅ |
reLaunch | 关闭所有页面跳转 | ✅ |
switchTab | TabBar 页面切换 | ✅ |
navigateBack | 页面返回 | ❌ |
4.2 白名单配置
javascript
// permission.js
const loginPage = '/pages/login/index'
const whiteList = [
loginPage, // 登录页
'/pages/register/index', // 注册页
'/pages/protocol/index', // 用户协议
'/pages/privacy/index', // 隐私政策
]
function checkWhite(url) {
return whiteList.some(path => url.includes(path))
}4.3 拦截逻辑
javascript
// 路由拦截器
const list = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab']
list.forEach(item => {
uni.addInterceptor(item, {
invoke(to) {
// 已登录用户访问登录页 → 跳转首页
if (getToken() && to.url === loginPage) {
uni.reLaunch({ url: '/' })
return false
}
// 已登录 → 允许访问
if (getToken()) {
return true
}
// 未登录 → 检查白名单
if (checkWhite(to.url)) {
return true
}
// 非白名单 → 弹框提示
useAppLayout().showUnloginNotify()
return false
},
})
})五、请求拦截层 (utils/request.js)
5.1 Token 携带规则
javascript
// 普通接口 - 自动携带 Token
const config1 = {
url: '/api/user/info',
// isToken 默认为 true
}
// 第三方接口 - 不携带 Token
const config2 = {
url: '/api/third/pay',
headers: {
isToken: false,
},
}5.2 请求拦截实现
javascript
// utils/request.js
export function request(options) {
return new Promise((resolve, reject) => {
const token = getToken()
// 自动携带 Token(除非配置 isToken: false)
if (options.headers?.isToken !== false && token) {
options.header = {
...options.header,
Authorization: `Bearer ${token}`,
}
}
uni.request({
...options,
success: (res) => {
const { statusCode, data } = res
if (statusCode === 200) {
resolve(data)
} else if (statusCode === 401) {
// Token 失效 → 弹框提示
useAppLayout().showUnloginNotify()
reject(new Error('未授权'))
} else {
uni.showToast({
title: data.message || '请求失败',
icon: 'none',
})
reject(new Error(data.message))
}
},
fail: (err) => {
uni.showToast({
title: '网络异常',
icon: 'none',
})
reject(err)
},
})
})
}5.3 响应状态码处理
| 状态码 | 处理方式 |
|---|---|
| 200 | 返回 res.data |
| 401 | 弹框提示,点击后跳转登录页 |
| 500 | Toast 提示错误信息 |
| 其他 | Toast 提示错误信息 |
六、登录与登出流程
6.1 登录流程
6.2 登出流程
6.3 关键设计
| 设计点 | 实现方式 |
|---|---|
| 登录态验证 | getInfo() 静默验证,失败弹框不跳转 |
| 失败保活 | Token 失效时不清除,保留登录入口 |
| 登录页保护 | 已登录访问登录页 → reLaunch 首页 |
| 401 处理 | 弹框提示而非强制跳转,用户体验更平滑 |
七、UnloginNotify 组件
未登录提示弹框,由全局容器通过 useAppLayout().showUnloginNotify() 调用:
vue
<!-- components/AppLayout/UnloginNotify.vue -->
<template>
<uni-popup ref="popup" type="center" :mask-click="false">
<view class="unlogin-popup">
<text class="title">提示</text>
<text class="content">您还未登录,请先登录</text>
<view class="actions">
<button class="cancel" @click="onCancel">取消</button>
<button class="confirm" @click="onConfirm">去登录</button>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { ref } from 'vue'
const popup = ref(null)
function show() {
popup.value.open()
}
function hide() {
popup.value.close()
}
function onCancel() {
hide()
}
function onConfirm() {
hide()
uni.reLaunch({ url: '/pages/login/index' })
}
defineExpose({ show, hide })
</script>
<style scoped>
.unlogin-popup {
width: 560rpx;
padding: 48rpx;
background: #fff;
border-radius: 24rpx;
}
.title {
display: block;
font-size: 36rpx;
font-weight: 600;
text-align: center;
margin-bottom: 24rpx;
}
.content {
display: block;
font-size: 28rpx;
color: #666;
text-align: center;
margin-bottom: 48rpx;
}
.actions {
display: flex;
gap: 24rpx;
}
.cancel,
.confirm {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
}
.cancel {
background: #f5f5f5;
color: #666;
}
.confirm {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
</style>八、错误处理策略
| 场景 | 处理方式 |
|---|---|
| 登录失败 | Toast 提示错误信息 |
| Token 过期 (401) | 弹框提示,点击后跳转登录页 |
| 请求超时 | Toast "系统接口请求超时" |
| 网络错误 | Toast "后端接口连接异常" |
| getInfo 失败 | 保留 Token,弹框提示 |
| 登出失败 | 强制清除本地状态,确保一致 |
| 非微信浏览器调用微信登录 | 提示"请在微信浏览器中打开" |
| 微信授权失败/取消 | 留在登录页,不跳转 |
九、总结
本篇文章阐述了登录鉴权模块的完整设计:
| 核心要点 | 说明 |
|---|---|
| 三层拦截 | App 初始化 / 路由跳转 / 请求调用 |
| 统一状态管理 | Pinia store 集中管理 token 和 userInfo |
| 白名单机制 | 配置式声明,无需登录的页面统一管理 |
| 平滑降级 | 401 不强制跳转,通过弹框让用户选择 |
| 持久化存储 | Token 通过 Storage 持久化,切换页面不丢失 |
| 多端登录 | 密码登录(通用)/ 微信 H5 授权登录(仅 H5) |
跨平台说明
- 密码登录:全平台支持
- 微信授权登录:仅 H5 平台支持,需在微信内置浏览器中打开
- 其他平台(App、小程序)需使用各自平台的登录方案,如 App 端使用
uni.login({ provider: 'weixin' })
系列总览
| 章节 | 主题 | 核心问题 |
|---|---|---|
| 第一章 | 全局容器的实现 | 如何让每个页面自动包裹布局组件? |
| 第二章 | Vite 虚拟模块 | 如何在运行时读取 pages.json 配置? |
| 第三章 | 组件设计 | 如何设计可配置的 NavBar 和 TabBar? |
| 第四章 | 动态调用设计 | 页面如何与全局容器进行通信? |
| 第五章 | 登录鉴权模块 | 如何设计完整的登录鉴权体系? |
