从0到1,带你搭建Vite+Vue3+Unocss+Pinia+Naive UI后台(四) - 完结篇

7,346 阅读11分钟

预览地址:vue-naive-admin

从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台 - 大脸怪的专栏 - 掘金 (juejin.cn)

  1. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(一) - 前置篇 - 掘金 (juejin.cn)
  2. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(二) - 配置篇(上) - 掘金 (juejin.cn)
  3. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(二) - 配置篇(中) - 掘金 (juejin.cn)
  4. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(二) - 配置篇(下) - 掘金 (juejin.cn)
  5. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(三) - UI组件篇 - 掘金 (juejin.cn)
  6. 从0到1,带你搭建Vite+Vue3+Pinia+Naive UI后台(四) - 完结篇 - 掘金 (juejin.cn)

前言

说好的路由篇,怎么变成完结篇了?

image.png

是这样的,本来计划这篇只写路由的,后来构思的时候发现路由篇的前置和关联知识实在太多了,我觉得权限这一块和路由一起讲比较好,但是讲到权限,那登录肯定也少不了吧,涉及到登录那axios封装自然也少不了喽,封装好了axios那么mock也要集成进来,索性就把剩下部分全讲了吧,就当是把之前拖更的补上吧。最后总结出这一篇具体讲以下几块内容

  1. 常用工具类封装
  2. axios封装
  3. mock集成
  4. 路由集成
  5. Pinia集成
  6. 登录页
  7. 路由守卫(权限等)

警告!!以下内容全程干货,代码过多,为防出错,文末会提供本篇内容的源码仓库以共查缺补漏~

image.png

常用工具类封装

一、在src目录下新建utils/is.js

utils/is.js

const toString = Object.prototype.toString

export function is(val, type) {
  return toString.call(val) === `[object ${type}]`
}

export function isDef(val) {
  return typeof val !== 'undefined'
}

export function isUndef(val) {
  return typeof val === 'undefined'
}

export function isNull(val) {
  return val === null
}

export function isWhitespace(val) {
  return val === ''
}

export function isObject(val) {
  return !isNull(val) && is(val, 'Object')
}

export function isArray(val) {
  return val && Array.isArray(val)
}

export function isString(val) {
  return is(val, 'String')
}

export function isNumber(val) {
  return is(val, 'Number')
}

export function isBoolean(val) {
  return is(val, 'Boolean')
}

export function isDate(val) {
  return is(val, 'Date')
}

export function isRegExp(val) {
  return is(val, 'RegExp')
}

export function isFunction(val) {
  return typeof val === 'function'
}

export function isPromise(val) {
  return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
}

export function isElement(val) {
  return isObject(val) && !!val.tagName
}

export function isWindow(val) {
  return typeof window !== 'undefined' && isDef(window) && is(val, 'Window')
}

export function isNullOrUndef(val) {
  return isNull(val) || isUndef(val)
}

export function isNullOrWhitespace(val) {
  return isNullOrUndef(val) || isWhitespace(val)
}

export function isEmpty(val) {
  if (isArray(val) || isString(val)) {
    return val.length === 0
  }

  if (val instanceof Map || val instanceof Set) {
    return val.size === 0
  }

  if (isObject(val)) {
    return Object.keys(val).length === 0
  }

  return false
}

/**
 * * 类似mysql的IFNULL函数
 * * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
 * @param {Number|Boolean|String} val
 * @param {Number|Boolean|String} def
 * @returns
 */
export function ifNull(val, def = '') {
  return isNullOrWhitespace(val) ? def : val
}

export function isUrl(path) {
  const reg =
    /(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/
  return reg.test(path)
}

/**
 * @param {string} path
 * @returns {Boolean}
 */
export function isExternal(path) {
  return /^(https?:|mailto:|tel:)/.test(path)
}

export const isServer = typeof window === 'undefined'

export const isClient = !isServer

二、封装 localStoragesessionStorage(支持命名空间和过期时间)

src下新建 utils/cache/index.jsutils/cache/storage.js

utils/cache/storage.js

import { isNullOrUndef } from '@/utils/is'

class Storage {
  constructor(option) {
    this.storage = option.storage
    this.prefixKey = option.prefixKey
  }

  getKey(key) {
    return `${this.prefixKey}${key}`.toUpperCase()
  }

  set(key, value, expire) {
    const stringData = JSON.stringify({
      value,
      time: Date.now(),
      expire: !isNullOrUndef(expire) ? new Date().getTime() + expire * 1000 : null,
    })
    this.storage.setItem(this.getKey(key), stringData)
  }

  get(key) {
    const { value } = this.getItem(key, {})
    return value
  }

  getItem(key, def = null) {
    const val = this.storage.getItem(this.getKey(key))
    if (!val) return def
    try {
      const data = JSON.parse(val)
      const { value, time, expire } = data
      if (isNullOrUndef(expire) || expire > new Date().getTime()) {
        return { value, time }
      }
      this.remove(key)
      return def
    } catch (error) {
      this.remove(key)
      return def
    }
  }

  remove(key) {
    this.storage.removeItem(this.getKey(key))
  }

  clear() {
    this.storage.clear()
  }
}

export function createStorage({ prefixKey = '', storage = sessionStorage }) {
  return new Storage({ prefixKey, storage })
}

utils/cache/index.js

import { createStorage } from './storage'

const prefixKey = 'Vue_Naive_Admin_'

export const createLocalStorage = function (option = {}) {
  return createStorage({
    prefixKey: option.prefixKey || '',
    storage: localStorage,
  })
}

export const createSessionStorage = function (option = {}) {
  return createStorage({
    prefixKey: option.prefixKey || '',
    storage: sessionStorage,
  })
}

export const lStorage = createLocalStorage({ prefixKey })

export const sStorage = createSessionStorage({ prefixKey })

三、src下新建 utils/token.js

utils/token.js

import { lStorage } from './cache'

const TOKEN_CODE = 'access_token'
const DURATION = 6 * 60 * 60

export function getToken() {
  return lStorage.get(TOKEN_CODE)
}

export function setToken(token) {
  lStorage.set(TOKEN_CODE, token, DURATION)
}

export function removeToken() {
  lStorage.remove(TOKEN_CODE)
}

增加文件结构如下图

image.png

axios封装

以下封装可以实现多axios实例,可完美应对多个baseUrl的业务场景

一、安装axios

pnpm i axios -S

二、修改 .env.development 文件增加环境变量

...

# base api
VITE_APP_BASE_API = '/api'

# test base api
VITE_APP_BASE_API_TEST = '/api-test'

三、src下新建 utils/http/index.jsutils/http/interceptors.js

utils/http/index.js

import axios from 'axios'
import { resResolve, resReject, reqResolve, reqReject } from './interceptors'

export function createAxios(options = {}) {
  const defaultOptions = {
    baseURL: import.meta.env.VITE_APP_BASE_API,
    timeout: 12000,
  }
  const service = axios.create({
    ...defaultOptions,
    ...options,
  })
  service.interceptors.request.use(reqResolve, reqReject)
  service.interceptors.response.use(resResolve, resReject)
  return service
}

export const defAxios = createAxios()

export const testAxios = createAxios({
  baseURL: import.meta.env.VITE_APP_BASE_API_TEST,
})

utils/http/interceptors.js

import { isNullOrUndef } from '@/utils/is'

export function reqResolve(config) {
  // 防止缓存,给get请求加上时间戳
  if (config.method === 'get') {
    config.params = { ...config.params, t: new Date().getTime() }
  }

  return config
}

export function reqReject(error) {
  return Promise.reject(error)
}

export function resResolve(response) {
  return response?.data
}

export function resReject(error) {
  let { code, message } = error.response?.data || {}
  if (isNullOrUndef(code)) {
    // 未知错误
    code = -1
    message = '接口异常!'
  } else {
    /**
     * TODO 此处可以根据后端返回的错误码自定义框架层面的错误处理
     */
    switch (code) {
      case 401:
        message = message || '登录已过期'
        break
      case 403:
        message = message || '没有权限'
        break
      case 404:
        message = message || '资源或接口不存在'
        break
      default:
        message = message || '未知异常'
        break
    }
  }
  console.error(`【${code}${error}`)
  return Promise.resolve({ code, message, error })
}

四、在src目录下新建api接口相关文件,文件结构如下

image.png

src/api/user/index.js

import { defAxios as request } from '@/utils/http'

export function getUsers(data = {}) {
  return request({
    url: '/users',
    method: 'get',
    data,
  })
}

export function getUser(id) {
  if (id) {
    return request({
      url: `/user/${id}`,
      method: 'get',
    })
  }
  return request({
    url: '/user',
    method: 'get',
  })
}

export function saveUser(data = {}, id) {
  if (id) {
    return request({
      url: '/user',
      method: 'put',
      data,
    })
  }

  return request({
    url: `/user/${id}`,
    method: 'put',
    data,
  })
}

src/api/auth/index.js

import { defAxios as request } from '@/utils/http'

export const login = (data) => {
  return request({
    url: '/auth/login',
    method: 'post',
    data,
  })
}

export const refreshToken = () => {
  return request({
    url: '/auth/refreshToken',
    method: 'post',
  })
}

mock集成

一、 安装 vite-plugin-mockmockjs

pnpm i vite-plugin-mock mockjs -D

二、在根路径新建以下文件结构

image.png

mock/index.js

import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'

const modules = import.meta.globEager('./modules/*.js')
const mockModules = []
Object.keys(modules).forEach((key) => {
  mockModules.push(...modules[key].default)
})

export function setupProdMockServer() {
  createProdMockServer(mockModules)
}

mock/utils.js

export function resolveToken(authorization) {
  /**
   * * jwt token
   * * Bearer + token
   * ! 认证方案: Bearer
   */
  const reqTokenSplit = authorization.split(' ')
  if (reqTokenSplit.length === 2) {
    return reqTokenSplit[1]
  }
  return ''
}

mock/modules/auth.js

import { resolveToken } from '../utils'

const token = {
  admin: 'admin',
  editor: 'editor',
}

export default [
  {
    url: '/api/auth/login',
    method: 'post',
    response: ({ body }) => {
      if (['admin', 'editor'].includes(body?.name)) {
        return {
          code: 0,
          data: {
            token: token[body.name],
          },
        }
      } else {
        return {
          code: -1,
          message: '没有此用户',
        }
      }
    },
  },
  {
    url: '/api/auth/refreshToken',
    method: 'post',
    response: ({ headers }) => {
      return {
        code: 0,
        data: {
          token: resolveToken(headers?.authorization),
        },
      }
    },
  },
]

mock/modules/user.js

import { resolveToken } from '../utils'

const users = {
  admin: {
    id: 1,
    name: 'admin',
    avatar: 'https://assets.qszone.com/images/avatar.jpg',
    email: 'Ronnie@123.com',
    role: ['admin'],
  },
  editor: {
    id: 2,
    name: 'editor',
    avatar: 'https://assets.qszone.com/images/avatar.jpg',
    email: 'Ronnie@123.com',
    role: ['editor'],
  },
  guest: {
    id: 3,
    name: 'guest',
    avatar: 'https://assets.qszone.com/images/avatar.jpg',
    role: [],
  },
}
export default [
  {
    url: '/api/user',
    method: 'get',
    response: ({ headers }) => {
      const token = resolveToken(headers?.authorization)
      return {
        code: 0,
        data: {
          ...(users[token] || users.guest),
        },
      }
    },
  },
]

三、集成mock插件

  1. 修改 .env.development 文件增加环境变量
...

# 是否启用MOCK
VITE_APP_USE_MOCK = true
  1. 在根路径下新建文件 build/plugin/mock.js

build/plugin/mock.js

import { viteMockServe } from 'vite-plugin-mock'

export function configMockPlugin(isBuild) {
  return viteMockServe({
    mockPath: 'mock/modules',
    localEnabled: !isBuild,
    prodEnabled: isBuild,
    injectCode: `
      import { setupProdMockServer } from '../mock';
      setupProdMockServer();
    `,
  })
}
  1. 修改文件 build/plugin/index.js (为了不误导,贴出整个文件内容,具体修改处看下图)

image.png

build/plugin/index.js

import vue from '@vitejs/plugin-vue'

/**
 * * 扩展setup插件,支持在script标签中使用name属性
 * usage: <script setup name="MyComp"></script>
 */
import VueSetupExtend from 'vite-plugin-vue-setup-extend'

// rollup打包分析插件
import visualizer from 'rollup-plugin-visualizer'

import { configHtmlPlugin } from './html'
import { unocss } from './unocss'

/**
 * * 组件库按需引入插件
 * usage: 直接使用组件,无需在任何地方导入组件
 */
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'

import { configMockPlugin } from './mock'

export function createVitePlugins(viteEnv, isBuild) {
  const plugins = [
    vue(),
    VueSetupExtend(),
    configHtmlPlugin(viteEnv, isBuild),
    unocss(),
    Components({
      resolvers: [NaiveUiResolver()],
    }),
  ]

  if (isBuild) {
    plugins.push(
      visualizer({
        open: true,
        gzipSize: true,
        brotliSize: true,
      })
    )
  }

  if (viteEnv?.VITE_APP_USE_MOCK) {
    plugins.push(configMockPlugin(isBuild))
  }

  return plugins
}

路由集成

一、安装 vue-router

pnpm i vue-router -S

二、新建文件 src/views/dashboard/index.vuesrc/views/login/index.vue

src/views/dashboard/index.vue

<template>
  <h1>首页</h1>
</template>

src/views/login/index.vue

<template>
  <h1>登录页</h1>
</template>

三、新建路由相关文件,结构如下图

image.png

router/index.js

import { createRouter, createWebHashHistory } from 'vue-router'
import { basicRoutes as routes } from './routes'

export const router = createRouter({
  history: createWebHashHistory('/'),
  routes,
  scrollBehavior: () => ({ left: 0, top: 0 }),
})

export function setupRouter(app) {
  app.use(router)
}

router/routes/index.js

export const basicRoutes = [
  {
    name: 'LOGIN',
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    isHidden: true,
    meta: {
      title: '登录页',
    },
  },

  {
    name: 'Dashboard',
    path: '/',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: 'Dashboard',
    },
  },

  {
    name: 'TestUnocss',
    path: '/test/unocss',
    component: () => import('@/views/test-page/unocss/index.vue'),
    meta: {
      title: '测试unocss',
    },
  },
]

四、修改 main.jsApp.vue

main.js

import '@/styles/index.scss'
import 'uno.css'

import { createApp } from 'vue'
import { setupRouter } from '@/router'
import App from './App.vue'

const app = createApp(App)

setupRouter(app)

app.mount('#app')

App.vue

<template>
  <AppProvider>
    <router-view v-slot="{ Component }">
      <component :is="Component" />
    </router-view>
  </AppProvider>
</template>

<script setup>
import AppProvider from '@/components/AppProvider/index.vue'
</script>

<style lang="scss">
#app {
  height: 100%;
  .n-config-provider {
    height: inherit;
  }
}
</style>

五、重启并验证

首页 image.png

登录页

image.png

unocss页

image.png

Pinia集成

Pinia集成是登录页和权限管理的前置内容,如果不明所以可以直接抄,不必太计较,后面用到了自然就理解了

一、安装依赖 pinia

pnpm i pinia -S

二、新建工具类文件 src/utils/auth.js

src/utils/auth.js

import { router } from '@/router'

export function toLogin() {
  router.replace({ path: '/login' })
}

二、新建pinia相关文件,结构如下

image.png

src/store/index.js

import { createPinia } from 'pinia'

export function setupStore(app) {
  app.use(createPinia())
}

src/store/modules/user.js

import { defineStore } from 'pinia'
import { getUser } from '@/api/user'
import { removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'

export const useUserStore = defineStore('user', {
  state() {
    return {
      userInfo: {},
    }
  },
  getters: {
    userId() {
      return this.userInfo?.id
    },
    name() {
      return this.userInfo?.name
    },
    avatar() {
      return this.userInfo?.avatar
    },
    role() {
      return this.userInfo?.role || []
    },
  },
  actions: {
    async getUserInfo() {
      try {
        const res = await getUser()
        if (res.code === 0) {
          const { id, name, avatar, role } = res.data
          this.userInfo = { id, name, avatar, role }
          return Promise.resolve(res.data)
        } else {
          return Promise.reject(res)
        }
      } catch (error) {
        return Promise.reject(error)
      }
    },
    async logout() {
      removeToken()
      this.userInfo = {}
      toLogin()
    },
    setUserInfo(userInfo = {}) {
      this.userInfo = { ...this.userInfo, ...userInfo }
    },
  },
})

src/modules/permission.js

import { defineStore } from 'pinia'
import { asyncRoutes, basicRoutes } from '@/router/routes'

function hasPermission(route, role) {
  const routeRole = route.meta?.role ? route.meta.role : []
  if (!role.length || !routeRole.length) {
    return false
  }
  return role.some((item) => routeRole.includes(item))
}

function filterAsyncRoutes(routes = [], role) {
  const ret = []
  routes.forEach((route) => {
    if (hasPermission(route, role)) {
      const curRoute = {
        ...route,
        children: [],
      }
      if (route.children && route.children.length) {
        curRoute.children = filterAsyncRoutes(route.children, role)
      } else {
        Reflect.deleteProperty(curRoute, 'children')
      }
      ret.push(curRoute)
    }
  })
  return ret
}

export const usePermissionStore = defineStore('permission', {
  state() {
    return {
      accessRoutes: [],
    }
  },
  getters: {
    routes() {
      return basicRoutes.concat(this.accessRoutes)
    },
    menus() {
      return this.routes.filter((route) => route.name && !route.isHidden)
    },
  },
  actions: {
    generateRoutes(role = []) {
      const accessRoutes = filterAsyncRoutes(asyncRoutes, role)
      this.accessRoutes = accessRoutes
      return accessRoutes
    },
  },
})

三、修改 main.js

main.js

import '@/styles/index.scss'
import 'uno.css'

import { createApp } from 'vue'
import { setupRouter } from '@/router'
import { setupStore } from '@/store'
import App from './App.vue'

const app = createApp(App)
setupStore(app)
setupRouter(app)

app.mount('#app')

登录页

样式全部使用unocss编写,最新unocss的集成配置请参考 vue-naive-admin/unocss.config.js at main · zclzone/vue-naive-admin (github.com),配置完成后有代码提示和悬浮显示css样式代码等

一、修改 src/views/login/index.vue

<template>
  <div flex h-full>
    <div m-auto bg-gray-100 w-350 flex flex-col items-center border border-gray-300 p-30 rounded-10>
      <h5 text-24 font-normal color="#6a6a6a">
        {{ title }}
      </h5>
      <div mt-30 w-full>
        <n-input
          v-model:value="loginInfo.name"
          autofocus
          class="text-16 items-center h-50 pl-10"
          placeholder="admin"
          :maxlength="20"
        >
        </n-input>
      </div>
      <div mt-30 w-full>
        <n-input
          v-model:value="loginInfo.password"
          class="text-16 items-center h-50 pl-10"
          type="password"
          show-password-on="mousedown"
          placeholder="123456"
          :maxlength="20"
          @keydown.enter="handleLogin"
        />
      </div>

      <div mt-20 w-full>
        <n-checkbox :checked="isRemember" label="记住我" :on-update:checked="(val) => (isRemember = val)" />
      </div>

      <div mt-20 w-full>
        <n-button w-full h-50 rounded-5 text-16 type="primary" @click="handleLogin">登录</n-button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { login } from '@/api/auth'
import { lStorage } from '@/utils/cache'
import { setToken } from '@/utils/token'
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const title = import.meta.env.VITE_APP_TITLE

const router = useRouter()

const loginInfo = ref({
  name: '',
  password: '',
})

initLoginInfo()

function initLoginInfo() {
  const localLoginInfo = lStorage.get('loginInfo')
  if (localLoginInfo) {
    loginInfo.value.name = localLoginInfo.name || ''
    loginInfo.value.password = localLoginInfo.password || ''
  }
}

const isRemember = ref(false)
async function handleLogin() {
  const { name, password } = loginInfo.value
  if (!name || !password) {
    $message.warning('请输入用户名和密码')
    return
  }
  try {
    const res = await login({ name, password: password.toString() })
    if (res.code === 0) {
      $message.success('登录成功')
      setToken(res.data.token)
      if (isRemember.value) {
        lStorage.set('loginInfo', { name, password })
      } else {
        lStorage.remove('loginInfo')
      }
      router.push('/')
    } else {
      $message.warning(res.message)
    }
  } catch (error) {
    $message.error(error.message)
  }
}
</script>

二、验证登录

现在登录页是下图这样,登录成功可跳转到首页

image.png

路由守卫(权限)

到关键地方了,再坚持坚持,快到底了

image.png

一、添加404页面及相应的路由

新增 src/views/error-page/404.vue

<template>
  <div flex h-full>
    <n-result m-auto status="404" title="404 资源不存在" description="生活总归带点荒谬">
      <template #footer>
        <n-button>找点乐子吧</n-button>
      </template>
    </n-result>
  </div>
</template>

修改 src/router/routes/index.js

export const basicRoutes = [
  {
    name: '404',
    path: '/404',
    component: () => import('@/views/error-page/404.vue'),
    isHidden: true,
  },

  {
    name: 'LOGIN',
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    isHidden: true,
    meta: {
      title: '登录页',
    },
  },

  {
    name: 'Dashboard',
    path: '/',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: 'Dashboard',
    },
  },

  {
    name: 'TestUnocss',
    path: '/test/unocss',
    component: () => import('@/views/test-page/unocss/index.vue'),
    meta: {
      title: '测试unocss',
    },
  },
]

image.png

二、修改http模块以支持token验证

添加文件src/utils/http/helpers.js

import { useUserStore } from '@/store/modules/user'

const WITHOUT_TOKEN_API = [{ url: '/auth/login', method: 'POST' }]

export function isWithoutToken({ url, method = '' }) {
  return WITHOUT_TOKEN_API.some((item) => item.url === url && item.method === method.toUpperCase())
}

export function addBaseParams(params) {
  if (!params.userId) {
    params.userId = useUserStore().userId
  }
}

修改文件src/utils/http/interceptors.js

...

export function reqResolve(config) {
  // 防止缓存,给get请求加上时间戳
  if (config.method === 'get') {
    config.params = { ...config.params, t: new Date().getTime() }
  }

  // 处理不需要token的请求
  if (isWithoutToken(config)) {
    return config
  }

  const token = getToken()
  if (!token) {
    /**
     * * 未登录或者token过期的情况下
     * * 跳转登录页重新登录,携带当前路由及参数,登录成功会回到原来的页面
     */
    toLogin()
    return Promise.reject({ code: '-1', message: '未登录' })
  }

  /**
   * * jwt token
   * ! 认证方案: Bearer
   */
  config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token

  return config
}

...

三、添加一些路由作为动态路由,并添加对应的页面文件

新建 page1、page2、page3 页面文件, 内容不重要

image.png

新建文件 src/router/routes/modules/test.js

export default [
  {
    name: 'Page1',
    path: '/page1',
    component: () => import('@/views/test-page/page1/index.vue'),
    meta: {
      title: '动态路由1',
      role: ['admin'],
    },
  },
  {
    name: 'Page2',
    path: '/page2',
    component: () => import('@/views/test-page/page2/index.vue'),
    meta: {
      title: '动态路由2',
      role: ['editor'],
    },
  },
  {
    name: 'Page3',
    path: '/page3',
    component: () => import('@/views/test-page/page3/index.vue'),
    meta: {
      title: '动态路由3',
      role: ['admin'],
    },
  },
]

修改文件 src/router/routes/index.js

...

export const NOT_FOUND_ROUTE = {
  name: 'NotFound',
  path: '/:pathMatch(.*)*',
  redirect: '/404',
  isHidden: true,
}

// modules文件夹下的路由都会作为动态路由
const modules = import.meta.globEager('./modules/*.js')
const asyncRoutes = []
Object.keys(modules).forEach((key) => {
  asyncRoutes.push(...modules[key].default)
})

export { asyncRoutes }

四、添加路由守卫相关文件,结构如下图,分别处理全局loading-bar、动态修改页面title、动态路由

image.png

page-loading-guard.js

export function createPageLoadingGuard(router) {
  router.beforeEach(() => {
    window.$loadingBar?.start()
  })

  router.afterEach(() => {
    setTimeout(() => {
      window.$loadingBar?.finish()
    }, 200)
  })

  router.onError(() => {
    window.$loadingBar?.error()
  })
}

page-title-guard.js

const baseTitle = import.meta.env.VITE_APP_TITLE

export function createPageTitleGuard(router) {
  router.afterEach((to) => {
    const pageTitle = to.meta?.title
    if (pageTitle) {
      document.title = `${pageTitle} | ${baseTitle}`
    } else {
      document.title = baseTitle
    }
  })
}

permission-guard.js

import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
import { NOT_FOUND_ROUTE } from '@/router/routes'
import { getToken, removeToken } from '@/utils/token'
import { toLogin } from '@/utils/auth'

const WHITE_LIST = ['/login']
export function createPermissionGuard(router) {
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()
  router.beforeEach(async (to, from, next) => {
    const token = getToken()
    if (token) {
      if (to.path === '/login') {
        next({ path: '/' })
      } else {
        if (userStore.userId) {
          // 已经拿到用户信息
          next()
        } else {
          await userStore.getUserInfo().catch((error) => {
            removeToken()
            toLogin()
            $message.error(error.message || '获取用户信息失败!')
            return
          })
          const accessRoutes = permissionStore.generateRoutes(userStore.role)
          accessRoutes.forEach((route) => {
            !router.hasRoute(route.name) && router.addRoute(route)
          })
          router.addRoute(NOT_FOUND_ROUTE)
          next({ ...to, replace: true })
        }
      }
    } else {
      if (WHITE_LIST.includes(to.path)) {
        next()
      } else {
        next({ path: '/login' })
      }
    }
  })
}

guard/index.js

import { createPageLoadingGuard } from './page-loading-guard'
import { createPageTitleGuard } from './page-title-guard'
import { createPermissionGuard } from './permission-guard'

export function setupRouterGuard(router) {
  createPageLoadingGuard(router)
  createPermissionGuard(router)
  createPageTitleGuard(router)
}

五、修改 src/router/index.js

src/router/index.js

import { createRouter, createWebHashHistory } from 'vue-router'
import { basicRoutes as routes } from './routes'
import { setupRouterGuard } from './guard'

export const router = createRouter({
  history: createWebHashHistory('/'),
  routes,
  scrollBehavior: () => ({ left: 0, top: 0 }),
})

export function setupRouter(app) {
  app.use(router)
  setupRouterGuard(router)
}

六、验证

修改 src/views/dashboard/index.vue

<template>
  <div p-35>
    <n-gradient-text flex items-center text-26 type="info">
      我的角色是:<n-gradient-text type="error">{{ userStore.name }}</n-gradient-text>
    </n-gradient-text>

    <n-gradient-text text-16 mt-10 type="info">我有这些页面的权限:</n-gradient-text>

    <ul mt-10>
      <li
        v-for="item in permissionStore.menus"
        :key="item.name"
        cursor-pointer
        hover-color-red
        @click="$router.push(item.path)"
      >
        {{ item.meta?.title }}
      </li>
    </ul>

    <n-button type="info" mt-20 size="small" @click="logout">换个角色看看</n-button>
  </div>
</template>

<script setup>
import { usePermissionStore } from '../../store/modules/permission'
import { useUserStore } from '../../store/modules/user'

const permissionStore = usePermissionStore()
const userStore = useUserStore()

function logout() {
  userStore.logout()
  $message.success('已退出登录')
}
</script>

image.png

image.png

可以看到不同的角色对应的权限是不一样的,并且手动切换到没有权限的页面时会重定向到404页面

完结,撒花~

image.png

总结

至此,从0到1系列专栏就结束了,当然,也不确定哪天心血来潮会补充几篇,不过那也是单独拎某些模块来讲,而不会像前面几篇一样有这么强的连贯性。

其实写这个专栏的时候我就在想写这个专栏的意义到底在哪呢?它一点也不像是文档,因为我基本没有在文中去解释一些概念性的东西,甚至没有解释某些代码为什么要这么写;它也不像是教程,全篇下来代码占了大多数内容。这就导致看我这个专栏的同学如果不跟着一起做的话基本学不到任何东西,我觉得这更像是一份实战笔记,一步一步帮你理清脉络,一步一步教你避过所有的坑。

整个专栏的代码我也是自己建了一个全新的项目一点一点完成的,我本地的代码跟文中的代码也是100%一样,并且坚持完结一篇做一次 git 提交,整个脉络和思路非常清晰,跟着做的同学可以根据Ronnie Zhang/奇思Admin (gitee.com)这个仓库的代码来查缺补漏。

最后,这个项目其实并没有真正完成,但是骨架和精华都已经完成了,剩下的就是一些页面和样式了,完全可以参考vue-naive-admin (github.com)这个完整版的开源项目,相信我,消化这个专栏的内容后再看这个项目将没有任何难度。

如有任何疑问可在评论区留言,或者扫描下方二维码入群跟群里的大佬一起交流

入群.png

开源不易,认真写专栏更不容易,请各位赏个赞和star再走吧,谢谢xdm。

image.png


源码-github:vue-naive-admin (github.com)

源码-gitee:vue-naive-admin (gitee.com)