找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3432

积分

0

好友

451

主题
发表于 前天 03:50 | 查看: 14| 回复: 0

在移动应用开发中,无论项目大小,一个健壮、清晰的权限控制系统往往是区分“玩具项目”与“商业级项目”的关键。今天,我们就来系统性地探讨如何在 UniApp 中,结合现代状态管理库 Pinia ,实现一套覆盖路由、按钮、区块等多个维度的“千人千面”用户权限控制方案,并提供完整的代码示例与避坑指南。

你是不是也遇到过这些问题?

  • 用户明明登录了,页面展示的还是游客内容。
  • 通过URL直链,无权限的页面也能被访问。
  • 退出登录后,权限数据仍有残留,导致“串号”。
  • 用户付费购买某个功能后,如何动态控制该功能的展示与使用?

如果你对上述任何一个问题感到头疼,那么本文将为你提供一套从设计思路到代码落地的完整解决方案。

一、权限系统设计:我们需要控制什么?

一个典型的业务系统,权限控制通常包含以下四个维度:

  1. 页面访问权限:哪些页面无需登录即可访问?哪些页面必须登录?哪些需要特定会员等级或角色才能进入?
  2. 按钮操作权限:例如,“删除订单”、“审核通过”等敏感操作按钮,只有具备特定权限的用户才能看到并点击。
  3. 区块展示权限:例如,普通用户看不到“专属客服”区块,VIP用户可以看到“会员专享”推荐区。
  4. 链接跳转权限:页面内的链接或导航,如果用户无访问权限,应在点击时被拦截。

为了简化模型,我们通常定义几个核心角色:

  • 游客 (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>

该组件接收 permissionrolelevel 三个主要条件,它们之间是 “与” (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,但 switchTabreLaunch 等跳转方式未被覆盖。
解决:确保在拦截器中为所有路由 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 页面只能使用 switchTabreLaunch 跳转。
解决:在登录成功后的跳转逻辑中,判断目标页面是否为 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 开发中遇到其他有趣或有挑战性的问题,欢迎来到 云栈社区 与更多开发者一起交流探讨。




上一篇:我在字节超前开发Vibe Coding产品:回顾AI编程的疯狂一年与人生思考
下一篇:时序策略解析:从机器学习基础到量化交易实战应用
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 09:02 , Processed in 0.791074 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表