基于vue3通用的后台管理模板

仿照 vue-element-admin 做一个通用版的管理后台,基于vue3和elementui涵盖国际化、权限验证、动态路由等管理后台常见的方案,还有一些管理后台常见的业务中的一些换肤、全屏和动态表格渲染等。后续还包括CI/CD,通过github Action完成自动化部署。

项目预览:http://118.31.222.50/vue-admin-pro/

首先来完成登录页面

登录

首先完成登录的ui处理。在src/view/login.vue写入登录样式

ini 复制代码
<el-form class="login-form" :model="loginForm" :rules="loginRules" ref="loginFromRef">
  <div class="title-container">
    <h3 class="title">{{ $t('msg.login.title') }}</h3>
  </div>
​
  <el-form-item prop="username">
    <span class="svg-container">
      <svg-icon icon="user" />
    </span>
    <el-input
      placeholder="username"
      name="username"
      type="text"
      v-model="loginForm.username"
    />
  </el-form-item>
​
  <el-form-item prop="password">
    <span class="svg-container">
      <svg-icon icon="password" />
    </span>
    <el-input
      placeholder="password"
      name="password"
      v-model="loginForm.password"
      :type="passwordType"
    />
    <span class="show-pwd">
      <svg-icon
        :icon="passwordType === 'password' ? 'eye' : 'eye-open'"
        @click="onChangePwdType"
      />
    </span>
  </el-form-item>
​
  <el-button
    type="primary"
    style="width: 100%; margin-bottom: 30px"
    :loading="loading"
    @click="handleLogin"
    >{{ $t('msg.login.loginBtn') }}</el-button
  >
</el-form>

这里样式比较简单,就两个输入框,用户名和密码,随后就是一个点击登录的按钮。

输入校验及密码处理

其中未输入框添加了输入校验, 这里的表单校验同element-ui 一样,校验规则是这样的

php 复制代码
// 验证规则
const loginRules = ref({
  username: [
    {
      required: true,
      trigger: 'blur',
      message: '用户名为必填项'
    }
  ],
  password: [
    {
      required: true,
      trigger: 'blur',
      validator: validatePassword()
    }
  ]
})
​
//密码校验规则
export const validatePassword = () => {
  return (rule, value, callback) => {
    if (value.length < 6) {
      callback(new Error(i18n.global.t('msg.login.passwordRule')))
    } else {
      callback()
    }
  }
}

这里用户名做了必输校验,而密码通过使用自定义的校验函数,添加了密码长度的校验,而i18n这里暂时先略过,是后续的做国际化所用到点。

上述密码框还做了密码状态的切换,通过点击小眼睛来切换密码的明文和密文,这里也比较简单

ini 复制代码
// 处理密码框文本显示状态
const passwordType = ref('password')
const onChangePwdType = () => {
  if (passwordType.value === 'password') {
    passwordType.value = 'text'
  } else {
    passwordType.value = 'password'
  }
}

通过定义一个passwordType变量,来更改el-input的type值,切换明文和密文,而眼睛的变化通过自定义组件svg-icon传入不同的icon来实现。

登录操作

当我们输入完用户名和密码,点击登录按钮后,通常会做如下处理

  1. 使用封装的axios向后端发起请求,获取请求数据
  2. 拿到请求数据后,存储用户信息及token
  3. 登录鉴权

其中用户信息后续在页面内进行一些展示,比如用户手机号、用户id等,后续都会用到,而token也是非常关键的,后续所有的请求都会带上这个token。而登录鉴权就是用户未登录时只能进入登录页,登录后在token过期之前不允许进入登录页。

接下来首先封装axios

请求封装

新建文件src/utils/request.js

javascript 复制代码
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 5000
})
​
// 请求拦截器
service.interceptors.request.use(
  config => {
    // 在这个位置需要统一的去注入token
    if (store.getters.token) {
      if (isCheckTimeout()) {
        // 登出操作
        store.dispatch('user/logout')
        return Promise.reject(new Error('token 失效'))
      }
      // 如果token存在 注入token
      config.headers.Authorization = `Bearer ${store.getters.token}`
    }
    // 配置接口国际化
    config.headers['Accept-Language'] = store.getters.language
    return config // 必须返回配置
  },
  error => {
    return Promise.reject(error)
  }
)
​
// 响应拦截器
service.interceptors.response.use(
  response => {
    const { success, message, data } = response.data
    //   要根据success的成功与否决定下面的操作
    if (success) {
      return data
    } else {
      // 业务错误
      ElMessage.error(message) // 提示错误消息
      return Promise.reject(new Error(message))
    }
  },
  error => {
    // 处理 token 超时问题
    if (
      error.response &&
      error.response.data &&
      error.response.data.code === 401
    ) {
      // token超时
      store.dispatch('user/logout')
    }
    ElMessage.error(error.message) // 提示错误信息
    return Promise.reject(error)
  }
)
​
export default service

首先在根目录下定义一些环境变量

ini 复制代码
.env.development
# 标志
ENV = 'development'
​
# base api
VUE_APP_BASE_API = '/api'
​
.env.production
# 标志
ENV = 'production'
​
# base api
VUE_APP_BASE_API = '/prod-api'

这是通过vue-cli脚手架创建项目可以通过这种方式来定义环境变量,通过process.env来获取。

首先创建一个service实例,这里就用到了刚刚创建的环境变量,通过process.env.VUE_APP_BASE_API来定义baseURL,紧接着定义了请求拦截器和响应拦截器。

在请求拦截器中,首先判断store.getters.token是否有token,如果有在判断是否过期,过期的话就直接做登出操作,未过期就直接在headers中注入token。后面还在headers中注入了Accept-Language字段,表明当前语言的环境,后续接口国际化处理。

这里判断过期的操作是通过对比登录时间来做的,定义在utils/auth的一个工具方法

javascript 复制代码
export function isCheckTimeout() {
  // 当前时间戳
  var currentTime = Date.now()
  // 缓存时间戳
  var timeStamp = getTimeStamp()
  return currentTime - timeStamp > TOKEN_TIMEOUT_VALUE
}

getTimeStamp就是获取登录时存储的时间戳,然后和当前时间戳来做一个差值,超过2小时就算是超时了。

在响应拦截中做了下错误的区分,是接口错误还是业务错误,接口错误就是常见的404,以及5开头的服务器错误,这是通常是服务端出现了异常,通过弹框提示,比如这里通过状态码异常,来处理token过期的问题;业务错误是指接口正常响应了,通常接口的入参出现了问题,这里也暂时做弹窗处理,实际可以根据业务做一些具体的区分。

装请求模块

请求封装了,还要封装请求模块,比如登录接口

新建文件src/api/sys.js

kotlin 复制代码
import request from '@/utils/request'
​
/**
 * 登录
 */
export const login = data => {
  return request({
    url: '/sys/login',
    method: 'POST',
    data
  })
}
封装登录请求动作

该动作我们期望把它封装到 vuexaction

对于vuex的用法,看下官网的一张我觉得就够了。state是我们的核心仓库,所有的数据都放在这里,要改变这个数据要使用 Mutation 来通过commit来触发,如果涉及到异步的操作了,就要通过在action里定义异步方法commit一个Mutation 来更改数据,而触发action的方法就是通过dispatch来触发。在实际项目中,更多的通过namespaced: true使用模块的方式定义vuex,这样好处就是可以隔离变量。

新建文件src/store/modules/user.js

javascript 复制代码
export default {
  namespaced: true,
  state: () => ({
    token: getItem(TOKEN) || '',
    userInfo: {}
  }),
  mutations: {
    setToken(state, token) {
      state.token = token
      setItem(TOKEN, token)
    },
    setUserInfo(state, userInfo) {
      state.userInfo = userInfo
    }
  },
  actions: {
    login(context, userInfo) {
      const { username, password } = userInfo
      return new Promise((resolve, reject) => {
        login({
          username,
          password: md5(password)
        })
          .then(data => {
            this.commit('user/setToken', data.token)
            // 保存登录时间
            setTimeStamp()
            resolve()
          })
          .catch(err => {
            reject(err)
          })
      })
    },
    async getUserInfo(context) {
      const res = await getUserInfo()
      this.commit('user/setUserInfo', res)
      return res
    },
    logout() {
      resetRouter()
      this.commit('user/setToken', '')
      this.commit('user/setUserInfo', {})
      removeAllItem()
      router.push('/login')
    }
  }
}
​

在state中定义了token和userInfo对象,在mutations中定义了setToken和setUserInfo方法,在action中定义了登录login方法、获取信息的getUserInfo和登出的logout方法。

这里的login方法就是刚刚封装的请求模块,传入username和加密后的password,然后将返回值中的token保存到state中,同时调用setTimeStamp方法,这个方法就是将当前登录的时间戳定义在localStorage中,也是前面前端计算token过期的一个方法。

触发登录

在按钮上绑定点击事件

ini 复制代码
// 登录动作处理
const loading = ref(false)
const loginFromRef = ref(null)
const store = useStore()
const router = useRouter()
const handleLogin = () => {
  loginFromRef.value.validate(valid => {
    if (!valid) return
​
    loading.value = true
    store
      .dispatch('user/login', loginForm.value)
      .then(() => {
        loading.value = false
        // 登录后操作
        router.push('/')
      })
      .catch(err => {
        console.log(err)
        loading.value = false
      })
  })
}

这里就是表单校验通过后,通过dispatch触发action,完成登录请求。成功后直接跳转首页。

登录鉴权

完成登录鉴权,需要用到路由守卫

创建文件permission.js

scss 复制代码
// 白名单
const whiteList = ['/login']
/**
 * 路由前置守卫
 */
router.beforeEach(async (to, from, next) => {
  // 存在 token ,进入主页
  // 快捷访问
  if (store.getters.token) {
    if (to.path === '/login') {
      next('/')
    } else {
      // 判断用户资料是否获取
      // 若不存在用户信息,则需要获取用户信息
      if (!store.getters.hasUserInfo) {
        // 触发获取用户信息的 action,并获取用户当前权限
        const { permission } = await store.dispatch('user/getUserInfo')
        // 处理用户权限,筛选出需要添加的权限
        const filterRoutes = await store.dispatch(
          'permission/filterRoutes',
          permission.menus
        )
        // 利用 addRoute 循环添加
        filterRoutes.forEach(item => {
          router.addRoute(item)
        })
        // 添加完动态路由之后,需要在进行一次主动跳转
        return next(to.path)
      }
      next()
    }
  } else {
    // 没有token的情况下,可以进入白名单
    if (whiteList.indexOf(to.path) > -1) {
      next()
    } else {
      next('/login')
    }
  }
})

这里实际上也比较简单,就是每次路由跳转都会检查下是否有token,如果有在检查下如果目标路由是登录页的话就直接跳转到首页,如果目标路由不是登录页的话,检查下是否获取了用户信息,没有的话就再次请求下重新获取,然后处理下权限;如果没有token,在根据具体情况是否直接跳转到登录页。

layout

layout实际上就是常见的管理后台的布局,左侧是菜单,右侧分为上下两块,上面是NavBar,下面就是Content部分了。如下面代码:

xml 复制代码
<template>
  <div class="app-wrapper">
    <!-- 左侧 menu -->
    <sidebar
      id="guide-sidebar"
      class="sidebar-container"
    />
    <div class="main-container">
      <div class="fixed-header">
        <!-- 顶部的 navbar -->
        <navbar />
      </div>
      <!-- 内容区 -->
      <app-main />
    </div>
  </div>
</template>
退出登录

对于退出登录而言,触发时机一般有两种:

  1. 主动退出,指的是用户主动点击退出按钮
  2. 被动退出,指的是token过期或者账户被其他人登录

当我们推出登录时,需要处理这些事情:

  1. 清理掉当前用户缓存数据
  2. 清理掉权限相关配置
  3. 返回到登录页

接下来首先处理下主动退出:

同样将行为封装在vuex中,就是在登录时定义的 store/modules/user.js

javascript 复制代码
import router from '@/router'
​
logout() {
    this.commit('user/setToken', '')
    this.commit('user/setUserInfo', {})
    removeAllItem()
    router.push('/login')
}

然后绑定下点击事件即可

javascript 复制代码
const logout = () => {
  store.dispatch('user/logout')
}

关于token失效,通常都会在服务端进行处理,而这里在前端也进行了处理,算是双保险吧。在前面登录鉴权和封装axios时已经做好了。而账户被其他人登录,这个需要服务端进行处理,这里一般会约定返回指定的状态码。

动态menu

在后台管理中都会涉及到权限管理,不同权限的人看到的菜单都会不一样。比较简单的实现动态菜单的方式就是后端根据用户权限返回一个树,前端遍历这个树来生成菜单,路由的话就直接指定全量路由,但是这种有一个问题,就是用户根据链接来访问他没有权限的路由实际上也是可以的,不够严谨。比较好的方式就是结合动态路由表来实现。

首先将路由表分为publicRoutesprivateRoutes,其中publicRoutes是不需要权限就能进入的公开路由表,比如登录页,404等;而privateRoutes是需要权限才能进入的全量私有路由表,在注册时,只注册公开路由表,私有路由表根据权限匹配动态添加。

注册路由代码:

arduino 复制代码
const router = createRouter({
  history:
    process.env.NODE_ENV === 'production'
      ? createWebHistory()
      : createWebHashHistory(),
  routes: publicRoutes
})
​
export default router

定义全量的私有路由代码:

arduino 复制代码
export const privateRoutes = [
  UserManageRouter,
  RoleListRouter,
  PermissionListRouter,
  ArticleRouter,
  ArticleCreaterRouter
]

如何拿到当前用户的权限呢,这里一般都是后端会返回的,比如当前这个项目返回的权限就在用户信息中,数据时这样的

json 复制代码
{
    "menus": [
        "userManage",
        "roleList",
        "permissionList",
        "articleRanking",
        "articleCreate"
    ],
    "points": [
        "distributeRole",
        "importUser",
        "removeUser",
        "distributePermission"
    ]
}

在前面的登录鉴权,定义在一个全局的路由守卫中,每次路由跳转都会触发

javascript 复制代码
if (!store.getters.hasUserInfo) {
    // 触发获取用户信息的 action,并获取用户当前权限
    const { permission } = await store.dispatch('user/getUserInfo')
    // 处理用户权限,筛选出需要添加的权限
    const filterRoutes = await store.dispatch(
        'permission/filterRoutes',
        permission.menus
    )
    console.log(filterRoutes)
    // 利用 addRoute 循环添加
    filterRoutes.forEach(item => {
        router.addRoute(item)
    })
    // 添加完动态路由之后,需要在进行一次主动跳转
    return next(to.path)
}

其中的permission就是权限数组,dispatch触发的action是这样写的

javascript 复制代码
filterRoutes(context, menus) {
      const routes = []
      // 路由权限匹配
      menus.forEach(key => {
        // 权限名 与 路由的 name 匹配
        routes.push(...privateRoutes.filter(item => item.name === key))
      })
      // 最后添加 不匹配路由进入 404
      routes.push({
        path: '/:catchAll(.*)',
        redirect: '/404'
      })
      context.commit('setRoutes', routes)
      return routes
    }

遍历下传入的权限数组,然后从私有路由表匹配出路由,通过addRoute添加到路由中。

这样复合用户当前权限的路由就生成了,然后就可以通过这个路由表的数据来动态生成菜单了。

SidebarMenu.vue

ruby 复制代码
<el-menu
    :default-active="activeMenu"
    :collapse="!$store.getters.sidebarOpened"
    :background-color="$store.getters.cssVar.menuBg"
    :text-color="$store.getters.cssVar.menuText"
    :active-text-color="$store.getters.cssVar.menuActiveText"
    :unique-opened="true"
    router
  >
    <sidebar-item
      v-for="item in routes"
      :key="item.path"
      :route="item"
    ></sidebar-item>
  </el-menu>

SidebarItem.vue

xml 复制代码
<!-- 支持渲染多级 menu 菜单 -->
<el-sub-menu v-if="route.children.length > 0" :index="route.path">
    <template #title>
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
    </template>
    <!-- 循环渲染 -->
    <sidebar-item
                  v-for="item in route.children"
                  :key="item.path"
                  :route="item"
                  ></sidebar-item>
</el-sub-menu>
<!-- 渲染 item 项 -->
<el-menu-item v-else :index="route.path">
    <menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</el-menu-item>
​

MenuItem.vue

ruby 复制代码
<template>
  <i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
  <svg-icon v-else :icon="icon"></svg-icon>
  <span>{{ generateTitle(title) }}</span>
</template>

上面就是我们的动态菜单了,其中routes是通过router.getRoutes()获得数据经过格式化处理得来的。

动态面包屑

动态面包屑就是根据当前的 url 自动生成面包屑导航菜单

xml 复制代码
​
  <el-breadcrumb class="breadcrumb" separator="/">
    <transition-group name="breadcrumb">
      <el-breadcrumb-item
        v-for="(item, index) in breadcrumbData"
        :key="item.path"
      >
        <!-- 不可点击项 -->
        <span v-if="index === breadcrumbData.length - 1" class="no-redirect">{{
          generateTitle(item.meta.title)
        }}</span>
        <!-- 可点击项 -->
        <a v-else class="redirect" @click.prevent="onLinkClick(item)">{{
          generateTitle(item.meta.title)
        }}</a>
      </el-breadcrumb-item>
    </transition-group>
  </el-breadcrumb>
​
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
​
const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {
  breadcrumbData.value = route.matched.filter(
    item => item.meta && item.meta.title
  )
}
// 监听路由变化时触发
watch(
  route,
  () => {
    getBreadcrumbData()
  },
  {
    immediate: true
  }
)
</script>
​
常见业务梳理
国际化

国际化就是根据业务需要来实现网站的语言切换,目前社区已经有了成熟的第三方库 vue-i18n

首先对i18n进行实例化

javascript 复制代码
import { createI18n } from 'vue-i18n'
import mZhLocale from './lang/zh'
import mEnLocale from './lang/en'
import store from '@/store'
​
const messages = {
  en: {
    msg: {
      ...mEnLocale
    }
  },
  zh: {
    msg: {
      ...mZhLocale
    }
  }
}
​
/**
 * 返回当前 lang
 */
function getLanguage() {
  return store && store.getters && store.getters.language
}
const i18n = createI18n({
  // 使用 Composition API 模式,则需要将其设置为false
  legacy: false,
  // 全局注入 $t 函数
  globalInjection: true,
  locale: getLanguage(),
  messages
})
​
export default i18n
​

mZhLocalemEnLocale使我们准备的文本,两个相同key不同value的JSON文件,然后在main.js中进行注册

javascript 复制代码
import i18n from '@/i18n'
​
installIcons(app)

接下来定义存储相关变量的仓库

javascript 复制代码
import { LANG } from '@/constant'
import { getItem, setItem } from '@/utils/storage'
export default {
  namespaced: true,
  state: () => ({
    ...
    language: getItem(LANG) || 'zh'
  }),
  mutations: {
    ...
    /**
     * 设置国际化
     */
    setLanguage(state, lang) {
      setItem(LANG, lang)
      state.language = lang
    }
  },
  actions: {}
}
​

用vuex来存储变量,然后写一个切换语言的组件

xml 复制代码
<template>
  <el-dropdown
    trigger="click"
    class="international"
    @command="handleSetLanguage"
  >
    <div>
      <el-tooltip :content="$t('msg.navBar.lang')" :effect="effect">
        <svg-icon id="guide-lang" icon="language" />
      </el-tooltip>
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item :disabled="language === 'zh'" command="zh">
          中文
        </el-dropdown-item>
        <el-dropdown-item :disabled="language === 'en'" command="en">
          English
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>
​
<script setup>
import { useI18n } from 'vue-i18n'
import { defineProps, computed } from 'vue'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
​
defineProps({
  effect: {
    type: String,
    default: 'dark',
    validator: function(value) {
      // 这个值必须匹配下列字符串中的一个
      return ['dark', 'light'].indexOf(value) !== -1
    }
  }
})
​
const store = useStore()
const language = computed(() => store.getters.language)
​
// 切换语言的方法
const i18n = useI18n()
const handleSetLanguage = lang => {
  i18n.locale.value = lang
  store.commit('app/setLanguage', lang)
  ElMessage.success(i18n.t('msg.toast.switchLangSuccess'))
}
</script>
​

然后再业务代码中这样使用

ini 复制代码
 <h3 class="title">{{ $t('msg.login.title') }}</h3>

title就是我们定义的JSON文件中的key。

这里对我们的内部组件就完成了国际化,根据切换language就可以完成语言的切换,

这里只是对页面上展示的文本,还记得前面的动态菜单嘛,哪里的数据就是从后端返回的,这就需要接口也实现国际化,即根据制定变量返回制定的语言,实际上我们在封装请求的请求拦截器中已经完成了:

arduino 复制代码
config.headers['Accept-Language'] = store.getters.language

在请求头中加上一个Accept-Language,剩下的就交给后端处理了。

换肤

换肤其实是一个不怎么常见的需求,相对于管理平台来说,如果是一个新项目比较好做,如果是维护一个老项目,要加上换肤操作,那就很是麻烦了。

接下来看一下换肤具体是怎么做的。

在画页面时,涉及到颜色的不要写死,通过变量来动态控制

bash 复制代码
// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;
​
$menuBg: #304156;
$menuHover: #263445;
​
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
​
$sideBarWidth: 210px;
$hideSideBarWidth: 54px;
$sideBarDuration: 0.28s;
​
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
  menuText: $menuText;
  menuActiveText: $menuActiveText;
  subMenuActiveText: $subMenuActiveText;
  menuBg: $menuBg;
  menuHover: $menuHover;
  subMenuBg: $subMenuBg;
  subMenuHover: $subMenuHover;
  sideBarWidth: $sideBarWidth;
}
​

然后通过变量来动态的改变这个颜色就可以了,还是比较简单的,当天这是针对自定义组件,对于第三方组件库的换肤,还是有点麻烦的。需要有以下三步:

  1. 获取当前 element-plus 的所有样式
  2. 找到我们想要替换的样式部分,通过正则完成替换
  3. 把替换后的样式写入到 style 标签中,利用样式优先级的特性,替代固有样式

首先新建一个颜色转换器

json 复制代码
{
  "shade-1": "color(primary shade(10%))",
  "light-1": "color(primary tint(10%))",
  "light-2": "color(primary tint(20%))",
  "light-3": "color(primary tint(30%))",
  "light-4": "color(primary tint(40%))",
  "light-5": "color(primary tint(50%))",
  "light-6": "color(primary tint(60%))",
  "light-7": "color(primary tint(70%))",
  "light-8": "color(primary tint(80%))",
  "light-9": "color(primary tint(90%))",
  "subMenuHover": "color(primary tint(70%))",
  "subMenuBg": "color(primary tint(80%))",
  "menuHover": "color(primary tint(90%))",
  "menuBg": "color(primary)"
}
​

就是以primary为基准,来添加shade(10%)的黑色,tint(10%)的白色,tint(20%)的白色等。

javascript 复制代码
import color from 'css-color-function'
import rgbHex from 'rgb-hex'
import formula from '@/constant/formula.json'
import axios from 'axios'
​
/**
 * 根据主色值,生成最新的样式表
 */
export const generateNewStyle = async primaryColor => {
  const colors = generateColors(primaryColor)
  let cssText = await getOriginalStyle()
​
  // 遍历生成的样式表,在 CSS 的原样式中进行全局替换
  Object.keys(colors).forEach(key => {
    cssText = cssText.replace(
      new RegExp('(:|\s+)' + key, 'g'),
      '$1' + colors[key]
    )
  })
​
  return cssText
}
​
/**
 * 根据主色生成色值表
 */
export const generateColors = primary => {
  if (!primary) return
  const colors = {
    primary
  }
  Object.keys(formula).forEach(key => {
    const value = formula[key].replace(/primary/g, primary)
    colors[key] = '#' + rgbHex(color.convert(value))
  })
  return colors
}
​
/**
 * 获取当前 element-plus 的默认样式表
 */
const getOriginalStyle = async () => {
  const version = require('element-plus/package.json').version
  const url = `https://unpkg.com/element-plus@${version}/dist/index.css`
  const { data } = await axios(url)
  // 把获取到的数据筛选为原样式模板
  return getStyleTemplate(data)
}
​
/**
 * 返回 style 的 template
 */
const getStyleTemplate = data => {
  // element-plus 默认色值
  const colorMap = {
    '#3a8ee6': 'shade-1',
    '#409eff': 'primary',
    '#53a8ff': 'light-1',
    '#66b1ff': 'light-2',
    '#79bbff': 'light-3',
    '#8cc5ff': 'light-4',
    '#a0cfff': 'light-5',
    '#b3d8ff': 'light-6',
    '#c6e2ff': 'light-7',
    '#d9ecff': 'light-8',
    '#ecf5ff': 'light-9'
  }
  // 根据默认色值为要替换的色值打上标记
  Object.keys(colorMap).forEach(key => {
    const value = colorMap[key]
    data = data.replace(new RegExp(key, 'ig'), value)
  })
  return data
}
​

首先根据主色生成色值表,就是根据上面的颜色转换器来实现的。然后获取到element-plus 的默认样式表,给所有需要替换的颜色打上标记,打标记就是为了待会替换颜色所用;打标记所用的模板就是colorMap,就getStyleTemplate方法,然后再遍历色值表进行替换就行了。

写一个方法将新生成的css样式写入到head标签里就行了,

ini 复制代码
export const writeNewStyle = elNewStyle => {
  const style = document.createElement('style')
  style.innerText = elNewStyle
  document.head.appendChild(style)
}
全屏

对于 screenfull 而言,浏览器本身已经提供了对用的 API,这个 API 中,主要提供了两个方法:

  1. Document.exitFullscreen():该方法用于请求从全屏模式切换到窗口模式

  2. Element.requestFullscreen():该方法用于请求浏览器(user agent)将特定元素(甚至延伸到它的后代元素)置为全屏模式

    1. 比如我们可以通过 document.getElementById('app').requestFullscreen() 在获取 id=appDOM 之后,把该区域置为全屏

但是该方法存在一定的小问题,比如:

  1. appmain 区域背景颜色为黑色

所以通常情况下我们不会直接使用该 API 来去实现全屏效果,而是会使用它的包装库 screenfull

xml 复制代码
<template>
  <div>
    <svg-icon
      :icon="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
      @click="onToggle"
    />
  </div>
</template>
​
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import screenfull from 'screenfull'
​
// 是否全屏
const isFullscreen = ref(false)
​
// 监听变化
const change = () => {
  isFullscreen.value = screenfull.isFullscreen
}
​
// 切换事件
const onToggle = () => {
  screenfull.toggle()
}
​
// 设置侦听器
onMounted(() => {
  screenfull.on('change', change)
})
​
// 删除侦听器
onUnmounted(() => {
  screenfull.off('change', change)
})
</script>
​
<style lang="scss" scoped></style>
​
头部搜索

headerSearch 是复杂后台系统中非常常见的一个功能,在指定搜索框中对当前应用中所有页面进行检索,以 select 的形式展示出被检索的页面,以达到快速进入的目的

这里其实拆分下需求,就是一个搜索框,根据输入的内容,检索数据源,显示一个select然后点击可以跳转路由就行了。数据源就是我们左侧的动态的动态菜单。

xml 复制代码
<template>
  <div :class="{ show: isShow }" class="header-search">
    <svg-icon
      id="guide-search"
      class-name="search-icon"
      icon="search"
      @click.stop="onShowClick"
    />
    <el-select
      ref="headerSearchSelectRef"
      class="header-search-select"
      v-model="search"
      filterable
      default-first-option
      remote
      placeholder="Search"
      :remote-method="querySearch"
      @change="onSelectChange"
    >
      <el-option
        v-for="option in searchOptions"
        :key="option.item.path"
        :label="option.item.title.join(' > ')"
        :value="option.item"
      ></el-option>
    </el-select>
  </div>
</template>
​
<script setup>
import { computed, ref, watch } from 'vue'
import { generateRoutes } from './FuseData'
import Fuse from 'fuse.js'
import { filterRouters } from '@/utils/route'
import { useRouter } from 'vue-router'
import { watchSwitchLang } from '@/utils/i18n'
​
// 控制 search 显示
const isShow = ref(false)
// el-select 实例
const headerSearchSelectRef = ref(null)
const onShowClick = () => {
  isShow.value = !isShow.value
  headerSearchSelectRef.value.focus()
}
​
// search 相关
const search = ref('')
// 搜索结果
const searchOptions = ref([])
// 搜索方法
const querySearch = query => {
  if (query !== '') {
    searchOptions.value = fuse.search(query)
  } else {
    searchOptions.value = []
  }
}
// 选中回调
const onSelectChange = val => {
  router.push(val.path)
  onClose()
}
​
// 检索数据源
const router = useRouter()
let searchPool = computed(() => {
  const filterRoutes = filterRouters(router.getRoutes())
  return generateRoutes(filterRoutes)
})
/**
 * 搜索库相关
 */
let fuse
const initFuse = searchPool => {
  fuse = new Fuse(searchPool, {
    // 是否按优先级进行排序
    shouldSort: true,
    // 匹配算法放弃的时机, 阈值 0.0 需要完美匹配(字母和位置),阈值 1.0 将匹配任何内容。
    threshold: 0.4,
    // 匹配长度超过这个值的才会被认为是匹配的
    minMatchCharLength: 1,
    // 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。
    // name:搜索的键
    // weight:对应的权重
    keys: [
      {
        name: 'title',
        weight: 0.7
      },
      {
        name: 'path',
        weight: 0.3
      }
    ]
  })
}
initFuse(searchPool.value)
​
/**
 * 关闭 search 的处理事件
 */
const onClose = () => {
  headerSearchSelectRef.value.blur()
  isShow.value = false
  searchOptions.value = []
}
/**
 * 监听 search 打开,处理 close 事件
 */
watch(isShow, val => {
  if (val) {
    document.body.addEventListener('click', onClose)
  } else {
    document.body.removeEventListener('click', onClose)
  }
})
​
// 处理国际化
watchSwitchLang(() => {
  searchPool = computed(() => {
    const filterRoutes = filterRouters(router.getRoutes())
    return generateRoutes(filterRoutes)
  })
  initFuse(searchPool.value)
})
</script>

这里用了fuse.js来做模糊匹配,

tagsview

tgas 就是位于 appmain 之上的标签,然后再加上动画和缓存,可以看下官方文档

ini 复制代码
<template>
  <div class="tags-view-container">
    <el-scrollbar class="tags-view-wrapper">
      <router-link
        class="tags-view-item"
        :class="isActive(tag) ? 'active' : ''"
        :style="{
          backgroundColor: isActive(tag) ? $store.getters.cssVar.menuBg : '',
          borderColor: isActive(tag) ? $store.getters.cssVar.menuBg : ''
        }"
        v-for="(tag, index) in $store.getters.tagsViewList"
        :key="tag.fullPath"
        :to="{ path: tag.fullPath }"
        @contextmenu.prevent="openMenu($event, index)"
      >
        {{ tag.title }}
        <i
          v-show="!isActive(tag)"
          class="el-icon-close"
          @click.prevent.stop="onCloseClick(index)"
        />
      </router-link>
    </el-scrollbar>
  </div>
</template>
用户权限处理

说道权限,得提一下RBAC(Role-Based Access Control) ,即基于角色的访问控制,是一种广泛应用于信息系统安全领域的访问控制机制。

核心概念主要包括用户(User)、角色(Role)、权限(Permission)和对象(Object)。

  1. 用户(User) :系统中的实际操作者,可以是个人或组织。用户是权限的拥有者,通过被分配到特定的角色来获得相应的权限。
  2. 角色(Role) :代表一组权限集合,通常与组织中的职位或职能相对应。角色是RBAC中的关键概念,它简化了权限管理,因为权限是分配给角色的,而不是直接分配给用户。
  3. 权限(Permission) :允许执行特定操作的权利,如读取、写入、删除等。权限定义了用户对特定对象的操作能力。
  4. 对象(Object) :系统中的资源,如文件、数据库记录、API接口等。用户通过权限对对象进行操作。

在RBAC中,权限不再直接分配给用户,而是与角色相关联。用户通过成为适当角色的成员,从而获得该角色的权限。这种设计使得角色的创建、修改和删除变得相对简单,同时也便于用户的权限管理。

在这个项目中,权限分为一级权限和二级权限,一级权限是指页面权限,二级权限指的是功能权限。实际上页面权限在前面已经处理了,在登录鉴权那里:

javascript 复制代码
if (!store.getters.hasUserInfo) {
    // 触发获取用户信息的 action,并获取用户当前权限
    const { permission } = await store.dispatch('user/getUserInfo')
    // 处理用户权限,筛选出需要添加的权限
    const filterRoutes = await store.dispatch(
    'permission/filterRoutes',
    permission.menus
    )
    // 利用 addRoute 循环添加
    filterRoutes.forEach(item => {
    router.addRoute(item)
    })
    // 添加完动态路由之后,需要在进行一次主动跳转
    return next(to.path)
}

这段代码是在全局路由守卫里,每次路由跳转都会调用一下,调用用户信息接口,拿到权限列表,根据这个权限列表去匹配路由,然后再动态添加到路由表中,这样只有相关权限的页面才能看的见,

对于功能权限,实际上就是按钮是否可见,这里采用了自定义指令来实现的。

javascript 复制代码
import store from '@/store'
​
function checkPermission(el, binding) {
  // 获取绑定的值,此处为权限
  const { value } = binding
  // 获取所有的功能指令
  const points = store.getters.userInfo.permission.points
  // 当传入的指令集为数组时
  if (value && value instanceof Array) {
    // 匹配对应的指令
    const hasPermission = points.some(point => {
      return value.includes(point)
    })
    // 如果无法匹配,则表示当前用户无该指令,那么删除对应的功能按钮
    if (!hasPermission) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  } else {
    // eslint-disabled-next-line
    throw new Error('v-permission value is ["admin","editor"]')
  }
}
​
export default {
  // 在绑定元素的父组件被挂载后调用
  mounted(el, binding) {
    checkPermission(el, binding)
  },
  // 在包含组件的 VNode 及其子组件的 VNode 更新后调用
  update(el, binding) {
    checkPermission(el, binding)
  }
}
​

绑定下改指令

dart 复制代码
import print from 'vue3-print-nb'
import permission from './permission'
​
export default app => {
  app.use(print)
  app.directive('permission', permission)
}

mian.js中进行注册

scss 复制代码
installDirective(app)

后续使用的话,直接这样就可以了

bash 复制代码
<el-button
    ...
    v-permission="['distributePermission']"
>
{{ $t('msg.role.assignPermissions') }}
</el-button>
CI/CD

前端的 CI/CD 是指 持续集成(Continuous Integration, CI)持续部署(Continuous Deployment, CD) 的缩写,是现代软件开发中一种自动化流程,旨在提高开发效率、减少人为错误并加快产品交付速度。

基本感念

持续集成(CI), 开发者频繁地将代码更改合并到主分支中,并通过自动化工具运行测试(如单元测试、集成测试等),以确保新代码不会引入问题。 用以检测代码冲突和潜在问题,同时自动化构建和测试,确保代码质量。

持续部署(CD), 在 CI 的基础上,进一步将通过测试的代码自动部署到生产环境或预发布环境。

社区对于CI/CD,已经有了非常成熟的部署方案了,接下来简单的介绍下

  1. Vercel

    • 特点:专为现代前端框架(如 React、Next.js、Vue 等)优化。
    • 功能:支持自动构建和部署,提供预览环境。
    • 使用场景:静态网站或单页应用。
  2. Netlify

    • 特点:支持 Git 集成,提供持续部署功能。
    • 功能:自动构建静态站点,支持自定义构建命令。
    • 使用场景:静态网站、JAMstack 应用。
  3. GitHub Pages

    • 特点:简单易用,免费托管静态网站。
    • 功能:结合 GitHub Actions 实现自动化部署。
    • 使用场景:小型项目或文档站点。
  4. GitHub Actions

    • 特点:与 GitHub 深度集成,支持自定义工作流。
开始部署

由于我们的项目已经部署成功了,在我们的云服务器上,当然是手动的,先配置nginx代理,然后将项目的dist包放到指定的目录下面即可,想体验下CI/CD流程,首先选用的是Vercel

首先去官网注册个账号,最好使用github账号来注册,这样可以直接导入仓库里的代码。注册完成以后,选择要部署的仓库,选择dist路径,就可以了

还是非常简单的,提供了预览的路径

点击Domains,添加域名,由于 Vercel 域名绑定机制只支持标准的域名格式(如 example.com),而不支持直接使用 IP 地址(如 http://118.31.222.50/vue-admin-pro/)。Vercel 的设计初衷是托管项目到其全球 CDN 网络中,而不是将项目部署到外部服务器。要通过ip部署,可以使用nginx代理一下,但是这样就很是麻烦了,转战 GitHub Actions

GitHub Actions 部署
  1. 生成SSH密钥对

    运行命令

    ssh-keygen -t rsa -b 4096 -C "your_email@example.com"

    这将生成两个文件:

    • ~/.ssh/id_rsa(私钥)
    • ~/.ssh/id_rsa.pub(公钥)
  2. 添加公钥

    将公钥添加到云服务器的~/.ssh/authorized_keys

    运行命令

    ssh-copy-id -i ~/.ssh/id_rsa.pub your_username@118.31.222.50

    其中 your_username 是你在云服务器上的用户名,118.31.222.50 是你的服务 ,然后输入密码即可

  3. 确保SSH服务允许公钥认证

    • PubkeyAuthentication yes
    • AuthorizedKeysFile .ssh/authorized_keys
    • PasswordAuthentication no(可选,建议禁用密码登录以提高安全性)
  4. 测试SSH连接

    ssh your_username@118.31.222.50 当不用输入密码就可以直接登录云服务器,说明我们的配置正确的

  5. 配置GitHub Secrets

    进入到我们的git仓库,点击 Settings > Secrets and variables > Actions

    添加密钥 SSH_PRIVATE_KEY :。。。

  6. 创建GitHub Actions 工作流文件

    yaml 复制代码
    name: Deploy Vue Admin Pro
    ​
    on:
      push:
        branches:
          - main # 当推送到 main 分支时触发
    ​
    jobs:
      build-and-deploy:
        runs-on: ubuntu-latest
    ​
        steps:
          - name: Checkout code
            uses: actions/checkout@v3
    ​
          - name: Set up Node.js
            uses: actions/setup-node@v3
            with:
              node-version: '16' # 使用 Node.js 16
    ​
          - name: Install dependencies
            run: npm install
    ​
          - name: Build project
            run: npm run build
    ​
          - name: Deploy to server
            uses: appleboy/scp-action@master
            with:
              host: 118.31.222.50 # 只使用 IP 地址
              username: root # 或者使用非 root 用户
              key: ${{ secrets.SSH_PRIVATE_KEY }}
              source: 'dist/' # 构建输出目录
              target: '/var/www/vue-admin-pro/' # 服务器目标路径
    ​
          - name: Restart Nginx
            uses: appleboy/ssh-action@master
            with:
              host: 118.31.222.50 # 只使用 IP 地址
              username: root # 或者使用非 root 用户
              key: ${{ secrets.SSH_PRIVATE_KEY }}
              script: |
                sudo systemctl restart nginx
    ​

然后将我们常见的文件提交到github即可。

正常情况下,使用vscode一般看不见.git开头的影藏文件,点击 File => Preferences => setting => 搜索File Explorer 然后将 **/.git 删除即可。

后续我们就可以通过git仓库中的Action来查看我们的部署情况,如果有什么问题查看日志即可。

到这里我们的自动化部署已经完成了,后续我们提交代码git push origin main时就会自动走我们的github Action,完整自动部署。

相关推荐
伍哥的传说37 分钟前
鸿蒙系统(HarmonyOS)应用开发之手势锁屏密码锁(PatternLock)
前端·华为·前端框架·harmonyos·鸿蒙
yugi98783839 分钟前
前端跨域问题解决Access to XMLHttpRequest at xxx from has been blocked by CORS policy
前端
浪裡遊1 小时前
Sass详解:功能特性、常用方法与最佳实践
开发语言·前端·javascript·css·vue.js·rust·sass
旧曲重听12 小时前
最快实现的前端灰度方案
前端·程序人生·状态模式
默默coding的程序猿2 小时前
3.前端和后端参数不一致,后端接不到数据的解决方案
java·前端·spring·ssm·springboot·idea·springcloud
夏梦春蝉2 小时前
ES6从入门到精通:常用知识点
前端·javascript·es6
归于尽2 小时前
useEffect玩转React Hooks生命周期
前端·react.js
G等你下课2 小时前
React useEffect 详解与运用
前端·react.js
我想说一句2 小时前
当饼干遇上代码:一场HTTP与Cookie的奇幻漂流 🍪🌊
前端·javascript
funnycoffee1232 小时前
Huawei 6730 Switch software upgrade example版本升级
java·前端·华为