Vue3 + Js + Element-Plus + VueX后台管理系统通用解决方案

前言

        本文是作为学习总结而写的一篇文章,也是方便以后有相关需求,可以直接拿来用,也算是记录吧,文中有一些文件的引入,没给出来,完整项目地址(后续代码仓库放这里)

1、layout解决方案

1.1、动态菜单

左侧整体文件 Sidebar.vue

<template><div class="a"><div class="logo-container"><el-avatar:size="logoHeight"shape="square"src="https://m.imooc.com/static/wap/static/common/img/logo-small@2x.png"/><span class="logo-title" v-if="$store.getters.sidebarOpened">imooc-admin</span></div><el-scrollbar><SidebarMenu :routes="routes" /></el-scrollbar></div>
</template><script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import SidebarMenu from './SidebarMenu.vue'
import { filterRouters, generateMenus } from '@/utils/route'const router = useRouter()
const routes = computed(() => {const filterRoutes = filterRouters(router.getRoutes())return generateMenus(filterRoutes)
})const logoHeight = 44
</script><style lang="scss" scoped>
.logo-container {height: v-bind(logoHeight) + 'px';padding: 10px 0;display: flex;align-items: center;justify-content: center;.logo-title {margin-left: 10px;color: #fff;font-weight: 600;line-height: 50px;font-size: 16px;white-space: nowrap;}
}
</style>

菜单文件 SidebarMenu.vue

<template><!-- 一级 menu 菜单 --><el-menu:collapse="!$store.getters.sidebarOpened":default-active="activeMenu":background-color="$store.getters.cssVar.menuBg":text-color="$store.getters.cssVar.menuText":active-text-color="$store.getters.cssVar.menuActiveText":unique-opened="true"router><sidebar-itemv-for="item in routes":key="item.path":route="item"></sidebar-item></el-menu>
</template><script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import SidebarItem from './SidebarItem.vue'defineProps({routes: {type: Array,required: true}
})// 计算高亮 menu 的方法
const route = useRoute()
const activeMenu = computed(() => {const { path } = routereturn path
})
</script><style></style>

控制是子菜单还是菜单项文件 SidebarItem.vue

<template><!-- 支持渲染多级 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-itemv-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>
</template><script setup>
import MenuItem from './MenuItem.vue'
import { defineProps } from 'vue'
// 定义 props
defineProps({route: {type: Object,required: true}
})
</script>

 显示菜单名字文件 MenuItem.vue

<template><el-icon><Location /></el-icon><span>{{ title }}</span>
</template><script setup>
import { Location } from '@element-plus/icons-vue'defineProps({title: {type: String,required: true},icon: {type: String,required: true}
})
</script>

1.2、动态面包屑

 代码文件

<template><el-breadcrumb class="breadcrumb" separator="/"><transition-group name="breadcrumb"><el-breadcrumb-itemv-for="(item, index) in breadcrumbData":key="item.path"><!-- 不可点击项 --><span v-if="index === breadcrumbData.length - 1" class="no-redirect">{{item.meta.title}}</span><!-- 可点击项 --><a v-else class="redirect" @click.prevent="onLinkClick(item)">{{item.meta.title}}</a></el-breadcrumb-item></transition-group></el-breadcrumb>
</template><script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {// route.matched 获取到匹配的路由// 比如 当前路由是 /article/create// 会匹配到 /article、/article/create 就可以用于面包屑点击跳转了breadcrumbData.value = route.matched.filter((item) => item.meta && item.meta.title)
}
// 监听路由变化时触发
watch(route,() => {getBreadcrumbData()},{immediate: true}
)// 处理点击事件
const router = useRouter()
const onLinkClick = (item) => {router.push(item.path)
}// 将来需要进行主题替换,所以这里获取下动态样式
const store = useStore()
const linkHoverColor = ref(store.getters.cssVar.menuBg)
</script><style lang="scss" scoped>
.breadcrumb {display: inline-block;font-size: 14px;line-height: 50px;margin-left: 8px;.redirect {color: #666;font-weight: 600;}.redirect:hover {// 将来需要进行主题替换,所以这里不去写死样式color: v-bind(linkHoverColor);}:deep(.no-redirect) {color: #97a8be;cursor: text;}
}
</style>

1.3、header部分

 Navbar.vue 文件

<template><div class="navbar"><hamburger class="hamburger-container" /><Breadcrumb /><div class="right-menu"><!-- 头像 --><el-dropdown class="avatar-container" trigger="click"><div class="avatar-wrapper"><el-avatarshape="square":size="40":src="$store.getters.userInfo.avatar"></el-avatar><el-icon><Tools /></el-icon></div><template #dropdown><el-dropdown-menu class="user-dropdown"><router-link to="/"><el-dropdown-item> 首页 </el-dropdown-item></router-link><a target="_blank" href=""><el-dropdown-item>课程主页</el-dropdown-item></a><el-dropdown-item @click="logout" divided>退出登录</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></div>
</template><script setup>
import { Tools } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import Hamburger from '@/components/hamburger/hamburger.vue'
import Breadcrumb from '@/components/breadcrumb/breadcrumb.vue'const store = useStore()
const logout = () => {store.dispatch('user/logout')
}
</script><style lang="scss" scoped>
.navbar {height: 50px;overflow: hidden;position: relative;background: #fff;box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);.breadcrumb-container {float: left;}.hamburger-container {line-height: 46px;height: 100%;float: left;cursor: pointer;// hover 动画transition: background 0.5s;&:hover {background: rgba(0, 0, 0, 0.1);}}.right-menu {display: flex;align-items: center;float: right;padding-right: 16px;:deep(.avatar-container) {cursor: pointer;.avatar-wrapper {margin-top: 5px;position: relative;.el-avatar {--el-avatar-background-color: none;margin-right: 12px;}}}}
}
</style>

2、国际化、主题等通用解决方案

2.1、国际化

原理

  •  通过一个变量来  控制 语言环境
  •  所有语言环境下的数据源要 预先 定义好
  •  通过一个方法来获取 当前语言 下  指定属性 的值
  •   该值即为国际化下展示值

vue-i18n 使用流程

  •  创建 messages 数据源
  •  创建 locale 语言变量
  •  初始化 i18n 实例
  •  注册 i18n 实例

1、安装

npm install vue-i18n@next

2、创建数据源 在src/i18n/index.js文件下

import { createI18n } from 'vue-i18n'const messages = {en: {msg: {test: 'hello world'}},zh: {msg: {test: '你好世界'}}
}const locale = 'en'const i18n = createI18n({// 使用 Composition API 模式,则需要将其设置为falselegacy: false,// 全局注入 $t 函数globalInjection: true,locale,messages
})export default i18n

3、在main.js中导入

import i18n from '@/i18n'

4、在组件中使用

// i18n 是直接挂载到 vue的所以在html上用的话不用引入,直接用就行
{{ $t('msg.test') }}

5、定义一个切换国际化的组件,主要是切换国际化,这里简单文字代替,实际使用的话就根据自己的需要搞,文件中有相关vuex代码,都会放在开头仓库里面

<template><el-dropdowntrigger="click"class="international"@command="handleSetLanguage"><div><el-tooltip :content="$t('msg.navBar.lang')" :effect="effect">{{ LANG[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 { computed } from 'vue'
import { useStore } from 'vuex'
import { useI18n } from 'vue-i18n'
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 LANG = {zh: '中文',en: 'English'
}
// 切换语言的方法
const i18n = useI18n()
const handleSetLanguage = (lang) => {i18n.locale.value = langstore.commit('app/setLanguage', lang)ElMessage.success('更新成功')
}
</script>

6、element-plus 国际化

关键步骤在App.vue文件这样配置即可

<template><ElConfigProvider :locale="elementLang"><router-view></router-view></ElConfigProvider>
</template><script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import en from 'element-plus//dist/locale//en.mjs'
import { computed } from 'vue'
import { useStore } from 'vuex'
import { ElConfigProvider } from 'element-plus'const store = useStore()const elementLang = computed(() => {return store.getters.language === 'en' ? en : zhCn
})
</script><style lang="scss"></style>

plugins/element.js文件 

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'export default app => {app.use(ElementPlus)
}

在main.js 中引入使用

import installElementPlus from './plugins/element'const app = createApp(App)...
installElementPlus(app)

7、自定义语言包

index.js的内容

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?.getters?.language
}
const i18n = createI18n({// 使用 Composition API 模式,则需要将其设置为falselegacy: false,// 全局注入 $t 函数globalInjection: true,locale: 'zh',messages
})export default i18n

使用就是像下面这样,为什么都要msg.模块.字段, msg开头就是因为上面就是把国际化的内容放到msg下的

然后其他模块类似这么处理即可

注意一下引入顺序

2.2、主题切换

原理

在 scss中,我们可以通过 $变量名:变量值 的方式定义  css 变量,然后通过该 css 变量 来去指定某一块 DOM 对应的颜色,当我改变了该  css 变量  的值,那么所对应的 DOM 颜色也会同步发生变化,当大量的  DOM 都依赖于这个  css 变量 设置颜色时,我们只需要改变这个 css 变量,那么所有  DOM  的颜色都会发生变化,所谓的 主题切换 就可以实现了,这个就是实现 主题切换 的原理。

而在我们的项目中想要实现主题切换,需要同时处理两个方面的内容:

1. element-plus 主题
2. 非 element-plus 主题

那么根据以上关键信息,我们就可以得出对应的实现方案

1. 创建一个组件 ThemeSelect 用来处理修改之后的 css 变量 的值(当然如果是只需要黑白两种主题,也可el-drapdown)
2. 根据新值修改 element-plus  主题色
3. 根据新值修改非 element-plus  主题色

其实主要就是修改样式  element-plus比较复杂

实现步骤:

1. 获取当前  element-plus 的所有样式
2. 定义我们要替换之后的样式
3. 在原样式中,利用正则替换新样式
4. 把替换后的样式写入到  style  标签中

需要用到两个库

 rgb-hex:转换RGB(A)颜色为十六进制
css-color-function:在CSS中提出的颜色函数的解析器和转换器

涉及到的文件

utils/theme.js

import color from 'css-color-function'
import rgbHex from 'rgb-hex'
import formula from '@/constant/formula.json'
import axios from 'axios'import version from 'element-plus/package.json'/*** 写入新样式到 style* @param {*} elNewStyle  element-plus 的新样式* @param {*} isNewStyleTag 是否生成新的 style 标签*/
export const writeNewStyle = elNewStyle => {const style = document.createElement('style')style.innerText = elNewStyledocument.head.appendChild(style)
}/*** 根据主色值,生成最新的样式表*/
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) returnconst 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 url = `https://unpkg.com/element-plus@${version.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
}

store/modules/theme.js

import { getItem, setItem } from '@/utils/storage'
import { MAIN_COLOR, DEFAULT_COLOR } from '@/constant'
import variables from '@/styles/variables.module.scss'export default {namespaced: true,state: () => ({mainColor: getItem(MAIN_COLOR) || DEFAULT_COLOR,variables}),mutations: {/*** 设置主题色*/setMainColor(state, newColor) {state.variables.menuBg = newColorstate.mainColor = newColorsetItem(MAIN_COLOR, newColor)}}
}

 store/getters/index.js

constant/formula.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)"
}

index.js 文件

layout.vue文件

SidebarMenu.vue

小总结

对于 element-plus:因为 element-plus 是第三方的包,所以它 不是完全可控 的,那么对于这种最简单直白的方案,就是直接拿到它编译后的 css 进行色值替换,利用  style 内部样式表优先级高于 外部样式表 的特性,来进行主题替换
对于自定义主题:因为自定义主题是 完全可控 的,所以我们实现起来就轻松很多,只需要修改对应的  scss 变量即可

2.3、全屏

使用screenfull 库

安装

npm i screenfull

封装一个处理全屏的组件,这里图标临时的,具体的需要根据自己项目实际需求来

<template><div><el-icon @click="onToggle"><component :is="isFullscreen ? Aim : FullScreen" /></el-icon></div>
</template><script setup>
import { FullScreen, Aim } from '@element-plus/icons-vue'
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>

2.4、头部搜索

整个 headerSearch 其实可以分为三个核心的功能点:

  • 根据指定内容对所有页面进行检索
  • 以 select 形式展示检索出的页面
  • 通过检索页面可快速进入对应页面

方案:对照着三个核心功能点和原理,想要指定对应的实现方案是非常简单的一件事情了

  • 创建 headerSearch 组件,用作样式展示和用户输入内容获取
  • 获取所有的页面数据,用作被检索的数据源
  • 根据用户输入内容在数据源中进行 [模糊搜索](https://fusejs.io/)
  • 把搜索到的内容以 select 进行展示
  • 监听 select 的 change 事件,完成对应跳转

其主要作用就是快速搜索我们的页面,然后进入页面,效果类似这样

index.vue文件

<template><div :class="{ show: isShow }" class="header-search"><el-icon @click.stop="onShowClick"><Search /></el-icon><el-selectref="headerSearchSelectRef"class="header-search-select"v-model="search"filterabledefault-first-optionremoteplaceholder="Search":remote-method="querySearch"@change="onSelectChange"><el-optionv-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 { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import Fuse from 'fuse.js'
import { watchSwitchLang } from '@/utils/i18n'
import { filterRouters, generateMenus } from '@/utils/route'
import { generateRoutes } from './FuseData'// 控制 search 显示
const isShow = ref(false)
// el-select 实例
const headerSearchSelectRef = ref(null)
const onShowClick = () => {isShow.value = !isShow.valueheaderSearchSelectRef.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)
}// 检索数据源
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,// 匹配长度超过这个值的才会被认为是匹配的minMatchCharLength: 1,// 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。// name:搜索的键// weight:对应的权重keys: [{name: 'title',weight: 0.7},{name: 'path',weight: 0.3}]})
}
initFuse(searchPool.value)// 处理国际化
watchSwitchLang(() => {searchPool = computed(() => {const filterRoutes = filterRouters(router.getRoutes())return generateRoutes(filterRoutes)})initFuse(searchPool.value)
})/*** 关闭 search 的处理事件*/
const onClose = () => {headerSearchSelectRef.value.blur()isShow.value = falsesearchOptions.value = []search.value = ''
}
/*** 监听 search 打开,处理 close 事件*/
watch(isShow, (val) => {if (val) {document.body.addEventListener('click', onClose)} else {document.body.removeEventListener('click', onClose)}
})
</script><style lang="scss" scoped>
.header-search {.search-icon {cursor: pointer;font-size: 18px;vertical-align: middle;}.header-search-select {font-size: 18px;transition: width 0.2s;width: 0;overflow: hidden;background: transparent;border-radius: 0;display: inline-block;vertical-align: middle;:deep(.el-select__wrapper) {border-radius: 0;border: 0;padding-left: 0;padding-right: 0;box-shadow: none !important;border-bottom: 1px solid #d9d9d9;vertical-align: middle;}}&.show {.header-search-select {width: 210px;margin-left: 10px;}}
}
</style>

 FuseData.js文件

import path from 'path'
import i18n from '@/i18n'
import { resolve } from "@/utils/route.js"
/*** 筛选出可供搜索的路由对象* @param routes 路由表* @param basePath 基础路径,默认为 /* @param prefixTitle*/
export const generateRoutes = (routes, basePath = '/', prefixTitle = []) => {// 创建 result 数据let res = []// 循环 routes 路由for (const route of routes) {// 创建包含 path 和 title 的 itemconst data = {path: resolve(basePath, route.path),title: [...prefixTitle]}// 当前存在 meta 时,使用 i18n 解析国际化数据,组合成新的 title 内容// 动态路由不允许被搜索// 匹配动态路由的正则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)}// 存在 children 时,迭代调用if (route.children) {const tempRoutes = generateRoutes(route.children, data.path, data.title)if (tempRoutes.length >= 1) {res = [...res, ...tempRoutes]}}}return res
}

 utils/i18n.js 增加下面的内容

import { watch } from 'vue'
import store from '@/store'/**** @param  {...any} cbs 所有的回调*/
export function watchSwitchLang(...cbs) {watch(() => store.getters.language,() => {cbs?.forEach(cb => cb(store.getters.language))})
}

2.5、tabViews 

实现方案

  1. 创建 tagsView 组件:用来处理 tags 的展示
  2. 处理基于路由的动态过渡,在 AppMain 中进行:用于处理 view 的部分

整个的方案就是这么两大部,但是其中我们还需要处理一些细节相关的,**完整的方案为**:

1. 监听路由变化,组成用于渲染  tags  的数据源
2. 创建 tags 组件,根据数据源渲染 tag,渲染出来的 tags 需要同时具备
   1. 国际化 title
   2. 路由跳转
3. 处理鼠标右键效果,根据右键处理对应数据源
4. 处理基于路由的动态过渡

创建数据源

在contant/index.js 文件下创建

// tags
export const TAGS_VIEW = 'tagsView'

在 store/app 中创建  tagsViewList

import { LANG, TAGS_VIEW } from '@/constant'
import { getItem, setItem } from '@/utils/storage'
export default {namespaced: true,state: () => ({...tagsViewList: getItem(TAGS_VIEW) || []}),mutations: {.../*** 添加 tags*/addTagsViewList(state, tag) {const isFind = state.tagsViewList.find(item => {return item.path === tag.path})// 处理重复if (!isFind) {state.tagsViewList.push(tag)setItem(TAGS_VIEW, state.tagsViewList)}}},actions: {}
}

创建  utils/tags.js

在  appmain 中监听路由的变化

<script setup>
import { watch } from 'vue'
import { isTags } from '@/utils/tags.js'
import { generateTitle } from '@/utils/i18n'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'const route = useRoute()/*** 生成 title*/
const getTitle = route => {let title = ''if (!route.meta) {// 处理无 meta 的路由const pathArr = route.path.split('/')title = pathArr[pathArr.length - 1]} else {title = generateTitle(route.meta.title)}return title
}/*** 监听路由变化*/
const store = useStore()
watch(route,(to, from) => {if (!isTags(to.path)) returnconst { fullPath, meta, name, params, path, query } = tostore.commit('app/addTagsViewList', {fullPath,meta,name,params,path,query,title: getTitle(to)})},{immediate: true}
)
</script>

在 store/ getters/index.js 添加

  tagsViewList: state => state.app.tagsViewList

在conponents/tagsView 创建 index.vue组件

<template><div class="tags-view-container"><el-scrollbar class="tags-view-wrapper"><router-linkclass="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 }}<el-iconv-show="!isActive(tag)"@click.prevent.stop="onCloseClick(index)"><Close /></el-icon></router-link></el-scrollbar><context-menuv-show="visible":style="menuStyle":index="selectIndex"></context-menu></div>
</template><script setup>
import { ref, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Close } from '@element-plus/icons-vue'
import ContextMenu from './ContextMenu.vue'
import { useStore } from 'vuex'const route = useRoute()/*** 是否被选中*/
const isActive = (tag) => {console.log('tag.path === route.path', tag.path === route.path)return tag.path === route.path
}// contextMenu 相关
const selectIndex = ref(0)
const visible = ref(false)
const menuStyle = reactive({left: 0,top: 0
})
/*** 展示 menu*/
const openMenu = (e, index) => {const { x, y } = emenuStyle.left = x + 'px'menuStyle.top = y + 'px'selectIndex.value = indexvisible.value = true
}
/*** 关闭 menu*/
const closeMenu = () => {visible.value = false
}/*** 监听变化*/
watch(visible, (val) => {if (val) {document.body.addEventListener('click', closeMenu)} else {document.body.removeEventListener('click', closeMenu)}
})
/*** 关闭 tag 的点击事件*/
const store = useStore()
const onCloseClick = (index) => {store.commit('app/removeTagsView', {type: 'index',index: index})
}
</script><style lang="scss" scoped>
.tags-view-container {height: 34px;width: 100%;background: #fff;border-bottom: 1px solid #d8dce5;box-shadow:0 1px 3px 0 rgba(0, 0, 0, 0.12),0 0 3px 0 rgba(0, 0, 0, 0.04);.tags-view-item {display: inline-block;position: relative;cursor: pointer;height: 26px;line-height: 26px;border: 1px solid #d8dce5;color: #495060;background: #fff;padding: 0 8px;font-size: 12px;margin-left: 5px;margin-top: 4px;&:first-of-type {margin-left: 15px;}&:last-of-type {margin-right: 15px;}&.active {color: #fff;&::before {content: '';background: #fff;display: inline-block;width: 8px;height: 8px;border-radius: 50%;position: relative;margin-right: 4px;}}// close 按钮.el-icon-close {width: 16px;height: 16px;line-height: 10px;vertical-align: 2px;border-radius: 50%;text-align: center;transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);transform-origin: 100% 50%;&:before {transform: scale(0.6);display: inline-block;vertical-align: -3px;}&:hover {background-color: #b4bccc;color: #fff;}}}
}
</style>

 在layout/layout.vue 中引入

<div class="fixed-header"><!-- 顶部的 navbar --><navbar /><!-- tags --><tags-view></tags-view>
</div>import TagsView from '@/components/TagsView.index.vue'

tagsView 的国际化处理可以理解为修改现有 tags 的 title

1. 监听到语言变化
2. 国际化对应的 title 即可

在 store/app 中,创建修改 ttile 的 mutations

/**
* 为指定的 tag 修改 title
*/
changeTagsView(state, { index, tag }) {state.tagsViewList[index] = tagsetItem(TAGS_VIEW, state.tagsViewList)
}

在 AppMain.vue 

<template><div class="app-main"><div class="app-main"><router-view v-slot="{ Component, route }"><transition name="fade-transform" mode="out-in"><keep-alive><component :is="Component" :key="route.path" /></keep-alive></transition></router-view></div></div>
</template><script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { isTags } from '@/utils/tags.js'
import { generateTitle, watchSwitchLang } from '@/utils/i18n'const route = useRoute()/*** 生成 title*/
const getTitle = (route) => {let title = ''if (!route.meta) {// 处理无 meta 的路由const pathArr = route.path.split('/')title = pathArr[pathArr.length - 1]} else {title = generateTitle(route.meta.title)}return title
}/*** 监听路由变化*/
const store = useStore()
watch(route,(to, from) => {if (!isTags(to.path)) returnconst { fullPath, meta, name, params, path, query } = tostore.commit('app/addTagsViewList', {fullPath,meta,name,params,path,query,title: getTitle(to)})},{immediate: true}
)/*** 国际化 tags*/
watchSwitchLang(() => {store.getters.tagsViewList.forEach((route, index) => {store.commit('app/changeTagsView', {index,tag: {...route,title: getTitle(route)}})})
})
</script><style lang="scss" scoped>
.app-main {min-height: calc(100vh - 50px - 43px);width: 100%;padding: 104px 20px 20px 20px;position: relative;overflow: hidden;padding: 61px 20px 20px 20px;box-sizing: border-box;
}
</style>

contextMenu 为 鼠标右键事件 

  1. contextMenu 的展示
  2. 右键项对应逻辑处理

创建 components/TagsView/ContextMenu.vue组件 组件,作为右键展示部分

<template><ul class="context-menu-container"><li @click="onRefreshClick">{{ $t('msg.tagsView.refresh') }}</li><li @click="onCloseRightClick">{{ $t('msg.tagsView.closeRight') }}</li><li @click="onCloseOtherClick">{{ $t('msg.tagsView.closeOther') }}</li></ul>
</template><script setup>
import { defineProps } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'const props = defineProps({index: {type: Number,required: true}
})const router = useRouter()
const onRefreshClick = () => {router.go(0)
}const store = useStore()
const onCloseRightClick = () => {store.commit('app/removeTagsView', {type: 'right',index: props.index})
}const onCloseOtherClick = () => {store.commit('app/removeTagsView', {type: 'other',index: props.index})
}
</script><style lang="scss" scoped>
.context-menu-container {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>

 在styles/transition.scss 增加下面样式

/* fade-transform */
.fade-transform-leave-active,
.fade-transform-enter-active {transition: all 0.5s;
}.fade-transform-enter-from {opacity: 0;transform: translateX(-30px);
}.fade-transform-leave-to {opacity: 0;transform: translateX(30px);
}

2.6、Guide 引导

guide 指的就是 引导页

流程

  1. 高亮某一块指定的样式
  2. 在高亮的样式处通过文本展示内容
  3. 用户可以进行下一次高亮或者关闭事件

安装 driver.js

npm i driver.js

components/Guide.vue 组件

<template><div><el-tooltip :content="$t('msg.navBar.guide')"><el-icon id="guide-start"><Guide /></el-icon></el-tooltip></div>
</template><script setup>
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { driver } from 'driver.js'
import 'driver.js/dist/driver.css'
import { Guide } from '@element-plus/icons-vue'
import steps from './steps'const i18n = useI18n()onMounted(() => {const driverObj = driver({showProgress: true,steps: steps(i18n)})driverObj.drive()
})
</script><style scoped></style>

在navbar 中导入该组件

<guide class="right-menu-item hover-effect" />import Guide from '@/components/Guide/index.vue'

steps.js 文件里面

const steps = i18n => {return [{element: '#guide-start',popover: {title: i18n.t('msg.guide.guideTitle'),description: i18n.t('msg.guide.guideDesc'),position: 'bottom-right'}},{element: '#guide-hamburger',popover: {title: i18n.t('msg.guide.hamburgerTitle'),description: i18n.t('msg.guide.hamburgerDesc')}},{element: '#guide-breadcrumb',popover: {title: i18n.t('msg.guide.breadcrumbTitle'),description: i18n.t('msg.guide.breadcrumbDesc')}},{element: '#guide-search',popover: {title: i18n.t('msg.guide.searchTitle'),description: i18n.t('msg.guide.searchDesc'),position: 'bottom-right'}},{element: '#guide-full',popover: {title: i18n.t('msg.guide.fullTitle'),description: i18n.t('msg.guide.fullDesc'),position: 'bottom-right'}},{element: '#guide-theme',popover: {title: i18n.t('msg.guide.themeTitle'),description: i18n.t('msg.guide.themeDesc'),position: 'bottom-right'}},{element: '#guide-lang',popover: {title: i18n.t('msg.guide.langTitle'),description: i18n.t('msg.guide.langDesc'),position: 'bottom-right'}},{element: '#guide-tags',popover: {title: i18n.t('msg.guide.tagTitle'),description: i18n.t('msg.guide.tagDesc')}},{element: '#guide-sidebar',popover: {title: i18n.t('msg.guide.sidebarTitle'),description: i18n.t('msg.guide.sidebarDesc'),position: 'right-center'}}]
}
export default steps

最后一步就是找到你需要在那个元素展示这些指引了,就将上面element 对应的id绑定到对应的元素,例如

其他元素也是如此即可

3、个人中心模块

根据功能划分,整个项目应该包含 4 个组件,分别对应着 4 个功能。

所以,我们想要完成  个人中心模块基本布局 那么就需要先创建出这四个组件

1. 在  views/profile/components 下创建 项目介绍 组件 ProjectCard
2. 在  views/profile/components 下创建 功能 组件 feature
3. 在  views/profile/components 下创建 章节 组件 chapter
4. 在  views/profile/components 下创建 作者 组件  author
5. 进入到 views/profile/index.vue 页面,绘制基本布局结构

效果

3.1、入口组件、即index.vue组件

<template><div class="my-container"><el-row><el-col :span="6"><project-card class="user-card" :features="featureData"></project-card></el-col><el-col :span="18"><el-card><el-tabs v-model="activeName"><el-tab-pane :label="$t('msg.profile.feature')" name="feature"><feature :features="featureData" /></el-tab-pane><el-tab-pane :label="$t('msg.profile.chapter')" name="chapter"><chapter /></el-tab-pane><el-tab-pane :label="$t('msg.profile.author')" name="author"><author /></el-tab-pane></el-tabs></el-card></el-col></el-row></div>
</template><script setup>
import ProjectCard from './components/ProjectCard.vue'
import Chapter from './components/Chapter.vue'
// eslint-disable-next-line
import Feature from './components/Feature.vue'
import Author from './components/Author.vue'
import { ref } from 'vue'
import { feature } from '@/api/user'
import { watchSwitchLang } from '@/utils/i18n'const activeName = ref('feature')const featureData = ref([])
const getFeatureData = async () => {featureData.value = await feature()
}
getFeatureData()
// 监听语言切换
watchSwitchLang(getFeatureData)
</script><style lang="scss" scoped>
.my-container {.user-card {margin-right: 20px;}
}
</style>

3.2、ProjectCard 组件

<template><el-card class="user-container"><template #header><div class="header"><span>{{ $t('msg.profile.introduce') }}</span></div></template><div class="user-profile"><!-- 头像 --><div class="box-center"><pan-thumb:image="$store.getters.userInfo.avatar":height="'100px'":width="'100px'":hoverable="false"><div>Hello</div>{{ $store.getters.userInfo.title }}</pan-thumb></div><!-- 姓名 && 角色 --><div class="box-center"><div class="user-name text-center">{{ $store.getters.userInfo.username }}</div><div class="user-role text-center text-muted">{{ $store.getters.userInfo.title }}</div></div></div><!-- 简介 --><div class="project-bio"><div class="project-bio-section"><div class="project-bio-section-header"><el-icon><Document /></el-icon><span>{{ $t('msg.profile.projectIntroduction') }}</span></div><div class="project-bio-section-body"><div class="text-muted">{{ $t('msg.profile.muted') }}</div></div></div><div class="project-bio-section"><div class="project-bio-section-header"><el-icon><Calendar /></el-icon><span>{{ $t('msg.profile.projectFunction') }} </span></div><div class="project-bio-section-body"><div class="progress-item" v-for="item in features" :key="item.id"><div>{{ item.title }}</div><el-progress :percentage="item.percentage" status="success" /></div></div></div></div></el-card>
</template><script setup>
import { Document, Calendar } from '@element-plus/icons-vue'
import PanThumb from './PanThumb.vue'defineProps({features: {type: Array,required: true}
})
</script><style lang="scss" scoped>
.user-container {.text-muted {font-size: 14px;color: #777;}.user-profile {text-align: center;.user-name {font-weight: bold;}.box-center {padding-top: 10px;}.user-role {padding-top: 10px;font-weight: 400;}}.project-bio {margin-top: 20px;color: #606266;span {padding-left: 4px;}.project-bio-section {margin-bottom: 36px;.project-bio-section-header {border-bottom: 1px solid #dfe6ec;padding-bottom: 10px;margin-bottom: 10px;font-weight: bold;}.project-bio-section-body {.progress-item {margin-top: 10px;div {font-size: 14px;margin-bottom: 2px;}}}}}
}
</style>

3.3、feature 组件

<template><el-collapse v-model="activeName" accordion><el-collapse-itemv-for="item in features":key="item.id":title="item.title":name="item.id"><div v-html="item.content"></div></el-collapse-item></el-collapse>
</template><script setup>
import { ref } from 'vue'
const activeName = ref(0)
defineProps({features: {type: Array,required: true}
})
</script><style lang="scss" scoped>
::v-deep .el-collapse-item__header {font-weight: bold;
}.el-collapse-item {:deep(a) {color: #2d62f7;margin: 0 4px;}
}
</style>

3.4、chapter组件

<template><el-timeline><el-timeline-itemv-for="item in chapterData":key="item.id":timestamp="item.timestamp"placement="top"><el-card><h4>{{ item.content }}</h4></el-card></el-timeline-item></el-timeline>
</template><script setup>
import { watchSwitchLang } from '@/utils/i18n'
import { chapter } from '@/api/user'
import { ref } from 'vue'
const chapterData = ref([])const getChapterData = async () => {chapterData.value = await chapter()
}
getChapterData()// 监听语言切换
watchSwitchLang(getChapterData)
</script><style lang="scss" scoped></style>

3.5、author 组件

<template><div class="author-container"><div class="header"><pan-thumbimage="https://img4.sycdn.imooc.com/61110c2b0001152907400741-140-140.jpg"height="60px"width="60px":hoverable="false">{{ $t('msg.profile.name') }}</pan-thumb><div class="header-desc"><h3>{{ $t('msg.profile.name') }}</h3><span>{{ $t('msg.profile.job') }}</span></div></div><div class="info">{{ $t('msg.profile.Introduction') }}</div></div>
</template><script setup>
import PanThumb from './PanThumb.vue'
</script><style lang="scss" scoped>
.author-container {.header {display: flex;.header-desc {margin-left: 12px;display: flex;flex-direction: column;justify-content: space-around;span {font-size: 14px;}}}.info {margin-top: 16px;line-height: 22px;font-size: 14px;text-indent: 26px;}
}
</style>

3.6、PanThumb 组件

<template><div:style="{ zIndex: zIndex, height: height, width: width }"class="pan-item"><div class="pan-info"><div class="pan-info-roles-container"><slot /></div></div><div :style="{ backgroundImage: `url(${image})` }" class="pan-thumb"></div></div>
</template><script setup>
defineProps({image: {type: String},zIndex: {type: Number,default: 1},width: {type: String,default: '150px'},height: {type: String,default: '150px'}
})
</script><style scoped>
.pan-item {width: 200px;height: 200px;border-radius: 50%;display: inline-block;position: relative;cursor: pointer;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);.pan-info {position: absolute;width: inherit;height: inherit;border-radius: 50%;overflow: hidden;box-shadow: inset 0 0 0 5px rgba(0, 0, 0, 0.05);h3 {color: #fff;text-transform: uppercase;position: relative;letter-spacing: 2px;font-size: 14px;margin: 0 60px;padding: 22px 0 0 0;height: 85px;font-family: 'Open Sans', Arial, sans-serif;text-shadow:0 0 1px #fff,0 1px 2px rgba(0, 0, 0, 0.3);}p {color: #fff;padding: 10px 5px;font-style: italic;margin: 0 30px;font-size: 12px;border-top: 1px solid rgba(255, 255, 255, 0.5);a {display: block;color: #333;width: 80px;height: 80px;background: rgba(255, 255, 255, 0.3);border-radius: 50%;color: #fff;font-style: normal;font-weight: 700;text-transform: uppercase;font-size: 9px;letter-spacing: 1px;padding-top: 24px;margin: 7px auto 0;font-family: 'Open Sans', Arial, sans-serif;opacity: 0;transition:transform 0.3s ease-in-out 0.2s,opacity 0.3s ease-in-out 0.2s,background 0.2s linear 0s;transform: translateX(60px) rotate(90deg);}a:hover {background: rgba(255, 255, 255, 0.5);}}.pan-info-roles-container {padding: 20px;text-align: center;}}.pan-thumb {width: 100%;height: 100%;background-position: center center;background-size: cover;border-radius: 50%;overflow: hidden;position: absolute;transform-origin: 95% 40%;transition: all 0.3s ease-in-out;}.pan-item:hover .pan-thumb {transform: rotate(-110deg);}.pan-item:hover .pan-info p a {opacity: 1;transform: translateX(0px) rotate(0deg);}
}
</style>

api/user.js文件

import request from '@/utils/request'export const feature = () => {return request({url: '/user/feature'})
}export const chapter = () => {return request({url: '/user/chapter'})
}

4、用户模块

4.1、用户列表

在src下创建 filters/index.js

import dayjs from 'dayjs'const dateFilter = (val, format = 'YYYY-MM-DD') => {if (!isNaN(val)) {val = parseInt(val)}return dayjs(val).format(format)
}export default app => {app.config.globalProperties.$filters = {dateFilter}
}

安装 dayjs

npm i dayjs

在main.js 中引入

// filter
import installFilter from '@/filters'installFilter(app)

这样子就可以,格式化时间列了

<el-table-column :label="$t('msg.excel.openTime')"><template #default="{ row }">{{ $filters.dateFilter(row.openTime) }}</template>
</el-table-column>

4.2、excel导入解决方案

其实对于这种导入的情况,我们一般是,导入文件,让后端去解释,然后导入成功之后,再请求一个接口,将导入的数据请求回来,并展示的,当然,也可以像这里这样导入后前端解释,再将数据存到后端,就相当于是批新建了。

方案:

搭建一个上传文件的组件,这里命名为 UploadExcel.vue

<template><div class="upload-excel"><div class="btn-upload"><el-button :loading="loading" type="primary" @click="handleUpload">{{ $t('msg.uploadExcel.upload') }}</el-button></div><inputref="excelUploadInput"class="excel-upload-input"type="file"accept=".xlsx, .xls"@change="handleChange"/><!-- https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API --><divclass="drop"@drop.stop.prevent="handleDrop"@dragover.stop.prevent="handleDragover"@dragenter.stop.prevent="handleDragover"><el-icon><UploadFilled /></el-icon><span>{{ $t('msg.uploadExcel.drop') }}</span></div></div>
</template><script setup>
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import * as XLSX from 'xlsx'
import { ref } from 'vue'
import { getHeaderRow, isExcel } from './utils'const props = defineProps({// 上传前回调beforeUpload: Function,// 成功回调onSuccess: Function
})/*** 点击上传触发*/
const loading = ref(false)
const excelUploadInput = ref(null)
const handleUpload = () => {excelUploadInput.value.click()
}
const handleChange = (e) => {const files = e.target.filesconst rawFile = files[0] // only use files[0]if (!rawFile) returnupload(rawFile)
}/*** 触发上传事件*/
const upload = (rawFile) => {excelUploadInput.value.value = null// 如果没有指定上传前回调的话if (!props.beforeUpload) {readerData(rawFile)return}// 如果指定了上传前回调,那么只有返回 true 才会执行后续操作const before = props.beforeUpload(rawFile)if (before) {readerData(rawFile)}
}/*** 读取数据(异步)*/
const readerData = (rawFile) => {loading.value = truereturn new Promise((resolve, reject) => {// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReaderconst reader = new FileReader()// 该事件在读取操作完成时触发// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader/onloadreader.onload = (e) => {// 1. 获取解析到的数据const data = e.target.result// 2. 利用 XLSX 对数据进行解析const workbook = XLSX.read(data, { type: 'array' })// 3. 获取第一张表格(工作簿)名称const firstSheetName = workbook.SheetNames[0]// 4. 只读取 Sheet1(第一张表格)的数据const worksheet = workbook.Sheets[firstSheetName]// 5. 解析数据表头const header = getHeaderRow(worksheet)// 6. 解析数据体const results = XLSX.utils.sheet_to_json(worksheet)// 7. 传入解析之后的数据generateData({ header, results })// 8. loading 处理loading.value = false// 9. 异步完成resolve()}// 启动读取指定的 Blob 或 File 内容reader.readAsArrayBuffer(rawFile)})
}/*** 根据导入内容,生成数据*/
const generateData = (excelData) => {props.onSuccess && props.onSuccess(excelData)
}/*** 拖拽文本释放时触发*/
const handleDrop = (e) => {// 上传中跳过if (loading.value) returnconst files = e.dataTransfer.filesif (files.length !== 1) {ElMessage.error('必须要有一个文件')return}const rawFile = files[0]if (!isExcel(rawFile)) {ElMessage.error('文件必须是 .xlsx, .xls, .csv 格式')return false}// 触发上传事件upload(rawFile)
}/*** 拖拽悬停时触发*/
const handleDragover = (e) => {// https://developer.mozilla.org/zh-CN/docs/Web/API/DataTransfer/dropEffect// 在新位置生成源项的副本e.dataTransfer.dropEffect = 'copy'
}
</script><style lang="scss" scoped>
.upload-excel {display: flex;justify-content: center;margin-top: 100px;.excel-upload-input {display: none;z-index: -9999;}.btn-upload,.drop {border: 1px dashed #bbb;width: 350px;height: 160px;text-align: center;line-height: 160px;}.drop {line-height: 60px;display: flex;flex-direction: column;justify-content: center;color: #bbb;display: flex;flex-direction: column;justify-content: center;align-items: center;cursor: pointer;i {font-size: 60px;display: block;}}
}
</style>

utils.js 文件

import * as XLSX from 'xlsx'
/*** 获取表头(通用方式)*/
export const getHeaderRow = sheet => {const headers = []const range = XLSX.utils.decode_range(sheet['!ref'])let Cconst R = range.s.r/* start in the first row */for (C = range.s.c; C <= range.e.c; ++C) {/* walk every column in the range */const cell = sheet[XLSX.utils.encode_cell({ c: C, r: R })]/* find the cell in the first row */let hdr = 'UNKNOWN ' + C // <-- replace with your desired defaultif (cell && cell.t) hdr = XLSX.utils.format_cell(cell)headers.push(hdr)}return headers
}export const isExcel = file => {return /\.(xlsx|xls|csv)$/.test(file.name)
}

这里有个小知识点,就使用 Keep-Alive 缓存的组件涉及到两个钩子 onActivated:组件激活时的钩子、onDeactivated:组件不激活时的钩子

4.3、Excel导出方案

需要安装两个库

npm i xlsx

npm i file-saver

主要就是两块

1、主逻辑

const onConfirm = async () => {loading.value = trueconst allUser = (await getUserManageAllList()).list// 将一个对象转成数组 例如 {a:"xxx", b:"yyyy"} => ["xxx","yyyy"]const data = formatJson(USER_RELATIONS, allUser)// 导入工具包(这里面就是处理json数据向excel文件转换的主要方法)const excel = await import('@/utils/Export2Excel.js')excel.export_json_to_excel({// excel 表头header: Object.keys(USER_RELATIONS),// excel 数据(二维数组结构)data,// 文件名称filename: excelName.value || exportDefaultName,// 是否自动列宽autoWidth: true,// 文件类型bookType: 'xlsx'})closed()
}

2、将列表数据转成excel 类型数据

// 该方法负责将数组转化成二维数组
const formatJson = (headers, rows) => {// 首先遍历数组// [{ username: '张三'},{},{}]  => [[’张三'],[],[]]return rows.map((item) => {return Object.keys(headers).map((key) => {// 角色特殊处理if (headers[key] === 'role') {const roles = item[headers[key]]return JSON.stringify(roles.map((role) => role.title))}return item[headers[key]]})})
}

2、调用网上成熟的处理 excel 的解决方案 (Export2Excel.js文件)

/* eslint-disable */
import { saveAs } from 'file-saver'
import * as XLSX from 'xlsx'function datenum(v, date1904) {if (date1904) v += 1462var 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 = Rif (range.s.c > C) range.s.c = Cif (range.e.r < R) range.e.r = Rif (range.e.c < C) range.e.c = Cvar cell = {v: data[R][C]}if (cell.v == null) continuevar 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) & 0xffreturn buf
}export const export_json_to_excel = ({multiHeader = [],header,data,filename,merges = [],autoWidth = true,bookType = 'xlsx'
} = {}) => {// 1. 设置文件名称filename = filename || 'excel-list'// 2. 把数据解析为数组,并把表头添加到数组的头部data = [...data]data.unshift(header)// 3. 解析多表头,把多表头的数据添加到数组头部(二维数组)for (let i = multiHeader.length - 1; i > -1; i--) {data.unshift(multiHeader[i])}// 4. 设置 Excel 表工作簿(第一张表格)名称var ws_name = 'SheetJS'// 5. 生成工作簿对象var wb = new Workbook()// 6. 将 data 数组(json格式)转化为 Excel 数据格式var ws = sheet_from_array_of_arrays(data)// 7. 合并单元格相关(['A1:A2', 'B1:D1', 'E1:E2'])if (merges.length > 0) {if (!ws['!merges']) ws['!merges'] = []merges.forEach((item) => {ws['!merges'].push(XLSX.utils.decode_range(item))})}// 8. 单元格宽度相关if (autoWidth) {/*设置 worksheet 每列的最大宽度*/const colWidth = data.map((row) =>row.map((val) => {/*先判断是否为null/undefined*/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}// 9. 添加工作表(解析后的 excel 数据)到工作簿wb.SheetNames.push(ws_name)wb.Sheets[ws_name] = ws// 10. 写入数据var wbout = XLSX.write(wb, {bookType: bookType,bookSST: false,type: 'binary'})// 11. 下载数据saveAs(new Blob([s2ab(wbout)], {type: 'application/octet-stream'}),`${filename}.${bookType}`)
}

4.4、打印

安装

npm i vue3-print-nb

新建 directives/index.js

import print from 'vue3-print-nb'export default app => {app.use(print)
}

在main.js中引入使用

import installDirective from '@/directives'
installDirective(app)

在使用的地方就直接使用指令的方式使用了

// 打印按钮  
<el-button type="primary" v-print="printObj" :loading="printLoading">
{{ $t('msg.userInfo.print') }}
</el-button>
// 一个配置对象
const printObj = {// 打印区域,打印这个元素下里面的内容id: 'userInfoBox',// 打印标题popTitle: 'test-vue-element-admin',// 打印前beforeOpenCallback(vue) {printLoading.value = true},// 执行打印openCallback(vue) {printLoading.value = false}
}

小知识点

路由配置像下面这样配置

则在user-info组件内就可以像组件传参一样接受参数了

5、权限控制解决方案与角色、权限

5.1、页面权限 ,也就是动态路由的处理

1、页面权限实现的核心在于 路由表配置

        请求用户信息的时候,有这样的信息

        

        那我们配置路由表可以像下面这样配置

        分别创建对应页面模块,例如 UserManage.js

import layout from '@/layout/layout.vue'export default {path: '/user',component: layout,redirect: '/user/manage',// 这个name 要与 权限信息对应上name: 'userManage',meta: {title: 'user',icon: 'personnel'},children: [{path: '/user/manage',component: () => import('@/views/user-manage/index.vue'),meta: {title: 'userManage',icon: 'personnel-manage'}},{path: '/user/info/:id',name: 'userInfo',component: () => import('@/views/user-info/index.vue'),props: true,meta: {title: 'userInfo'}},{path: '/user/import',name: 'import',component: () => import('@/views/import/index.vue'),meta: {title: 'excelImport'}}]
}

    RoleList.js

import layout from '@/layout/layout.vue'export default {path: '/user',component: layout,redirect: '/user/manage',name: 'roleList',meta: {title: 'user',icon: 'personnel'},children: [{path: '/user/role',component: () => import('@/views/role-list/index.vue'),meta: {title: 'roleList',icon: 'role'}}]
}

不一一列举,他们对应的页面展示是这样的,layout.vue就是最外层布局组件了

对应的私有路由表

2、路由表配置的核心在于根据获取到的用户权限从私有路由表 privateRoutes 过滤出当前用户拥有的页面路由

privateRoutes 数据是这样的,这样就可以和我们上面权限信息,menus匹配上了

然后就可以通过下面这方法过滤出,用户所拥有的权限了 

/*** 根据权限筛选路由* menus 请求接口返回的 拥有的权限信息(与我们的路由名字匹配)* 例如是 ['userManage', 'import'...]*/filterRoutes(context, menus) {const routes = []// 路由权限匹配menus.forEach(key => {// 权限名 与 路由的 name 匹配routes.push(...privateRoutes.filter(item => item.name === key))})// 最后添加 不匹配路由进入 404routes.push({path: '/:catchAll(.*)',redirect: '/404'})context.commit('setRoutes', routes)return routes}

3、然后根据过滤出来的路由,遍历调用 addRoute​ 方法将路由添加进路由表中

4、添加完路由后需要手动跳转一次路由​​​​​​,也就是上面 return next(to.path)

5.2、功能权限、一般是控制按钮显示与否

需要定义一个指令即可

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-linethrow new Error('v-permission value must be  ["admin","editor"]')}
}export default {// 在绑定元素的父组件被挂载后调用mounted(el, binding) {checkPermission(el, binding)},// 在包含组件的 VNode 及其子组件的 VNode 更新后调用update(el, binding) {checkPermission(el, binding)}
}

然后全局注册一下指令即可,然后在用的地方


import permission from './permission'
app.directive('permission', permission)

然后在使用的地方像下面这样使用即可

<el-buttonv-permission="['edit']"
>

5.3、1element-plus table 动态列 与 拖拽行

3.1、动态列

其实主要就是涉及三块数据源

1、动态展示哪里用于展示的数据 这里称为 dynamicData

2、选中的数据 这里称为 selectDynamicLabel

3、根据 selectDynamicLabel 在 dynamicData过滤 出来的数据这里称为 tableColumns (也就是用于表格列展示的)

页面代码

<template><div class="article-ranking-container"><el-card class="header"><div class="dynamic-box"><span class="title">{{ $t('msg.article.dynamicTitle') }}</span><el-checkbox-group v-model="selectDynamicLabel"><el-checkboxv-for="(item, index) in dynamicData":label="item.label":key="index">{{ item.label }}</el-checkbox></el-checkbox-group></div></el-card><el-card><el-table ref="tableRef" :data="tableData" border><el-table-columnv-for="(item, index) in tableColumns":key="index":prop="item.prop":label="item.label"><template #default="{ row }" v-if="item.prop === 'publicDate'">{{ $filters.relativeTime(row.publicDate) }}</template><template #default="{ row }" v-else-if="item.prop === 'action'"><el-button type="primary" size="mini" @click="onShowClick(row)">{{$t('msg.article.show')}}</el-button><el-button type="danger" size="mini" @click="onRemoveClick(row)">{{$t('msg.article.remove')}}</el-button></template></el-table-column></el-table><el-paginationclass="pagination"@size-change="handleSizeChange"@current-change="handleCurrentChange":current-page="page":page-sizes="[5, 10, 50, 100, 200]":page-size="size"layout="total, sizes, prev, pager, next, jumper":total="total"></el-pagination></el-card></div>
</template><script setup>
import { ref, onActivated } from 'vue'
import { getArticleList } from '@/api/article'
import { watchSwitchLang } from '@/utils/i18n'
import { dynamicData, selectDynamicLabel, tableColumns } from './dynamic'
import { tableRef, initSortable } from './sortable'// 数据相关
const tableData = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(10)// 获取数据的方法
const getListData = async () => {const result = await getArticleList({page: page.value,size: size.value})tableData.value = result.listtotal.value = result.total
}
getListData()
// 监听语言切换
watchSwitchLang(getListData)
// 处理数据不重新加载的问题
onActivated(getListData)/*** size 改变触发*/
const handleSizeChange = (currentSize) => {size.value = currentSizegetListData()
}/*** 页码改变触发*/
const handleCurrentChange = (currentPage) => {page.value = currentPagegetListData()
}// 表格拖拽相关
onMounted(() => {initSortable(tableData, getListData)
})
</script><style lang="scss" scoped>
.article-ranking-container {.header {margin-bottom: 20px;.dynamic-box {display: flex;align-items: center;.title {margin-right: 20px;font-size: 14px;font-weight: bold;}}}:deep(.el-table__row) {cursor: pointer;}.pagination {margin-top: 20px;text-align: center;}
}:deep(.sortable-ghost) {opacity: 0.6;color: #fff !important;background: #304156 !important;
}
</style>

 处理动态列逻辑的代码

import getDynamicData from './DynamicData'
import { watchSwitchLang } from '@/utils/i18n'
import { watch, ref } from 'vue'// 暴露出动态列数据
export const dynamicData = ref(getDynamicData())// 监听 语言变化
watchSwitchLang(() => {// 重新获取国际化的值dynamicData.value = getDynamicData()// 重新处理被勾选的列数据initSelectDynamicLabel()
})// 创建被勾选的动态列数据
export const selectDynamicLabel = ref([])
// 默认全部勾选
const initSelectDynamicLabel = () => {selectDynamicLabel.value = dynamicData.value.map(item => item.label)
}
initSelectDynamicLabel()// 声明 table 的列数据
export const tableColumns = ref([])
// 监听选中项的变化,根据选中项动态改变 table 列数据的值
watch(selectDynamicLabel,val => {tableColumns.value = []// 遍历选中项const selectData = dynamicData.value.filter(item => {return val.includes(item.label)})tableColumns.value.push(...selectData)},{immediate: true}
)

列的数据源的代码

import i18n from '@/i18n'const t = i18n.global.t// 这样,当国际化改变的时候,才能跟随改变
export default () => [{label: t('msg.article.ranking'),prop: 'ranking'},{label: t('msg.article.title'),prop: 'title'},{label: t('msg.article.author'),prop: 'author'},{label: t('msg.article.publicDate'),prop: 'publicDate'},{label: t('msg.article.desc'),prop: 'desc'},{label: t('msg.article.action'),prop: 'action'}
]

3.2、拖拽行

安装sorttablejs

npm i sortablejs

排序逻辑处理

import { ref } from 'vue'
import Sortable from 'sortablejs'
import { articleSort } from '@/api/article'
import i18n from '@/i18n'
// 排序相关
export const tableRef = ref(null)/*** 初始化排序* tableData: 表格数据* cb:重新获取列表数据*/
export const initSortable = (tableData, cb) => {// 设置拖拽效果const el = tableRef.value.$el.querySelectorAll('.el-table__body tbody')[0]// 1. 要拖拽的元素// 2. 配置对象Sortable.create(el, {// 拖拽时类名,就是控制拖拽行的颜色ghostClass: 'sortable-ghost',// 拖拽结束的回调方法async onEnd(event) {const { newIndex, oldIndex } = event// 修改数据await articleSort({// 获取对应数据的排名initRanking: tableData.value[oldIndex].ranking,finalRanking: tableData.value[newIndex].ranking})ElMessage.success({message: i18n.global.t('msg.article.sortSuccess'),type: 'success'})// 直接重新获取数据无法刷新 table!!tableData.value = []// 重新获取数据cb && cb()}})
}

使用的地方

// 表格拖拽相关
onMounted(() => {initSortable(tableData, getListData)
})

6、markdown与富文本

这里会使用到两个库,这稍微讲一下怎么选择我们的库

  1. 开源协议最好是BSM、MIT的
  2. start最好是10k以上的(5k也行)
  3. 关注上一个版本发布时间不能间隔太久
  4. 关注issue是否有及时回应
  5. 文档是否详尽,最好有中文文档了

markdown  编辑器:tui.editor
富文本编辑器:wangEditor

6.1、markdown

安装

npm i @toast-ui/editor@3.0.2

基本使用

// 绑定一个html
<div id="markdown-box"></div>// 逻辑处理
import MkEditor from '@toast-ui/editor'
import '@toast-ui/editor/dist/toastui-editor.css'
import '@toast-ui/editor/dist/i18n/zh-cn'let mkEditor
let el
onMounted(() => {el = document.querySelector('#markdown-box')initMkEditor()
})const initMkEditor = () => {mkEditor = new MkEditor({el, height: '500px',previewStyle: 'vertical',language: store.getters.language === 'zh' ? 'zh-CN' : 'en'})mkEditor.getMarkdown()
}// 涉及markdown 销毁相关的
const htmlStr = mkEditor.getHTML()mkEditor.destroy()initMkEditor()mkEditor.setHTML(htmlStr)

6.2、富文本

安装

npm i wangeditor@4.7.6

基本逻辑使用

// html<div id="editor-box"></div>// 引入库
import E from 'wangeditor'// 基本逻辑处理
// Editor实例
let editor
// 处理离开页面切换语言导致 dom 无法被获取
let el
onMounted(() => {el = document.querySelector('#editor-box')initEditor()
})const initEditor = () => {editor = new E(el)editor.config.zIndex = 1// // 菜单栏提示editor.config.showMenuTooltips = trueeditor.config.menuTooltipPosition = 'down'editor.create()
}// 内容通过html展示editor.txt.html(val.content)

7、数据可视化

7.1、可视化解读

可视化其实分为,大可视化与数据可视化,大屏可视化通常是自己自成一个项目,而数据可视化则是一般集成在我们的后台管理系统里面,他们都是为了让我们数据可以通过图标的方式比较直观的查看,而可视化的解决方案主要有两种,AntV与Echarts

7.2、countUp的使用

countUp主要是用于数据变化的时候时期具有动画效果

7.3、文字云图

通过echarts 和 echarts-wordcloud实现

8、项目部署

1、为什么需要打包?

答: 为了让浏览器识别

2、浏览器可以直接通过url访问打包后的项目嘛?

答:不行,通过打包后的index.html 直接打包,会报文件找不到模块的错误

3、为啥需要服务器?

答:为了避免出现找不到模块的错误,所以需要一个服务器,把模块都放到服务器上

8.1、电脑访问网页图解

8.2、服务器购买 

云服务器 ECS 自定义购买

常见的链接服务器的方式

  1. 阿里云控制台中进行远程链接
  2. 通过 SSH 工具(XShell)
  3. SSH 指令远程登录

8.3、Xshell连接服务器可以使用

1、新建会话

2、确定会话信息,协议为 SSH、主机为服务器 IP(也就是我们购买的服务器)、端口号为 22

3、确定之后就会看到我们的会话列表

4、双击我们的会话列表中的会话,然后输入用户名(默认用户名是root)

5、输入你的密码

6、出现下面信息表示连接成功

8.4、配置nginx

1、nginx 编译时依赖 gcc 环境

yum -y install gcc gcc-c++

2、安装 prce,让 nginx 支持重写功能

yum -y install pcre*

3、安装 zlibnginx 使用 zlib 对 http 包内容进行 gzip 压缩

yum -y install zlib zlib-devel 

4、安装 openssl,用于通讯加密

yum -y install openssl openssl-devel

5、下载 nginx 压缩包

wget https://nginx.org/download/nginx-1.11.5.tar.gz

6、解压 nginx

tar -zxvf  nginx-1.11.5.tar.gz

7、进入 nginx-1.11.5 目录

cd nginx-1.11.5

8、检查平台安装环境

./configure --prefix=/usr/local/nginx

9、进行源码编译

make 

10、安装 nginx

make install

11、查看 nginx 配置

/usr/local/nginx/sbin/nginx -t

12、制作nginx 软连接,进入 usr/bin 目录

cd /usr/bin

13、制作软连接

ln -s /usr/local/nginx/sbin/nginx nginx

14、首先打开 nginx 的默认配置文件中

vim /usr/local/nginx/conf/nginx.conf

15、在最底部增加配置项(按下 i 进入 输入模式)

include /nginx/*.conf;

16、按下 esc 键,通过 :wq! 保存并退出

17、创建新的配置文件

touch /nginx/nginx.conf

18、打开 /root/nginx/nginx.conf 文件

vim /nginx/nginx.conf

19、写入如下配置

# nginx config
server {# 端口 根据实际情况来listen       8081;# 域名 申请的时候是啥就些啥就可以了 比如 http://www.xx.xx.yyserver_name  localhost;# 资源地址root   /nginx/dist/;# 目录浏览autoindex on;# 缓存处理add_header Cache-Control "no-cache, must-revalidate";# 请求配置location / {# 跨域add_header Access-Control-Allow-Origin *;# 返回 index.htmltry_files $uri $uri/ /index.html;}
}

20、通过 :wq! 保存退出

21、在 root/nginx 中创建 dist 文件夹

mkdir /nginx/dist

22、在 nginx/dist 中写入 index.html 进行测试,也就是创建一个index.html文件,然后随便写入一些东西,然后保存

23、通过 nginx -s reload 重启服务

24、在 浏览器中通过,IP 测试访问,看能不能访问到我们的index.html中的内容

25、将我们 npm run build 打包后的dist下的所有文件传入到我们上面的dist目录下

可以通过 XFTP 进行传输

26、之后我们就可以通过我们申请的域名进行访问我们的项目了

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/637591.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

公司卓越之路:七种关键成功因素深度解析

引言 在竞争激烈的市场环境中&#xff0c;公司的成功并非偶然&#xff0c;而是多种因素的共同作用。本文将详细探讨公司优秀的七大关键成功因素&#xff0c;并结合实际案例&#xff0c;对这些因素进行深入分析。 公司优秀的原因七种关键成功因素 1.成功的企业关注:关注少而精的…

4月阿里offer被毁,我该怎么进字节?

在校招求职的浪潮中&#xff0c;有些故事总是让人唏嘘不已。比如最近在社交平台上广泛讨论的一个话题&#xff1a;“4月阿里offer被毁&#xff0c;我该怎么进字节&#xff1f;”这不仅反映了当下职场的变动性&#xff0c;也映射了求职者在面对突如其来的变故时的无助与挣扎。 …

开发实战(5)--fofa进行漏洞poc的信息收集

目录 前言 安全开发专栏 个人介绍 编写详情 1.1 了解结构 1.2 发起请求 1.2.1 请求头 1.2.2 进行请求 1.2.3 提取数据,并进行存储 方式一: 方式二: 1.3 完整代码(爬取一页) 1.4 突破注册会员限制批量采集(爬取指定数量页面) ​总结 前言 主要还是围绕渗透测试的…

贝叶斯分类 python

贝叶斯分类 python 贝叶斯分类器是一种基于贝叶斯定理的分类方法&#xff0c;常用于文本分类、垃圾邮件过滤等领域。 在Python中&#xff0c;我们可以使用scikit-learn库来实现贝叶斯分类器。 下面是一个使用Gaussian Naive Bayes(高斯朴素贝叶斯)分类器的简单示例&#xff1…

STL-list的使用及其模拟实现

在C标准库中&#xff0c;list 是一个双向链表容器&#xff0c;用于存储一系列元素。与 vector 和 deque 等容器不同&#xff0c;list 使用带头双向循环链表的数据结构来组织元素&#xff0c;因此list插入删除的效率非常高。 list的使用 list的构造函数 list迭代器 list的成员函…

目标检测综述

2D图像的目标检测是深度学习的热门领域&#xff0c; 在学术研究领域取得了巨大的进展&#xff0c;在工程中也被广泛应用。 按照stage划分&#xff0c; 主要可以分为one-stage 和two-stage 算法。 近年来&#xff0c; 随着transformer的流行&#xff0c; 基于transformer的检测…

还在找投稿邮箱?推荐一个靠谱的投稿平台给你

亲爱的朋友: 听说你还在为单位的信息宣传投稿考核而烦恼,四处寻找投稿邮箱,却屡屡碰壁,是吗?别着急,作为过来人,我想给你推荐一个靠谱的投稿平台——智慧软文发布系统网站。相信它能帮你轻松完成考核任务,让你的稿件更快更好地被媒体采纳。 想当年,我也曾像你一样,为了完成单…

代码随想录算法训练营第三十二天|122.买卖股票的最佳时机II,55. 跳跃游戏,45.跳跃游戏II

目录 122.买卖股票的最佳时机II思路代码 55. 跳跃游戏思路代码 45.跳跃游戏II思路代码 122.买卖股票的最佳时机II 题目链接&#xff1a;122.买卖股票的最佳时机II 文档讲解&#xff1a;代码随想录 视频讲解&#xff1a;贪心算法也能解决股票问题&#xff01;LeetCode&#xff1…

单链表的介绍

链表是一种常见的线性数据结构&#xff0c;用于存储一系列元素。它由一系列节点组成&#xff0c;每个节点包含两部分&#xff1a;数据域和指针域。其中&#xff0c;数据域用于存储元素的值&#xff0c;指针域用于指向下一个节点。 单链表的特点包括&#xff1a; 节点组成&…

Flowable 基本用法

一. 什么是Flowable Flowable 是一个基于 Java 的开源工作流引擎&#xff0c;用于实现和管理业务流程。它提供了强大的工作流引擎和一套丰富的工具&#xff0c;使开发人员能够轻松地建模、部署、执行和监控各种类型的业务流程。Flowable 是 Activiti 工作流引擎的一个分支&am…

线程池中常见的几大问题

说说你对线程池的了解&#xff1f; 线程池&#xff0c;是对一系列线程进行管理的资源池&#xff0c;当有任务来时&#xff0c;我们可以使用线程池中的线程&#xff0c;完成任务时不需要被销毁&#xff0c;会重新回到池子中&#xff0c;等待下一次的复用。 为什么要使用线程池…

垃圾收集器ParNewCMS与底层三色标记算法详解

垃圾收集算法 分代收集理论 当前虚拟机的垃圾收集都是采用分代收集算法,这种算法没有什么新思想,只是依据对象的存活周期不同将内存分为几块.一般将Java堆分为新生代和老年代,这样就可以根据各个年代的特点选择合适的垃圾收集算法. 比如在新生代中,每次收集都会有大量对象(近…