接下来我们来处理 动态换肤 功能
关于 动态换肤 我们之前已经提到过了,在 layout/components/SidebarMenu.vue 中,我们实现 el-menu 的背景色时,说过 此处将来会实现换肤功能,所以我们不能直接写死,而需要通过一个动态的值进行指定
<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
>那么换句话而言,想要实现 动态换肤 的一个前置条件就是:色值不可以写死!
那么为什么会有这个前置条件呢?动态换肤又是如何去进行实现的呢?这一小节我们来看一下这个问题。
首先我们先来说一下动态换肤的实现方式。
在 scss 中,我们可以通过 $变量名:变量值 的方式定义 css 变量 ,然后通过该 css 来去指定某一块 DOM 对应的颜色。
那么大家可以想一下,如果我此时改变了该 css 变量的值,那么对应的 DOM 颜色是不是也会同步发生变化。
当大量的 DOM 都依赖这个 css 变量 设置颜色时,我们是不是只需要改变这个 css 变量 ,那么所有 DOM 的颜色是不是都会发生变化,所谓的 动态换肤 是不是就可以实现了!
这个就是 动态换肤 的实现原理
而在我们的项目中想要实现动态换肤,需要同时处理两个方面的内容:
element-plus 主题element-plus 主题那么下面我们就分别来去处理这两块主题对应的内容
明确好了原理之后,接下来我们就来理一下咱们的实现思路。
从原理中我们可以得到以下两个关键信息:
css 变量 的值element-pluselement-plus那么根据以上关键信息,我们就可以得出对应的实现方案
ThemeSelect 用来处理修改之后的 css 变量 的值element-plus 主题色element-plus 主题色查看完成之后的项目我们可以发现,ThemeSelect 组件将由两部分组成:
navbar 中的展示图标那么本小节我们就先来处理第一个 navbar 中的展示图标
创建 components/ThemeSelect/index 组件
<template>
<!-- 主题图标
v-bind:https://v3.cn.vuejs.org/api/instance-properties.html#attrs -->
<el-dropdown
v-bind="$attrs"
trigger="click"
class="theme"
@command="handleSetTheme"
>
<div>
<el-tooltip :content="$t('msg.navBar.themeChange')">
<svg-icon icon="change-theme" />
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="color">
{{ $t('msg.theme.themeColorChange') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 展示弹出层 -->
<div></div>
</template>
<script setup>
const handleSetTheme = command => {}
</script>
<style lang="scss" scoped></style>
在 layout/components/navbar 中进行引用
<div class="right-menu">
<theme-picker class="right-menu-item hover-effect"></theme-picker>
...
</div>
<script setup>
import ThemePicker from '@/components/ThemeSelect/index'
</script>在有了 ThemeSelect 之后,接下来我们来去处理颜色选择的组件 SelectColor,在这里我们会用到 element 中的 el-color-picker 组件
对于 SelectColor 的处理,我们需要分成两步进行:
SelectColor 弹窗展示的双向数据绑定那么下面咱们先来看第一步:完成 SelectColor 弹窗展示的双向数据绑定
创建 components/ThemePicker/components/SelectColor.vue
<template>
<el-dialog title="提示" :model-value="modelValue" @close="closed" width="22%">
<div class="center">
<p class="title">{{ $t('msg.theme.themeColorChange') }}</p>
<el-color-picker
v-model="mColor"
:predefine="predefineColors"
></el-color-picker>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{ $t('msg.universal.cancel') }}</el-button>
<el-button type="primary" @click="comfirm">{{
$t('msg.universal.confirm')
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
// 预定义色值
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'rgba(255, 69, 0, 0.68)',
'rgb(255, 120, 0)',
'hsv(51, 100, 98)',
'hsva(120, 40, 94, 0.5)',
'hsl(181, 100%, 37%)',
'hsla(209, 100%, 56%, 0.73)',
'#c7158577'
]
// 默认色值
const mColor = ref('#00ff00')
/**
* 关闭
*/
const closed = () => {
emits('update:modelValue', false)
}
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/
const comfirm = async () => {
// 3. 关闭 dialog
closed()
}
</script>
<style lang="scss" scoped>
.center {
text-align: center;
.title {
margin-bottom: 12px;
}
}
</style>
在 ThemePicker/index 中使用该组件
<template>
...
<!-- 展示弹出层 -->
<div>
<select-color v-model="selectColorVisible"></select-color>
</div>
</template>
<script setup>
import SelectColor from './components/SelectColor.vue'
import { ref } from 'vue'
const selectColorVisible = ref(false)
const handleSetTheme = command => {
selectColorVisible.value = true
}
</script>
完成双向数据绑定之后,我们来处理第二步:把选中的色值进行本地缓存
缓存的方式分为两种:
vuex在 constants/index 下新建常量值
// 主题色保存的 key
export const MAIN_COLOR = 'mainColor'
// 默认色值
export const DEFAULT_COLOR = '#409eff'创建 store/modules/theme 模块,用来处理 主题色 相关内容
import { getItem, setItem } from '@/utils/storage'
import { MAIN_COLOR, DEFAULT_COLOR } from '@/constant'
export default {
namespaced: true,
state: () => ({
mainColor: getItem(MAIN_COLOR) || DEFAULT_COLOR
}),
mutations: {
/**
* 设置主题色
*/
setMainColor(state, newColor) {
state.mainColor = newColor
setItem(MAIN_COLOR, newColor)
}
}
}在 store/getters 下指定快捷访问
mainColor: state => state.theme.mainColor在 store/index 中导入 theme
...
import theme from './modules/theme.js'
export default createStore({
getters,
modules: {
...
theme
}
})在 selectColor 中,设置初始色值 和 缓存色值
...
<script setup>
import { ref } from 'vue'
import { useStore } from 'vuex'
...
const store = useStore()
// 默认色值
const mColor = ref(store.getters.mainColor)
...
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/
const comfirm = async () => {
// 2. 保存最新的主题色
store.commit('theme/setMainColor', mColor.value)
// 3. 关闭 dialog
closed()
}
</script>
对于 element-plus 的主题变更,相对比较复杂,所以说整个过程我们会分为三部分:
实现原理:
在之前我们分析主题变更的实现原理时,我们说过,核心的原理是:通过修改 scss 变量 的形式修改主题色完成主题变更
但是对于 element-plus 而言,我们怎么去修改这样的主题色呢?
其实整体的原理非常简单,分为三步:
element-plus 的所有样式style 标签中,利用样式优先级的特性,替代固有样式实现步骤:
那么明确了原理之后,我们的实现步骤也就呼之欲出了,对应原理总体可分为四步:
element-plus 的所有样式style 标签中创建 utils/theme 工具类,写入两个方法
/**
* 写入新样式到 style
* @param {*} elNewStyle element-plus 的新样式
* @param {*} isNewStyleTag 是否生成新的 style 标签
*/
export const writeNewStyle = elNewStyle => {
}
/**
* 根据主色值,生成最新的样式表
*/
export const generateNewStyle = primaryColor => {
}那么接下来我们先实现第一个方法 generateNewStyle,在实现的过程中,我们需要安装两个工具类:
然后还需要写入一个 颜色转化计算器 formula.json
创建 constants/formula.json (https://gist.github.com/benfrain/7545629)
{
"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)"
}准备就绪后,我们来实现 generateNewStyle 方法:
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
}
接下来处理 writeNewStyle 方法:
/**
* 写入新样式到 style
* @param {*} elNewStyle element-plus 的新样式
* @param {*} isNewStyleTag 是否生成新的 style 标签
*/
export const writeNewStyle = elNewStyle => {
const style = document.createElement('style')
style.innerText = elNewStyle
document.head.appendChild(style)
}最后在 SelectColor.vue 中导入这两个方法:
...
<script setup>
...
import { generateNewStyle, writeNewStyle } from '@/utils/theme'
...
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/
const comfirm = async () => {
// 1.1 获取主题色
const newStyleText = await generateNewStyle(mColor.value)
// 1.2 写入最新主题色
writeNewStyle(newStyleText)
// 2. 保存最新的主题色
store.commit('theme/setMainColor', mColor.value)
// 3. 关闭 dialog
closed()
}
</script>
一些处理完成之后,我们可以在 profile 中通过一些代码进行测试:
<el-row>
<el-button>Default</el-button>
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
<el-button type="info">Info</el-button>
<el-button type="warning">Warning</el-button>
<el-button type="danger">Danger</el-button>
</el-row>到目前我们已经完成了 element-plus 的主题变更,但是当前的主题变更还有一个小问题,那就是:在刷新页面后,新主题会失效
那么出现这个问题的原因,非常简单:因为没有写入新的 style
所以我们只需要在 应用加载后,写入 style 即可
那么写入的时机,我们可以放入到 app.vue 中
<script setup>
import { useStore } from 'vuex'
import { generateNewStyle, writeNewStyle } from '@/utils/theme'
const store = useStore()
generateNewStyle(store.getters.mainColor).then(newStyleText => {
writeNewStyle(newStyleText)
})
</script>
自定义主题变更相对来说比较简单,因为 自己的代码更加可控。
目前在我们的代码中,需要进行 自定义主题变更 为 menu 菜单背景色
而目前指定 menu 菜单背景色的位置在 layout/components/sidebar/SidebarMenu.vue 中
<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
>此处的 背景色是通过 getters 进行指定的,该 cssVar 的 getters 为:
cssVar: state => variables,所以,我们想要修改 自定义主题 ,只需要从这里入手即可。
根据当前保存的 mainColor 覆盖原有的默认色值
import variables from '@/styles/variables.scss'
import { MAIN_COLOR } from '@/constant'
import { getItem } from '@/utils/storage'
import { generateColors } from '@/utils/theme'
const getters = {
...
cssVar: state => {
return {
...variables,
...generateColors(getItem(MAIN_COLOR))
}
},
...
}
export default getters
但是我们这样设定之后,整个自定义主题变更,还存在两个问题:
menuBg 背景颜色没有变化
(配图缺失:通用功能开发示意图)这个问题是因为咱们的 sidebar 的背景色未被替换,所以我们可以在 layout/index 中设置 sidebar 的 backgroundColor
<sidebar
id="guide-sidebar"
class="sidebar-container"
:style="{ backgroundColor: $store.getters.cssVar.menuBg }"
/>这个是因为 getters 中没有监听到 依赖值的响应变化,所以我们希望修改依赖值
在 store/modules/theme 中
...
import variables from '@/styles/variables.scss'
export default {
namespaced: true,
state: () => ({
...
variables
}),
mutations: {
/**
* 设置主题色
*/
setMainColor(state, newColor) {
...
state.variables.menuBg = newColor
...
}
}
}
在 getters 中
....
const getters = {
...
cssVar: state => {
return {
...state.theme.variables,
...generateColors(getItem(MAIN_COLOR))
}
},
...
}
export default getters
那么到这里整个自定义主题我们就处理完成了。
对于 自定义主题而言,核心的原理其实就是 修改scss变量来进行实现主题色变化
明确好了原理之后,对后续实现的步骤就具体情况具体分析了。
element-plus:因为 element-plus 是第三方的包,所以它 不是完全可控 的,那么对于这种最简单直白的方案,就是直接拿到它编译后的 css 进行色值替换,利用 style 内部样式表 优先级高于 外部样式表 的特性,来进行主题替换scss变量即可那么在之后大家遇到 自定义主题 的处理时,就可以按照我们所梳理的方案进行处理了。
接下来我们来看 screenfull (全屏) 功能实现
对于 screenfull 和之前一样 ,我们还是先分析它的原理,然后在制定对应的方案实现
原理:
对于 screenfull 而言,浏览器本身已经提供了对用的 API,点击这里即可查看,这个 API 中,主要提供了两个方法:
Document.exitFullscreen():该方法用于请求从全屏模式切换到窗口模式Element.requestFullscreen():该方法用于请求浏览器(user agent)将特定元素(甚至延伸到它的后代元素)置为全屏模式
document.getElementById('app').requestFullscreen() 在获取 id=app 的 DOM 之后,把该区域置为全屏但是该方法存在一定的小问题,比如:
appmain 区域背景颜色为黑色所以通常情况下我们不会直接使用该 API 来去实现全屏效果,而是会使用它的包装库 screenfull
方案:
那么明确好了原理之后,接下来实现方案就比较容易了。
整体的方案实现分为两步:
screenfull 组件
navbar 中引入该组件明确好了方案之后,接下来我们就落地该方案
封装 screenfull 组件:
下来依赖包 screenfull
npm i screenfull@5.1.0创建 components/Screenfull/index
<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>
在 navbar 中引入该组件:
<template>
<div class="right-menu">
<screenfull class="right-menu-item hover-effect" />
...
</div>
</template>
<script setup>
import Screenfull from '@/components/Screenfull'
</script>所谓
headerSearch指 页面搜索
原理:
headerSearch 是复杂后台系统中非常常见的一个功能,它可以:在指定搜索框中对当前应用中所有页面进行检索,以 select 的形式展示出被检索的页面,以达到快速进入的目的
那么明确好了 headerSearch 的作用之后,接下来我们来看一下对应的实现原理
根据前面的目的我们可以发现,整个 headerSearch 其实可以分为三个核心的功能点:
select 形式展示检索出的页面那么围绕着这三个核心的功能点,我们想要分析它的原理就非常简单了:根据指定内容检索所有页面,把检索出的页面以 select 展示,点击对应 option 可进入
方案:
对照着三个核心功能点和原理,想要指定对应的实现方案是非常简单的一件事情了
headerSearch 组件,用作样式展示和用户输入内容获取select 进行展示select 的 change 事件,完成对应跳转创建 components/headerSearch/index 组件:
<template>
<div :class="{ show: isShow }" class="header-search">
<svg-icon
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 5"
:key="option"
:label="option"
:value="option"
></el-option>
</el-select>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 控制 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 querySearch = () => {
console.log('querySearch')
}
// 选中回调
const onSelectChange = () => {
console.log('onSelectChange')
}
</script>
<style lang="scss" scoped>
.header-search {
font-size: 0 !important;
.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;
::v-deep .el-input__inner {
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>
在 navbar 中导入该组件
<template>
<div class="right-menu">
<header-search class="right-menu-item hover-effect"></header-search>
...
</div>
</template>
<script setup>
import HeaderSearch from '@/components/HeaderSearch'
</script>在有了 headerSearch 之后,接下来就可以来处理对应的 检索数据源了
检索数据源 表示:有哪些页面希望检索
那么对于我们当前的业务而言,我们希望被检索的页面其实就是左侧菜单中的页面,那么我们检索数据源即为:左侧菜单对应的数据源
根据以上原理,我们可以得出以下代码:
<script setup>
import { ref, computed } from 'vue'
import { filterRouters, generateMenus } from '@/utils/route'
import { useRouter } from 'vue-router'
...
// 检索数据源
const router = useRouter()
const searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
console.log(generateMenus(filterRoutes))
return generateMenus(filterRoutes)
})
console.log(searchPool)
</script>如果我们想要进行 模糊搜索 的话,那么需要依赖一个第三方的库 fuse.js
安装 fuse.js
npm install --save fuse.js@6.4.6初始化 Fuse,更多初始化配置项 可点击这里
import Fuse from 'fuse.js'
/**
* 搜索库相关
*/
const fuse = new Fuse(list, {
// 是否按优先级进行排序
shouldSort: true,
// 匹配长度超过这个值的才会被认为是匹配的
minMatchCharLength: 1,
// 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。
// name:搜索的键
// weight:对应的权重
keys: [
{
name: 'title',
weight: 0.7
},
{
name: 'path',
weight: 0.3
}
]
})
参考 Fuse Demo 与 最终效果,可以得出,我们最终期望得到如下的检索数据源结构
[
{
"path":"/my",
"title":[
"个人中心"
]
},
{
"path":"/user",
"title":[
"用户"
]
},
{
"path":"/user/manage",
"title":[
"用户",
"用户管理"
]
},
{
"path":"/user/info",
"title":[
"用户",
"用户信息"
]
},
{
"path":"/article",
"title":[
"文章"
]
},
{
"path":"/article/ranking",
"title":[
"文章",
"文章排名"
]
},
{
"path":"/article/create",
"title":[
"文章",
"创建文章"
]
}
]所以我们之前处理了的数据源并不符合我们的需要,所以我们需要对数据源进行重新处理
在上一小节,我们明确了最终我们期望得到数据源结构,那么接下来我们就对重新计算数据源,生成对应的 searchPool
创建 compositions/HeaderSearch/FuseData.js
import path from 'path'
import i18n from '@/i18n'
/**
* 筛选出可供搜索的路由对象
* @param routes 路由表
* @param basePath 基础路径,默认为 /
* @param prefixTitle
*/
export const generateRoutes = (routes, basePath = '/', prefixTitle = []) => {
// 创建 result 数据
let res = []
// 循环 routes 路由
for (const route of routes) {
// 创建包含 path 和 title 的 item
const data = {
path: 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
}
在 headerSearch 中导入 generateRoutes
<script setup>
import { computed, ref } from 'vue'
import { generateRoutes } from './FuseData'
import Fuse from 'fuse.js'
import { filterRouters } from '@/utils/route'
import { useRouter } from 'vue-router'
...
// 检索数据源
const router = useRouter()
const searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateRoutes(filterRoutes)
})
/**
* 搜索库相关
*/
const fuse = new Fuse(searchPool.value, {
...
})
</script>通过 querySearch 测试搜索结果
// 搜索方法
const querySearch = query => {
console.log(fuse.search(query))
}数据源处理完成之后,最后我们就只需要完成:
那么下面我们按照步骤进行实现:
渲染检索出的数据
<template>
<el-option
v-for="option in searchOptions"
:key="option.item.path"
:label="option.item.title.join(' > ')"
:value="option.item"
></el-option>
</template>
<script setup>
...
// 搜索结果
const searchOptions = ref([])
// 搜索方法
const querySearch = query => {
if (query !== '') {
searchOptions.value = fuse.search(query)
} else {
searchOptions.value = []
}
}
...
</script>
完成对应跳转
// 选中回调
const onSelectChange = val => {
router.push(val.path)
}到这里我们的 headerSearch 功能基本上就已经处理完成了,但是还存在一些小 bug ,那么最后这一小节我们就处理下这些剩余的 bug
search 打开时,点击 body 关闭 searchsearch 关闭时,清理 searchOptionsheaderSearch 应该具备国际化能力明确好问题之后,接下来我们进行处理
首先我们先处理前面两个问题:
/**
* 关闭 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)
}
})接下来是国际化的问题,想要处理这个问题非常简单,我们只需要:监听语言变化,重新计算数据源初始化 fuse 即可
在 utils/i18n 下,新建方法 watchSwitchLang
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))
}
)
}在 headerSearch 监听变化,重新赋值
<script setup>
...
import { watchSwitchLang } from '@/utils/i18n'
...
// 检索数据源
const router = useRouter()
let searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateRoutes(filterRoutes)
})
/**
* 搜索库相关
*/
let fuse
const initFuse = searchPool => {
fuse = new Fuse(searchPool, {
...
}
initFuse(searchPool.value)
...
// 处理国际化
watchSwitchLang(() => {
searchPool = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateRoutes(filterRoutes)
})
initFuse(searchPool.value)
})
</script>那么到这里整个的 headerSearch 我们就已经全部处理完成了,整个 headerSearch 我们只需要把握住三个核心的关键点
select 形式展示检索出的页面保证大方向没有错误,那么具体的细节处理我们具体分析就可以了。
关于细节的处理,可能比较复杂的地方有两个:
对于这两块,我们依赖于 fuse.js 进行了实现,大大简化了我们的业务处理流程。
所谓 tagsView 可以分成两部分来去看:
好像和废话一样是吧。那怎么分开看呢?
首先我们先来看 tags:
所谓 tgas 指的是:位于 appmain 之上的标签
那么现在我们忽略掉 view,现在只有一个要求:
在
view之上渲染这个tag
仅看这一个要求,很简单吧。
views:
明确好了 tags 之后,我们来看 views。
脱离了 tags 只看 views 就更简单了,所谓 views :指的就是一个用来渲染组件的位置,就像我们之前的 Appmain 一样,只不过这里的 views 可能稍微复杂一点,因为它需要在渲染的基础上增加:
这两个额外的功能。
加上这两个功能之后可能会略显复杂,但是 官网已经帮助我们处理了这个问题
所以 单看 views 也是一个很简单的功能。
那么接下来我们需要做的就是把 tags 和 view 合并起来而已。
那么明确好了原理之后,我们就来看 实现方案:
tagsView 组件:用来处理 tags 的展示AppMain 中进行:用于处理 view 的部分整个的方案就是这么两大部,但是其中我们还需要处理一些细节相关的,完整的方案为:
tags 的数据源tags 组件,根据数据源渲染 tag,渲染出来的 tags 需要同时具备
title那么明确好了方案之后,接下来我们根据方案进行处理即可。
tags 的数据源分为两部分:
appmain 组件中进行tags 组件中进行所以 tags 的数据我们最好把它保存到 vuex 中。
在 constant 中新建常量
// 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: {}
}
在 appmain 中监听路由的变化
<script setup>
import { watch } from 'vue'
import { isTags } from '@/utils/tags'
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)) return
const { fullPath, meta, name, params, path, query } = to
store.commit('app/addTagsViewList', {
fullPath,
meta,
name,
params,
path,
query,
title: getTitle(to)
})
},
{
immediate: true
}
)
</script>
创建 utils/tags
const whiteList = ['/login', '/import', '/404', '/401']
/**
* path 是否需要被缓存
* @param {*} path
* @returns
*/
export function isTags(path) {
return !whiteList.includes(path)
}
目前数据已经被保存到 store 中,那么接下来我们就依赖数据渲染 tags
创建 store/app 中 tagsViewList 的快捷访问
tagsViewList: state => state.app.tagsViewList创建 components/tagsview
<template>
<div class="tags-view-container">
<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 }"
>
{{ tag.title }}
<i
v-show="!isActive(tag)"
class="el-icon-close"
@click.prevent.stop="onCloseClick(index)"
/>
</router-link>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
/**
* 是否被选中
*/
const isActive = tag => {
return tag.path === route.path
}
/**
* 关闭 tag 的点击事件
*/
const onCloseClick = 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/index 中导入
<template>
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
<!-- tags -->
<tags-view></tags-view>
</div>
</template>
<script setup>
import TagsView from '@/components/TagsView'
</script>tagsView 的国际化处理可以理解为修改现有 tags 的 title。
所以我们只需要:
title 即可根据方案,可生成如下代码:
在 store/app 中,创建修改 ttile 的 mutations
/**
* 为指定的 tag 修改 title
*/
changeTagsView(state, { index, tag }) {
state.tagsViewList[index] = tag
setItem(TAGS_VIEW, state.tagsViewList)
}在 appmain 中监听语言变化
import { generateTitle, watchSwitchLang } from '@/utils/i18n'
/**
* 国际化 tags
*/
watchSwitchLang(() => {
store.getters.tagsViewList.forEach((route, index) => {
store.commit('app/changeTagsView', {
index,
tag: {
...route,
title: getTitle(route)
}
})
})
})contextMenu 为 鼠标右键事件
contextMenu 事件的处理分为两部分:
contextMenu 的展示那么这一小节我们先处理第一部分:contextMenu 的展示:
创建 components/TagsView/ContextMenu 组件,作为右键展示部分
<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>
defineProps({
index: {
type: Number,
required: true
}
})
const onRefreshClick = () => {}
const onCloseRightClick = () => {}
const onCloseOtherClick = () => {}
</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>
在 tagsview 中控制 contextMenu 的展示
<template>
<div class="tags-view-container">
<el-scrollbar class="tags-view-wrapper">
<router-link
...
@contextmenu.prevent="openMenu($event, index)"
>
...
</el-scrollbar>
<context-menu
v-show="visible"
:style="menuStyle"
:index="selectIndex"
></context-menu>
</div>
</template>
<script setup>
import ContextMenu from './ContextMenu.vue'
import { ref, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
...
// contextMenu 相关
const selectIndex = ref(0)
const visible = ref(false)
const menuStyle = reactive({
left: 0,
top: 0
})
/**
* 展示 menu
*/
const openMenu = (e, index) => {
const { x, y } = e
menuStyle.left = x + 'px'
menuStyle.top = y + 'px'
selectIndex.value = index
visible.value = true
}
</script>对于 contextMenu 的事件一共分为三个:
但是不要忘记,我们之前 关闭单个 tags 的事件还没有进行处理,所以这一小节我们一共需要处理 4 个对应的事件
刷新事件
const router = useRouter()
const onRefreshClick = () => {
router.go(0)
}在 store/app 中,创建删除 tags 的 mutations,该 mutations 需要同时具备以下三个能力:
根据以上理论得出以下代码:
/**
* 删除 tag
* @param {type: 'other'||'right'||'index', index: index} payload
*/
removeTagsView(state, payload) {
if (payload.type === 'index') {
state.tagsViewList.splice(payload.index, 1)
return
} else if (payload.type === 'other') {
state.tagsViewList.splice(
payload.index + 1,
state.tagsViewList.length - payload.index + 1
)
state.tagsViewList.splice(0, payload.index)
} else if (payload.type === 'right') {
state.tagsViewList.splice(
payload.index + 1,
state.tagsViewList.length - payload.index + 1
)
}
setItem(TAGS_VIEW, state.tagsViewList)
},关闭右侧事件
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
})
}关闭当前(tagsview)
/**
* 关闭 tag 的点击事件
*/
const store = useStore()
const onCloseClick = index => {
store.commit('app/removeTagsView', {
type: 'index',
index: index
})
}/**
* 关闭 menu
*/
const closeMenu = () => {
visible.value = false
}
/**
* 监听变化
*/
watch(visible, val => {
if (val) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})处理基于路由的动态过渡 官方已经给出了示例代码,结合 router-view 和 transition 我们可以非常方便的实现这个功能
在 appmain 中处理对应代码逻辑
<template>
<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>
</template>增加了 tags 之后,app-main 的位置需要进行以下处理
<style lang="scss" scoped>
.app-main {
min-height: calc(100vh - 50px - 43px);
...
padding: 104px 20px 20px 20px;
...
}
</style>在 styles/transition 中增加动画渲染
/* 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);
}那么到这里关于 tagsView 的内容我们就已经处理完成了。
整个 tagsView 就像我们之前说的,拆开来看之后,会显得明确很多。
整个 tagsView 整体来看就是三块大的内容:
tags:tagsView 组件contextMenu:contextMenu 组件view:appmain 组件再加上一部分的数据处理即可。
最后关于 tags 的国际化部分,其实处理的方案有非常多,大家也可以在后面的 讨论题 中探讨一下关于 此处国家化 的实现,相信会有很多新的思路被打开的。
所谓 guide 指的就是 引导页
引导页是软件中经常见到的一个功能,无论是在后台项目还是前台或者是移动端项目中。
那么对于引导页而言,它是如何实现的呢?我们来分析一下。
通常情况下引导页是通过 聚焦 的方式,高亮一块视图,然后通过文字解释的形式来告知用户该功能的作用。
所以说对于引导页而言,它的实现其实就是:页面样式 的实现。
我们只需要可以做到:
那么就可以实现对应的引导功能。
方案:
对于引导页来说,市面上有很多现成的轮子,所以我们不需要手动的去进行以上内容的处理,我们这里可以直接使用 driver.js 进行引导页处理。
基于 driver.js 我们的实现方案如下:
components/Guide<template>
<div>
<el-tooltip :content="$t('msg.navBar.guide')">
<svg-icon icon="guide" />
</el-tooltip>
</div>
</template>
<script setup></script>
<style scoped></style>
在 navbar 中导入该组件
<guide class="right-menu-item hover-effect" />
import Guide from '@/components/Guide'导入 driver.js
npm i driver.js@0.9.8在 guide.vue 中初始化 driver
<script setup>
import Driver from 'driver.js'
import 'driver.js/dist/driver.min.css'
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const i18n = useI18n()
let driver = null
onMounted(() => {
driver = new Driver({
// 禁止点击蒙版关闭
allowClose: false,
closeBtnText: i18n.t('msg.guide.close'),
nextBtnText: i18n.t('msg.guide.next'),
prevBtnText: i18n.t('msg.guide.prev')
})
})
</script>创建 步骤 steps.js
// 此处不要导入 @/i18n 使用 i18n.global ,因为我们在 router 中 layout 不是按需加载,所以会在 Guide 会在 I18n 初始化完成之前被直接调用。导致 i18n 为 undefined
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在 guide 中导入“步骤”
<template>
...
<svg-icon icon="guide" @click="onClick" />
...
</template>
<script setup>
...
import steps from './steps'
...
const onClick = () => {
driver.defineSteps(steps(i18n))
driver.start()
}
</script>
<style scoped></style>
为 引导高亮区域增加 ID
在 components/Guide/index 中增加
<svg-icon id="guide-start" icon="guide" @click="onClick" />在 components/Hamburger/index 增加
<svg-icon id="guide-hamburger" class="hamburger" :icon="icon"></svg-icon>在 src/layout/components 增加
<breadcrumb id="guide-breadcrumb" class="breadcrumb-container" />在 components/HeaderSearch/index 增加
<svg-icon
id="guide-search"
class-name="search-icon"
icon="search"
@click.stop="onShowClick"
/>在 components/Screenfull/index 增加
<svg-icon
id="guide-full"
:icon="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
@click="onToggle"
/>在 components/ThemePicker/index 增加
<svg-icon id="guide-theme" icon="change-theme" />在 components/LangSelect/index 增加
<svg-icon id="guide-lang" icon="language" />在 layout/index 增加
<tags-view id="guide-tags"></tags-view>在 layout/index 增加
<sidebar
id="guide-sidebar"
class="sidebar-container"
:style="{ backgroundColor: $store.getters.cssVar.menuBg }"
/>那么到这里我们整个的 后台项目前端综合解决方案之通用功能开发 这一章节就算是处理完成了。
在本章中我们对以下通用功能进行了处理:
screenfullheaderSearchtagsViewguide其中除了 screenfull 和 guide 之外其他的功能都是具备一定的复杂度的。
但是只要我们可以根据功能分析出对应原理,就可以根据原理实现对应方案,有了方案就可以制定出对应的实现步骤。
只要大的步骤没有错误,那么具体的细节功能实现只需要具体情况具体分析即可。
不过大家要注意,对于这些实现方案而言,并非 只有我们课程中的这一种实现方式。大家也可以针对这些实现方案在咱们的 群里 或者 讨论区 中,和我们一起多多发言或者讨论。