在移动应用开发中,无论项目大小,一个健壮、清晰的权限控制系统往往是区分“玩具项目”与“商业级项目”的关键。今天,我们就来系统性地探讨如何在 UniApp 中,结合现代状态管理库 Pinia ,实现一套覆盖路由、按钮、区块等多个维度的“千人千面”用户权限控制方案,并提供完整的代码示例与避坑指南。
你是不是也遇到过这些问题?
- 用户明明登录了,页面展示的还是游客内容。
- 通过URL直链,无权限的页面也能被访问。
- 退出登录后,权限数据仍有残留,导致“串号”。
- 用户付费购买某个功能后,如何动态控制该功能的展示与使用?
如果你对上述任何一个问题感到头疼,那么本文将为你提供一套从设计思路到代码落地的完整解决方案。
一、权限系统设计:我们需要控制什么?
一个典型的业务系统,权限控制通常包含以下四个维度:
- 页面访问权限:哪些页面无需登录即可访问?哪些页面必须登录?哪些需要特定会员等级或角色才能进入?
- 按钮操作权限:例如,“删除订单”、“审核通过”等敏感操作按钮,只有具备特定权限的用户才能看到并点击。
- 区块展示权限:例如,普通用户看不到“专属客服”区块,VIP用户可以看到“会员专享”推荐区。
- 链接跳转权限:页面内的链接或导航,如果用户无访问权限,应在点击时被拦截。
为了简化模型,我们通常定义几个核心角色:
- 游客 (guest):未登录,仅能访问公开页面。
- 普通会员 (member):已登录,拥有基础功能权限。
- 黄金/钻石会员 (vip):在普通会员基础上,享有更多特权(可通过
level 字段区分,如 1, 2, 3)。
- 管理员 (admin):拥有系统管理权限。
实际业务可能更复杂,但这套基于角色、等级和细粒度权限点的模型足以应对绝大多数场景。
二、核心:权限数据的存储与管理
权限控制的核心在于 用户信息 和 权限规则。首先,我们需要明确数据的结构和存放位置。
1. 用户信息数据结构
通常,用户登录后,后端会返回类似如下的信息:
{
userId: 1001,
nickname: '张三',
role: 'member', // 角色:guest, member, admin
level: 2, // 等级:1普通会员,2黄金会员,3钻石会员
permissions: [ // 细粒度权限点数组
'order:view',
'order:create',
'vip:access', // 访问VIP专区
'button:delete' // 使用删除按钮
]
}
2. 数据存储策略
- Pinia Store:用于管理内存中的用户状态。它是响应式的,任何状态的改变都会自动驱动UI更新,这是实现动态权限控制的关键。
- 本地存储 (uni.setStorageSync):用于持久化用户登录态。应用冷启动时,可以直接从本地读取,无需用户重新登录。
重要提醒:用户退出登录时,必须同步清空 Pinia Store 和本地存储,否则会导致严重的权限残留问题。
三、实现路由跳转权限守卫
在 UniApp 中,所有页面跳转都可以通过 uni.addInterceptor 进行拦截。我们可以在跳转发生前,校验用户是否有权访问目标页面。
步骤1:定义页面权限映射表
创建一个配置文件,集中管理所有页面的访问规则。
// config/permission.js
// 页面权限配置
export const pagePermissions = {
// 格式:页面路径 => 权限要求
'/pages/index/index': { auth: false }, // 公开页面
'/pages/user/user': { auth: true }, // 必须登录
'/pages/vip/vip': {
auth: true,
level: 2, // 至少需要黄金会员 (level >= 2)
// 也可使用 permission: 'vip:access' 进行更细粒度的控制
},
'/pages/admin/admin': {
auth: true,
role: 'admin' // 必须为管理员角色
}
};
// 白名单(完全不进行权限校验)
export const whiteList = ['/pages/login/login', '/pages/register/register'];
步骤2:编写路由拦截器
实现核心的拦截逻辑,检查用户状态并匹配权限规则。
// utils/permission.js
import { useUserStore } from '@/stores/user'
import { pagePermissions, whiteList } from '@/config/permission'
export function setupPermissionGuard() {
const methods = ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab'] // 拦截所有路由API
methods.forEach(method => {
uni.addInterceptor(method, {
invoke(args) {
const userStore = useUserStore()
const url = args.url.split('?')[0] // 去掉查询参数,只保留路径
// 1. 白名单放行
if (whiteList.includes(url)) return true
const pageConfig = pagePermissions[url]
// 2. 未配置权限的页面,默认放行(可根据需求改为拦截)
if (!pageConfig) return true
// 3. 需要登录但未登录
if (pageConfig.auth && !userStore.isLogin) {
uni.showToast({ title: '请先登录', icon: 'none' })
// 跳转至登录页,并携带原目标地址以便登录后回跳
uni.navigateTo({
url: `/pages/login/login?redirect=${encodeURIComponent(args.url)}`
})
return false // 拦截原跳转
}
// 4. 角色校验
if (pageConfig.role && userStore.userInfo.role !== pageConfig.role) {
uni.showToast({ title: '权限不足', icon: 'none' })
return false
}
// 5. 等级校验(统一转为数字比较)
if (pageConfig.level !== undefined) {
const userLevel = Number(userStore.userInfo.level)
if (userLevel < pageConfig.level) {
uni.showToast({ title: '等级不足', icon: 'none' })
return false
}
}
// 6. 细粒度权限点校验
if (pageConfig.permission) {
if (!userStore.hasPermission(pageConfig.permission)) {
uni.showToast({ title: '无权限访问', icon: 'none' })
return false
}
}
// 所有检查通过,允许跳转
return true
},
fail(err) {
console.error('路由拦截器错误', err)
}
})
})
}
核心要点:
- 在
invoke 阶段进行拦截,返回 false 即可阻止跳转。
- 所有权限判断都基于 响应式的 Pinia Store,确保逻辑使用的总是最新用户数据。
- 未登录时跳转登录页,并传递
redirect 参数,优化用户体验。
四、组件级权限控制:封装 <Auth> 组件
对于页面内的按钮、区块、链接等元素的权限控制,推荐封装一个权限组件,它比自定义指令更灵活,且能完美利用 Vue 的响应式系统。
封装可复用的 Auth.vue 组件
<!-- components/Auth.vue -->
<template>
<view v-if="hasAccess">
<slot/>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
const props = defineProps({
// 权限点(字符串或数组)
permission: [String, Array],
// 角色
role: String,
// 最低等级
level: Number,
// 当permission为数组时的匹配模式:'some' (任一) 或 'every' (全部)
mode: {
type: String,
default: 'some'
}
})
const userStore = useUserStore()
const hasAccess = computed(() => {
// 1. 角色判断
if (props.role && userStore.userInfo.role !== props.role) return false
// 2. 等级判断
if (props.level !== undefined && Number(userStore.userInfo.level) < props.level) return false
// 3. 权限点判断
if (props.permission) {
return userStore.hasPermission(props.permission, props.mode)
}
// 4. 未设置任何条件,默认显示
return true
})
</script>
该组件接收 permission、role、level 三个主要条件,它们之间是 “与” (AND) 关系,必须全部满足才会显示内容。
在页面中使用
<template>
<view>
<!-- 仅对黄金会员(level>=2)显示 -->
<Auth :level="2">
<view class="vip-section">
<text>黄金会员专享优惠</text>
<button @click="goToVipDeal">立即领取</button>
</view>
</Auth>
<!-- 需要拥有 'order:delete' 权限才显示按钮 -->
<Auth permission="'order:delete'">
<button type="warn" @click="deleteOrder">删除订单</button>
</Auth>
<!-- 拥有'vip:access'或'admin:access'任意一个权限即显示链接 -->
<Auth :permission="['vip:access', 'admin:access']" mode="some">
<navigator url="/pages/vip/vip">VIP入口</navigator>
</Auth>
</view>
</template>
<script setup>
import Auth from '@/components/Auth.vue'
// ... 其他逻辑
</script>
五、用户状态管理中心:Pinia Store
这是整个权限系统的“大脑”,负责管理用户状态和提供权限判断方法。
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// 状态定义
const token = ref('')
const userInfo = ref({
userId: null,
nickname: '',
role: 'guest', // 默认角色为游客
level: 0,
permissions: []
})
// 计算属性:是否已登录
const isLogin = computed(() => !!token.value)
// 方法:判断是否拥有权限
function hasPermission(permission, mode = 'some') {
if (!permission) return true // 未设置条件,默认有权限
if (!userInfo.value.permissions || userInfo.value.permissions.length === 0) return false
const userPerms = userInfo.value.permissions
if (Array.isArray(permission)) {
// 数组权限:根据 mode 判断
return mode === 'some'
? permission.some(p => userPerms.includes(p))
: permission.every(p => userPerms.includes(p))
} else {
// 单个权限
return userPerms.includes(permission)
}
}
// 登录
function login(userData) {
token.value = userData.token
userInfo.value = userData.userInfo
// 持久化到本地
uni.setStorageSync('token', token.value)
uni.setStorageSync('userInfo', userInfo.value)
}
// 登出(关键!)
function logout() {
token.value = ''
userInfo.value = { role: 'guest', level: 0, permissions: [] }
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
}
// 初始化:从本地存储恢复登录态
function init() {
const savedToken = uni.getStorageSync('token')
const savedUserInfo = uni.getStorageSync('userInfo')
if (savedToken && savedUserInfo) {
token.value = savedToken
userInfo.value = {
permissions: [], // 确保字段存在
...savedUserInfo
}
}
}
return {
token,
userInfo,
isLogin,
hasPermission,
login,
logout,
init
}
})
六、应用启动与初始化
在 App.vue 中,我们需要在应用启动时初始化用户状态并安装路由守卫。
<!-- App.vue -->
<script setup>
import { onLaunch } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { setupPermissionGuard } from '@/utils/permission'
onLaunch(() => {
// 1. 初始化用户状态(从本地存储读取)
const userStore = useUserStore()
userStore.init()
// 2. 设置路由权限守卫
setupPermissionGuard()
})
</script>
七、登录页与重定向处理
登录页需要处理路由守卫传递过来的 redirect 参数,并在登录成功后跳转回原页面。
<!-- pages/login/login.vue -->
<template>
<view class="login-container">
<button @click="mockLogin">模拟登录</button>
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const redirect = ref('') // 存储回跳地址
onLoad((options) => {
if (options.redirect) {
redirect.value = decodeURIComponent(options.redirect)
}
})
const mockLogin = () => {
// 模拟登录成功
const userData = {
token: 'fake_token',
userInfo: {
userId: 1001,
nickname: '张三',
role: 'member',
level: 2, // 黄金会员
permissions: ['order:view', 'order:create', 'vip:access']
}
}
userStore.login(userData)
// 登录后跳转
if (redirect.value) {
// 使用 reLaunch 清空页面栈并跳转,可同时处理普通页面和 tabBar 页面
uni.reLaunch({ url: redirect.value })
} else {
uni.switchTab({ url: '/pages/index/index' }) // 默认跳首页
}
}
</script>
八、常见问题与解决方案(避坑指南)
问题1:登录后界面未刷新
现象:登录成功返回页面,v-if 判断的逻辑未重新执行,仍显示游客内容。
原因:权限判断逻辑写在了 created/mounted 等生命周期中,数据更新后未触发重新计算。
解决:始终使用计算属性 (computed) 或方法 (method) 来进行权限判断,确保依赖响应式数据。使用上文封装的 <Auth> 组件可自动解决此问题。
问题2:路由拦截器对某些跳转无效
现象:用户通过扫码或输入URL直达无权限页面,未被拦截。
原因:可能只拦截了 navigateTo,但 switchTab、reLaunch 等跳转方式未被覆盖。
解决:确保在拦截器中为所有路由 API (navigateTo, redirectTo, reLaunch, switchTab) 都添加拦截逻辑。
问题3:退出登录后权限残留
现象:用户退出后,页面仍显示会员专属内容。
原因:登出逻辑未清空 Pinia Store 中的用户状态。
解决:在 logout 方法中,务必重置 store 中的所有用户相关状态,并同步清理本地存储。
问题4:等级判断逻辑错误
现象:配置 level: 2,用户等级也是2却无法访问。
原因:数据类型不一致。后端返回的 level 可能是字符串 "2",直接与数字 2 比较 (userLevel < pageConfig.level) 可能产生非预期结果。
解决:在比较前,使用 Number() 将双方统一转换为数字类型。
问题5:登录后重定向到 TabBar 页面报错
现象:登录成功后,使用 uni.redirectTo({ url: redirect }) 跳转,若原页面是 TabBar 页面则会报错。
原因:TabBar 页面只能使用 switchTab 或 reLaunch 跳转。
解决:在登录成功后的跳转逻辑中,判断目标页面是否为 TabBar 页面,或直接使用 uni.reLaunch(会关闭所有页面并打开新页面)作为通用方案。
九、完整项目结构参考
your-uniapp-project/
├── pages/
│ ├── index/
│ │ └── index.vue
│ ├── user/
│ │ └── user.vue
│ ├── vip/
│ │ └── vip.vue
│ └── login/
│ └── login.vue
├── components/
│ └── Auth.vue // 权限组件
├── stores/
│ └── user.js // Pinia 用户Store
├── utils/
│ └── permission.js // 路由守卫
├── config/
│ └── permission.js // 权限配置表
├── App.vue
├── main.js
└── pages.json
别忘了在 main.js 中创建并安装 Pinia:
// main.js
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}
写在最后
权限控制是一个系统工程,远不止几个 v-if 那么简单。它要求开发者具备全局思维,充分考虑数据一致性(状态更新后所有依赖视图同步刷新)、入口全覆盖(页面跳转、Tab切换、URL直链)以及前后端职责划分(前端重体验,后端保安全)。
本文提供的基于 UniApp 与 Pinia 的解决方案,涵盖了从路由守卫到组件权限、从状态管理到持久化的完整链条,已在多个生产项目中得到验证。你可以直接参考其中的代码结构,并根据自己项目的业务需求,调整 config/permission.js 中的权限配置和 Store 中的数据结构。
请始终牢记一条原则:客户端权限控制的核心目标是优化用户体验与界面交互,而所有关键的安全校验必须在服务器端坚定不移地执行。
希望这套详细的实战方案能帮助你构建出更健壮、更专业的跨平台应用。如果你在 Vue 和 UniApp 开发中遇到其他有趣或有挑战性的问题,欢迎来到 云栈社区 与更多开发者一起交流探讨。