巩固学习:用自己的语言描述功能如何做的,总结所做过的功能。
功能点
- 后台管理系统目录设计
- 环境变量如何配置
- 通用请求如何封装
- 封装通用Svg组件
- 动态列表格实现
- 可拖拽可改变排序的表格实现
- 如何根据路由生成菜单
- headerSearch组件页面检索菜单处理方案
- tagsView 处理方案
后台管理系统目录设计
需求
后台管理系统,由于功能都类似,所以总结要有哪些文件夹和文件,都是做什么用的。以后新建一个后台系统项目,可以照搬。
实现
以下为单页应用的示范
--- src文件夹
------ api文件夹 // 存放所有的接口,每个业务模块为一个js文件
-------------- 模块名.js
------ assets 文件夹 // 存放静态图片
------ components文件夹 // 存放自定义的公共组件的文件夹
------ constant文件夹// 存放全局常量,里面的js文件根据业务功能模块划分
------ i18n文件夹 // 国际化配置相关
-------------- lang文件夹 // 存放具体的国际化语言包,里面通常还要进一步根据业务模块划分文件夹
---------------------- XX模块文件夹
-------------- index.js // 国际化全局配置,初始化国际化
------ icons文件夹 // 存放自定义图标的文件夹
------ plugins文件夹 // 在此安装第三方插件
------ router文件夹 // 路由相关配置,里面通常会再细分具体模块
-------------- XX.js // 具体某个模块的路由
-------------- index.js // 总的路由配置,在此导入所有模块的路由
------ store文件夹 // vuex全局响应式数据,具体要细分业务模块
-------------- modules // 存放每个业务模块的全局变量
-------------------- XX模块.js
-------------- index.js // 初始化vuex配置,导入所有模块的数据
------ styles文件夹 // 全局公共样式,具体要根据功能点拆分文件,不能全部写入到一个文件内
-------------- XX功能.scss
------ utils文件夹 // 存放全局公用的工具函数
-------------- XX功能.js // 对于后台管理系统,常用的工具函数也几乎都是一样的,通常不同项目之间,这些工具函数可以被复用。
------ views // 存放具体页面,通常按业务模块划分文件夹,必须有的模块有:layout,login,error-page
-------------- layout文件夹 // 页面总体布局,所有页面的统一入口
-------------- login文件夹 // 登录页面
-------------- error-page文件夹 // 公用报错页面, 如404
-------------- XX模块文件夹 // 其他具体的业务模块页面
------ App.vue // 项目根组件,入口,通常在此不做具体的业务
------ mian.js // 项目根js文件,入口,通常在此引入,注册所有全局的组件,插件,css等
环境变量如何配置
需求
多数项目都分有开发环境,测试环境,生产环境。这些不同的环境,配置项各不相同,比如接口地址不同,打包规则不同等。在开发过程中,如何使业务代码识别到当前属于什么开发环境,并且使用对应的配置项。
实现
针对vue-cli,我们可以建立三个文件:
.env.development.env.production.env.test
这三个文件对应开发,生成和测试环境,在可以在里面进行配置,注意:里面写的配置除了ENV这个变量以外,其余所有的变量,必须以VUE_APP_前缀命名,否则vue-cli不识别
例子:
开发环境.env.development
# 标志
ENV = 'development'
# base api
VUE_APP_BASE_API = '/api'
# 其他...
VUE_APP_XX = 'XXXX'
生成环境.env.production
# 标志
ENV = 'production'
# base api
VUE_APP_BASE_API = '/proc-api'
# 其他...
VUE_APP_XX = 'XXXX'
如果npm想对某一个环境打包,则应该使用命令添加环境参数,如:
"script": {
"build:test": "vue-cli-service serve --mode=test"
}
通用请求如何封装
需求
为什么必须要封装通用请求,因为一个请求有很多通用逻辑要处理,如:对各种不同状态码的响应,添加通用请求头,报错处理等。
实现
通常封装请求这个文件写在utils文件夹里面,通常命名为request或者ajax。
需要封装的东西根据项目实际需要来封装,绝大多数项目都有以下东西要封装:
- baseURL,根据不同环境来制定
- 超时时间,要和后台商议
- 通用请求头,如token,当前language语言
- 请求拦截器:根据业务需求加入请求头,根据业务需求判断是否需要取消请求。
- 响应拦截器:a)判断接口是否成功,成功则返回过滤后的数据。b)根据不同的状态码对不同的异常场景做处理,如未登录状态,并报出错误提示
具体怎么处理应该是和后台提前约定好的,且约定后所有的接口都必须遵守这个规范。
代码示范:
import axios from 'axios'
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 在此写入各种通用业务
// TODO...
return config // 必须返回配置
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
const { success, message, data } = response.data
// 要根据success的成功与否决定下面的操作
if (success) {
return data
} else {
// 业务错误
ElMessage.error(message) // 提示错误消息
return Promise.reject(new Error(message))
}
},
error => {
// 在此处理各种请求失败的逻辑,如未登录
// 处理 token 超时问题
if (
error.response &&
error.response.data &&
error.response.data.code === 401
) {
// token超时
store.dispatch('user/logout')
}
ElMessage.error(error.message) // 提示错误信息
return Promise.reject(error)
}
)
export default service
封装通用Svg组件
需求
项目当中通常有显示自定义svg图标的需求,我们希望使用的时候,像使用element-ui的图标一样方便,当成一个组件使用。所以封装一个通用svg图标组件。
前置知识点
- use标签:use标签可以重复引用svg图片,只要该svg图片设置了唯一id即可(该svg图片必须已经写入到html中)
- svg图片默认不是组件,不可被vue-cli识别,在webpack环境下,需要用
svg-sprite-loader插件对其进行打包,以及写入唯一id(供use标签识别) - 有了loader打包svg图片,我们还需要把本地的svg图片导入到项目中,只有导入了的svg图片,才会被
svg-sprite-loader打包
导入代码示范:注意require.context这个函数的使用,这个是webpack提供的导入方法。
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)
}
实现
我们希望该组件:
- 通过传入一个icon参数就可以识别到显示哪一个svg,该参数就是svg图片的定义的id
- 可以传入一个class,通过这个class自定义样式,如颜色,大小
代码示范
<template>
<div
v-if="isExternal"
:style="styleExternalIcon"
class="svg-external-icon svg-icon"
:class="className"
/>
<svg v-else class="svg-icon" :class="className" aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>
<script setup>
import { isExternal as external } from '@/utils/validate'
import { defineProps, computed } from 'vue'
const props = defineProps({
// icon 图标
icon: {
type: String,
required: true
},
// 图标类名
className: {
type: String,
default: ''
}
})
/**
* 判断是否为外部图标
*/
const isExternal = computed(() => external(props.icon))
/**
* 外部图标样式
*/
const styleExternalIcon = computed(() => ({
mask: `url(${props.icon}) no-repeat 50% 50%`,
'-webkit-mask': `url(${props.icon}) no-repeat 50% 50%`
}))
/**
* 项目内图标
*/
const iconName = computed(() => `#icon-${props.icon}`)
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>
/**
* 判断是否为外部资源
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
动态列表格实现
需求
一个table表格,用户根据自己的查看需要动态添加某一列或者删除某一列,这在后台管理系统中是常用的功能。
实现
核心实现逻辑:表格的列数据是要响应式的,监听选中列的数据,当有变化后,重新表格的列。
实现步骤
- 给出所有可以显示的列
- 初始化checkbox数据,定义一开始应该显示哪些列
- 利用watch方法,监听CheckBox选中数据的变化,当数据变化后,根据checkbox选中的值,重新计算应该显示哪些列
代码示范
// 所有的列数据
const dynamicData = ref([
{
label: '名称',
prop: 'name'
},
{
label: '日期',
prop: 'date'
},
{
label: '地址',
prop: 'address'
},
])
const selectDynamicLabel = ref([])
// 初始化,全部选中
const initSelectDynamicLabel = () => {
selectDynamicLabel.value = dynamicData.value.map((item) => {
return item.prop
})
}
initSelectDynamicLabel()
// 定义表格列,一定要是响应式的
const tableColumns = ref([])
// 监听选中项的变化,根据选中项动态改变 table 列数据的值
watch(
selectDynamicLabel,
val => {
tableColumns.value = []
const selectData = dynamicData.value.filter(item => {
return val.includes(item.prop)
})
tableColumns.value.push(...selectData)
},
{
immediate: true
}
)
<div class="dynamic-box">
<span class="title">显示列</span>
<el-checkbox-group v-model="selectDynamicLabel">
<el-checkbox v-for="(item, index) in dynamicData" :label="item.prop" :key="index">{{ item.label }}</el-checkbox>
</el-checkbox-group>
</div>
<br>
<el-card>
<el-table :data="tableData" style="width: 100%">
<el-table-column v-for="(item) in tableColumns" :prop="item.prop" :label="item.label" :key="item.prop" />
</el-table>
</el-card>
可拖拽可改变排序的表格实现
需求
一个表格,做成可以行拖动的,通过拖动和交换行,达到重新给表格排序的目的。
前置知识点
html5的拖拽相关api
draggable="true"属性,把某一个元素变成可以拖动的元素- html5的拖拽相关事件
dragstart // 当鼠标开始拖放时被触发
drag // 当鼠标拖放过程中,类似于mousemove事件
dragend // 当鼠标结束拖放时被触发
拖拽表格技术原理
- 给表格的每一行tr,都加上
draggable="true"属性,把每一行表格都变成可以拖动的元素。 - 表格每一行,都监听
dragstart事件,在拖动时候触发记录当前拖动的是哪一行,以及这行有什么数据 - 表格每一行,都监听
dragend事件,当触发此事件的时候,记录当前行数据,然后把拖动行数据和当前行数据进行交换,然后重新渲染表格
利用插件实现
实际工作中,拖拽表格该功能已有很多类似的轮子,自己造一个太花时间,使用轮子是好的选择,能兼容vue的轮子有sortablejs,我们通过使用这个插件能快速实现拖拽表格
代码演示
<el-table ref="tableRef" :data="tableData" style="width: 100%">
<el-table-column v-for="(item) in tableColumns" :prop="item.prop" :label="item.label" :key="item.prop" />
</el-table>
const tableRef = ref(null)
/**
* 初始化排序
*/
const initSortable = (tableData, cb) => {
// 设置拖拽效果
// 1. 要拖拽的元素
const el = tableRef.value.$el.querySelectorAll(
'.el-table__body-wrapper > table > tbody'
)[0]
// 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: '排序成功',
type: 'success'
})
// 直接重新获取数据无法刷新 table!!
tableData.value = []
// 重新获取数据
cb && cb()
}
})
}
onMounted(() => {
initSortable(tableData, getListData)
})
如何根据路由生成菜单
需求
为什么不能直接写死菜单,而是要根据路由生成动态菜单?因为路由是有权限的,每一个用户看到的路由可能都不一样,所以只能根据路由生成菜单,且要能做到当路由表变化时候,菜单也实时变化。
前置知识点
需要知道通过router.getRoutes()能获取所有路由
router.options.routes // 获取初始路由列表,新增的路由无法获取到
router.getRoutes() // 获取所有路由记录的完整列表
实现思路
根据上图的最终效果图,可知最终菜单需要显示有图标和名称,还可能存在层级关系。可以有如下实现思路:
- 在定义路由的时候,给将来需要在菜单上显示的路由配置mate对象,mate对象里有两个字段icon和title,分别对应菜单的icon和名称,没有这两个字段则表示这个路由不需要显示在菜单上。
- 在菜单组件初始化的时候,获取当前路由表路由路由。
- 过滤掉没有icon和title的路由,即过滤掉不需要显示在菜单上的路由。
- 得到最终可以用的菜单数据,渲染到html上。
关键js代码实现
给路由对象加上icon和title字段
...
{
"path":"/user/role",
"meta":{
"title":"roleList",
"icon":"role"
}
},
{
"path":"/user/permission",
"meta":{
"title":"permissionList",
"icon":"permission"
}
},
...
在菜单组件中,获取完整路由表,并且过滤出需要在菜单栏显示的路由,生成菜单。
const router = useRouter()
const routes = computed(() => {
// 获取所有的路由
const allRouter = router.getRoutes()
// 根据icon和title,过滤出需要显示在菜单上的路由
return generateMenus(allRouter)
})
function generateMenus(routes, basePath = '') {
const result = []
// 遍历路由表
routes.forEach(item => {
// 不存在 children && 不存在 meta 直接 return
if (isNull(item.meta) && isNull(item.children)) return
// 存在 children 不存在 meta,进入迭代
if (isNull(item.meta) && !isNull(item.children)) {
result.push(...generateMenus(item.children))
return
}
// 合并 path 作为跳转路径+
const routePath = path.resolve(basePath, item.path)
// 存在同名父路由的情况,需要单独处理,如果不存在,则可以不要这一步处理
let route = result.find(item => item.path === routePath)
if (!route) {
route = {
...item,
path: routePath,
children: []
}
// icon 与 title 必须全部存在
if (route.meta.icon && route.meta.title) {
// meta 存在生成 route 对象,放入 arr
result.push(route)
}
}
// 存在 children 进入迭代到children
if (item.children) {
route.children.push(...generateMenus(item.children, route.path))
}
})
return result
}
headerSearch组件页面检索菜单处理方案
需求
headerSearch组件,是一个能检索当前应用所有菜单的组件,当检索到对应的菜单后,以选项形式进行展示,
前置知识点
理解 模糊搜索算法(近似字符串匹配算法)
定义:系统允许被搜索信息和搜索提问之间存在一定的差异,这种差异就是“模糊”在搜索中的含义。例如,查找名字Smith时,就会找出与之相似的Smithe, Smythe, Smyth, Smitt等。
模糊搜索功能的实现过于复杂,不在当前项目的范围中,我们只需要知道模糊搜索的定义即可。这里我们实现模糊搜索时,使用第三方插件fuse.js
fuse.js demo使用示范
import Fuse from 'fuse.js'
// 被检索的数据源
const list = [
{
"path":"/user",
"title":[
"用户"
]
},
{
"path":"/user/manage",
"title":[
"用户",
"用户管理"
]
}
]
const fuse = new Fuse(list, {
// 是否按优先级进行排序
shouldSort: true,
// 匹配长度超过这个值的才会被认为是匹配的
minMatchCharLength: 1,
// 将被搜索的键列表。 这支持嵌套路径、加权搜索、在字符串和对象数组中搜索。
// name:搜索的键
// weight:对应的权重
keys: [
{
name: 'title',
weight: 0.7
},
{
name: 'path',
weight: 0.3
}
]
})
实现思路
- 获取所有的菜单数据,并将这些数据转化成可以被检索的数据格式,作为可以被检索的数据源
- 制作可以模糊搜索的select组件,把模糊搜索到的内容当成options选项
- 监听select的change事件,当触发后拿到对应的路由数据,并跳转
核心js实现代码
获取所有的菜单数据,作为可以被检索的数据。
注意点
实际项目中,很多页面虽然允许被访问,但需要经过一定的业务流程,传递一定的参数才允许进入,这一部分页面路由,是不应该被当成可以检索的数据的,应该当在获取菜单数据的时候就把这些排除掉。
import { ref, computed } from 'vue'
import { filterRouters, generateMenus } from '@/utils/route'
import { useRouter } from 'vue-router'
...
// 检索数据源
const router = useRouter()
// 获取菜单的方法generateMenus在上文中有写,这里不在重复
const menusData = generateMenus(router.getRoutes())
将获得的菜单数据源menusData转化为fuse.js需要的格式
const searchPool = computed(() => {
return generateRoutes(router.getRoutes())
})
console.log(searchPool)
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]
}
// 动态路由不允许被搜索
// 匹配动态路由的正则
const re = /.*\/:.*/
if (route.meta && route.meta.title && !re.exec(route.path)) {
// 当前存在 meta 时,使用 i18n 解析国际化数据,组合成新的 title 内容
// const i18ntitle = i18n.global.t(`msg.route.${route.meta.title}`)
// data.title = [...data.title, i18ntitle]
data.title = [...data.title]
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的html模板代码,渲染被搜索到的数据,并监听change事件跳转路由
<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 searchOptions"
:key="option.item.path"
:label="option.item.title.join(' > ')"
:value="option.item"
></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 searchOptions = ref([])
// 搜索方法
const querySearch = query => {
if (query !== '') {
searchOptions.value = fuse.search(query)
} else {
searchOptions.value = []
}
}
// 选中回调
const onSelectChange = val => {
router.push(val.path)
}
</script>
tagsView 处理方案
需求
- 我们希望被打开过的页面,能够缓存下来,下次再次打开时候不需要完全重新加载,达到更快打开的目的。
- 我们希望有一组tag标签,可以查看哪些页面已经被打开过且正在被缓存,并且可以通过点击tag再次快速切换被打开过的页面。整个功能非常类似浏览器的页面标签。
前置知识点
keep-alive组件,路由缓存的原理
keep-alive组件,能将一个不活跃的组件实例保存在内存中(组件实例是指根据数据生成虚拟dom VNode),而不是直接销毁,当需要再次激活的时候,从cache中取出缓存的虚拟dom,而不需要再次重新生成。达到用内存换计算时间的效果。
- 需要一个tag标签组件,用于展示当前已经打开了哪些页面,每一个tag标签可以点击跳转路由。
- 需要一个渲染页面的router-view组件,这个组件要有缓存功能。
核心实现思路
- 需要一个渲染页面的router-view组件,这个组件要有缓存功能,缓存实例化过的组件,这个vue 的
keep-alive组件已经帮我们实现,我们只需要把router-view外层包裹上keep-alive组件即可。 - 创建一个tagView组件,用于展示已经被打开过哪些页面,一个页面对应一个tag。
- 创建一个全局字段tagViewList,用于记录当前已经被缓存了路由对象,tagViewList的数据来源于监听路由的变化,当路由变化的时候,把最新的路由添加进tagViewList中(需要检测以前是否曾经打开过,如果打开过就不再重复添加)。
- 为tagView添加点击事件,当点击某一个tag之后,路由切换到对应的新的页面。
- 为每一个tag添加删除按钮,当点击删除后,删除对应的tagViewList里的字段,对应的页面缓存实例也要删除。
核心js代码实现
在appMain组件中监听路由的变化,当路由变化的时候,把新的路由添加进tabView的数组中,tagsViewList数据建议写在store当中,因为全局都要用。
import { watch } from 'vue'
import { isTags } from '@/utils/tags'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
const route = useRoute()
// 获得title没有title则用path路径代替
const getTitle = route => {
let title = ''
if (!route.meta) {
// 处理无 meta 的路由
const pathArr = route.path.split('/')
title = pathArr[pathArr.length - 1]
} else {
title = route.meta.title
}
return title
}
// 监听路由变化,当路由变化的时候,把新的路由添加进tabView的数组中
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
}
)
在sotre当中添加tagsViewList字段,以及新增添加tag标签的方法addTagsViewList,删除tag标签的方法removeTagsView
import { LANG, TAGS_VIEW } from '@/constant'
import { getItem, setItem } from '@/utils/storage'
export default {
namespaced: true,
state: () => ({
...
tagsViewList: getItem(TAGS_VIEW) || []
}),
mutations: {
...
// 添加新的tag标签
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)
}
}
},
// 删除某一个tag标签
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)
},
actions: {}
}
创建tagView组件,用于展示tagView,数据源即上一步通过监听路由添加到全局的tagsViewList
<template>
<div class="tags-view-container">
<router-link
class="tags-view-item"
:class="isActive(tag) ? 'active' : ''"
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 store = useStore()
const onCloseClick = index => {
store.commit('app/removeTagsView', {
type: 'index',
index: index
})
}
</script>
</style>
对于路由的缓存方案,vue-router已经帮我们做好了,使用keep-alive组件。
<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>