3w+字的后台管理通用功能解决方案送给你

以下都是后台管理系统一些通用的技术,参考这篇文章,你可以了解到如何做git提交规范,国际化的实现,主题切换的实现,全屏,excel导入导出等等。

入职之前,狂补技术,4w字的前端技术解决方案送给你(vue3 + vite )

仓库地址,每个部分都有对应的分支,如果对你有帮助,欢迎star。❤

代码编写规范

vite中配置eslint

yarn add eslint vite-plugin-eslint eslint-plugin-vue standard -D

执行npx eslint --init生成配置文件。

注意:vscode的eslint插件只能给出格式错误提示和自动格式化。而不是安装了插件就可以有格式提示。还需要我们在开发的时候安装对应的eslint包,配置是否开启对应的格式检查。从而配合vscode的插件。

他一般和prettier代码格式化工具配合使用。并结合vscode prettier code formatter插件来达到格式化代码的效果(保存代码时)

{
  "useTabs": false,
  "tabWidth": 4,
  "printWidth": 80,
  "singleQuote": true,
  "trailingComma": "none",
  "semi": false,
  "endOfLine": "auto"
}

git提交规范

一般都会遵循angular团队的提交规范。

如果我们提交代码时自己去遵循这种规范,那将是很痛苦的。所以我们需要工具来代替我们完成。

commitizen,他表示当我们使用commitizen进行代码提交(git commit)时, commitizen会提交你在提交时填写的所有必须的提交字段。

下面我们来看其具体使用。

全局安装commitizen

yarn add commitizen -g

安装并配置cz-customizable插件。

yarn add cz-customizable -D

 "config": {
    "commitizen": {
      "path": "node_modules/cz-customizable"
    }
  }

项目根目录下创建.cz-config.js自定义提示文件。并且将package.json中的type改成commonjs。

module.exports = {
  
  types: [
    { value: 'feat', name: 'feat: 新功能' },
    { value: 'fix', name: 'fix: 修复' },
    { value: 'docs', name: 'docs: 文档变更' },
    { value: 'style', name: 'style: 代码格式(不影响代码运行的变动)' },
    { value: 'refactor', name: 'refactor: 重构代码' },
    { value: 'perf', name: 'perf: 性能优化' },
    { value: 'test', name: 'test: 测试' },
    { value: 'chore', name: 'chore: 构建过程或辅助工具的变动' },
    { value: 'revert', name: 'revert: 回滚' },
    { value: 'build', name: 'build: 打包' }
  ],
  
  messages: {
    type: '选择你的提交类型:',
    customScope: '选择你的修改范围(可选):',
    subject: '请简要描述提交(必填):',
    body: '请输入详细内容(可选):',
    footer: '请输入要关闭的issue(可选):',
    confirmCommit: '确认提交?(y/n)'
  },
  
  skipQuestions: ['body', 'footer'],
  
  subjectLimit: 72
}








































































但是这样只是在使用git cz的情况下才可以做到规范化提交。但是还是可以使用git commit来提交的。所以我们要杜绝使用git commit直接进行不规范的提交。

可以使用`commitlint`[4]来达到效果。主要的目的是检测提交信息的规范性。

yarn add @commitlint/config-conventional @commitlint/cli -D

在根目录创建commitlint.config.js文件,配置commitlint

module.exports = {
  
  extends: ['@commitlint/config-conventional'],
  
  rules: {
    
    'type-enum': [
      
      
      2,
      
      'always',
      
      [
        'feat', 
        'fix', 
        'docs', 
        'style', 
        'refactor', 
        'perf', 
        'test', 
        'chore', 
        'revert', 
        'build' 
      ]
    ],
    
    'subject-case': [0]
  }
}

有了commitlint配置,我们还需要执行在什么时机触发git消息提交检验,所以需要使用`husky`[5]来完成。 主要是监听commit-msg钩子来校验其规范性。

husky是一个git hook工具,可以帮助我们触发git提交的各个阶段:pre-commit、commit-msg、pre-push。

yarn add husky -D

启动husky,生成.husky文件夹

yarn husky install

在package中配置指令。

prepare: "husky install"

添加 commitlint的hook到husky中,并指令在commit-msg的hooks下执行npx --no-install commitlint --edit "$1"指令。

yarn husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

可能有些人提交代码,代码并不是很规范(例如未配置代码保存时格式化)。所以我们需要监听提交的钩子函数,来做一些代码格式化工作。所以我们依旧是使用husky来监听git hooks触发,然后做一些校验工作。

所以需要监听pre-commit钩子来对代码进行格式化。

yarn husky add .husky/pre-commit "npx eslint --ext .js,.vue src"

我们修改一个文件,测试发现。

上面这种方式只能提示代码出错的位置,并且还会检查src目录下的所有代码,浪费大量时间。我们只需要检测修改后的代码文件即可。 所以我们需要使用`lint-staged`[6]只检查本次修改更新的代码,并在出现错误的时候,自动修复并推送。

"gitHooks": {
    "pre-commit": "lint-staged"
  },
  "lint-staged": {
    "src/**/*.{js,vue}": [
      "eslint --fix",
      "git add"
    ]
  }

然后将.husky/pre-commit文件下的npx eslint --ext .js,.vue src修改成npx lint-staged

案例代码[7]

svg图标使用

一般项目中,我们会使用到组件库提供的svg图标,如果不能满足条件,我们也会使用自定义的svg图标,那么如何使用呢?我们将对svg图标的使用封装成一个通用的组件。

在webpack中实现svg图标注册。使用`require.context()`[8]来引入指定文件夹下的所有svg图标。

import SvgIcon from '@/components/SvgIcon'



const svgRequire = require.context('./svg', false, /\.svg$/)



svgRequire.keys().forEach(svgIcon => svgRequire(svgIcon))

export default app => {
  app.component('svg-icon', SvgIcon)
}

并且需要使用`svg-sprite-loader`[9]插件来协助我们显示svg图标。

const path = require('path')
function resolve(dir) {
  return path.join(__dirname, dir)
}
module.exports = {
  chainWebpack(config) {
    
    config.module.rule('svg').exclude.add(resolve('src/icons')).end()
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/icons'))
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
      .end()
  }
}

但是在vite中,我们可以通过插件完成,具体看这里[10]

element-plus表单验证要素

  • el-form 指定model,rules字段。

  • el-form-item 指定prop字段。

并且可以通过validator来自定义校验规则。具体参考这里[11]

接口设计

对于接口的设计,如果可以参入后端设计,最好让后端都返回一个标识,来表示当前请求是否成功,方便我们在拦截器中做message提示。

service.interceptors.response.use(
  (response) => {
    const { success, message, data } = response.data
    
    if (success) {
      ElMessage.success(message)
      
      return data
    } else {
      
      ElMessage.error(message) 
      return Promise.reject(new Error(message))
    }
  },
  (error) => {
    
    if (
      error.response &&
      error.response.data &&
      error.response.data.code === 401
    ) {
      
    }
    ElMessage.error(error.message) 
    return Promise.reject(error)
  }
)

在响应拦截器统一处理message提示。让我们在页面逻辑中不需要在过多的判断,来处理message消息。

我们注意到上面出现错误,我们将返回一个error promise。所以如果在页面中我们想要控制加载的状态。我们可以通过try catch来捕获错误。无论成功还是失败,都关闭加载状态。

const store = useStore()
const loading = ref(false)

 * 对于接口设计,最好都返回一个成功 / 失败的标识。让我们更好的在axios难解其中处理message提示,然后返回error promise。

 在代码逻辑中就不需要处理message了。只需要去判断按钮加载的状态即可。
 */
const handleLogin = async () => {
  loading.value = true
  try {
    await store.dispatch('user/postLogin', loginForm.value)
  } finally {
    loading.value = false
  }
}

退出登录

  • 清除当前用户缓存的数据

  • 清除掉权限相关的配置

  • 返回登录页面

主动退出和被动退出

  • 主动退出:用户点击退出按钮

  • 被动退出:token失效或者单点登录。(这些都是后端判断完毕,返回不同的状态码,前端在拦截器中处理一下就行。

被动退出,主动处理

在前端判断token是否过期,过期后,直接退出。

  • 登录成功后,我们保存一个时间戳在localStorage中。

  • 设置一个过期时间,每次请求判断当前token是否在过期时间段内。

 * 获取时间戳
 */
export function getTimeStamp() {
  return getItem(TIME_STAMP)
}

 * 设置时间戳
 */
export function setTimeStamp() {
  setItem(TIME_STAMP, Date.now())
}

 * 是否超时
 */
export function isCheckTimeout() {
  
  const currentTime = Date.now()
  
  const timeStamp = getTimeStamp()
  return currentTime - timeStamp > TOKEN_TIMEOUT_VALUE
}

vite中js使用scss变量

文件命名...module.scss。以module.scss为后缀。然后通过:export进行导出,即可在js中导入直接使用。具体可查看这里[12]

$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;

$menuBg: #304156;
$menuHover: #263445;

$subMenuBg: #1f2d3d;
$subMenuHover: #001528;

$sideBarWidth: 210px;

$hideSideBarWidth: 54px;
$tagViewsList:#42b983;


$sideBarDuration: 0.28s;

:export {
  menuText: $menuText;
  menuActiveText: $menuActiveText;
  subMenuActiveText: $subMenuActiveText;
  menuBg: $menuBg;
  menuHover: $menuHover;
  subMenuBg: $subMenuBg;
  subMenuHover: $subMenuHover;
  sideBarWidth: $sideBarWidth;
  tagViewsList:#42b983;
}

菜单列表处理

一般情况下,我们都会通过当前用户的路由列表来获取到对应的菜单列表。所以我们就需要去处理一些路由列表。

获取路由表

  • `router.options.routes`[13]: 获取初始路由表 (新增的路由表无法获取到)

  • `router.getRoutes()`[14]: 获取所有路由列表。并且可以获取父级路由和子级路由。

由于router.getRoutes()获将二级路由也获取到当前列表中。所以我们需要将其过滤掉。我们只是用,嵌套路由下的列表即可。而无需将其提升到一级路由。

 * 获取全部二级路由
 通过当前嵌套路由的children来获取即可。
 */

function getRouteChildren(routes) {
  const _routes = []
  for (const item of routes) {
    if (item?.children?.length > 0) {
      _routes.push(...item.children)
    }
  }
  return _routes
}

过滤二级路由,因为我们菜单列表展示的和我们路由列表的结构是一样的。所以我们需要将二级路由过滤掉。

 * 过滤重复路由
 */
export function filterRoutes(routes) {
  
  const routeChildren = getRouteChildren(routes)
  return routes.filter((item) => {
    return !routeChildren.find((route) => route.path === item.path)
  })
}

然后就是处理那些路由是在菜单栏中可见的。这个需要根据你们的逻辑来判断。主要就是递归处理route.children中的路由而已。

下面来介绍menu组件的一些属性

:collapse 
:default-active 
:background-color 
:text-color 
:active-text-color 
:unique-opened="true" 
:collapse-transition 
router 

在设计菜单组件的时候,我们需要将子菜单封装成一个·组件,这样便于我们处理嵌套菜单。

<template>
  <!-- 判断是否直接展示menu-item还是submenu-item -->
  <el-sub-menu v-if="menu.children.length > 0" :index="menu.path">
    <template #title>
      <menu-item :title="menu.meta.title" :icon="menu.meta.icon"></menu-item>
    </template>
    
    <side-bar-item
      v-for="route in menu.children"
      :menu="route"
      :key="route.path"
    ></side-bar-item>
  </el-sub-menu>
  <el-menu-item v-else :index="menu.path">
    <menu-item
      :title="menu.meta.title"
      :icon="menu.meta.icon"
      :iconName="menu.meta.iconName"
    ></menu-item>
  </el-menu-item>
</template>

案例代码[15]

el-dropdown中使用el-tooltip出现警告

需要将el-tooltip组件包裹一层。

 <el-dropdown @command="handleLanguageSelect" trigger="click">
  <!-- 这里需要包裹一层,不然会报错 -->
  <div>
    <el-tooltip :effect="effect" :content="$t('msg.navBar.lang')">
      <svg-icon
        id="guide-lang"
        icon="language"
        class="language-icon"
      ></svg-icon>
    </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>

国际化

对于国际化,我们需要处理两部分。一部分是组件库的国际化,一部分是我们自己文本的国际化。

  • 组件库国际化,我们可以使用对应组件库提供的api来完成。比如,element-plus。

import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/lib/locale/lang/en'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'


app
  .use(ElementPlus, {
    locale: store.getters.language === 'zh' ? zhCn : en
  })

  • 自定义国际化。我们需要定义对应的语言包。就是将对应的文本事先编写多种对应的语言。然后使用`vue-i18n`[16]来注册我们的语言包。

import { createI18n } from 'vue-i18n'
import zhLocale from './lang/zh'
import enLocale from './lang/en'
import store from '@/store'

const messages = {
  en: {
    msg: {
      ...enLocale
    }
  },
  zh: {
    msg: {
      ...zhLocale
    }
  }
}

const i18nInstance = createI18n({
  
  legacy: false,
  
  globalInjection: true,
  messages,
  locale: store.getters.language
})

export default i18nInstance

语言包

export default {
  login: {
    title: '用户登录',
    loginBtn: '登录',
    usernameRule: '用户名为必填项',
    passwordRule: '密码不能少于6位',
    usernamePlaceholder: '请输入用户名',
    passwordPlaceholder: '请输入密码'
  }
}

 
 export default {
   login: {
    title: 'User Login',
    loginBtn: 'Login',
    usernameRule: 'Username is required',
    passwordRule: 'Password cannot be less than 6 digits',
    usernamePlaceholder: 'please enter your username',
    passwordPlaceholder: 'please enter your password'
  },
}

v-bind的最佳实践(多个组件使用相同的css)

对于多个组件都需要使用相同的css时,我们需要在每个组件根元素上绑定v-bind='$attrs'。然后只需要在使用该组件的父组件中设置对应的class即可将这些class加载对应的组件中,达到css复用的目的。

对于class而言,单一根元素,会被主动加在根元素上。 多个根元素,我们就需要使用v-bind="$attrs"来指定具体绑定到哪个元素上了。

 <!-- 主题更换 -->
  <theme-select class="right-wrapper-item"></theme-select>
  <!-- 国际化 -->
  <language-select
    effect="dark"
    class="right-wrapper-item"
  ></language-select>

如果不想让其挂载到根标签,我们需要设置inheritAttrs: false,来阻止这种默认行为。 不管inheritAttrs设置成true还是false,都可以通过attrs获取到全部的非props。包括class, style

    
    defineOptions({
      inheritAttrs: false
    })

    const attrs = useAttrs()
    console.log(attrs)

主题切换

了解了国际化,我们就来了解一下主题切换吧。他也是主要分为组件库的主题和我们自己内容的主题。

对于组件库的主题切换,我们就直接修改他的css变量就行了。例如element-plus

  • 获取当前elemen-plus的所有样式。我们可以通过cdn进行获取当前版本的css文件

async function getElementPlusStyles() {
  const { version } = await import('element-plus/package.json')
  const url = `https://unpkg.com/element-plus@${version}/dist/index.css`
  const styles = await axios(url)
  return styles.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'
  }

用正则替换掉获取到的css文本中的对应的颜色值为标记。例如(#3a8ee6 => shade-1)

 * 将主题颜色对应的css值改成对应的关键标志
 */

async function generateElementPlusTemplate() {
  
  let styles = await getElementPlusStyles()
  
  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) => {
    
    styles = styles?.replace(new RegExp(key, 'ig'), colorMap[key])
  })
  return styles
}

将styles文本中的标志替换成我们当前的主题色,在此之前,我们还需要处理一下根据当前主题色生成其他对应的辅色。

这里需要使用到两个库来处理`css-color-function`[17]他是用来处理color()生成rgb颜色值,`rgb-hex`[18]他是将rgb颜色转换成16进制颜色值。

定义根据主色生成辅色对象

{
  "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)"
}

 * @param {*} currentColor 当前主题色
 * 更改color模板对应的color函数,根据传入的颜色值,将其转化成对应的16进制颜色值
 */
export function generateColors(currentColor) {
  if (!currentColor) return
  
  const colors = {
    primary: currentColor
  }

  Object.keys(themeTemplate).forEach((key) => {
    
    const theme = themeTemplate[key].replace('primary', currentColor)
    colors[key] = '#' + rgbHex(color.convert(theme))
  })
  return colors
}

  • 把替换后的样式写入到style标签中,利用样式优先级的特性代替固有样式。

生成完毕后,我们就可以替换掉styles中的标志了。然后生成style再插入到head中。

 *
 * @param {*} currentColor 当前主题颜色
 *  修改样式表模板,生成最终element-plus
 */
export async function generateStyleTemplate(currentColor) {
  
  const colors = generateColors(currentColor)
  
  let styles = await generateElementPlusTemplate()
  
  Object.keys(colors).forEach((key) => {
    styles = styles.replace(
      new RegExp('(:|\\s+)' + key, 'g'),
      '$1' + colors[key]
    )
  })

  return styles
}


 *
 * @param {*} currentColor 当前主题颜色
 * 将样式表插入到head中
 */
export async function insertStyleToPage(currentColor) {
  const style = document.createElement('style')
  style.innerText = await generateStyleTemplate(currentColor)
  document.head.appendChild(style)
}

对于第三方包主题,他是不可控的,我们需要拿到他编译后的css进行色值替换,利用style内部样式表优先级高于外部样式表的特性,来进行主题替换。

对于自定义内容主题,我们只需要改变对应的css变量即可。在项目开发时,我们的menu菜单背景等,都是通过js变量的方式绑定到css中的。所以我们可以很轻松的改变js变量来达到css的变化。

比如在vuex中设置getters,当主题色发生变化,该getters就会重新计算,然后就会得到新的颜色变量并赋值。

 
  cssVar(state, getters) {
    return {
      ...variables,
      
      ...generateColors(getters.themeColor)
    }
  },

案例代码[19]

全屏

可以使用`screenfull`[20]库去实现。通过toggle触发全屏,并监听他的change事件来监听全屏的切换更改展示图标。

import { computed, onMounted, onUnmounted, ref } from 'vue'
import screenfull from 'screenfull'

const isFull = ref(false)
const iconName = computed(() =>
  isFull.value ? 'exit-fullscreen' : 'fullscreen'
)


const change = () => {
  isFull.value = screenfull.isFullscreen
}


const handleClick = () => {
  screenfull.toggle()
}


onMounted(() => {
  screenfull.on('change', change)
})


onUnmounted(() => {
  screenfull.off('change', change)
})

案例代码[21]

搜索

全局搜索功能在后台管理系统中是非常常见的,主要是让用户快速定位到目标。所以我们可以使用fuse.js[22]库,来协助我们完成。

由于fuse对收索的数据结构有特定要求,并结合当前我们的需求,搜索的关键字段需要是对象的直接属性。所以需要处理好数据。这里是相关demo[23]

处理数据

 * 筛选出可供搜索的路由对象
 * @param routes 路由表
 * @param basePath 基础路径,默认为 /
 * @param prefixTitle
 */
export const getFuseData = (routes, basePath = '/', prefixTitle = []) => {
  
  let res = []
  
  for (const route of routes) {
    
    const data = {
      path: route.path,
      title: [...prefixTitle]
    }
    
    
    
    const re = /.*\/:.*/
    if (route.meta && route.meta.title && !re.exec(route.path)) {
      const i18ntitle = i18n.global.t(`msg.route.${route.meta.title}`)
      data.title = [...data.title, i18ntitle]
      res.push(data)
    }

    
    if (route.children) {
      const tempRoutes = getFuseData(route.children, data.path, data.title)
      if (tempRoutes.length >= 1) {
        res = [...res, ...tempRoutes]
      }
    }
  }
  return res
}

初始化fuse

let fuse
const initFuse = (searchPool) => {
  fuse = new Fuse(searchPool, {
    
    shouldSort: true,
    
    minMatchCharLength: 1,
    
    
    
    keys: [
      {
        name: 'title',
        weight: 0.7
      },
      {
        name: 'path',
        weight: 0.3
      }
    ]
  })
}


initFuse(generateStandardData.value)

然后调用fuse.search(value)方法并传入搜索关键字就可以得到搜索列表了。

如果搜索数据需要做到国际化,我们是事先在搜索代码中已经将对应的字段转化了。所以在切换国际化时,将不会被改变。这时候我们需要监听国际化的切换,然后再重新初始化一下fuse的数据源。

 watch(
    () => store.getters.language,
    () => {
      
      const generateStandardData = computed(() => {
          
          const originData = generateMenus(router.getRoutes())
          
          return getFuseData(originData)
      })
      initFuse(generateStandardData.value)
    }
  )

这里需要注意一下,我们在搜索时,一般使用的是select组件,如element-plus中的select, 我们需要添加remote才可以将初始化的下拉字标去掉。然后绑定remote-method一个方法去处理搜索。

案例代码[24]

自定义元素右键菜单

通过web api `contextMenu`[25]去实现。

在元素中绑定contextMenu事件。并控制菜单的展示和隐藏,菜单的位置。菜单是我们自定义的组件。

<mouse-menu
  v-show="isMenu"
  :style="menuStyle"
  :currentTagIndex="currentTagIndex"
  @close-menu="handleCloseMenu"
></mouse-menu>


 * 鼠标右键,菜单展示
 */
const isMenu = ref(false)
const currentTagIndex = ref(0)
const menuStyle = reactive({
  left: 0,
  top: 0
})
const handleOpenMenu = (e, index) => {
  const { x, y } = e
  menuStyle.left = x + 'px'
  menuStyle.top = y + 'px'
  isMenu.value = true
  currentTagIndex.value = index
}

菜单组件。

<template>
  <ul class="mouse-menu">
    <li @click="handleRefreshClick">
      {{ $t('msg.tagsView.refresh') }}
    </li>
    <li @click="handleCloseRightClick">
      {{ $t('msg.tagsView.closeRight') }}
    </li>
    <li @click="handleCloseOtherClick">
      {{ $t('msg.tagsView.closeOther') }}
    </li>
  </ul>
</template>

<script setup>
import { useStore } from 'vuex'

const props = defineProps({
  currentTagIndex: {
    type: Number,
    required: true
  }
})

const emits = defineEmits('closeMenu')


 * 刷新
 */
const handleRefreshClick = () => {
  location.reload()
  emits('closeMenu')
}


 * 关闭右侧
 */
const store = useStore()
const handleCloseRightClick = () => {
  store.commit('app/removeRightTags', props.currentTagIndex)
  emits('closeMenu')
}


 * 关闭其他
 */
const handleCloseOtherClick = () => {
  store.commit('app/removeOtherTags', props.currentTagIndex)
  emits('closeMenu')
}
</script>

<style scoped lang="scss">
.mouse-menu {
  position: fixed;
  background: #fff;
  z-index: 3000;
  list-style-type: none;
  padding: 5px 0;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 400;
  color: #333;
  box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
  li {
    margin: 0;
    padding: 7px 16px;
    cursor: pointer;
    &:hover {
      background: #eee;
    }
  }
}
</style>

我们需要注意,在点击完菜单项时,菜单并不会关闭,所以我们需要自定义关闭事件,触发关闭。 但是如果用户不点击菜单项,那么菜单也不会关闭,所以我们需要在菜单显示的时候,给body添加事件,让其关闭。

const closeMenu = () => {
  isMenu.value = false
}


 * 监听变化,没点击时,关闭menu
 */
watch(isMenu, (val) => {
  if (val) {
    document.body.addEventListener('click', closeMenu)
  } else {
    document.body.removeEventListener('click', closeMenu)
  }
})

案例代码[26]

路由动效切换错误

路由过度动效[27]

<router-view v-slot="{ Component, route }">
  <transition name="fade" mode="out-in">
    <keep-alive>
      <component :is="Component" :key="route.path"></component>
    </keep-alive>
  </transition>
</router-view>

在我们使用动效路由时,我们的路由组件不能是多个根标签。 不然会报警告不显示内容。

定义全局属性

这个还是挺常用的。例如组件中时间戳的处理,我们可以定义一个通用的处理方法,将其作为全局属性,然后再组件中直接使用即可,不需要每次都在组件中引入dayjs去处理了。

import dayjs from 'dayjs'


 * 格式化时间
 */
export const $timeFormat = (val, format = 'YYYY-MM-DD') => {
  if (!isNaN(val)) {
    val = parseInt(val)
  }

  return dayjs(val).format(format)
}

export default function (app) {
  app.config.globalProperties = {
    $timeFormat
  }
}


在组件模板中可以直接使用,在setup语法中,我们可以通过getCurrentInstance().appContext.config.globalProperties获取到全局属性。

excel导入功能

使用xlsx[28]来解析excel。然后传递解析后的数据。其实excel上传,也可以让后端去解析。

主要分为以下步骤,我们需要借助`FileReader`[29]来读取文件内容。

const readFile = new FileReader()

readFile.readAsArrayBuffer(file)
readFile.onload = (e) => {
  
  const data = e.target.result
  
  const workbook = XLSX.read(data, { type: 'array' })
  
  const firstSheetName = workbook.SheetNames[0]
  
  const worksheet = workbook.Sheets[firstSheetName]
  
  const header = getHeaderRow(worksheet)
  
  const results = XLSX.utils.sheet_to_json(worksheet)
  
  generateData({ header, results })

有个需要注意的地方就是解析excel的时间是有误的,需要处理转化一下。

 * 解析 excel 导入的时间格式
 */
export const formatDate = (numb) => {
  const time = new Date((numb - 1) * 24 * 3600000 + 1)
  time.setYear(time.getFullYear() - 70)
  const year = time.getFullYear() + ''
  const month = time.getMonth() + 1 + ''
  const date = time.getDate() - 1 + ''
  return (
    year +
    '-' +
    (month < 10 ? '0' + month : month) +
    '-' +
    (date < 10 ? '0' + date : date)
  )
}

还有就是直接导入XLSX时,会报错,因为他默认是没有到处默认变量的。所以我们要么结构,要么通过* as语法导入。

import * as XLSX from 'xlsx'

如果我们想要拖拽文件到上传区域,我们需要使用拖拽的一些事件[30]进行处理。

使用拖拽api的注意事项

  • 拖拽对象需要设置draggable属性,目标对象可以不需要。

  • 拖拽对象触发ondragstart事件。

  • 如果想触发目标事件的drop方法,我们需要先触发目标事件的dragover方法,并且设置阻止默认事件

一些事件

  • dragstart: 开始拖拽对象时触发。在这里开始传递一些源数据。e.dataTransfer.setData()`

  • dragover: 当被拖拽元素未离开可释放目标元素上时,触发该事件。在拖拽的过程中。

  • dragleave: 当被拖拽元素离开可释放目标元素上时,触发该事件。

  • drop: 拖拽元素拖拽到可释放目标对象释放后触发。即在这边获取拖拽元素时传入的一些源数据,做一些其他的逻辑处理。通过e.dataTransfer.getData()来获取对应的属性。

这里有一个小案例[31]

案例代码[32]

导出为excel

  • 获取数据

  • 将数据转为excel数据,并下载。

处理数据主要就是excel表头和数据主体。数据主体处理成一个二维数组即可。子项如果是对象,我们可以通过JSON.stringify将其转json字符串,然后插入到excel中。

使用到两个库

  • file-saver: 处理文件下载

  • xlsx

import { saveAs } from 'file-saver'
import XLSX from 'xlsx'

function datenum(v, date1904) {
  if (date1904) v += 1462
  var epoch = Date.parse(v)
  return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000)
}

function sheet_from_array_of_arrays(data, opts) {
  var ws = {}
  var range = {
    s: {
      c: 10000000,
      r: 10000000
    },
    e: {
      c: 0,
      r: 0
    }
  }
  for (var R = 0; R != data.length; ++R) {
    for (var C = 0; C != data[R].length; ++C) {
      if (range.s.r > R) range.s.r = R
      if (range.s.c > C) range.s.c = C
      if (range.e.r < R) range.e.r = R
      if (range.e.c < C) range.e.c = C
      var cell = {
        v: data[R][C]
      }
      if (cell.v == null) continue
      var cell_ref = XLSX.utils.encode_cell({
        c: C,
        r: R
      })

      if (typeof cell.v === 'number') cell.t = 'n'
      else if (typeof cell.v === 'boolean') cell.t = 'b'
      else if (cell.v instanceof Date) {
        cell.t = 'n'
        cell.z = XLSX.SSF._table[14]
        cell.v = datenum(cell.v)
      } else cell.t = 's'

      ws[cell_ref] = cell
    }
  }
  if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range)
  return ws
}

function Workbook() {
  if (!(this instanceof Workbook)) return new Workbook()
  this.SheetNames = []
  this.Sheets = {}
}

function s2ab(s) {
  var buf = new ArrayBuffer(s.length)
  var view = new Uint8Array(buf)
  for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xff
  return buf
}

export const export_json_to_excel = ({
  multiHeader = [],
  header,
  data,
  filename,
  merges = [],
  autoWidth = true,
  bookType = 'xlsx'
} = {}) => {
  
  filename = filename || 'excel-list'
  
  data = [...data]
  data.unshift(header)
  
  for (let i = multiHeader.length - 1; i > -1; i--) {
    data.unshift(multiHeader[i])
  }
  
  var ws_name = 'SheetJS'
  
  var wb = new Workbook()
  
  var ws = sheet_from_array_of_arrays(data)
  
  if (merges.length > 0) {
    if (!ws['!merges']) ws['!merges'] = []
    merges.forEach((item) => {
      ws['!merges'].push(XLSX.utils.decode_range(item))
    })
  }
  
  if (autoWidth) {
    
    const colWidth = data.map((row) =>
      row.map((val) => {
        
        if (val == null) {
          return {
            wch: 10
          }
        } else if (val.toString().charCodeAt(0) > 255) {
          
          return {
            wch: val.toString().length * 2
          }
        } else {
          return {
            wch: val.toString().length
          }
        }
      })
    )
    
    let result = colWidth[0]
    for (let i = 1; i < colWidth.length; i++) {
      for (let j = 0; j < colWidth[i].length; j++) {
        if (result[j]['wch'] < colWidth[i][j]['wch']) {
          result[j]['wch'] = colWidth[i][j]['wch']
        }
      }
    }
    ws['!cols'] = result
  }

  
  wb.SheetNames.push(ws_name)
  wb.Sheets[ws_name] = ws
  
  var wbout = XLSX.write(wb, {
    bookType: bookType,
    bookSST: false,
    type: 'binary'
  })
  
  saveAs(
    new Blob([s2ab(wbout)], {
      type: 'application/octet-stream'
    }),
    `${filename}.${bookType}`
  )
}

案例代码[33]

打印功能

使用vue3-print-nb[34] 进行打印。

注册全局指令

import print from 'vue3-print-nb'

export default (app) => {
  app.use(print)
}

定义指令值

const printObj = {
  
  id: 'userInfoBox',
  
  popTitle: '成员信息',
  
  beforeOpenCallback(vue) {
    printLoading.value = true
  },
  
  openCallback(vue) {
    printLoading.value = false
  }
}

使用

 <el-button type="primary" :loading="printLoading" v-print="printObj" >打印</el-button>

案例代码[35]

权限控制

  • 用户列表可以指定角色。

  • 角色列表可以分配权限。

  • 权限列表可以所有权限。

用户-> 角色 -> 权限。RBAC权限控制体系,他就是基于角色的权限控制用户的访问。

就是结合后台返回的角色权限列表,配合动态路由router.addRoute()进行路由动态注册。

需要注意的是我们添加完动态路由后,需要加上 // 防止刷新后丢失添加的路由 next({ ...to, replace: true }),防止刷新页面动态添加路由的丢失。并且我们每次刷新都需要重新获取该用户的权限列表来重新动态添加路由。

vue-router官网有说明[36]

自定义指令

一般自定义指令都是全局的,局部的也没必要使用指令了。通过app.directive('name', directiveObj)来进行注册。

内部提供一些生命周期钩子去辅助我们编写指令逻辑。不明白的可以看这里[37]

import store from '@/store'

export default function () {
  return {
    mounted(el, bindings) {
      
      const { value } = bindings
      
      const actionPermissions = store.getters.userInfo.permission.points || []
      
      if (
        (value &&
          ['number', 'string'].includes(typeof value) &&
          actionPermissions.includes(value)) ||
        (value &&
          Array.isArray(value) &&
          value.filter((item) => actionPermissions.includes(item)).length ===
            value.length)
      ) {
        
        return
      } else {
        el.parentNode && el.parentNode.removeChild(el)
      }
    }
  }
}

import permission from './permission'

export default (app) => {
  app.directive('permission', permission())
}


表格拖动

监听鼠标时间,完成对应的页面重绘。

  • 监听鼠标按下事件

  • 监听鼠标移动事件

  • 生成对应的UI样式

  • 监听鼠标抬起事件

我们可以使用sortablejs[38]去实现。拖拽只是在视觉上实现了交换,如果刷新页面还是会回到原来的排序状态,我们还需要调用后端接口,去改变数据库中的数据顺序。onEnd事件可以拿到拖拽前后的下标值。从0开始。

export const tableRef = ref(null)


 * 初始化排序
 */
export const initSortable = (tableData, cb) => {
  
  const el = tableRef.value.$el.querySelectorAll('.el-table__body > tbody')[0]
  
  Sortable.create(el, {
    
    ghostClass: 'sortable-ghost',
    
    async onEnd(event) {
      const { newIndex, oldIndex } = event
      
     
      
      tableData.value = []
      
      cb && cb()
    }
  })
}

这里需要注意一下,如果我们向外界传递ref值,我们应该直接传递,而不是取出value在传递。防止响应式丢失。

报错

  • `defineProps` is referencing locally declared variables.[39]

  • Uncaught (in promise) SyntaxError: Must be called at the top of a `setup` function[40]。在引入I18n库时,报错。我们需要引入自己本地创建的I18n实例。

  • v-model cannot be used on a prop, because local prop bindings are not writable. Use a v-bind binding combined with a v-on listener that emits update:x event instead. 这个报错是因为模板中v-model直接绑定了props变量,所以报错。解决办法是我们在当前组件定义一个ref类型,通过watch去监听props变化,做到响应式。因为setup语法只执行一次,不具备响应式,所以需要通过watch监听。模板中直接使用是没有问题的。(除了v-model绑定外)

  • Cannot access 'publicRoutes' before initialization[41]。在vuex中动态添加路由时,给state变量初始化值报错。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Web面试那些事儿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值