技术栈:vue3+TypeScript+Vite2+Pinia+Element Plus+VueRouter

初始化项目

安装vue-ts模板,和vite,并设置项目名称GBT(General Background Template)。

// npm
npm init vite@latest GBT --template vue-ts

// yarn 
yarn create vite GBT --template vue-ts

启动项目:

初始化项目后安装项目依赖,之后执行npm run dev命令启动项目,浏览器打开 http://127.0.0.1:5173/ 就可以看到启动后的项目

npm install --registry=https://registry.npm.taobao.org
//yarn install

npm run dev

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yBekxOiN-1670077065046)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/74adf791eda64198a00c5a8bc769629e~tplv-k3u1fbpfcp-watermark.image?)]

安装Element-Plus

Element-Plus官方文档有两种引入方式,这里使用全局引入。

// 图标组件需要单独安装
npm install element-plus @element-plus/icons-vue sass --registry=https://registry.npm.taobao.org

1.全局注册组件

在main.ts中引入ElementPlus和样式文件,并通过use方法安装ElementPlus插件。

// main.ts
import ElementPlus from 'element-plus'
import 'element-plus/theme-chalk/index.css'

createApp(App)
    .use(ElementPlus)
    .mount('#app')

2.全局组件类型声明&路径别名配置

在tsconfig.json文件中对ts进行配置,配置正确会有类型提示,这也是使用ts的好处。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": ["element-plus/global"],
    "baseUrl": "./", // 解析非相对模块的基础地址,默认是当前目录
    "paths": {"@/*": ["src/*"]},  // 路径映射,相对于baseUrl
    "allowSyntheticDefaultImports": true // 允许默认导入
  }
}

配置环境变量

  • 开发环境配置:.env.development

    # 变量必须以 VITE_ 为前缀才能暴露给外部读取
    VITE_APP_TITLE = 'oursBlog'
    VITE_APP_PORT = 3080
    VITE_APP_BASE_API = '/dev-api'
    VITE_APP_BASE_API_MOCK = 'https://mock.mengxuegu.com/mock/636eff7ef22edd4bbbcd9919/mmServer'
    
  • 生产环境配置:.env.production

    VITE_APP_TITLE = 'oursBlog'
    VITE_APP_PORT = 3080
    VITE_APP_BASE_API = '/prod-api'
    
  • 测试环境配置:.env.testing

    VITE_APP_TITLE = 'oursBlog'
    VITE_APP_PORT = 3080
    VITE_APP_BASE_API = '/testing-api'
    

Vite配置

官方文档:Home | Vite中文网 (vitejs.cn)

先安装ts的类型描述文件,再在vite.config.ts文件中配置代理服务可以解决跨域问题。

在tsconfig.node.json文件中配置基础路径和路径映射。

// 安装TypeScript类型描述文件
npm install @types/node -S --registry=https://registry.npm.taobao.org

// vite.config.ts
import { UserConfig, ConfigEnv, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default ({ command, mode }: ConfigEnv): UserConfig => {
  const env = loadEnv(mode, process.cwd())

  return {
    plugins: [ vue(),],
    server: {
      host: '0.0.0.0',
      port: Number(env.VITE_APP_PORT),
      open: true,
      proxy: {
        [env.VITE_APP_BASE_API]: {
          target: 'https://mock.mengxuegu.com/mock/636eff7ef22edd4bbbcd9919/mmServer',
          changeOrigin: true,
          rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
        }
      }
    },
    resolve: {
      alias: {
        '@': path.resolve('./src')
      }
    }
  }
}



// tsconfig.node.json
{
  "compilerOptions": {
    "composite": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true,
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { "@/*": ["src/*"] } //路径映射,相对于baseUrl
  },
  "include": ["vite.config.ts"]
}

自动导入插件

由于vue3的api是要先引入再使用的,这样每次都要引入就会很麻烦,于是相应的插件应运而生,一个是引入api的一个是引入组件的。

npm install unplugin-auto-import unplugin-vue-components -D --registry=https://registry.npm.taobao.org
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

plugins: [
  vue(),
  AutoImport({
    resolvers: [ElementPlusResolver()],
    include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
    imports: [
      // 插件预设支持导入的api
      'vue'
    ],
    dts: './auto-imports.d.ts'
  }),
  Components({
    resolvers: [ElementPlusResolver()]
  })
],

tsconfig.json文件中添加"./auto-imports.d.ts",否则识别不到会不生效。

// tsconfig.json

"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","./auto-imports.d.ts"],

启动项目测试一下,能否正常运行。

// App.vue

<template>
  <div>omg 成功了</div>
</template>

<script setup lang="ts"></script>

<style lang="scss"></style>


Pinia状态管理

新一代状态管理工具,好用!

1.pinia安装

npm install pinia --registry=https://registry.npm.taobao.org

2.pinia注册

// src/main.ts
import { createPinia } from "pinia"

createApp(App)
    .use(ElementPlus)
    .use(createPinia())
    .mount('#app')

3.pinia模块封装

// store/modules/user.ts

import { defineStore } from 'pinia'
import { UserState } from '../storeTypes'

const useUserStore = defineStore({
  id: 'user',
  state: (): UserState => ({
    token: '',
    roles: [],
    perms: []
  }),
  actions: {}
})

export default useUserStore

// src\store\storeTypes.ts

export interface UserState {
  token: string
  nickname?: string
  avatar?: string
  roles: string[]
  perms: string[]
}

// store/index.ts
import useUserStore from './modules/user';

const useStore = () => ({
  user: useUserStore(),
});

export default useStore;

Axios封装

1.安装axios

npm install axios --registry=https://registry.npm.taobao.org

2.浏览器缓存封装

// utils/storage.ts

// window.localStorage 浏览器永久缓存
export const localStorage = {
  // 设置永久缓存
  set(key: string, val: any) {
    window.localStorage.setItem(key, JSON.stringify(val));
  },
  // 获取永久缓存
  get(key: string) {
    const json: any = window.localStorage.getItem(key);
    return JSON.parse(json);
  },
  // 移除永久缓存
  remove(key: string) {
    window.localStorage.removeItem(key);
  },
  // 移除全部永久缓存
  clear() {
    window.localStorage.clear();
  }
};


// window.sessionStorage 浏览器临时缓存
export const sessionStorage = {
  // 设置临时缓存
  set(key: string, val: any) {
    window.sessionStorage.setItem(key, JSON.stringify(val));
  },
  // 获取临时缓存
  get(key: string) {
    const json: any = window.sessionStorage.getItem(key);
    return JSON.parse(json);
  },
  // 移除临时缓存
  remove(key: string) {
    window.sessionStorage.removeItem(key);
  },
  // 移除全部临时缓存
  clear() {
    window.sessionStorage.clear();
  }
};

3.请求封装

实际开发过程中接口不会很快就出来,所以mock接口是必要的,那么在封装的时候就可以考虑mock接口的功能加进去。

// src\utils\request.ts

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { localStorage } from '../utils/storage'
import useStore from '../store'

const baseURLAll = ref(import.meta.env.VITE_APP_BASE_API)

type TOptions = {
  mock?: boolean // 是否启用mock
  mockUrl?: string // 自定义mock地址
}

const request = (options: TOptions & AxiosRequestConfig) => {
  if (options?.mock) baseURLAll.value = options.mockUrl ?? import.meta.env.VITE_APP_BASE_API_MOCK
  // 对当前环境进行二次判断 防止生产环境调用了测试环境接口------------------********待添加*********------------------

  const service = axios.create({
    baseURL: baseURLAll.value,
    timeout: 50000
  })

  // 请求拦截器
  service.interceptors.request.use(
    (config: AxiosRequestConfig) => {
      if (!config.headers) {
        throw new Error(`Expected 'config' and 'config.headers' not to be undefined`)
      }
      const { user } = useStore()
      if (user.token) {
        config.headers.Authorization = `${localStorage.get('token')}`
      }
      return config
    },
    (error) => {
      return Promise.reject(error)
    }
  )

  // 响应拦截器
  service.interceptors.response.use(
    (response: AxiosResponse) => {
      const { code, msg } = response.data
      if (code === '200') {
        return response.data
      } else {
        ElMessage({
          message: msg || '系统出错',
          type: 'error'
        })
        return Promise.reject(new Error(msg || 'Error'))
      }
    },
    (error) => {
      const { code, msg } = error.response.data
      if (code === 'A0230') {
        // token 过期
        localStorage.clear() // 清除浏览器全部缓存
        window.location.href = '/' // 跳转登录页
        ElMessageBox.alert('当前页面已失效,请重新登录', '提示', {})
          .then(() => {})
          .catch(() => {})
      } else {
        ElMessage({
          message: msg || '系统出错',
          type: 'error'
        })
      }
      return Promise.reject(new Error(msg || 'Error'))
    }
  )
  return service(options)
}

export default request

4.API封装

// src\api\user\index.ts

import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { UserInfo } from './types'

export function getUserInfo(data: { userId: Number | null }): AxiosPromise<UserInfo> {
  return request({
    url: '/api/users/userInfo',
    method: 'post',
    data
  })
}

// src\api\user\types.ts

export interface UserInfo {
  nickname: string;
  avatar: string;
  roles: string[];
  perms: string[];
}

utils

// src\utils\index.ts

/**
 * Check if an element has a class
 * @param {HTMLElement} elm
 * @param {string} cls
 * @returns {boolean}
 */
export function hasClass(ele: HTMLElement, cls: string) {
  return !!ele.className.match(new RegExp('(\\s|^)' + cls + '(\\s|$)'));
}

/**
 * Add class to element
 * @param {HTMLElement} elm
 * @param {string} cls
 */
export function addClass(ele: HTMLElement, cls: string) {
  if (!hasClass(ele, cls)) ele.className += ' ' + cls;
}

/**
 * Remove class from element
 * @param {HTMLElement} elm
 * @param {string} cls
 */
export function removeClass(ele: HTMLElement, cls: string) {
  if (hasClass(ele, cls)) {
    const reg = new RegExp('(\\s|^)' + cls + '(\\s|$)');
    ele.className = ele.className.replace(reg, ' ');
  }
}

export function mix(color1: string, color2: string, weight: number) {
  weight = Math.max(Math.min(Number(weight), 1), 0);
  const r1 = parseInt(color1.substring(1, 3), 16);
  const g1 = parseInt(color1.substring(3, 5), 16);
  const b1 = parseInt(color1.substring(5, 7), 16);
  const r2 = parseInt(color2.substring(1, 3), 16);
  const g2 = parseInt(color2.substring(3, 5), 16);
  const b2 = parseInt(color2.substring(5, 7), 16);
  const r = Math.round(r1 * (1 - weight) + r2 * weight);
  const g = Math.round(g1 * (1 - weight) + g2 * weight);
  const b = Math.round(b1 * (1 - weight) + b2 * weight);
  const rStr = ('0' + (r || 0).toString(16)).slice(-2);
  const gStr = ('0' + (g || 0).toString(16)).slice(-2);
  const bStr = ('0' + (b || 0).toString(16)).slice(-2);
  return '#' + rStr + gStr + bStr;
}

动态权限路由&router

1. 安装 vue-router

npm install vue-router@next nprogress @types/nprogress --registry=https://registry.npm.taobao.org

2. 创建路由实例

创建路由实例并导出,其中包括静态路由数据,动态路由后面将通过接口从后端获取并整合用户角色的权限控制。

// 新建src\layout\index.vue文件
// 新建src\views\error-page\401.vue文件
// 新建src\views\error-page\404.vue文件
// 新建src\views\login\index.vue文件
// 新建src\views\redirect\index.vue文件
// 新建src\views\dashboard\index.vue文件

// src/router/index.ts

import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import useStore from '@/store'

export const Layout = () => import('@/layout/index.vue')

// 静态路由
export const constantRoutes: Array<RouteRecordRaw> = [
  {
    path: '/redirect',
    component: Layout,
    meta: { hidden: true },
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index.vue')
      }
    ]
  },
  {
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    meta: { hidden: true }
  },
  {
    path: '/404',
    component: () => import('@/views/error-page/404.vue'),
    meta: { hidden: true }
  },
  {
    path: '/401',
    component: () => import('@/views/error-page/401.vue'),
    meta: { hidden: true }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        name: 'Dashboard',
        meta: { title: 'dashboard', icon: 'dashboard', affix: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHashHistory(),
  routes: constantRoutes as RouteRecordRaw[],
  // 刷新时,滚动条位置还原
  scrollBehavior: () => ({ left: 0, top: 0 })
})

// 重置路由
export function resetRouter() {
  // doing...
}

export default router

3. 路由实例全局注册

// main.ts
import router from "@/router";
// import '@/permission';

createApp(App).use(router).use(ElementPlus).use(createPinia()).mount('#app')
// App.vue

<template>
  <router-view v-if="isRouterAlive" />
</template>

<script setup lang="ts">
const isRouterAlive = ref(true)
const reload = () => {
  isRouterAlive.value = false
  nextTick(() => (isRouterAlive.value = true))
}
provide('reload', reload)
</script>

<style lang="scss"></style>

试一试!启动项目!成功一大半!

4.登录退出等api封装

auth

// src\api\auth\types.ts

/**
 * 登录表单类型声明
 */
export interface LoginForm {
  username: string
  password: string
  grant_type: string
  /**
   * 验证码Code
   */
  //verifyCode: string;
  /**
   * 验证码Code服务端缓存key(UUID)
   */
  // verifyCodeKey: string;
}

/**
 * 登录响应类型声明
 */
export interface LoginResult {
  access_token: string
  token_type: string
}

/**
 * 验证码类型声明
 */
export interface VerifyCode {
  verifyCodeImg: string
  verifyCodeKey: string
}

// src\api\auth\index.ts

import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { LoginForm, VerifyCode } from './types'

/**
 *
 * @param data {LoginForm}
 * @returns
 */
export function loginApi(data: LoginForm): AxiosPromise<string> {
  return request({
    url: '/api/auth/login',
    method: 'post',
    params: data,
    headers: {
      Authorization: 'Basic dnVlMy1lbGVtZW50LWFkbWluOnNlY3JldA==' // 客户端信息Base64明文:vue3-element-admin:secret
    }
  })
}

/**
 * 注销
 */
export function logoutApi() {
  return request({
    url: '/api/auth/logout',
    method: 'delete'
  })
}

/**
 * 获取图片验证码
 */
export function getCaptcha(): AxiosPromise<VerifyCode> {
  return request({
    url: '/captcha?t=' + new Date().getTime().toString(),
    method: 'get'
  })
}

// src\api\menu\types.ts

/**
 * 菜单查询参数类型声明
 */
export interface MenuQuery {
  keywords?: string
}

/**
 * 菜单分页列表项声明
 */

export interface Menu {
  id?: number
  parentId: number
  type?: string | 'CATEGORY' | 'MENU' | 'EXTLINK'
  createTime: string
  updateTime: string
  name: string
  icon: string
  component: string
  sort: number
  visible: number
  children: Menu[]
}

/**
 * 菜单表单类型声明
 */
export interface MenuForm {
  //菜单ID
  id?: string
  //父菜单ID
  parentId: string
  //菜单名称
  name: string
  //菜单是否可见(1:是;0:否;)
  visible: number
  icon?: string
  //排序
  sort: number
  //组件路径
  component?: string
  //路由路径
  path: string
  //跳转路由路径
  redirect?: string
  //菜单类型
  type: string
  //权限标识
  perm?: string
}

/**
 * 资源(菜单+权限)类型
 */
export interface Resource {
  // 菜单值
  value: string
  //菜单文本
  label: string
  //子菜单
  children: Resource[]
}

/**
 * 权限类型
 */
export interface Permission {
  // 权限值
  value: string
  //权限文本
  label: string
}


// src\api\menu\index.ts

import request from '@/utils/request'
import { AxiosPromise } from 'axios'
import { MenuQuery, Menu, Resource, MenuForm } from './types'

type OptionType = {
  value: string
  label: string
  checked?: boolean
  children?: OptionType[]
}

/**
 * 获取路由列表
 */
export function listRoutes() {
  return request({
    url: '/api/v1/menus/routes',
    method: 'get'
  })
}

/**
 * 获取菜单表格列表
 *
 * @param queryParams
 */
export function listMenus(queryParams: MenuQuery): AxiosPromise<Menu[]> {
  return request({
    url: '/api/v1/menus',
    method: 'get',
    params: queryParams
  })
}

/**
 * 获取菜单下拉树形列表
 */
export function listMenuOptions(): AxiosPromise<OptionType[]> {
  return request({
    url: '/api/v1/menus/options',
    method: 'get'
  })
}

/**
 * 获取资源(菜单+权限)树形列表
 */
export function listResources(): AxiosPromise<Resource[]> {
  return request({
    url: '/api/v1/menus/resources',
    method: 'get'
  })
}

/**
 * 获取菜单详情
 * @param id
 */
export function getMenuDetail(id: string): AxiosPromise<MenuForm> {
  return request({
    url: '/api/v1/menus/' + id,
    method: 'get'
  })
}

/**
 * 添加菜单
 *
 * @param data
 */
export function addMenu(data: MenuForm) {
  return request({
    url: '/api/v1/menus',
    method: 'post',
    data: data
  })
}

/**
 * 修改菜单
 *
 * @param id
 * @param data
 */
export function updateMenu(id: string, data: MenuForm) {
  return request({
    url: '/api/v1/menus/' + id,
    method: 'put',
    data: data
  })
}

/**
 * 批量删除菜单
 *
 * @param ids 菜单ID,多个以英文逗号(,)分割
 */
export function deleteMenus(ids: string) {
  return request({
    url: '/api/v1/menus/' + ids,
    method: 'delete'
  })
}

5.store公共方法

// src\store\storeTypes.ts
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';

export interface AppState {
  device: string;
  sidebar: {
    opened: boolean;
    withoutAnimation: boolean;
  };
  language?: string;
  size: string;
}

export interface PermissionState {
  routes: RouteRecordRaw[];
  addRoutes: RouteRecordRaw[];
}

export interface SettingState {
  theme: string;
  tagsView: boolean;
  fixedHeader: boolean;
  showSettings: boolean;
  sidebarLogo: boolean;
}

export interface UserState {
  token: string;
  nickname: string;
  avatar: string;
  roleList?: string[];
  perms: string[];
  roles: string[];
  userId: Number | null;
}

export interface TagView extends Partial<RouteLocationNormalized> {
  title?: string;
}

export interface TagsViewState {
  visitedViews: TagView[];
  cachedViews: string[];
}

// src/store/modules/user.ts 
import { defineStore } from 'pinia'
import { UserState } from '../storeTypes'
import { localStorage } from '@/utils/storage'
import { loginApi, logoutApi } from '@/api/auth'
import { getUserInfo } from '@/api/user'
import { resetRouter } from '@/router'
import { LoginForm } from '@/api/auth/types'

const useUserStore = defineStore({
  id: 'user',
  state: (): UserState => ({
    token: localStorage.get('token') || '',
    nickname: '',
    avatar: '',
    userId: null,
    roles: [],
    perms: []
  }),
  actions: {
    //  重置仓库到初始状态
    async RESET_STATE() {
      this.$reset()
    },

    // 登录
    login(data: LoginForm) {
      const { username, password } = data
      return new Promise((resolve, reject) => {
        loginApi({
          grant_type: 'password',
          username: username.trim(),
          password: password
        })
          .then((response: { data: any }) => {
            const { userId, token } = response.data
            localStorage.set('token', token)
            this.token = token
            this.userId = userId
            resolve(token)
          })
          .catch((error) => {
            reject(error)
          })
      })
    },

    //  获取用户信息(昵称、头像、角色集合、权限集合)
    getUserInfo() {
      return new Promise((resolve, reject) => {
        getUserInfo({ userId: this.userId })
          .then(({ data }) => {
            if (!data) {
              return reject('Verification failed, please Login again.')
            }
            const { nickname, avatar, roles, perms } = data
            if (!roles || roles.length <= 0) {
              reject('getUserInfo: roles must be a non-null array!')
            }
            this.nickname = nickname
            this.avatar = avatar
            this.roles = roles
            this.perms = perms
            resolve(data)
          })
          .catch((error) => {
            reject(error)
          })
      })
    },

    //  注销
    logout() {
      return new Promise((resolve, reject) => {
        logoutApi()
          .then(() => {
            localStorage.remove('token')
            this.RESET_STATE()
            resetRouter()
            resolve(null)
          })
          .catch((error) => {
            reject(error)
          })
      })
    },

    //  清除 Token
    resetToken() {
      return new Promise((resolve) => {
        localStorage.remove('token')
        this.RESET_STATE()
        resolve(null)
      })
    }
  }
})

export default useUserStore

// src\store\modules\permission.ts

import { PermissionState } from '../storeTypes'
import { RouteRecordRaw } from 'vue-router'
import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { listRoutes } from '@/api/menu'

const modules = import.meta.glob('../../views/**/**.vue')
export const Layout = () => import('@/layout/index.vue')

const hasPermission = (roles: string[], route: RouteRecordRaw) => {
  if (route.meta && route.meta.roles) {
    if (roles.includes('ROOT')) {
      return true
    }
    return roles.some((role) => {
      if (route.meta?.roles !== undefined) {
        return (route.meta.roles as string[]).includes(role)
      }
    })
  }
  return false
}

// 角色过滤路由
export const filterAsyncRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
  const res: RouteRecordRaw[] = []
  routes.forEach((route) => {
    const tmp = { ...route } as any
    if (hasPermission(roles, tmp)) {
      if (tmp.component == 'Layout') {
        tmp.component = Layout
      } else {
        const component = modules[`../../views/${tmp.component}.vue`] as any
        if (component) {
          tmp.component = component
        } else {
          tmp.component = modules[`../../views/error-page/404.vue`]
        }
      }
      res.push(tmp)

      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
    }
  })
  return res
}

const usePermissionStore = defineStore({
  id: 'permission',
  state: (): PermissionState => ({
    routes: [], // 静态路由 + 动态路由
    addRoutes: [] //动态路由
  }),
  actions: {
    setRoutes(routes: RouteRecordRaw[]) {
      this.addRoutes = routes
      this.routes = constantRoutes.concat(routes)
    },
    generateRoutes(roles: string[]) {
      return new Promise((resolve, reject) => {
        listRoutes()
          .then((response) => {
            const asyncRoutes = response.data
            const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
            this.setRoutes(accessedRoutes)
            resolve(accessedRoutes)
          })
          .catch((error) => {
            reject(error)
          })
      })
    }
  }
})

export default usePermissionStore

// src\store\index.ts

import useUserStore from './modules/user'
import usePermissionStore from './modules/permission'

const useStore = () => ({
  user: useUserStore(),
  permission: usePermissionStore(),
})

export default useStore

6. 动态权限路由,路由鉴权,鉴权文件引入

// main.ts引入

// src/permission.ts
import router from '@/router'
import { ElMessage } from 'element-plus'
import useStore from '@/store'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false }) // 进度环显示/隐藏

// 白名单路由
const whiteList = ['/login']

router.beforeEach(async (to, form, next) => {
  NProgress.start()
  const { user, permission } = useStore()
  const hasToken = user.token
  // 有token
  if (hasToken) {
    // 登录成功,跳转到首页
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      const hasGetUserInfo = user.roles.length > 0
      // 有用户信息
      if (hasGetUserInfo) {
        next()
      } else {
        // 无用户信息,重新获取用户信息路由信息
        try {
          await user.getUserInfo()
          const roles = user.roles
          // 用户拥有权限的路由集合(accessRoutes)
          // 是根据用户角色获取拥有权限的路由(静态路由+动态路由)
          const accessRoutes: any = await permission.generateRoutes(roles)
          accessRoutes.forEach((route: any) => {
            router.addRoute(route)
          })
          next({ ...to, replace: true })
        } catch (error) {
          // 移除 token 并跳转登录页
          await user.resetToken()
          ElMessage.error((error as any) || 'Has Error')
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      }
    }
  } else {
    // 未登录可以访问白名单页面(登录页面),无token
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

启动项目测试权限控制

SVG图标

1. 安装 vite-plugin-svg-icons

npm i fast-glob@3.2.11 vite-plugin-svg-icons@2.0.1 -D --registry=https://registry.npm.taobao.org

2. 创建图标文件夹

​ 项目创建 src/assets/icons 文件夹,存放 iconfont 下载的 SVG 图标

3. main.ts 引入注册脚本

// main.ts
import 'virtual:svg-icons-register';

4. vite.config.ts 插件配置

// vite.config.ts
import {UserConfig, ConfigEnv, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

export default ({command, mode}: ConfigEnv): UserConfig => {
    // 获取 .env 环境配置文件
    const env = loadEnv(mode, process.cwd())

    return (
        {
            plugins: [
                //...
                createSvgIconsPlugin({
                    // 指定需要缓存的图标文件夹
                    iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
                    // 指定symbolId格式
                    symbolId: 'icon-[dir]-[name]',
                })
            ]
        }
    )
}

5. TypeScript支持

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vite-plugin-svg-icons/client"]
  }
}

6. 组件封装

<!-- src/components/SvgIcon/index.vue -->
<template>
  <svg aria-hidden="true" class="svg-icon">
    <use :xlink:href="symbolId" :fill="color" />
  </svg>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props=defineProps({
  prefix: {
    type: String,
    default: 'icon',
  },
  iconClass: {
    type: String,
    required: true,
  },
  color: {
    type: String,
    default: ''
  }
})

const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
</script>

<style scoped>
.svg-icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  overflow: hidden;
  fill: currentColor;
}
</style>

7. 使用示例

<template>
  <svg-icon icon-class="menu"/>
</template>

<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
</script>  

样式文件

// src\styles\index.scss

body {
  margin: 0;
  padding: 0;
  height: 100%;
  -moz-osx-font-smoothing: grayscale;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB,
    Microsoft YaHei, Arial, sans-serif;
}

label {
  font-weight: 700;
}

html {
  height: 100%;
  box-sizing: border-box;
}

#app {
  height: 100%;
}

*,
*:before,
*:after {
  box-sizing: inherit;
}

a:focus,
a:active {
  outline: none;
}

a,
a:focus,
a:hover {
  cursor: pointer;
  color: inherit;
  text-decoration: none;
}

div:focus {
  outline: none;
}

.clearfix {
  &:after {
    visibility: hidden;
    display: block;
    font-size: 0;
    content: ' ';
    clear: both;
    height: 0;
  }
}

// main-container global css
.app-container {
  padding: 20px;
}

.search{
  padding:18px  0 0  10px;
  margin-bottom: 10px;
  box-shadow: var(--el-box-shadow-light);
  border-radius: var(--el-card-border-radius);
  border: 1px solid var(--el-card-border-color);
}


登录页面

//src\views\login\index.vue

<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
      auto-complete="on"
      label-position="left"
    >
      <div class="title-container">
        <h3 class="title">登录</h3>
      </div>

      <el-form-item prop="username">
        <span class="svg-container">
          <svg-icon icon-class="user" />
        </span>
        <el-input
          ref="username"
          v-model="loginForm.username"
          placeholder="login.username"
          name="username"
          type="text"
          tabindex="1"
          auto-complete="on"
        />
      </el-form-item>

      <el-tooltip :disabled="capslockTooltipDisabled" content="Caps lock is On" placement="right">
        <el-form-item prop="password">
          <span class="svg-container">
            <svg-icon icon-class="password" />
          </span>
          <el-input
            ref="passwordRef"
            :key="passwordType"
            v-model="loginForm.password"
            :type="passwordType"
            placeholder="Password"
            name="password"
            tabindex="2"
            auto-complete="on"
            @keyup="checkCapslock"
            @blur="capslockTooltipDisabled = true"
            @keyup.enter="handleLogin"
          />
          <span class="show-pwd" @click="showPwd">
            <svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
          </span>
        </el-form-item>
      </el-tooltip>

      <el-button
        size="default"
        :loading="loading"
        type="primary"
        style="width: 100%; margin-bottom: 30px"
        @click.prevent="handleLogin"
        >login
      </el-button>

      <!-- 账号密码提示 -->
      <div class="tips">
        <div style="position: relative">
          <span style="margin-right: 20px">username: admin</span>
          <span> password: 123456</span>
        </div>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import router from '@/router'
import SvgIcon from '@/components/SvgIcon/index.vue'
import useStore from '@/store'
import { useRoute } from 'vue-router'
import { LoginForm } from '@/api/auth/types'

const { user } = useStore()
const route = useRoute()

const loginFormRef = ref(ElForm)
const passwordRef = ref(ElInput)

const state = reactive({
  redirect: '',
  loginForm: {
    username: 'admin',
    password: '123456'
  } as LoginForm,
  loginRules: {
    username: [{ required: true, trigger: 'blur' }],
    password: [{ required: true, trigger: 'blur', validator: validatePassword }]
  },
  loading: false,
  passwordType: 'password',
  // 大写提示禁用
  capslockTooltipDisabled: true,
  otherQuery: {},
  clientHeight: document.documentElement.clientHeight,
  showDialog: false
})

function validatePassword(rule: any, value: any, callback: any) {
  if (value.length < 6) {
    callback(new Error('The password can not be less than 6 digits'))
  } else {
    callback()
  }
}

const { loginForm, loginRules, loading, passwordType, capslockTooltipDisabled } = toRefs(state)

function checkCapslock(e: any) {
  const { key } = e
  state.capslockTooltipDisabled = key && key.length === 1 && key >= 'A' && key <= 'Z'
}

function showPwd() {
  if (passwordType.value === 'password') {
    passwordType.value = ''
  } else {
    passwordType.value = 'password'
  }
  nextTick(() => {
    passwordRef.value.focus()
  })
}

//登录
function handleLogin() {
  loginFormRef.value.validate((valid: boolean) => {
    if (valid) {
      state.loading = true
      user
        .login(state.loginForm)
        .then(() => {
          router.push({ path: state.redirect || '/', query: state.otherQuery })
          state.loading = false
        })
        .catch(() => {
          state.loading = false
        })
    } else {
      return false
    }
  })
}

watch(
  route,
  () => {
    const query = route.query
    if (query) {
      state.redirect = query.redirect as string
      state.otherQuery = getOtherQuery(query)
    }
  },
  {
    immediate: true
  }
)

function getOtherQuery(query: any) {
  return Object.keys(query).reduce((acc: any, cur: any) => {
    if (cur !== 'redirect') {
      acc[cur] = query[cur]
    }
    return acc
  }, {})
}
</script>

<style lang="scss">
$bg: #a7c3e6;
$light_gray: #fff;
$cursor: #fff;

/* reset element-ui css */
.login-container {
  .title-container {
    position: relative;

    .title {
      font-size: 26px;
      color: $light_gray;
      margin: 0px auto 40px auto;
      text-align: center;
      font-weight: bold;
    }

    .set-language {
      color: #fff;
      position: absolute;
      top: 3px;
      font-size: 18px;
      right: 0px;
      cursor: pointer;
    }
  }

  .el-input {
    display: inline-block;
    height: 36px;
    width: 85%;
    .el-input__wrapper {
      padding: 0;
      background: transparent;
      box-shadow: none;
      width: 100%;
      .el-input__inner {
        background: transparent;
        border: 0px;
        -webkit-appearance: none;
        border-radius: 0px;
        color: $light_gray;
        height: 36px;
        caret-color: $cursor;

        &:-webkit-autofill {
          box-shadow: 0 0 0px 1000px $bg inset !important;
          -webkit-text-fill-color: $cursor !important;
        }
      }
    }
  }

  .el-input__inner {
    &:hover {
      border-color: var(--el-input-hover-border, var(--el-border-color-hover));
      box-shadow: none;
    }
    box-shadow: none;
  }

  .el-form-item {
    border: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(0, 0, 0, 0.1);
    border-radius: 5px;
    color: #454545;
  }

  .copyright {
    width: 100%;
    position: absolute;
    bottom: 0;
    font-size: 12px;
    text-align: center;
    color: #cccccc;
  }
}
</style>

<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #889aa4;
$light_gray: #eee;

.login-container {
  min-height: 100%;
  width: 100%;
  background-color: $bg;
  overflow: hidden;

  .login-form {
    position: relative;
    width: 520px;
    max-width: 100%;
    padding: 160px 35px 0;
    margin: 0 auto;
    overflow: hidden;
  }

  .tips {
    font-size: 14px;
    color: #fff;
    margin-bottom: 10px;

    span {
      &:first-of-type {
        margin-right: 16px;
      }
    }
  }

  .svg-container {
    padding: 5px 10px;
    color: $dark_gray;
    vertical-align: middle;
    width: 30px;
    display: inline-block;
  }

  .title-container {
    position: relative;

    .title {
      font-size: 26px;
      color: $light_gray;
      margin: 0px auto 40px auto;
      text-align: center;
      font-weight: bold;
    }
  }

  .show-pwd {
    position: absolute;
    right: 10px;
    top: 7px;
    font-size: 16px;
    color: $dark_gray;
    cursor: pointer;
    user-select: none;
  }

  .captcha {
    position: absolute;
    right: 0;
    top: 0;

    img {
      height: 42px;
      cursor: pointer;
      vertical-align: middle;
    }
  }
}

.thirdparty-button {
  position: absolute;
  right: 40px;
  bottom: 6px;
}

@media only screen and (max-width: 470px) {
  .thirdparty-button {
    display: none;
  }
}
</style>

!!!到这里一个管理系统最基础的部分就算完成了,如果项目创新程度比较高可以从这里开始:refreshing分支
如果要做一个比较传统的管理后台可以选择master分支!!!

layout布局组件

传统基础布局

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2VgbQpcd-1670077065047)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/10ae7707281c49599c77000226472d56~tplv-k3u1fbpfcp-watermark.image?)]

按钮权限

1. Directive 自定义指令

// src/directive/permission/index.ts

import useStore from "@/store";
import { Directive, DirectiveBinding } from "vue";

/**
 * 按钮权限校验
 */
export const hasPerm: Directive = {
    mounted(el: HTMLElement, binding: DirectiveBinding) {
        // 「超级管理员」拥有所有的按钮权限
        const { user } = useStore()
        const roles = user.roles;
        if (roles.includes('ROOT')) {
            return true
        }
        // 「其他角色」按钮权限校验
        const { value } = binding;
        if (value) {
            const requiredPerms = value; // DOM绑定需要的按钮权限标识

            const hasPerm = user.perms.some(perm => {
                return requiredPerms.includes(perm)
            })

            if (!hasPerm) {
                el.parentNode && el.parentNode.removeChild(el);
            }
        } else {
            throw new Error("need perms! Like v-has-perm="['sys:user:add','sys:user:edit']"");
        }
    }
};

2. 自定义指令全局注册

// src/main.ts

const app = createApp(App)
// 自定义指令
import * as directive from "@/directive";

Object.keys(directive).forEach(key => {
    app.directive(key, (directive as { [key: string]: Directive })[key]);
});

3. 指令使用

// src/views/system/user/index.vue
<el-button v-hasPerm="['sys:user:add']">新增</el-button>
<el-button v-hasPerm="['sys:user:delete']">删除</el-button>
Logo

前往低代码交流专区

更多推荐