一、规范
# 第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks
## 2-01:为什么需要编程规范?
**工欲善其事,必先利其器**
对于一些大型的企业级项目而言,通常情况下我们都是需要一个团队来进行开发的。而又因为团队人员对技术理解上的参差不齐,所以就会导致出现一种情况,那就是《**一个项目无法具备统一的编程规范,导致项目的代码像多个不同材质的补丁拼接起来一样**》
设想一下,下面的这段代码有一个团队进行开发,因为没有具备统一的代码标准,所以生成了下面的代码:
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210903190021029.png" alt="image-20210903190021029" style="zoom:67%;" />`
这段代码可以正常运行没有问题,但是整体的代码结构却非常的难看。
> 有的地方有空格进行分割,有的地方却没有
>
> 有的地方是单引号,有的地方却是双引号
>
> 有的地方有分号,有的地方没有分号
>
> ....
这样的项目虽然可以正常运行,但是如果把它放到大厂的项目中,确实 **不及格** 的,它会被认为是 **不可维护、不可扩展的代码内容**
那么所谓的大厂标准的代码结构应该是什么样子的呢?
我们把上面的代码进行一下修正,做一个对比:
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210903193913261.png" alt="image-20210903193913261" style="zoom:67%;" />`
修改之后的代码具备了统一的规范之后,是不是看起来就舒服多了!
并且以上所列举出来的只是《编程规范》中的一小部分内容!
那么有些同学可能就会说了,你列举出来这些编程规范有什么用啊!
哪怕你写上一部书,我们一个团队这么多人,总不能指望所有人都看一遍,并且严格的遵守你所说的规范吧!
说的没错!指望人主动的遵守这些规范不太现实
那怎么办呢?
那么我们可不可以另辟蹊径,让程序自动处理规范化的内容呢?
答案是:可以的!
这些也是我们本章节所需要讲解的重点内容!
本章节中我们会为大家讲解,如何自动化的对代码进行规范,其中主要包括:
1. 编码规范
2. git 规范
两大类
那么明确好了我们的范围之后,接下来就让我们创建一个项目,开始我们的代码规范之旅吧!
## 2-02:使用 vue-cli 创建项目(图文)
本章节为 **图文节**,请点击 [这里](./图文课程/2-02:使用 vue-cli 创建项目.md) 查看对应文档
## 2-03:升级最新的 vue 版本以支持 script setup 语法(图文)
本章节为 **图文**,请点击 [这里](./图文课程/2-03:升级最新的 vue 版本以支持 script setup 语法 .md) 查看对应文档
## 2-04:大厂编程规范一:代码检测工具 ESLint 你了解多少?
在我们去创建项目的时候,脚手架工具已经帮助我们安装了 `ESLint` 代码检测工具。
对于 `ESLint` 的大名,同学们或多或少的应该都听说过,只不过有些同学可能了解的多一些,有些同学了解的少一些。
那么本小节我们就先来聊一下,这个赫赫有名的代码检测工具 `ESLint`
首先 `ESLint` 是 `2013年6月` 创建的一个开源项目,它的目标非常简单,只有一个,那就是 **提供一个插件化的 `javascript` 代码检测工具** ,说白了就是做 **代码格式检测使用的**
在咱们当前的项目中,包含一个 `.eslintrc.js` 文件,这个文件就是 `eslint` 的配置文件。
随着大家对代码格式的规范性越来越重视,`eslint` 也逐渐被更多的人所接收,同时也有很多大厂在原有的 `eslint` 规则基础之上进行了一些延伸。
我们在创建项目时,就进行过这样的选择:
```js
? Pick a linter / formatter config:
ESLint with error prevention only // 仅包含错误的 ESLint
ESLint + Airbnb config // Airbnb 的 ESLint 延伸规则
ESLint + Standard config // 标准的 ESLint 规则
```
我们当前选择了 **标准的 ESLint 规则** ,那么接下来我们就在该规则之下,看一看 `ESLint` 它的一些配置都有什么?
打开项目中的 `.eslintrc.js` 文件
```js
// ESLint 配置文件遵循 commonJS 的导出规则,所导出的对象就是 ESLint 的配置对象
// 文档:https://eslint.bootcss.com/docs/user-guide/configuring
module.exports = {
// 表示当前目录即为根目录,ESLint 规则将被限制到该目录下
root: true,
// env 表示启用 ESLint 检测的环境
env: {
// 在 node 环境下启动 ESLint 检测
node: true
},
// ESLint 中基础配置需要继承的配置
extends: ["plugin:vue/vue3-essential", "@vue/standard"],
// 解析器
parserOptions: {
parser: "babel-eslint"
},
// 需要修改的启用规则及其各自的错误级别
/**
* 错误级别分为三种:
* "off" 或 0 - 关闭规则
* "warn" 或 1 - 开启规则,使用警告级别的错误:warn (不会导致程序退出)
* "error" 或 2 - 开启规则,使用错误级别的错误:error (当被触发的时候,程序会退出)
*/
rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off"
}
};
```
那么到这里咱们已经大致的了解了 `.eslintrc.js` 文件,基于 `ESLint` 如果我们出现不符合规范的代码格式时,那么就会得到一个对应的错误。
比如:
> 我们可以把 `Home.vue` 中的 `name` 属性值,由单引号改为双引号
此时,只要我们一保存代码,那么就会得到一个对应的错误
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904185336318.png" alt="image-20210904185336318" style="zoom:67%;" />`
这个错误表示:
1. 此时我们触发了一个 《错误级别的错误》
2. 触发该错误的位置是 在 `Home.vue` 的第 13 行 第九列 中
3. 错误描述为:字符串必须使用单引号
4. 错误规则为:`quotes`
那么想要解决这个错误,通常情况下我们有两种方式:
1. 按照 `ESLint` 的要求修改代码
2. 修改 `ESLint` 的验证规则
**按照 `ESLint` 的要求修改代码:**
> 在 `Home.vue` 的第 13 行中把双引号改为单引号
**修改 `ESLint` 的验证规则:**
1. 在 `.eslintrc.js` 文件中,新增一条验证规则
```json
"quotes": "error" // 默认
"quotes": "warn" // 修改为警告
"quotes": "off" // 修改不校验
```
那么这一小节,我们了解了 `vue-cli` 创建 `vue3` 项目时,`Standard config` 的 `ESLint` 配置,并且知道了如何解决 `ESLint` 报错的问题。
但是一个团队中,人员的水平高低不齐,大量的 `ESLint` 规则校验,会让很多的开发者头疼不已,从而大大影响了项目的开发进度。
试想一下,在你去完成项目代码的同时,还需要时时刻刻注意代码的格式问题,这将是一件多么痛苦的事情!
那么有没有什么办法,既可以保证 `ESLint` 规则校验,又可以解决严苛的格式规则导致的影响项目进度的问题呢?
欲知后事如何,请听下一节《`Prettier` ,让你的代码变得更漂亮!》
## 2-05:大厂编程规范二:你知道代码格式化 Prettier 吗?
在上一小节中,我们知道了 `ESLint` 可以让我们的代码格式变得更加规范,但是同样的它也会带来开发时编码复杂度上升的问题。
那么有没有办法既可以保证 `ESLint` 规则校验,又可以让开发者无需关注格式问题来进行顺畅的开发呢?
答案是:有的!
而解决这个问题的关键就是 `prettier`!(点击 [这里](https://www.prettier.cn/) 进入 `prettier` 中文官网!)
**`prettier` 是什么?**
1. 一个代码格式化工具
2. 开箱即用
3. 可以直接集成到 `VSCode` 之中
4. 在保存时,让代码直接符合 `ESLint` 标准(需要通过一些简单配置)
那么这些简单配置具体指的是什么呢?
请看下一小节《ESLint 与 Prettier 配合解决代码格式问题》
## 2-06:ESLint 与 Prettier 配合解决代码格式问题
在上一小节中,我们提到《`prettier` 可以在保存代码时,让我们的代码直接符合 `ESLint` 标准》但是想要实现这样的功能需要进行一些配置。
那么这一小节,我们就来去完成这个功能:
1. 在 `VSCode` 中安装 `prettier` 插件(搜索 `prettier`),这个插件可以帮助我们在配置 `prettier` 的时候获得提示
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904195026475.png" alt="image-20210904195026475" style="zoom:50%;" />`
2. 在项目中新建 `.prettierrc` 文件,该文件为 `perttier` 默认配置文件
3. 在该文件中写入如下配置:
```json
{
// 不尾随分号
"semi": false,
// 使用单引号
"singleQuote": true,
// 多行逗号分割的语法中,最后一行不加逗号
"trailingComma": "none"
}
```
4. 打开 `VSCode` 《设置面板》
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904200638072.png" alt="image-20210904200638072" style="zoom:67%;" />`
5. 在设置中,搜索 `save` ,勾选 `Format On Save`
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904200738067.png" alt="image-20210904200738067" style="zoom:67%;" />`
至此,你即可在 **`VSCode` 保存时,自动格式化代码!**
**但是!** 你只做到这样还不够!
> 1. VSCode 而言,默认一个 tab 等于 4 个空格,而 ESLint 希望一个 tab 为两个空格
> 2. 如果大家的 VSCode 安装了多个代码格式化工具的化
> 3. ESLint 和 prettier 之间的冲突问题
我们尝试在 `Home.vue` 中写入一个 `created` 方法,写入完成之后,打开我们的控制台我们会发现,此时代码抛出了一个 `ESLint` 的错误
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904201057594.png" alt="image-20210904201057594" style="zoom:67%;" />`
这个错误的意思是说:**`created` 这个方法名和后面的小括号之间,应该有一个空格!**
但是当我们加入了这个空格之后,只要一保存代码,就会发现 `prettier` 会自动帮助我们去除掉这个空格。
那么此时的这个问题就是 `prettier` 和 `ESLint` 的冲突问题。
针对于这个问题我们想要解决也非常简单:
1. 打开 `.eslintrc.js` 配置文件
2. 在 `rules` 规则下,新增一条规则
```json
'space-before-function-paren': 'off'
```
3. 该规则表示关闭《方法名后增加空格》的规则
4. 重启项目
至此我们整个的 `perttier` 和 `ESLint` 的配合使用就算是全部完成了。
在之后我们写代码的过程中,只需要保存代码,那么 `perttier` 就会帮助我们自动格式化代码,使其符合 `ESLint` 的校验规则。而无需我们手动进行更改了。
## 2-07:大厂编程规范三:约定式提交规范
在前面我们通过 `prettier + ESLint` 解决了代码格式的问题,但是我们之前也说过 **编程规范** 指的可不仅仅只是 **代码格式规范** 。
除了 **代码格式规范** 之外,还有另外一个很重要的规范就是 **`git` 提交规范!**
在现在的项目开发中,通常情况下,我们都会通过 `git` 来管理项目。只要通过 `git` 来管理项目,那么就必然会遇到使用 `git` 提交代码的场景
当我们执行 `git commit -m "描述信息"` 的时候,我们知道此时必须添加一个描述信息。但是中华文化博大精深,不同的人去填写描述信息的时候,都会根据自己的理解来进行描述。
而很多人的描述 “天马行空” ,这样就会导致别人在看你的提交记录时,看不懂你说的什么意思?不知道你当前的这次提交到底做了什么事情?会不会存在潜在的风险?
比如说,我们来看这几条提交记录:

你能够想象得到它们经历了什么吗?
所以 **`git` 提交规范** 势在必行。
对于 **`git` 提交规范** 来说,不同的团队可能会有不同的标准,那么咱们今天就以目前使用较多的 [Angular团队规范](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) 延伸出的 [Conventional Commits specification(约定式提交)](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 为例,来为大家详解 **`git` 提交规范**
约定式提交规范要求如下:
```js
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
-------- 翻译 -------------
<类型>[可选 范围]: <描述>
[可选 正文]
[可选 脚注]
```
其中 `<type>` 类型,必须是一个可选的值,比如:
1. 新功能:`feat`
2. 修复:`fix`
3. 文档变更:`docs`
4. ....
也就是说,如果要按照 **约定式提交规范** 来去做的化,那么你的一次提交描述应该式这个样子的:
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210904205519762.png" alt="image-20210904205519762" />`
我想大家看到这样的一个提交描述之后,心里的感觉应该和我一样是崩溃的!要是每次都这么写,写到猴年马月了!
如果你有这样的困惑,那么 ”恭喜你“ ,接下来我们将一起解决这个问题!
欲知后事如何,请看下一节《Commitizen助你规范化提交代码》
## 2-08:Commitizen助你规范化提交代码
在上一小节我们讲述了 [约定式提交规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/) ,我们知道如果严格安装 **约定式提交规范**, 来手动进行代码提交的话,那么是一件非常痛苦的事情,但是 **git 提交规范的处理** 又势在必行,那么怎么办呢?
你遇到的问题,也是其他人所遇到的!
经过了很多人的冥思苦想,就出现了一种叫做 **git 提交规范化工具** 的东西,而我们要学习的 `commitizen` 就是其中的佼佼者!
`commitizen` 仓库名为 [cz-cli](https://github.com/commitizen/cz-cli) ,它提供了一个 `git cz` 的指令用于代替 `git commit`,简单一句话介绍它:
> 当你使用 `commitizen` 进行代码提交(git commit)时,`commitizen` 会提交你在提交时填写所有必需的提交字段!
这句话怎么解释呢?不用着急,下面我们就来安装并且使用一下 `commitizen` ,使用完成之后你自然就明白了这句话的意思!
1. 全局安装 `Commitizen`
```js
npm install -g commitizen@4.2.4
```
2. 安装并配置 `cz-customizable` 插件
1. 使用 `npm` 下载 `cz-customizable`
```node
npm i cz-customizable@6.3.0 --save-dev
```
2. 添加以下配置到 `package.json ` 中
```json
...
"config": {
"commitizen": {
"path": "node_modules/cz-customizable"
}
}
```
3. 项目根目录下创建 `.cz-config.js` 自定义提示文件
```js
module.exports = {
// 可选类型
types: [
{ value: 'feat', name: 'feat: 新功能' },
{ value: 'fix', name: 'fix: 修复' },
{ value: 'docs', name: 'docs: 文档变更' },
{ value: 'style', name: 'style: 代码格式(不影响代码运行的变动)' },
{
value: 'refactor',
name: 'refactor: 重构(既不是增加feature,也不是修复bug)'
},
{ value: 'perf', name: 'perf: 性能优化' },
{ value: 'test', name: 'test: 增加测试' },
{ value: 'chore', name: 'chore: 构建过程或辅助工具的变动' },
{ value: 'revert', name: 'revert: 回退' },
{ value: 'build', name: 'build: 打包' }
],
// 消息步骤
messages: {
type: '请选择提交类型:',
customScope: '请输入修改范围(可选):',
subject: '请简要描述提交(必填):',
body: '请输入详细描述(可选):',
footer: '请输入要关闭的issue(可选):',
confirmCommit: '确认使用以上信息提交?(y/n/e/h)'
},
// 跳过问题
skipQuestions: ['body', 'footer'],
// subject文字长度默认是72
subjectLimit: 72
}
```
4. 使用 `git cz` 代替 `git commit`
使用 `git cz` 代替 `git commit`,即可看到提示内容
那么到这里我们就已经可以使用 `git cz` 来代替了 `git commit` 实现了规范化的提交诉求了,但是当前依然存在着一个问题,那就是我们必须要通过 `git cz` 指令才可以完成规范化提交!
那么如果有马虎的同事,它们忘记了使用 `git cz` 指令,直接就提交了怎么办呢?
那么有没有方式来限制这种错误的出现呢?
答案是有的!
下一节我们来看 《什么是 Git Hooks》
## 2-09:什么是 Git Hooks
上一小节中我们使用了 `git cz` 来代替了 `git commit` 实现了规范化的提交诉求,但是依然存在着有人会忘记使用的问题。
那么这一小节我们就来看一下这样的问题,我们应该如何去进行解决。
先来明确一下我们最终要实现的效果:
> 我们希望:
>
> 当《提交描述信息》不符合 [约定式提交规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/) 的时候,阻止当前的提交,并抛出对应的错误提示
而要实现这个目的,我们就需要先来了解一个概念,叫做 `Git hooks(git 钩子 || git 回调方法)`
也就是:**`git` 在执行某个事件之前或之后进行一些其他额外的操作**
而我们所期望的 **阻止不合规的提交消息**,那么就需要使用到 `hooks` 的钩子函数。
下面是我整理出来的所有的 `hooks` ,大家可以进行一下参考,其中加粗的是常用到的 `hooks`:
| Git Hook | 调用时机 | 说明 |
| :-------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| pre-applypatch | `git am`执行前 | |
| applypatch-msg | `git am`执行前 | |
| post-applypatch | `git am`执行后 | 不影响 `git am`的结果 |
| **pre-commit** | `git commit`执行前 | 可以用 `git commit --no-verify`绕过 |
| **commit-msg** | `git commit`执行前 | 可以用 `git commit --no-verify`绕过 |
| post-commit | `git commit`执行后 | 不影响 `git commit`的结果 |
| pre-merge-commit | `git merge`执行前 | 可以用 `git merge --no-verify`绕过。 |
| prepare-commit-msg | `git commit`执行后,编辑器打开之前 | |
| pre-rebase | `git rebase`执行前 | |
| post-checkout | `git checkout`或 `git switch`执行后 | 如果不使用 `--no-checkout`参数,则在 `git clone`之后也会执行。 |
| post-merge | `git commit`执行后 | 在执行 `git pull`时也会被调用 |
| pre-push | `git push`执行前 | |
| pre-receive | `git-receive-pack`执行前 | |
| update | | |
| post-receive | `git-receive-pack`执行后 | 不影响 `git-receive-pack`的结果 |
| post-update | 当 `git-receive-pack`对 `git push` 作出反应并更新仓库中的引用时 | |
| push-to-checkout | 当``git-receive-pack `对`git push `做出反应并更新仓库中的引用时,以及当推送试图更新当前被签出的分支且`receive.denyCurrentBranch `配置被设置为`updateInstead`时 | |
| pre-auto-gc | `git gc --auto`执行前 | |
| post-rewrite | 执行 `git commit --amend`或 `git rebase`时 | |
| sendemail-validate | `git send-email`执行前 | |
| fsmonitor-watchman | 配置 `core.fsmonitor`被设置为 `.git/hooks/fsmonitor-watchman`或 `.git/hooks/fsmonitor-watchmanv2`时 | |
| p4-pre-submit | `git-p4 submit`执行前 | 可以用 `git-p4 submit --no-verify`绕过 |
| p4-prepare-changelist | `git-p4 submit`执行后,编辑器启动前 | 可以用 `git-p4 submit --no-verify`绕过 |
| p4-changelist | `git-p4 submit`执行并编辑完 `changelist message`后 | 可以用 `git-p4 submit --no-verify`绕过 |
| p4-post-changelist | `git-p4 submit`执行后 | |
| post-index-change | 索引被写入到 `read-cache.c do_write_locked_index`后 | |
PS:详细的 `HOOKS介绍` 可点击[这里](https://git-scm.com/docs/githooks)查看
整体的 `hooks` 非常多,当时我们其中用的比较多的其实只有两个:
| Git Hook | 调用时机 | 说明 |
| :------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| **pre-commit** | `git commit`执行前<br />它不接受任何参数,并且在获取提交日志消息并进行提交之前被调用。脚本 `git commit`以非零状态退出会导致命令在创建提交之前中止。 | 可以用 `git commit --no-verify`绕过 |
| **commit-msg** | `git commit`执行前<br />可用于将消息规范化为某种项目标准格式。<br />还可用于在检查消息文件后拒绝提交。 | 可以用 `git commit --no-verify`绕过 |
简单来说这两个钩子:
1. `commit-msg`:可以用来规范化标准格式,并且可以按需指定是否要拒绝本次提交
2. `pre-commit`:会在提交前被调用,并且可以按需指定是否要拒绝本次提交
而我们接下来要做的关键,就在这两个钩子上面。
## 2-10:使用 husky + commitlint 检查提交描述是否符合规范要求
在上一小节中,我们了解了 `git hooks` 的概念,那么接下来我们就使用 `git hooks` 来去校验我们的提交信息。
要完成这么个目标,那么我们需要使用两个工具:
1. [commitlint](https://github.com/conventional-changelog/commitlint):用于检查提交信息
2. [husky](https://github.com/typicode/husky):是 `git hooks`工具
注意:**`npm` 需要在 7.x 以上版本!!!!!**
那么下面我们分别来去安装一下这两个工具:
### commitlint
1. 安装依赖:
```
npm install --save-dev @commitlint/config-conventional@12.1.4 @commitlint/cli@12.1.4
```
2. 创建 `commitlint.config.js` 文件
```
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
```
3. 打开 `commitlint.config.js` , 增加配置项( [config-conventional 默认配置点击可查看](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/index.js) ):
```js
module.exports = {
// 继承的规则
extends: ['@commitlint/config-conventional'],
// 定义规则类型
rules: {
// type 类型定义,表示 git 提交的 type 必须在以下类型范围内
'type-enum': [
2,
'always',
[
'feat', // 新功能 feature
'fix', // 修复 bug
'docs', // 文档注释
'style', // 代码格式(不影响代码运行的变动)
'refactor', // 重构(既不增加新功能,也不是修复bug)
'perf', // 性能优化
'test', // 增加测试
'chore', // 构建过程或辅助工具的变动
'revert', // 回退
'build' // 打包
]
],
// subject 大小写不做校验
'subject-case': [0]
}
}
```
**注意:确保保存为 `UTF-8` 的编码格式**,否则可能会出现以下错误:

接下来我们来安装 `husky`
### husky
1. 安装依赖:
```
npm install husky@7.0.1 --save-dev
```
2. 启动 `hooks` , 生成 `.husky` 文件夹
```
npx husky install
```

3. 在 `package.json` 中生成 `prepare` 指令( **需要 npm > 7.0 版本** )
```
npm set-script prepare "husky install"
```
`<img src="第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210906202128323.png" alt="image-20210906202128323" style="zoom:50%;" />`
4. 执行 `prepare` 指令
```
npm run prepare
```
5. 执行成功,提示
`<img src=" 第二章:标准化大厂编程规范解决方案之ESLint + Git Hooks .assets/image-20210710120053221.png" alt="image-20210710120053221" style="zoom:80%;" />`
6. 添加 `commitlint` 的 `hook` 到 `husky`中,并指令在 `commit-msg` 的 `hooks` 下执行 `npx --no-install commitlint --edit "$1"` 指令
```
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'
```
7. 此时的 `.husky` 的文件结构

至此, 不符合规范的 commit 将不再可提交:
```
PS F:\xxxxxxxxxxxxxxxxxxxxx\imooc-admin> git commit -m "测试"
⧗ input: 测试
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
husky - commit-msg hook exited with code 1 (error)
```
那么至此,我们就已经可以处理好了 **强制规范化的提交要求**,到现在 **不符合规范的提交信息,将不可在被提交!**
那么到这里我们的 **规范化目标** 就完成了吗?
当然没有!
现在我们还缺少一个 **规范化的处理** ,那就是 **代码格式提交规范处理!**
有同学看到这里可能说,咦! 这个怎么看着这么眼熟啊?这个事情我们之前不是做过了吗?还需要在处理什么?
欲知后事如何,请看下一节《通过 pre-commit 处理提交时代码规范》
## 2-11:通过 pre-commit 检测提交时代码规范
在 **`ESLint` 与 `Prettier` 配合解决代码格式问题** 的章节中,我们讲解了如何处理 **本地!代码格式问题。**
但是这样的一个格式处理问题,他只能够在本地进行处理,并且我们还需要 **手动在 `VSCode` 中配置自动保存** 才可以。那么这样就会存在一个问题,要是有人忘记配置这个东西了怎么办呢?他把代码写的乱七八糟的直接就提交了怎么办呢?
所以我们就需要有一种方式来规避这种风险。
那么想要完成这么一个操作就需要使用 `husky` 配合 `eslint` 才可以实现。
我们期望通过 **`husky` 监测 `pre-commit` 钩子,在该钩子下执行 `npx eslint --ext .js,.vue src`** 指令来去进行相关检测:
1. 执行 `npx husky add .husky/pre-commit "npx eslint --ext .js,.vue src"` 添加 `commit` 时的 `hook` (`npx eslint --ext .js,.vue src` 会在执行到该 hook 时运行)
2. 该操作会生成对应文件 `pre-commit`:

3. 关闭 `VSCode` 的自动保存操作
4. 修改一处代码,使其不符合 `ESLint` 校验规则
5. 执行 **提交操作** 会发现,抛出一系列的错误,代码无法提交
```
PS F:\xxxxxxxxxxxxxxxxxxx\imooc-admin> git commit -m 'test'
F:\xxxxxxxxxxxxxxxx\imooc-admin\src\views\Home.vue
13:9 error Strings must use singlequote quotes
✖ 1 problem (1 error, 0 warnings)
1 error and 0 warnings potentially fixable with the `--fix` option.
husky - pre-commit hook exited with code 1 (error)
```
6. 想要提交代码,必须处理完成所有的错误信息
那么到这里位置,我们已经通过 `pre-commit` 检测到了代码的提交规范问题。
那么到这里就万事大吉了吗?
在这个世界上从来不缺的就是懒人,错误的代码格式可能会抛出很多的 `ESLint` 错误,让人看得头皮发麻。严重影响程序猿的幸福指数。
那么有没有办法,让程序猿在 0 配置的前提下,哪怕代码格式再乱,也可以 **”自动“** 帮助他修复对应的问题,并且完成提交呢?
你别说,还真有!
那么咱们来看下一节《lint-staged 自动修复格式错误》
## 2-12:lint-staged 自动修复格式错误
在上一章中我们通过 `pre-commit` 处理了 **检测代码的提交规范问题,当我们进行代码提交时,会检测所有的代码格式规范** 。
但是这样会存在两个问题:
1. 我们只修改了个别的文件,没有必要检测所有的文件代码格式
2. 它只能给我们提示出对应的错误,我们还需要手动的进行代码修改
那么这一小节,我们就需要处理这两个问题
那么想要处理这两个问题,就需要使用另外一个插件 [lint-staged](https://github.com/okonet/lint-staged) !
[lint-staged](https://github.com/okonet/lint-staged) 可以让你当前的代码检查 **只检查本次修改更新的代码,并在出现错误的时候,自动修复并且推送**
[lint-staged](https://github.com/okonet/lint-staged) 无需单独安装,我们生成项目时,`vue-cli` 已经帮助我们安装过了,所以我们直接使用就可以了
1. 修改 `package.json` 配置
```js
"lint-staged": {
"src/**/*.{js,vue}": [
"eslint --fix",
"git add"
]
}
```
2. 如上配置,每次它只会在你本地 `commit` 之前,校验你提交的内容是否符合你本地配置的 `eslint`规则(这个见文档 [ESLint](https://panjiachen.github.io/vue-element-admin-site/zh/guide/advanced/eslint.html) ),校验会出现两种结果:
1. 如果符合规则:则会提交成功。
2. 如果不符合规则:它会自动执行 `eslint --fix` 尝试帮你自动修复,如果修复成功则会帮你把修复好的代码提交,如果失败,则会提示你错误,让你修好这个错误之后才能允许你提交代码。
3. 修改 `.husky/pre-commit` 文件
```js
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
```
4. 再次执行提交代码
5. 发现 **暂存区中** 不符合 `ESlint` 的内容,被自动修复
## 2-13:关于 `vetur` 检测 `template` 的单一根元素的问题(图文)
本章节为 **图文**,请点击 [这里](./图文课程/2-13:关于vetur检测 template的单一根元素的问题.md) 查看对应文档
## 2-14:总结
本章中我们处理了 **编程格式规范的问题**,整个规范大体可以分为两大类:
1. 代码格式规范
2. `git` 提交规范
**代码格式规范:**
对于 **代码格式规范** 而言,我们通过 `ESLint` + `Prettier` + `VSCode 配置` 配合进行了处理。
最终达到了在保存代码时,自动规范化代码格式的目的。
**`git` 提交规范:**
对于 **`git` 提交规范** 而言我们使用了 `husky` 来监测 `Git hooks` 钩子,并且通过以下插件完成了对应的配置:
1. [约定式提交规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/)
2. [commitizen](https://github.com/commitizen/cz-cli):git 提交规范化工具
3. [commitlint](https://github.com/conventional-changelog/commitlint):用于检查提交信息
4. `pre-commit`: `git hooks` 钩子
5. [lint-staged](https://github.com/okonet/lint-staged):只检查本次修改更新的代码,并在出现错误的时候,自动修复并且推送
那么处理完成这些规范操作之后,在下一章我们将会正式进入到咱们的项目开发之中!
二、登录解决方案
# 第三章:项目架构之搭建登录架构解决方案与实现
## 3-01:前言
在上一章中,我们处理了基本的编码规范,那么接下来我们就可以实现对应的项目开发了。
那么在之后的项目开发中,我们将会使用最新的 `vue3 script setup` 语法。
所以说在本章节中我们需要做两件事情:
1. `vue3` 最新特性及最新语法
2. 登录功能开发
不过大家放心,我们不会把大量的时间花费到 **枯燥的语法学习之中**,而是会在实际的项目开发中和大家一起逐渐深入学习 `script setup` 语法,毕竟 **学以致用** 才是我们遵循的唯一目标。
那么明确好了我们接下来要做的事情之后,咱们就开始新的篇章吧!
## 3-02:vue3 项目结构解析
想要进行项目的开发,那么首先我们需要先去了解一下 `vue3` 项目的初始结构
在这里我们把它和 `vue2` 的项目进行对比来去说明
1. `main.js`
1. 通过 **按需导入**的 `createApp` 方法来来构建 `vue` 实例
2. 通过 `vue实例.use` 方法来挂载插件(`router`、`vuex`)
3. 没有了 `Vue` 构造方法,无法再挂载原型
2. `App.vue`
1. 组件内部结构无变化,依然是
1. `tempalte`
2. `script`
3. `style`
2. `<template>` 标签中支持多个根标签
3. `store/index.js`
1. 通过 **按需导入**的 `createStore` 方法来来构建 `store` 实例
2. 无需再通过 `Vue.use(Vuex)` 的形式进行挂载
4. `router/index.js`
1. 通过 **按需导入**的 `createRouter` 方法来构建 `router` 实例
2. 通过 **按需导入**的 `createWebHashHistory` 方法来创建 **`hash` 模式对象**,进行路由模式指定
3. 无需再通过 `Vue.use(VueRouter)` 的形式进行挂载
4. `routes` 路由表的定义无差别
综上所述,在 `vue3` 的初始化项目中,与 `vue2` 对比的最大差异其实就是两点:
1. `vue3` 使用 **按需导入的形式** 进行初始化操作
2. `<template>` 标签中支持多个根标签
那么这一小节我们主要了解了 `vue3` 项目的初始结构,通过了解我们也可以发现现在的项目中,存在很多的 **无用代码**,那么下一小节我们就需要 **删除掉这些无用的默认代码**,也就是进行 **初始化项目** 操作。
## 3-03:初始化项目结构(图文)
本章节为 **图文节**,请点击 [这里](./图文课程/3-03:初始化项目结构.md) 查看对应文档
## 3-04:vue3 新特性介绍
在开始本小节的内容之前,我必须要先声明一点:
**我们不会在课程中专门开辟出一段内容讲解 `vue3` 的知识。而是会在项目开发的过程中,通过实际场景逐步解锁对应的知识点,以达到学以致用的目的!**
所以说本小节的 **`vue3` 新特性介绍** ,我们只会概述性的来介绍一下 `vue3` 中新增的主要内容。
那么明确好了我们的目标之后,`vue3` 中到底新增了哪些比较核心的东西呢?:
1. `composition API`
2. 使用了 `Proxy` 代替 `Object.defineProperty()` 实现响应式
3. 全新的全家桶
4. 全新的 `TS` 支持
5. `vite`
### Composition API:组合式 API
想要了解 **组合式 API**,那么首先我们需要先了解 `options API`,也就是 `vue2` 中的开发形式。
`vue2` 中的开发形式被称为 `options API`,`options API` 存在
- 方便
- 易学
- 清晰
等一些特点,但是也存在着一些问题。
而其中最主要的一个问题就是:**当你的组件变得越来越复杂时,组件的可读性会变得越来越差。**
不知道大家有没有遇到过一种情况,那就是:**你在完成一个组件代码时,总是需要不停的上下滚动滚轮,来查看 `data` 、`methods`、`computed` 之间的配合使用,就像下面一样**

这个截图中的代码大家不需要深究。
在这个动图中我们定义的两个数据 `optionsData` 和 `selectOption`,然后我们在多个方法中都使用到了它们,但是大家可以发现,我们在使用或查看的过程中,得一直不停的翻动页面!
因为我们的整体组件代码结构是这样的:
`<img src="第三章:项目架构之搭建登录架 构解决方案与实现.assets/image-20210907203504116.png" alt="image-20210907203504116" style="zoom:67%;" />`
**定义数据与使用数据被分割在组件的各个位置,导致我们需要不断地翻滚页面来查看具体的业务逻辑!**
并且这种现象随着组件越来越复杂,这种情况会变得越来越严重!
而这个就是 `options API` 所存在的问题:**当你的组件变得越来越复杂时,组件的可读性会变得越来越差。**
而 `Composition API` 所期望解决的就是这么一个问题

**把定义数据与使用数据的逻辑放在一起进行处理,以达到更加易读,更加方便扩展的目的!**
那么具体怎么去做的,我们会在后面的课程中通过最新的 `RFC -> script setup` 语法为大家进行解读。
### 使用了 `Proxy` 代替 `Object.defineProperty()` 实现响应式
在 [vue 2 的文档中](https://cn.vuejs.org/v2/guide/reactivity.html#%E6%A3%80%E6%B5%8B%E5%8F%98%E5%8C%96%E7%9A%84%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9) 有这么一段话:
> 由于 JavaScript 的限制,Vue **不能检测**数组和对象的变化。
这里所谓的 **`JavaScript` 的限制**,所指的就是 [Object.defineProperty()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 的限制。
因为 [Object.defineProperty()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 是通过:**为对象属性指定描述符** 的方式来监听 **对象中某个属性的 `get` 和 `set`**。
所以在以下两种情况下,新的属性是 **非响应式的**:
1. [对于对象](https://cn.vuejs.org/v2/guide/reactivity.html#对于对象):
```js
var vm = new Vue({
data:{
a:1
}
})
// `vm.a` 是响应式的
vm.b = 2
// `vm.b` 是非响应式的
```
2. [对于数组](https://cn.vuejs.org/v2/guide/reactivity.html#对于数组):
```js
var vm = new Vue({
data: {
items: ['a', 'b', 'c']
}
})
vm.items[1] = 'x' // 不是响应性的
vm.items.length = 2 // 不是响应性的
```
这也是为什么会存在 [Vue.set](https://cn.vuejs.org/v2/api/#Vue-set) 这个 `API` 的原因。
但是,这样的一种情况其实一直都是不合理的,因为这只是无意义的增加了复杂度而已,但是一直以来因为 [Object.defineProperty()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 这个 `API` 本身的限制,所以一直无法处理。
直到 [Proxy](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 被广泛支持,这种情况才发生了变化。
[Proxy](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy) 用于:**为对象创建一个代理,从而实现基本操作的拦截。** 那么这样就从根本上解决了 [Object.defineProperty()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty) 所面临的那么两个问题。这也是为什么 `vue3` 中不再有 `Vue.set` 方法的原因。
关于 `proxy` 的详细介绍,大家可以查看这一篇博客 [Vue 3 深入响应式原理 - 聊一聊响应式构建的那些经历](https://www.imooc.com/article/320582),在视频中,我们就不过多赘述了
### 全新的全家桶
`vue` 被称为是 **渐进式框架**,就是因为,对于 `vue` 而言,它不仅仅只有 `vue.js` 这一个核心库,还有其他的比如 [vue-router](https://next.router.vuejs.org/zh/),[vuex](https://next.vuex.vuejs.org/zh/index.html) 等一些周边库。这些周边库和 `vue.js` 一起共同组成了 `vue` 。
所以说当 `vue3` 发布之后,`vue-router`、`vuex` 等全家桶也迎来了一波更新。在前面的 **vue3 项目结构解析** 这一小节,大家应该也能看到对应的代码变化。
那么关于全家桶的更新内容,我们会在后面的课程中进行详细的讲解,所以就不在这里进行赘述了。
### 全新的 `TS` 支持
`vue 3` 使用 `TypeScript` 进行了重构,其目的是 **为了防止随着应用的增长,而产生的许多潜在的运行时静态类型的错误** 。同时这也意味着以后在 `vue` 中使用 `TypeScript` 不再需要其他的任何工具。
但是有一点我需要提醒大家,虽然 `vue` 对 `TypeScript` 进行全面支持,这并不代表我们应该在任何情况下都**无条件**的使用 `TypeScript`(后面我们简称 `TypeScript` 为 `TS`)。
`TS` 的优势主要在于 **静态类型检查和环境声明**,但同时它也会为你的项目增加复杂度。如果你的项目需要使用到以上两点,那么我推荐你使用 `TS` 。否则只是增加了无谓的复杂度而已。
**决定我们应该使用哪种技术的唯一条件,就是我们的目标。** 我们需要做的是在可以 **完成目标** 的基础上,寻找最简单的实现方案。
所以,基于以上原因,我们项目中并**没有**使用 `TS` 进行项目的开发。如果在后续的过程中,发现大家有这方面的需要,那么我会考虑专门针对 `TS` 的特性来开发一个对应的项目。
### vite
最后就是一个新的打包工具 [vite](https://cn.vitejs.dev/),[vite](https://cn.vitejs.dev/) 严格来说不能算是 `vue3` 的内容,只不过它跟随 `vue3` 进行了发布所以我们这里就把它算到了新特性里面。
[vite](https://cn.vitejs.dev/) 的作用其实和 [webpack](https://webpack.docschina.org/) 是一样的,都是一个 **前端构建工具**。它区别于 `webpack` 的地方在于它完全使用了 `ES Module` 的特性,可以无需预先打包,而是采用实时编译的方式。这样让它具备了远高于 `webpack` 的启动速度和热更新速度。
但是 **成也萧何,败也萧何** 因为 `vite` 完全依赖 `ES Module` 就导致了 它无法直接对 `commonJS` 的模块化方式进行支持,必须得采用 [依赖预构建](https://cn.vitejs.dev/guide/dep-pre-bundling.html) 的形式。
目前 `vite` 还不够稳定到足够支持商用,所以如果大家只是想要尝鲜,那么没有问题。如果大家希望创建一个商用的大型项目,那么个人还是推荐更加成熟的 `webpack` 方案。
而我们当前的项目旨在构建一个 **后台前端解决方案系统**,所以我们这里依然选择了 `webpack` ,而不是 `vite`。
## 3-05:全新的提案语法:script setup
如果大家使用过 早期的 `composition API` ,那么应该会对 `setup 函数` 感触颇深,它的语法是反人类的。
所以在 `vue3` 正式发布 40天 之后, 也就是 `2020年10月28日` (`vue3` 正式发布日期为 `2020年9月18日`)提出了新的 [script setup](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0040-script-setup.md) 提案,该提案的目的只有一个:**那就是让大家可以更爽的使用 `composition API` 语法**!
该提案在 `2021年8月9日` 正式定稿,并伴随着最新的 `vue3` 版本进行了发布,这也是为什么前面我们需要升级到最新的 `vue` 版本的原因。
下面两张截图为对比 `原setup函数` 与 `script setup`:
1. `原 setup`函数
`<img src="第三章:项目架构之搭建登录架 构解决方案与实现.assets/image-20210908103648564.png" style="zoom:67%;" />`
2. `script setup`
`<img src="第三章:项目架构之搭建登录架 构解决方案与实现.assets/image-20210908104702818.png" alt="image-20210908104702818" style="zoom:67%;" />`
从截图中可以看出 `script setup` 语法更加符合我们开发者书写 `JS` 代码的习惯,它让我们书写 `vue` 就像再写普通的 `js` 一样。
所以以后的 `composition API` 将是 `script setup` 语法的时代,`原setup函数` 将会逐渐退出历史舞台。
而我们的项目也将会全部使用最新的 `script setup` 语法,让大家紧抓时代脉搏!
## 3-06:导入 element-plus (图文)
本章节为 **图文节**,请点击 [这里](./图文课程/3-06:导入 element-plus.md) 查看对应文档
## 3-07:构建登录页面 UI 结构
1. 在 `views` 中 `login` 文件夹,创建 `index.vue` 文件

2. 在 `router/index.js` 中增加以下路由配置
```js
/**
* 公开路由表
*/
const publicRoutes = [
{
path: '/login',
component: () => import('@/views/login/index')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: publicRoutes
})
```
3. 在 `login/index.vue` 中,生成基本页面结构
```vue
<template>
<div class=""></div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped></style>
```
4. 创建登录页面基本结构
```vue
<template>
<div class="login-container">
<el-form class="login-form" >
<div class="title-container">
<h3 class="title">用户登录</h3>
</div>
<el-form-item prop="username">
<span class="svg-container">
<el-icon>
<avatar />
</el-icon>
</span>
<el-input
placeholder="username"
name="username"
type="text"
/>
</el-form-item>
<el-form-item prop="password">
<span class="svg-container">
<el-icon>
<avatar />
</el-icon>
</span>
<el-input
placeholder="password"
name="password"
/>
<span class="show-pwd">
<el-icon>
<avatar />
</el-icon>
</span>
</el-form-item>
<el-button type="primary" style="width: 100%; margin-bottom: 30px"
>登录</el-button
>
</el-form>
</div>
</template>
<script setup>
// 导入组件之后无需注册可直接使用
import { Avatar } from '@element-plus/icons'
import {} from 'vue'
</script>
```
## 3-08:美化登录页面样式
1. 创建全局的 `style`
1. 在 `src` 下创建 `styles/index.scss` 文件,并写入以下内容:
```scss
html,
body {
height: 100%;
margin: 0;
padding: 0;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB,
Microsoft YaHei, Arial, sans-serif;
}
#app {
height: 100%;
}
*,
*:before,
*:after {
box-sizing: inherit;
margin: 0;
padding: 0;
}
a:focus,
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
div:focus {
outline: none;
}
.clearfix {
&:after {
visibility: hidden;
display: block;
font-size: 0;
content: ' ';
clear: both;
height: 0;
}
}
```
2. 在 `main.js` 中导入全局样式
```js
...
// 导入全局样式
import './styles/index.scss'
...
```
3. 在 `views/login/index.vue` 中写入以下样式
```vue
<style lang="scss" scoped>
$bg: #2d3a4b;
$dark_gray: #889aa4;
$light_gray: #eee;
$cursor: #fff;
.login-container {
min-height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
.login-form {
position: relative;
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
::v-deep .el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
color: #454545;
}
::v-deep .el-input {
display: inline-block;
height: 47px;
width: 85%;
input {
background: transparent;
border: 0px;
-webkit-appearance: none;
border-radius: 0px;
padding: 12px 5px 12px 15px;
color: $light_gray;
height: 47px;
caret-color: $cursor;
}
}
}
.svg-container {
padding: 6px 5px 6px 15px;
color: $dark_gray;
vertical-align: middle;
display: inline-block;
}
.title-container {
position: relative;
.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
}
.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
}
</style>
```
## 3-09:Icon 图标处理方案:SvgIcon
在上一小节中我们完成了登陆页面的基本样式 。但是现在在登录页面中,还存在着最后一个样式问题,那就是 `icon` 图标。
在我们的项目中所使用的 `icon` 图标,一共分为两类:
1. `element-plus` 的图标
2. 自定义的 `svg` 图标
这也是通常情况下企业级项目开发时,所遇到的一种常见情况。
对于 `element-plus` 的图标我们可以直接通过 `el-icon` 来进行显示,但是自定义图标的话,我们暂时还缺少显示的方式,所以说我们需要一个自定义的组件,来显示我们自定义的 `svg` 图标。
那么这种自定义组件处理 **自定义 `svg` 图标的形式**,就是我们在面临这种问题时的通用解决方案。
那么对于这个组件的话,它就需要拥有两种能力:
1. 显示外部 `svg` 图标
2. 显示项目内的 `svg` 图标
基于以上概念,我们可以创建出以下对应代码:
创建 `components/SvgIcon/index.vue`:
```vue
<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>
```
创建 `utils/validate.js`:
```js
/**
* 判断是否为外部资源
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
```
在 `views/login/index.vue` 中使用 **外部 `svg` (`https://res.lgdsunday.club/user.svg`):**
```html
<span class="svg-container">
<svg-icon icon="https://res.lgdsunday.club/user.svg"></svg-icon>
</span>
```
外部图标可正常展示。
那么在本小节中,我们创建了 `SvgIcon` 组件,用来处理了 **外部图标** 的展示,但是对于内部图标而言,我们此时依然无法进行展示。所以在下一小节中,我们就需要看一下,如何处理内部的 `svg` 图标。
## 3-10:处理内部 svg 图标显示
在上一章中,我们创建了 `SvgIcon` 组件用于显示 **非 Element-ui** 的图标。但是目前我们只处理了 **外部 `svg` 的图标展示**,内部的图标还无法展示。
所以这一小节,我们就需要处理 **内部的 `svg` 图标展示。**
1. 首先导入所有的 `svg` 图标(大家可以从 讲师源代码 -> `src -> icons -> svg` 处,获取所有 `svg` 图标),导入到 `src -> icons -> svg` 处
2. 在 `icons` 下创建 `index.js` 文件,该文件中需要完成两件事情:
1. 导入所有的 `svg` 图标
2. 完成 `SvgIcon` 的全局注册
3. 得出以下代码:
```js
import SvgIcon from '@/components/SvgIcon'
// https://webpack.docschina.org/guides/dependency-management/#requirecontext
// 通过 require.context() 函数来创建自己的 context
const svgRequire = require.context('./svg', false, /\.svg$/)
// 此时返回一个 require 的函数,可以接受一个 request 的参数,用于 require 的导入。
// 该函数提供了三个属性,可以通过 require.keys() 获取到所有的 svg 图标
// 遍历图标,把图标作为 request 传入到 require 导入函数中,完成本地 svg 图标的导入
svgRequire.keys().forEach(svgIcon => svgRequire(svgIcon))
export default app => {
app.component('svg-icon', SvgIcon)
}
```
4. 在 `main.js` 中引入该文件
```js
...
// 导入 svgIcon
import installIcons from '@/icons'
...
installIcons(app)
...
```
5. 删除 `views/login` 下 局部导入 `SvgIcon` 的代码
6. 在 `login/index.vue` 中使用 `SvgIcon` 引入本地 `svg`
```html
```
// 用户名
`<svg-icon icon="user" />`
// 密码
`<svg-icon icon="password" />`
// 眼睛
`<svg-icon icon="eye" />`
```
7. 此时 **处理内容 `svg` 图标的代码** 已经完成
打开浏览器,我们发现 **图标依然无法展示!** 这又是因为什么原因呢?
来看下一节 《使用 svg-sprite-loader 处理 svg 图标》
## 3-11:使用 svg-sprite-loader 处理 svg 图标
[svg-sprite-loader](https://www.npmjs.com/package/svg-sprite-loader) 是 `webpack` 中专门用来处理 `svg` 图标的一个 `loader` ,在上一节中我们的图标之所有没有展示,就是因为我们缺少该 `loader`。
那么想要使用该 `loader` 我们需要做两件事情:
1. 下载该 `laoder`,执行:`npm i --save-dev svg-sprite-loader@6.0.9`
2. 创建 `vue.config.js` 文件,新增如下配置:
```js
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
// https://cli.vuejs.org/zh/guide/webpack.html#%E7%AE%80%E5%8D%95%E7%9A%84%E9%85%8D%E7%BD%AE%E6%96%B9%E5%BC%8F
module.exports = {
chainWebpack(config) {
// 设置 svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
}
}
```
处理完以上配置之后,重新启动项目,图标即可显示!
## 3-12:Vue3.2 响应式优化对应用层的改变
在处理好了 `SvgIcon` 图标之后,接下来我们就需要处理登陆页面的逻辑问题了。不过在处理这个逻辑之前,我们需要先来明确一点 `vue3` 新的更新内容。
如果大家之前有过了解 `Vue3` 代码的话,那么会知道 `Vue3` 中声明响应式数据的方式有两种:
1. [ref](https://v3.cn.vuejs.org/api/refs-api.html#ref)
2. [reactive](https://v3.cn.vuejs.org/api/basic-reactivity.html#reactive)
对于这两种使用方式而言,它们在应用层上并没有明确的界限,也就是说我们可能很难仅通过官网的介绍来判断我应该在什么情况下使用什么。
但是这种情况在现在已经不存在了。
2020年10月29日,社区大佬 [basvanmeurs](https://github.com/basvanmeurs) 提出了一个新的 [PR](https://github.com/vuejs/vue-next/pull/2345),大概的意思是说:他重构了响应式的部分内容,大大增加了性能。
详细的介绍如下:
> - Big runtime performance improvement for ref, computed, watch and watchEffect (30%-80% depending on the amount of dependencies)
> - Memory usage decreased by about 30% when creating ref, computed, watch and watchEffect
> - Creation time performance improvement, most notably for watchers and watchEffects
>
> ---
>
> ref、calculated、watch 和 watchEffect 的运行时性能大幅提升(30%-80% 取决于依赖项的数量)
> 创建 ref、calculated、watch 和 watchEffect 时内存使用量减少了约 30%
> 创建时间性能改进,最显著的是 watchers 和 watchEffects
这是一个非常强大的变化,同时又因为这个变化过于庞大,所以一直等待到 `2021年8月5日` 伴随着 [vue 3.2发布](https://blog.vuejs.org/posts/vue-3.2.html),尤大才合并对应的代码,并在这次变化中对该性能改进进行了如下的介绍:
> - [More efficient ref implementation (~260% faster read / ~50% faster write)](https://github.com/vuejs/vue-next/pull/3995)
> - [~40% faster dependency tracking](https://github.com/vuejs/vue-next/pull/4017)
> - [~17% less memory usage](https://github.com/vuejs/vue-next/pull/4001)
>
> ---
>
> 更高效的 ref 实现(约 260% 的读取速度/约 50% 的写入速度)
> 依赖项跟踪速度提高约 40%
> 内存使用量减少约 17%
毫无疑问,这绝对是一个伟大的变化。
那么针对于这个变化,在应用层中最大的体现就是 `ref` 这个 `API` ,性能得到了大幅度的提升。
所以说,拥抱新的变化吧!
在之后能使用 `ref` 的地方就使用 `ref` 吧。毕竟现在它的性能得到了大幅的提升!
那么在咱们之后的代码中,我们同样也会全部使用 `ref` 来作为响应式数据构建的方式,无论是 **基本数据类型** 或者是 **复杂数据类型**, 毕竟这样做本身并没有什么问题,对不对?
## 3-13:完善登录表单校验
表单校验是表单使用的一个通用能力,在 `element-plus` 中想要为表单进行表单校验那么我们需要关注以下三点:
1. 为 `el-form` 绑定 `model` 属性
2. 为 `el-form` 绑定 `rules` 属性
3. 为 `el-form-item` 绑定 `prop` 属性
保证以上三点即可为 `el-from` 添加表单校验功能。
因为这一块是比较简单的功能,只要有过 `element-ui` 使用经验的同学,应该对这里都不陌生,所以这里就不对这块内容进行过多赘述了。对这里不是很了解的同学可以参考下 [element-plus 中 from 表单部分](https://element-plus.org/#/zh-CN/component/form)
以下为对应的代码实现:
**views/login**
```vue
<template>
<div class="login-container">
<el-form class="login-form" :model="loginForm" :rules="loginRules">
...
<el-form-item prop="username">
...
<el-input
...
v-model="loginForm.username"
/>
</el-form-item>
<el-form-item prop="password">
...
<el-input
...
v-model="loginForm.password"
/>
...
</el-form-item>
...
</el-form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { validatePassword } from './rules'
// 数据源
const loginForm = ref({
username: 'super-admin',
password: '123456'
})
// 验证规则
const loginRules = ref({
username: [
{
required: true,
trigger: 'blur',
message: '用户名为必填项'
}
],
password: [
{
required: true,
trigger: 'blur',
validator: validatePassword()
}
]
})
</script>
```
**views/login/rules.js**
```js
export const validatePassword = () => {
return (rule, value, callback) => {
if (value.length < 6) {
callback(new Error('密码不能少于6位'))
} else {
callback()
}
}
}
```
## 3-14:密码框状态通用处理
对于密码框存在两种状态:
1. 密文状态
2. 明文状态
点击 **眼睛** 可以进行切换。
该功能实现为通用的处理方案,只需要动态修改 `input` 的 `type` 类型即可,其中:
1. `password` 为密文显示
2. `text` 为明文显示
根据以上理论,即可得出以下代码:
```vue
<template>
<div class="login-container">
<el-form class="login-form" :model="loginForm" :rules="loginRules">
...
<el-input
...
:type="passwordType"
/>
<span class="show-pwd">
<svg-icon
:icon="passwordType === 'password' ? 'eye' : 'eye-open'"
@click="onChangePwdType"
/>
</span>
...
</el-form>
</div>
</template>
<script setup>
import { ref } from 'vue'
...
// 处理密码框文本显示状态
const passwordType = ref('password')
const onChangePwdType = () => {
if (passwordType.value === 'password') {
passwordType.value = 'text'
} else {
passwordType.value = 'password'
}
}
</script>
```
## 3-15:通用后台登录方案解析
处理完了表单的基本操作之后,接下来就是登录操作的实现了。
对于登录操作在后台项目中是一个通用的解决方案,具体可以分为以下几点:
1. 封装 `axios` 模块
2. 封装 接口请求 模块
3. 封装登录请求动作
4. 保存服务端返回的 `token`
5. 登录鉴权
这些内容就共同的组成了一套 **后台登录解决方案** 。那么在后面的章节中,我们就分别来去处理这些内容。
## 3-16:配置环境变量封装 axios 模块
首先我们先去完成第一步:封装 `axios` 模块。
在当前这个场景下,我们希望封装出来的 `axios` 模块,至少需要具备一种能力,那就是:**根据当前模式的不同,设定不同的 `BaseUrl`** ,因为通常情况下企业级项目在 **开发状态** 和 **生产状态** 下它的 `baseUrl` 是不同的。
对于 `@vue/cli` 来说,它具备三种不同的模式:
1. `development`
2. `test`
3. `production`
具体可以点击 [这里](https://cli.vuejs.org/zh/guide/mode-and-env.html#%E6%A8%A1%E5%BC%8F) 进行参考。
根据我们前面所提到的 **开发状态和生产状态** 那么此时我们的 `axios` 必须要满足:**在 开发 || 生产 状态下,可以设定不同 `BaseUrl` 的能力**
那么想要解决这个问题,就必须要使用到 `@vue/cli` 所提供的 [环境变量](https://cli.vuejs.org/zh/guide/mode-and-env.html#%E6%A8%A1%E5%BC%8F) 来去进行实现。
我们可以在项目中创建两个文件:
1. `.env.development`
2. `.env.production`
它们分别对应 **开发状态** 和 **生产状态**。
我们可以在上面两个文件中分别写入以下代码:
**`.env.development`**:
```
# 标志
ENV = 'development'
# base api
VUE_APP_BASE_API = '/api'
```
**`.env.production`:**
```
# 标志
ENV = 'production'
# base api
VUE_APP_BASE_API = '/prod-api'
```
有了这两个文件之后,我们就可以创建对应的 `axios` 模块
创建 `utils/request.js` ,写入如下代码:
```js
import axios from 'axios'
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 5000
})
export default service
```
## 3-17:封装请求动作
有了 `axios` 模块之后,接下来我们就可以
1. 封装接口请求模块
2. 封装登录请求动作
**封装接口请求模块:**
创建 `api` 文件夹,创建 `sys.js`:
```js
import request from '@/utils/request'
/**
* 登录
*/
export const login = data => {
return request({
url: '/sys/login',
method: 'POST',
data
})
}
```
**封装登录请求动作:**
该动作我们期望把它封装到 `vuex` 的 `action` 中
在 `store` 下创建 `modules` 文件夹,创建 `user.js` 模块,用于处理所有和 **用户相关** 的内容(此处需要使用第三方包 `md5` ):
```js
import { login } from '@/api/sys'
import md5 from 'md5'
export default {
namespaced: true,
state: () => ({}),
mutations: {},
actions: {
login(context, userInfo) {
const { username, password } = userInfo
return new Promise((resolve, reject) => {
login({
username,
password: md5(password)
})
.then(data => {
resolve()
})
.catch(err => {
reject(err)
})
})
}
}
}
```
在 `store/index` 中完成注册:
```js
import { createStore } from 'vuex'
import user from './modules/user.js'
export default createStore({
modules: {
user
}
})
```
## 3-18:登录触发动作
在 `login` 中,触发定义的 `action`
```vue
<template>
<el-button
type="primary"
style="width: 100%; margin-bottom: 30px"
:loading="loading"
@click="handleLogin"
>登录</el-button
>
</template>
<script setup>
import { ref } from 'vue'
import { validatePassword } from './rules'
import { useStore } from 'vuex'
...
// 登录动作处理
const loading = ref(false)
const loginFromRef = ref(null)
const store = useStore()
const handleLogin = () => {
loginFromRef.value.validate(valid => {
if (!valid) return
loading.value = true
store
.dispatch('user/login', loginForm.value)
.then(() => {
loading.value = false
// TODO: 登录后操作
})
.catch(err => {
console.log(err)
loading.value = false
})
})
}
</script>
```
触发之后会得到以下错误:

该错误表示,我们当前请求的接口不存在。
出现这个问题的原因,是因为我们在前面配置环境变量时指定了 **开发环境下**,请求的 `BaseUrl` 为 `/api` ,所以我们真实发出的请求为:`/api/sys/login` 。
这样的一个请求会被自动键入到当前前端所在的服务中,所以我们最终就得到了 `http://192.168.18.42:8081/api/sys/login` 这样的一个请求路径。
而想要处理这个问题,那么可以通过指定 [webpack DevServer 代理](https://webpack.docschina.org/configuration/dev-server/) 的形式,代理当前的 `url` 请求。
而指定这个代理非常简单,是一种近乎固定的配置方案。
在 `vue.config.js` 中,加入以下代码:
```js
module.exports = {
devServer: {
// 配置反向代理
proxy: {
// 当地址中有/api的时候会触发代理机制
'/api': {
// 要代理的服务器地址 这里不用写 api
target: 'https://api.imooc-admin.lgdsunday.club/',
changeOrigin: true // 是否跨域
}
}
},
...
}
```
重新启动服务,再次进行请求,即可得到返回数据
`<img src="第三章:项目架构之搭建登录架 构解决方案与实现.assets/image-20210910172808352.png" alt="image-20210910172808352" style="zoom:50%;" />`
`<img src="第三章:项目架构之搭建登录架 构解决方案与实现.assets/image-20210910172827207.png" alt="image-20210910172827207" style="zoom:50%;" />`
## 3-19:本地缓存处理方案
通常情况下,在获取到 `token` 之后,我们会把 `token` 进行缓存,而缓存的方式将会分为两种:
1. 本地缓存:`LocalStorage`
2. 全局状态管理:`Vuex`
保存在 `LocalStorage` 是为了方便实现 **自动登录功能**
保存在 `vuex` 中是为了后面在其他位置进行使用
那么下面我们就分别来实现对应的缓存方案:
**LocalStorage:**
1. 创建 `utils/storage.js` 文件,封装三个对应方法:
```js
/**
* 存储数据
*/
export const setItem = (key, value) => {
// 将数组、对象类型的数据转化为 JSON 字符串进行存储
if (typeof value === 'object') {
value = JSON.stringify(value)
}
window.localStorage.setItem(key, value)
}
/**
* 获取数据
*/
export const getItem = key => {
const data = window.localStorage.getItem(key)
try {
return JSON.parse(data)
} catch (err) {
return data
}
}
/**
* 删除数据
*/
export const removeItem = key => {
window.localStorage.removeItem(key)
}
/**
* 删除所有数据
*/
export const removeAllItem = key => {
window.localStorage.clear()
}
```
2. 在 `vuex` 的 `user` 模块下,处理 `token` 的保存
```js
import { login } from '@/api/sys'
import md5 from 'md5'
import { setItem, getItem } from '@/utils/storage'
import { TOKEN } from '@/constant'
export default {
namespaced: true,
state: () => ({
token: getItem(TOKEN) || ''
}),
mutations: {
setToken(state, token) {
state.token = token
setItem(TOKEN, token)
}
},
actions: {
login(context, userInfo) {
...
.then(data => {
this.commit('user/setToken', data.data.data.token)
resolve()
})
...
})
}
}
}
```
3. 处理保存的过程中,需要创建 `constant` 常量目录 `constant/index.js`
```js
export const TOKEN = 'token'
```
此时,当点击登陆时,即可把 `token` 保存至 `vuex` 与 `localStorage` 中
## 3-20:响应数据的统一处理
在上一小节中,我们保存了服务端返回的 `token` 。但是有一个地方比较难受,那就是在 `vuex 的 user 模块` 中,我们获取数据端的 `token` 数据,通过 `data.data.data.token` 的形式进行获取。
一路的 `data.` 确实让人比较难受,如果有过 `axios` 拦截器处理经验的同学应该知道,对于这种问题,我们可以通过 [axios 响应拦截器](http://axios-js.com/zh-cn/docs/index.html#%E6%8B%A6%E6%88%AA%E5%99%A8) 进行处理。
在 `utils/request.js` 中实现以下代码:
```js
import axios from 'axios'
import { ElMessage } from 'element-plus'
...
// 响应拦截器
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 => {
// TODO: 将来处理 token 超时问题
ElMessage.error(error.message) // 提示错误信息
return Promise.reject(error)
}
)
export default service
```
此时,对于 `vuex 中的 user 模块` 就可以进行以下修改了:
```js
this.commit('user/setToken', data.token)
```
## 3-21:登录后操作
那么截止到此时,我们距离登录操作还差最后一个功能就是 **登录鉴权** 。
只不过在进行 **登录鉴权** 之前我们得先去创建一个登录后的页面,也就是我们所说的登录后操作。
1. 创建 `layout/index.vue` ,写入以下代码:
```vue
<template>
<div class="">Layout 页面</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped></style>
```
2. 在 `router/index` 中,指定对应路由表:
```js
const publicRoutes = [
...
{
path: '/',
component: () => import('@/layout/index')
}
]
```
3. 在登录成功后,完成跳转
```js
// 登录后操作
router.push('/')
```
## 3-22:登录鉴权解决方案
在处理了登陆后操作之后,接下来我们就来看一下最后的一个功能,也就是 **登录鉴权**
首先我们先去对 **登录鉴权** 进行一个定义,什么是 **登录鉴权** 呢?
> 当用户未登陆时,不允许进入除 `login` 之外的其他页面。
>
> 用户登录后,`token` 未过期之前,不允许进入 `login` 页面
而想要实现这个功能,那么最好的方式就是通过 [路由守卫](https://router.vuejs.org/zh/guide/advanced/navigation-guards.html#%E5%85%A8%E5%B1%80%E5%89%8D%E7%BD%AE%E5%AE%88%E5%8D%AB) 来进行实现。
那么明确好了 **登录鉴权** 的概念之后,接下来就可以去实现一下
在 `main.js` 平级,创建 `permission` 文件
```js
import router from './router'
import store from './store'
// 白名单
const whiteList = ['/login']
/**
* 路由前置守卫
*/
router.beforeEach(async (to, from, next) => {
// 存在 token ,进入主页
// if (store.state.user.token) {
// 快捷访问
if (store.getters.token) {
if (to.path === '/login') {
next('/')
} else {
next()
}
} else {
// 没有token的情况下,可以进入白名单
if (whiteList.indexOf(to.path) > -1) {
next()
} else {
next('/login')
}
}
})
```
在此处我们使用到了 `vuex 中的 getters` ,此时的 `getters` 被当作 **快捷访问** 的形式进行访问
所以我们需要声明对应的模块,创建 `store/getters`
```js
const getters = {
token: state => state.user.token
}
export default getters
```
在 `store/index` 中进行导入:
```js
import getters from './getters'
export default createStore({
getters,
...
})
```
## 3-23:总结
那么到这里我们整个的第三章就算是全部讲解完成了。
整个第三章讲解了两个大部分:
1. `vue3` 的一些基本概念
1. `vue3` 的新特性
2. 全新的 `script setup` 语法
3. 最新的 `ref` 优化
2. 登录方案相关的业务代码
1. `element-plus` 相关
1. `el-form` 表单
2. 密码框状态处理
2. 后台登录解决方案
1. 封装 `axios` 模块
2. 封装 接口请求 模块
3. 封装登录请求动作
4. 保存服务端返回的 `token`
5. 登录鉴权
那么从下一章开始,我们就会进入到项目内部业务的处理过程。在项目内部的业务处理中,我们又会遇到什么样的业务需求,以及提出什么样的对应解决方案呢?
敬请期待吧!
三、layout 架构解决方案
# 第四章:项目架构之搭建Layout架构 解决方案与实现
## 4-01:前言
在上一章中我们处理完成登录之后,从这一章开始,我们就需要处理项目的 `Layout` 架构了。那么什么叫做 `Layout` 架构呢?
我们来看这张图:
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911095711012.png" alt="image-20210911095711012" style="zoom:50%;" />`
在这张图中,我们把页面分为了三个部分,分别是:
1. 左侧的 `Menu` 菜单
2. 顶部的 `NavBar`
3. 中间的内容区 `Main`
可能有同学看到这里就说了,你这不就是个基本的页面布局吗? 还弄个这么洋气的名字干嘛?
外行看热闹,内行看门道对不对。
本章中我们将会实现以下的核心解决方案:
1. 用户退出方案
2. 动态侧边栏方案
3. 动态面包屑方案
除了这些核心内容之外,还有一些其他的小功能,比如:
1. 退出的通用逻辑封装
2. 伸缩侧边栏动画
3. `vue3` 动画
4. 组件状态驱动的动态 `CSS` 值等等
等等
换句话而言,掌握了本章中的内容之后,后台项目的通用 `Layout` 处理,对于来说将变得小菜一碟!
## 4-02:创建基于 Layout 的基础架构
在本小节我们需要创建基于 `Layout` 的基本架构布局,所以说会涉及到大量的 `CSS` 内容,这些 `CSS` 大部分都是比较基础的可复用的 `CSS` 样式,又因为量比较大,所以说我们不会在视频中把这些所有的 `CSS` 全部手敲一遍,而是从中间挑出一些比较重要的 `Css` 内容去进行手写和介绍。这是本小节中一个比较特殊的地方,先和大家进行一下明确。
那么明确好了之后,我们再来看一下我们 `Layout` 的基本布局结构:
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911095711012.png" alt="image-20210911095711012" style="zoom:50%;" />`
我们知道,当登录完成之后,那么我们会进入到 `Layout` 页面,这个 `Layout` 页面组件位于 `Layout/index.vue` 中,所以说想要实现这样的结构,那么我们就需要到对应的 `layout` 组件中进行。
1. 整个页面分为三部分,所以我们需要先去创建对应的三个组件:
1. `layout/components/Sidebar/index.vue`
2. `layout/components/Navbar.vue`
3. `layout/components/AppMain.vue`
2. 然后在 `layout/index.vue` 中引入这三个组件
```vue
<script setup>
import Navbar from './components/Navbar'
import Sidebar from './components/Sidebar'
import AppMain from './components/AppMain'
</script>
```
3. 完成对应的布局结构
```vue
<template>
<div class="app-wrapper">
<!-- 左侧 menu -->
<sidebar
id="guide-sidebar"
class="sidebar-container"
/>
<div class="main-container">
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
</div>
<!-- 内容区 -->
<app-main />
</div>
</div>
</template>
```
4. 在 `styles` 中创建如下 `css` 文件:
1. `variables.scss` : 定义常量
2. `mixin.scss` :定义通用的 `css`
3. `sidebar.scss`:处理 `menu` 菜单的样式
5. 为 `variables.scss` ,定义如下常量并进行导出( `:export` 可见 [scss 与 js 共享变量](https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass)):
```scss
// sidebar
$menuText: #bfcbd9;
$menuActiveText: #ffffff;
$subMenuActiveText: #f4f4f5;
$menuBg: #304156;
$menuHover: #263445;
$subMenuBg: #1f2d3d;
$subMenuHover: #001528;
$sideBarWidth: 210px;
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
}
```
6. 为 `mixin.scss` 定义如下样式:
```scss
@mixin clearfix {
&:after {
content: '';
display: table;
clear: both;
}
}
@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
@mixin relative {
position: relative;
width: 100%;
height: 100%;
}
```
7. 为 `sidebar.scss` 定义如下样式:
```scss
#app {
.main-container {
min-height: 100%;
transition: margin-left 0.28s;
margin-left: $sideBarWidth;
position: relative;
}
.sidebar-container {
transition: width 0.28s;
width: $sideBarWidth !important;
height: 100%;
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
// 重置 element-plus 的css
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out,
0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}
.is-horizontal {
display: none;
}
a {
display: inline-block;
width: 100%;
overflow: hidden;
}
.svg-icon {
margin-right: 16px;
}
.sub-el-icon {
margin-right: 12px;
margin-left: -2px;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
}
.is-active > .el-submenu__title {
color: $subMenuActiveText !important;
}
& .nest-menu .el-submenu > .el-submenu__title,
& .el-submenu .el-menu-item {
min-width: $sideBarWidth !important;
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.submenu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
.sub-el-icon {
margin-left: 19px;
}
}
}
.el-submenu {
overflow: hidden;
& > .el-submenu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
.sub-el-icon {
margin-left: 19px;
}
.el-submenu__icon-arrow {
display: none;
}
}
}
.el-menu--collapse {
.el-submenu {
& > .el-submenu__title {
& > span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-submenu {
min-width: $sideBarWidth !important;
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
.el-menu--vertical {
& > .el-menu {
.svg-icon {
margin-right: 16px;
}
.sub-el-icon {
margin-right: 12px;
margin-left: -2px;
}
}
// 菜单项过长时
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
```
8. 在 `index.scss` 中按照顺序导入以上样式文件
```scss
@import './variables.scss';
@import './mixin.scss';
@import './sidebar.scss';
```
9. 在 `layout/index.vue` 中写入如下样式
```vue
<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
@import '~@/styles/variables.scss';
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
}
</style>
```
10. 因为将来要实现 **主题更换**,所以为 `sidebar` 赋值动态的背景颜色
```vue
<template>
...
<!-- 左侧 menu -->
<sidebar
class="sidebar-container"
:style="{ backgroundColor: variables.menuBg }"
/>
...
</template>
<script setup>
import variables from '@/styles/variables.scss'
</script>
```
11. 为 `Navbar`、`Sidebar`、`AppMain` 组件进行初始化代码
```vue
<template>
<div class="">{组件名}</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped></style>
```
12. 至此查看效果为
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911111525589.png" alt="image-20210911111525589" style="zoom:50%;" />`
13. 可见 `Navbar` 与 `AppMain` 重叠
14. 为 `AppMain` 进行样式处理
```vue
<template>
<div class="app-main">AppMain</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped>
.app-main {
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
padding: 61px 20px 20px 20px;
box-sizing: border-box;
}
</style>
```
15. 查看效果
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911111716595.png" alt="image-20210911111716595" style="zoom:50%;" />`
在本章节中,我们写入了大量的代码,其中以 `css` 代码为主,因为其中的大量 `css` 都是可服用的,比如 `sidebar.scss` ,所以我们这里并没有进行手写。那么对于大家来说,这里的 `css` 代码也没有手写的必要,毕竟这些重复的体力活,是没有必要所有的事情都亲历亲为的。
那么下一章节中,我们就去实现一下 `navbar` 中的功能操作。
## 4-03:获取用户基本信息
处理完了基本的 `Layout` 架构之后,接下来我们实现一下 `navbar` 中的 **头像菜单** 功能
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911112904783.png" alt="image-20210911112904783" style="zoom:67%;" />`
这样的一个功能主要分为三个部分:
1. 获取并展示用户信息
2. `element-plus` 中的 `dropdown` 组件使用
3. 退出登录的方案实现
那么接下来我们就去实现第一部分的功能 **获取并展示用户信息**
**获取并展示用户信息** 我们把它分为三部分进行实现:
1. 定义接口请求方法
2. 定义调用接口的动作
3. 在权限拦截时触发动作
那么接下来我们就根据这三个步骤,分别来进行实现:
**定义接口请求方法:**
在 `api/sys.js` 中定义如下方法:
```js
/**
* 获取用户信息
*/
export const getUserInfo = () => {
return request({
url: '/sys/profile'
})
}
```
因为获取用户信息需要对应的 `token` ,所以我们可以利用 `axios` 的 **请求拦截器** 对 `token` 进行统一注入,在 `utils/request.js` 中写入如下代码:
```js
import store from '@/store'
// 请求拦截器
service.interceptors.request.use(
config => {
// 在这个位置需要统一的去注入token
if (store.getters.token) {
// 如果token存在 注入token
config.headers.Authorization = `Bearer ${store.getters.token}`
}
return config // 必须返回配置
},
error => {
return Promise.reject(error)
}
)
```
**定义调用接口的动作:**
在 `store/modules/user` 中写入以下代码:
```js
import { login, getUserInfo } from '@/api/sys'
...
export default {
namespaced: true,
state: () => ({
...
userInfo: {}
}),
mutations: {
...
setUserInfo(state, userInfo) {
state.userInfo = userInfo
}
},
actions: {
...
async getUserInfo(context) {
const res = await getUserInfo()
this.commit('user/setUserInfo', res)
return res
}
}
}
```
**在权限拦截时触发动作:**
在 `permission.js` 中写入以下代码:
```js
if (to.path === '/login') {
...
} else {
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action
await store.dispatch('user/getUserInfo')
}
next()
}
}
```
在 `store/getters.js` 中写入判断用户信息代码:
```js
const getters = {
...
userInfo: state => state.user.userInfo,
/**
* @returns true 表示已存在用户信息
*/
hasUserInfo: state => {
return JSON.stringify(state.user.userInfo) !== '{}'
}
}
...
```
**注意:出现 `401` 错误表示登录超时:**
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911144540729.png" alt="image-20210911144540729" style="zoom:80%;" />`
如遇到此错误,可 **手动到控制到 `Application` 中,删除 `LocalStorage` 中的 `token`**
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911144726082.png" alt="image-20210911144726082" style="zoom:67%;" />`
删除后,重新刷新页面,重新进行登录操作(该问题如何解决,会在后续进行讲解)
至此,即可获取用户信息数据
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210911144958117.png" alt="image-20210911144958117" style="zoom:67%;" />`
## 4-04:渲染用户头像菜单
到现在我们已经拿到了 **用户数据,并且在 `getters` 中做了对应的快捷访问** ,那么接下来我们就可以根据数据渲染出 **用户头像内容**
渲染用户头像,我们将使用到 `element-plus` 的两个组件:
1. `avatar`
2. `Dropdown`
在 `layout/components/navbar.js` 中实现以下代码:
```vue
<template>
<div class="navbar">
<div class="right-menu">
<!-- 头像 -->
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<el-avatar
shape="square"
:size="40"
:src="$store.getters.userInfo.avatar"
></el-avatar>
<i class="el-icon-s-tools"></i>
</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 divided>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import {} from 'vue'
</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);
.right-menu {
display: flex;
align-items: center;
float: right;
padding-right: 16px;
::v-deep .avatar-container {
cursor: pointer;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.el-avatar {
--el-avatar-background-color: none;
margin-right: 12px;
}
}
}
}
}
</style>
```
那么至此,用户头像和对应的下拉菜单就已经实现完成了,那么下一小节我们就可以在此基础上实现对应的 **退出登录** 功能
## 4-05:退出登录方案实现
**退出登录** 一直是一个通用的前端实现方案,对于退出登录而言,它的触发时机一般有两种:
1. 用户**主动**退出
2. 用户**被动**退出
其中:
1. 主动退出指:用户点击登录按钮之后退出
2. 被动退出指:`token` 过期或被 其他人”顶下来“ 时退出
那么无论是什么退出方式,在用户退出时,所需要执行的操作都是固定的:
1. 清理掉当前用户缓存数据
2. 清理掉权限相关配置
3. 返回到登录页
那么明确好了对应的方案之后,接下来咱们就先来实现 **用户主动退出的对应策略**
在 `store/modules/user.js` 中,添加对应 `action`
```js
import router from '@/router'
logout() {
this.commit('user/setToken', '')
this.commit('user/setUserInfo', {})
removeAllItem()
router.push('/login')
}
```
为退出登录按钮添加点击事件,触发 `logout` 的 `action`
```js
import { useStore } from 'vuex'
const store = useStore()
const logout = () => {
store.dispatch('user/logout')
}
```
那么至此,我们就完成了 **用户主动退出** 对应的实现。
## 4-06:用户被动退出方案解析
在上一节我们实现了 **用户主动退出** 场景,同时也提到 **用户被动退出** 的场景主要有两个:
1. `token` 失效
2. 单用户登录:其他人登录该账号被 “顶下来”
那么这两种场景下,在前端对应的处理方案一共也分为两种,共分为 **主动处理** 、**被动处理** 两种 :
1. 主动处理:主要应对 `token` 失效
2. 被动处理:同时应对 `token` 失效 与 **单用户登录**
那么这两种方案基本上就覆盖了用户被动推出时的主要业务场景了
那么这一小节内容比较少,但是东西还是挺重要的。因为我们主要分析了 **用户被动退出** 的场景,那么从下一小节开始,我们分别来实现这两种处理方案。
## 4-07:用户被动退出解决方案之主动处理
想要搞明白 **主动处理** 方案,那么首先我们得先去搞明白对应的 **背景** 以及 **业务逻辑** 。
那么首先我们先明确一下对应的 **背景:**
> 我们知道 `token` 表示了一个用户的身份令牌,对 服务端 而言,它是只认令牌不认人的。所以说一旦其他人获取到了你的 `token` ,那么就可以伪装成你,来获取对应的敏感数据。
>
> 所以为了保证用户的信息安全,那么对于 `token` 而言就被制定了很多的安全策略,比如:
>
> 1. 动态 `token`(可变 `token`)
> 2. 刷新 `token`
> 3. 时效 `token`
> 4. ...
>
> 这些方案各有利弊,没有绝对的完美的策略。
而我们此时所选择的方案就是 **时效 `token`**
对于 `token` 本身是拥有时效的,这个大家都知道。但是通常情况下,这个时效都是在服务端进行处理。而此时我们要在 **服务端处理 `token` 时效的同时,在前端主动介入 `token` 时效的处理中**。 从而保证用户信息的更加安全性。
那么对应到我们代码中的实现方案为:
1. 在用户登陆时,记录当前 **登录时间**
2. 制定一个 **失效时长**
3. 在接口调用时,根据 **当前时间** 对比 **登录时间** ,看是否超过了 **时效时长**
1. 如果未超过,则正常进行后续操作
2. 如果超过,则进行 **退出登录** 操作
那么明确好了对应的方案之后,接下来我们就去实现对应代码
创建 `utils/auth.js` 文件,并写入以下代码:
```js
import { TIME_STAMP, TOKEN_TIMEOUT_VALUE } from '@/constant'
import { setItem, getItem } from '@/utils/storage'
/**
* 获取时间戳
*/
export function getTimeStamp() {
return getItem(TIME_STAMP)
}
/**
* 设置时间戳
*/
export function setTimeStamp() {
setItem(TIME_STAMP, Date.now())
}
/**
* 是否超时
*/
export function isCheckTimeout() {
// 当前时间戳
var currentTime = Date.now()
// 缓存时间戳
var timeStamp = getTimeStamp()
return currentTime - timeStamp > TOKEN_TIMEOUT_VALUE
}
```
在 `constant` 中声明对应常量:
```js
// token 时间戳
export const TIME_STAMP = 'timeStamp'
// 超时时长(毫秒) 两小时
export const TOKEN_TIMEOUT_VALUE = 2 * 3600 * 1000
```
在用户登录成功之后去设置时间,到 `store/user.js` 的 `login` 中:
```js
import { setTimeStamp } from '@/utils/auth'
login(context, userInfo) {
...
return new Promise((resolve, reject) => {
...
.then(data => {
...
// 保存登录时间
setTimeStamp()
resolve()
})
})
},
```
在 `utils/request` 对应的请求拦截器中进行 **主动介入**
```js
import { isCheckTimeout } from '@/utils/auth'
if (store.getters.token) {
if (isCheckTimeout()) {
// 登出操作
store.dispatch('user/logout')
return Promise.reject(new Error('token 失效'))
}
...
}
```
那么至此我们就完成了 **主动处理** 对应的业务逻辑
## 4-08:用户被动退出解决方案之被动处理
上一节我们处理了 **用户被动退出时的主动处理** ,那么在这一小节我们去处理 **用户被动退出时的被动处理** 。
还是和上一小节一样,我们还是先明确背景,然后再来明确业务逻辑。
**背景:**
首先我们需要先明确 **被动处理** 需要应对两种业务场景:
1. `token` 过期
2. 单用户登录
然后我们一个一个来去看,首先是 `token` 过期
> 我们知道对于 `token` 而言,本身就是具备时效的,这个是在服务端生成 `token` 时就已经确定的。
>
> 而此时我们所谓的 `token` 过期指的就是:
>
> **服务端生成的 `token` 超过 服务端指定时效** 的过程
而对于 单用户登录 而言,指的是:
> 当用户 A 登录之后,`token` 过期之前。
>
> 用户 A 的账号在其他的设备中进行了二次登录,导致第一次登录的 A 账号被 “顶下来” 的过程。
>
> 即:**同一账户仅可以在一个设备中保持在线状态**
那么明确好了对应的背景之后,接下来我们来看对应的业务处理场景:
从背景中我们知道,以上的两种情况,都是在 **服务端进行判断的**,而对于前端而言其实是 **服务端通知前端的一个过程。**
所以说对于其业务处理,将遵循以下逻辑:
1. 服务端返回数据时,会通过特定的状态码通知前端
2. 当前端接收到特定状态码时,表示遇到了特定状态:**`token` 时效** 或 **单用户登录**
3. 此时进行 **退出登录** 处理
但是这里大家需要注意,因为咱们课程的特性,**同一个账号需要在多个设备中使用**,所以说此时将不会指定 **单用户登录** 的状态码,仅有 **`token` 失效** 状态码。之后当大家需要到 **单用户登录** 时,只需要增加一个状态码判断即可。
那么明确好了业务之后,接下来我们来实现对应代码:
在 `utils/request` 的响应拦截器中,增加以下逻辑:
```js
// 响应拦截器
service.interceptors.response.use(
response => {
...
},
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)
}
)
```
那么至此,我们就已经完成了 **整个用户退出** 方案。
## 4-09:创建页面组件,使用临时 menu 菜单
处理完了 **退出登录** 之后,接下来我们来处理 **动态 `menu`菜单**。
只不过为了方便大家理解,这里我们先不去直接处理动态菜单,我们先生成一个临时的 `menu` 菜单。
创建 `layout/Sidebar/SidebarMenu` 文件
```vue
<template>
<!-- 一级 menu 菜单 -->
<el-menu
:uniqueOpened="true"
default-active="2"
background-color="#545c64"
text-color="#fff"
active-text-color="#ffd04b"
>
<!-- 子集 menu 菜单 -->
<el-submenu index="1">
<template #title>
<i class="el-icon-location"></i>
<span>导航一</span>
</template>
<el-menu-item index="1-1">选项1</el-menu-item>
<el-menu-item index="1-2">选项2</el-menu-item>
</el-submenu>
<!-- 具体菜单项 -->
<el-menu-item index="4">
<i class="el-icon-setting"></i>
<template #title>导航四</template>
</el-menu-item>
</el-menu>
</template>
```
在 `layout/Sidebar/index` 中导入该组件
```vue
<template>
<div class="">
<h1>占位</h1>
<el-scrollbar>
<sidebar-menu></sidebar-menu>
</el-scrollbar>
</div>
</template>
<script setup>
import SidebarMenu from './SidebarMenu'
import {} from 'vue'
</script>
```
那么至此我们生成了一个临时的 `menu` 菜单,从这个临时的 `menu` 菜单出可以看到,`el-menu` 其实分成了三个部分:
1. `el-menu`:整个 `menu` 菜单
2. `el-submenu`:子集 `menu` 菜单
3. `el-menu-item`:具体菜单项
那么明确好了这些内容之后,接下来我们就可以来去分析一下 **动态 `menu` 菜单如何生成了**
## 4-10:动态menu菜单处理方案解析
上一小节我们处理了 **静态 `menu`**,那么接下来我们来去处理 **动态 `menu` 菜单**
其实 **动态 `menu`菜单** 其实主要是和 **动态路由表** 配合来去实现 **用户权限** 的。
但是 **用户权限处理** 需要等到后面的章节中才可以接触到,因为咱们想要处理 **用户权限** 还需要先去处理很多的业务场景,所以在这里我们就先只处理 **动态 `menu`菜单** 这一个概念。
那么 **动态 `menu`菜单** 指的到底是什么意思呢?
所谓 **动态 `menu`菜单** 指的是:
> 根据路由表的配置,自动生成对应的 `menu` 菜单。
>
> 当路由表发生变化时,`menu` 菜单自动发生变化
那么明确了 **动态 `menu`菜单** 的含义之后,接下来咱们就需要来明确以下 **动态 `menu`菜单** 的实现方案:
1. 定义 **路由表** 对应 **`menu` 菜单规则**
2. 根据规则制定 **路由表**
3. 根据规则,依据 **路由表** ,生成 **`menu` 菜单**
那么根据我们的实现方案可以发现,实现 **动态 `menu`菜单** 最核心的关键点其实就在步骤一,也就是
> 定义 **路由表** 对应 **`menu` 菜单规则**
那么下面我们就来看一下,这个规则如何制定:
1. 对于单个路由规则而言(循环):
1. 如果 `meta && meta.title && meta.icon` :则显示在 `menu` 菜单中,其中 `title` 为显示的内容,`icon` 为显示的图标
1. 如果存在 `children` :则以 `el-sub-menu(子菜单)` 展示
2. 否则:则以 `el-menu-item(菜单项)` 展示
2. 否则:不显示在 `menu` 菜单中
那么明确好了对应的规则之后,接下来我们就可以来去看一下如何进行实现啦
## 4-11:业务落地:生成项目页面组件
明确了对应的方案之后,那么下面咱们就来实现对应的代码逻辑。
根据我们的分析,想要完成动态的 `menu`,那么我们需要按照以下的步骤来去实现:
1. 创建页面组件
2. 生成路由表
3. 解析路由表
4. 生成 `menu` 菜单
那么明确好了步骤之后,接下来我们就先来实现第一步
**创建页面组件**
在 `views` 文件夹下,创建如下页面:
1. 创建文章:`article-create`
2. 文章详情:`article-detail`
3. 文章排名:`article-ranking`
4. 错误页面:`error-page`
1. `404`
2. `401`
5. 导入:`import`
6. 权限列表:`permission-list`
7. 个人中心:`profile`
8. 角色列表:`role-list`
9. 用户信息:`user-info`
10. 用户管理:`user-manage`
大家也可以从 **课程资料** 中直接复制 **`views(不含 login)`** 的内容到项目的 `views` 文件夹下
## 4-12:业务落地:创建结构路由表
想要实现结构路由表,那么我们需要先知道最终我们要实现的结构是什么样子的,大家来看下面的截图:
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210913150402667.png" alt="image-20210913150402667" />`
这是我们最终要实现的 `menu` 截图
根据此截图,我们可以知道两点内容:
1. 我们创建的页面并没有全部进行展示
1. 根据该方案
`<img src="第四章:项目架构之搭建Layout架构 解决方案与实现.assets/image-20210913151331012.png" alt="image-20210913151331012" />`
2. 即不显示页面 **不满足** 该条件 `meta && meta.title && meta.icon`
2. `menu` 菜单将具备父子级的结构
1. 按照此结构规划数据,则数据应为
```json
[
{
"title": "个人中心",
"path": ""
},
{
"title": "用户",
"children": [
{
"title": "员工管理",
"path": ""
},
{
"title": "角色列表",
"path": ""
},
{
"title": "权限列表",
"path": ""
}
]
},
{
"title": "文章",
"children": [
{
"title": "文章排名",
"path": ""
},
{
"title": "创建文章",
"path": ""
}
]
}
]
```
又因为将来我们需要进行 **用户权限处理**,所以此时我们需要先对路由表进行一个划分:
1. 私有路由表 `privateRoutes` :权限路由
2. 公有路由表 `publicRoutes`:无权限路由
根据以上理论,生成以下路由表结构:
```js
/**
* 私有路由表
*/
const privateRoutes = [
{
path: '/user',
component: layout,
redirect: '/user/manage',
meta: {
title: 'user',
icon: 'personnel'
},
children: [
{
path: '/user/manage',
component: () => import('@/views/user-manage/index'),
meta: {
title: 'userManage',
icon: 'personnel-manage'
}
},
{
path: '/user/role',
component: () => import('@/views/role-list/index'),
meta: {
title: 'roleList',
icon: 'role'
}
},
{
path: '/user/permission',
component: () => import('@/views/permission-list/index'),
meta: {
title: 'permissionList',
icon: 'permission'
}
},
{
path: '/user/info/:id',
name: 'userInfo',
component: () => import('@/views/user-info/index'),
meta: {
title: 'userInfo'
}
},
{
path: '/user/import',
name: 'import',
component: () => import('@/views/import/index'),
meta: {
title: 'excelImport'
}
}
]
},
{
path: '/article',
component: layout,
redirect: '/article/ranking',
meta: {
title: 'article',
icon: 'article'
},
children: [
{
path: '/article/ranking',
component: () => import('@/views/article-ranking/index'),
meta: {
title: 'articleRanking',
icon: 'article-ranking'
}
},
{
path: '/article/:id',
component: () => import('@/views/article-detail/index'),
meta: {
title: 'articleDetail'
}
},
{
path: '/article/create',
component: () => import('@/views/article-create/index'),
meta: {
title: 'articleCreate',
icon: 'article-create'
}
},
{
path: '/article/editor/:id',
component: () => import('@/views/article-create/index'),
meta: {
title: 'articleEditor'
}
}
]
}
]
/**
* 公开路由表
*/
const publicRoutes = [
{
path: '/login',
component: () => import('@/views/login/index')
},
{
path: '/',
// 注意:带有路径“/”的记录中的组件“默认”是一个不返回 Promise 的函数
component: layout,
redirect: '/profile',
children: [
{
path: '/profile',
name: 'profile',
component: () => import('@/views/profile/index'),
meta: {
title: 'profile',
icon: 'el-icon-user'
}
},
{
path: '/404',
name: '404',
component: () => import('@/views/error-page/404')
},
{
path: '/401',
name: '401',
component: () => import('@/views/error-page/401')
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: [...publicRoutes, ...privateRoutes]
})
```
最后不要忘记在 `layout/appMain` 下设置路由出口
```vue
<template>
<div class="app-main">
<router-view></router-view>
</div>
</template>
```
## 4-13:业务落地:解析路由表,获取结构化数据
本小节的目标只有一点,那就是获取到之前明确的结构化数据:
```json
[
{
"title": "个人中心",
"path": ""
},
{
"title": "用户",
"children": [
{
"title": "员工管理",
"path": ""
},
{
"title": "角色列表",
"path": ""
},
{
"title": "权限列表",
"path": ""
}
]
},
{
"title": "文章",
"children": [
{
"title": "文章排名",
"path": ""
},
{
"title": "创建文章",
"path": ""
}
]
}
]
```
那么想要完成本小节的目标,我们就需要先来看一下,现在的路由表结构是什么样子的。
想要获取路由表数据,那么有两种方式:
1. [router.options.routes](https://next.router.vuejs.org/zh/api/#routes):初始路由列表([新增的路由](https://next.router.vuejs.org/zh/api/#addroute) 无法获取到)
2. [router.getRoutes()](https://next.router.vuejs.org/zh/api/#getroutes):获取所有 [路由记录](https://next.router.vuejs.org/zh/api/#routerecord) 的完整列表
所以,我们此时使用 [router.getRoutes()](https://next.router.vuejs.org/zh/api/#getroutes)
在 `layout/components/Sidebar/SidebarMenu` 下写入以下代码:
```vue
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
console.log(router.getRoutes())
</script>
```
得到返回的数据:
```json
[
{
"path":"/user/info/:id",
"name":"userInfo",
"meta":{
"title":"userInfo"
},
"children":[
]
},
{
"path":"/article/editor/:id",
"meta":{
"title":"articleEditor"
},
"children":[
]
},
{
"path":"/user/manage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
},
"children":[
]
},
{
"path":"/user/role",
"meta":{
"title":"roleList",
"icon":"role"
},
"children":[
]
},
{
"path":"/user/permission",
"meta":{
"title":"permissionList",
"icon":"permission"
},
"children":[
]
},
{
"path":"/user/import",
"name":"import",
"meta":{
"title":"excelImport"
},
"children":[
]
},
{
"path":"/article/ranking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
},
"children":[
]
},
{
"path":"/article/create",
"meta":{
"title":"articleCreate",
"icon":"article-create"
},
"children":[
]
},
{
"path":"/article/:id",
"meta":{
"title":"articleDetail"
},
"children":[
]
},
{
"path":"/login",
"meta":{
},
"children":[
]
},
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
},
"children":[
]
},
{
"path":"/404",
"name":"404",
"meta":{
},
"children":[
]
},
{
"path":"/401",
"name":"401",
"meta":{
},
"children":[
]
},
{
"path":"/",
"redirect":"/profile",
"meta":{
},
"children":[
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
}
},
{
"path":"/404",
"name":"404"
},
{
"path":"/401",
"name":"401"
}
]
},
{
"path":"/user",
"redirect":"/user/manage",
"meta":{
"title":"user",
"icon":"personnel"
},
"children":[
{
"path":"/user/manage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
}
},
{
"path":"/user/role",
"meta":{
"title":"roleList",
"icon":"role"
}
},
{
"path":"/user/permission",
"meta":{
"title":"permissionList",
"icon":"permission"
}
},
{
"path":"/user/info/:id",
"name":"userInfo",
"meta":{
"title":"userInfo"
}
},
{
"path":"/user/import",
"name":"import",
"meta":{
"title":"excelImport"
}
}
]
},
{
"path":"/article",
"redirect":"/article/ranking",
"meta":{
"title":"article",
"icon":"article"
},
"children":[
{
"path":"/article/ranking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
}
},
{
"path":"/article/:id",
"meta":{
"title":"articleDetail"
}
},
{
"path":"/article/create",
"meta":{
"title":"articleCreate",
"icon":"article-create"
}
},
{
"path":"/article/editor/:id",
"meta":{
"title":"articleEditor"
}
}
]
}
]
```
从返回的数据来看,它与我们想要的数据结构相去甚远。
出现这个问题的原因,是因为它返回的是一个 **完整的路由表**
这个路由表距离我们想要的存在两个问题:
1. 存在重复的路由数据
2. 不满足该条件 `meta && meta.title && meta.icon` 的数据不应该存在
那么接下来我们就应该来处理这两个问题
创建 `utils/route` 文件,创建两个方法分别处理对应的两个问题:
1. `filterRouters`
2. `generateMenus`
写入以下代码:
```js
import path from 'path'
/**
* 返回所有子路由
*/
const getChildrenRoutes = routes => {
const result = []
routes.forEach(route => {
if (route.children && route.children.length > 0) {
result.push(...route.children)
}
})
return result
}
/**
* 处理脱离层级的路由:某个一级路由为其他子路由,则剔除该一级路由,保留路由层级
* @param {*} routes router.getRoutes()
*/
export const filterRouters = routes => {
const childrenRoutes = getChildrenRoutes(routes)
return routes.filter(route => {
return !childrenRoutes.find(childrenRoute => {
return childrenRoute.path === route.path
})
})
}
/**
* 判断数据是否为空值
*/
function isNull(data) {
if (!data) return true
if (JSON.stringify(data) === '{}') return true
if (JSON.stringify(data) === '[]') return true
return false
}
/**
* 根据 routes 数据,返回对应 menu 规则数组
*/
export 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
}
```
在 `SidebarMenu` 中调用该方法
```vue
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { filterRouters, generateMenus } from '@/utils/route'
const router = useRouter()
const routes = computed(() => {
const filterRoutes = filterRouters(router.getRoutes())
return generateMenus(filterRoutes)
})
console.log(JSON.stringify(routes.value))
</script>
```
得到该数据结构
```json
[
{
"path":"/profile",
"name":"profile",
"meta":{
"title":"profile",
"icon":"el-icon-user"
},
},
{
"path":"/user",
"redirect":"/user/manage",
"meta":{
"title":"user",
"icon":"personnel"
},
"props":{
"default":false
},
"children":[
{
"path":"/user/manage",
"name":"userManage",
"meta":{
"title":"userManage",
"icon":"personnel-manage"
},
"children":[
]
},
{
"path":"/user/role",
"name":"userRole",
"meta":{
"title":"roleList",
"icon":"role"
},
"children":[
]
},
{
"path":"/user/permission",
"name":"userPermission",
"meta":{
"title":"permissionList",
"icon":"permission"
},
"children":[
]
}
],
},
{
"path":"/article",
"redirect":"/article/ranking",
"meta":{
"title":"article",
"icon":"article"
},
"props":{
"default":false
},
"children":[
{
"path":"/article/ranking",
"name":"articleRanking",
"meta":{
"title":"articleRanking",
"icon":"article-ranking"
},
"children":[
]
},
{
"path":"/article/create",
"name":"articleCreate",
"meta":{
"title":"articleCreate",
"icon":"article-create"
},
"children":[
]
}
],
}
]
```
## 4-14: 业务落地:生成动态 menu 菜单
有了数据结构之后,最后的步骤就水到渠成了
整个 `menu` 菜单,我们将分成三个组件来进行处理
1. `SidebarMenu`:处理数据,作为最顶层 `menu` 载体
2. `SidebarItem`:根据数据处理 **当前项为 `el-submenu` || `el-menu-item`**
3. `MenuItem`:处理 `el-menu-item` 样式
那么下面我们一个个来处理
首先是 `SidebarMenu`
```vue
<template>
<!-- 一级 menu 菜单 -->
<el-menu
...
>
<sidebar-item
v-for="item in routes"
:key="item.path"
:route="item"
></sidebar-item>
</el-menu>
</template>
```
创建 `SidebarItem` 组件,用来根据数据处理 **当前项为 `el-submenu` || `el-menu-item`**
```vue
<template>
<!-- 支持渲染多级 menu 菜单 -->
<el-submenu v-if="route.children.length > 0" :index="route.path">
<template #title>
<menu-item :title="route.meta.title" :icon="route.meta.icon"></menu-item>
</template>
<!-- 循环渲染 -->
<sidebar-item
v-for="item in route.children"
:key="item.path"
:route="item"
></sidebar-item>
</el-submenu>
<!-- 渲染 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'
import { defineProps } from 'vue'
// 定义 props
defineProps({
route: {
type: Object,
required: true
}
})
</script>
```
创建 `MenuItem` 用来处理 `el-menu-item` 样式
```vue
<template>
<i v-if="icon.includes('el-icon')" class="sub-el-icon" :class="icon"></i>
<svg-icon v-else :icon="icon"></svg-icon>
<span>{{ title }}</span>
</template>
<script setup>
import { defineProps } from 'vue'
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
required: true
}
})
</script>
<style lang="scss" scoped>
</style>
```
至此,整个的 `menu` 菜单结构就已经完成了
但是此时我们的 `menu` 菜单还存在三个小的问题:
1. 样式问题
2. 路由跳转问题
3. 默认激活项
那么下一小节,我们来修复这些残余的问题
## 4-15:业务落地:修复最后残余问题
目前 `menu` 菜单存在三个问题
1. 样式问题
2. 路由跳转问题
3. 默认激活项
**样式问题:**
首先处理样式,因为后面我们需要处理 **主题替换** ,所以此处我们不能把样式写死
在 `store/getters` 中创建一个新的 **快捷访问**
```js
import variables from '@/styles/variables.scss'
const getters = {
...
cssVar: state => variables
}
export default getters
```
在 `SidebarMenu` 中写入如下样式
```html
<el-menu
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
:unique-opened="true"
>
```
**路由跳转问题:**
为 `el-menu` 指定 `router`
```html
<el-menu
...
router
>
```
**默认激活项:**
根据当前 `url` 进行判断即可
```vue
<el-menu
:default-active="activeMenu"
...
>
<script setup>
...
// 计算高亮 menu 的方法
const route = useRoute()
const activeMenu = computed(() => {
const { path } = route
return path
})
</script>
```
至此整个 **动态 `menu`完成**
## 4-16:动画逻辑,左侧菜单伸缩功能实现
下面我们来实现一个标准化功能 **左侧菜单伸缩** ,对于这个功能核心的点在于动画处理
样式的改变总是由数据进行驱动,所以首先我们去创建对应的数据
创建 `store/app` 模块,写入如下代码
```js
export default {
namespaced: true,
state: () => ({
sidebarOpened: true
}),
mutations: {
triggerSidebarOpened(state) {
state.sidebarOpened = !state.sidebarOpened
}
},
actions: {}
}
```
在 `store/index` 中进行导入
```js
...
import app from './modules/app'
export default createStore({
getters,
modules: {
...
app
}
})
```
在 `store/getters` 中创建快捷访问
```js
sidebarOpened: state => state.app.sidebarOpened
```
创建 `components/hamburger` 组件,用来控制数据
```vue
<template>
<div class="hamburger-container" @click="toggleClick">
<svg-icon class="hamburger" :icon="icon"></svg-icon>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
const store = useStore()
const toggleClick = () => {
store.commit('app/triggerSidebarOpened')
}
const icon = computed(() =>
store.getters.sidebarOpened ? 'hamburger-opened' : 'hamburger-closed'
)
</script>
<style lang="scss" scoped>
.hamburger-container {
padding: 0 16px;
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
}
</style>
```
在 `navbar` 中使用该组件
```vue
<template>
<div class="navbar">
<hamburger class="hamburger-container" />
...
</div>
</template>
<script setup>
import Hamburger from '@/components/Hamburger'
...
</script>
<style lang="scss" scoped>
.navbar {
...
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
// hover 动画
transition: background 0.5s;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
...
}
</style>
```
在 `SidebarMenu` 中,控制 `el-menu` 的 [collapse](https://element-plus.org/#/zh-CN/component/menu) 属性
```vue
<el-menu
:collapse="!$store.getters.sidebarOpened"
...
```
在 `layout/index` 中指定 **整个侧边栏的宽度和缩放动画**
```vue
<div
class="app-wrapper"
:class="[$store.getters.sidebarOpened ? 'openSidebar' : 'hideSidebar']"
>
...
```
在 `layout/index` 中 处理 `navbar` 的宽度
```vue
<style lang="scss" scoped>
...
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - #{$hideSideBarWidth});
}
</style>
```
在 `styles/variables.scss` 中指定 `hideSideBarWidth`
```scss
$hideSideBarWidth: 54px;
```
## 4-17: SidebarHeader 处理
整个左侧的 `menu` 菜单,到现在咱们还剩下最后一个 `header` 没有进行处理
在 `sidebar/index` 中写入如下代码
```vue
<template>
<div class="">
<div class="logo-container">
<el-avatar
size="44"
shape="square"
src="https://m.imooc.com/static/wap/static/common/img/logo-small@2x.png"
/>
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">
imooc-admin
</h1>
</div>
...
</div>
</template>
<style lang="scss" scoped>
.logo-container {
height: 44px;
padding: 10px 0 22px 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>
```
创建 `styles/element.scss` 文件,统一处理 `el-avatar` 的背景问题
```scss
.el-avatar {
--el-avatar-background-color: none;
}
```
在 `styles/index.scss` 中导入
```scss
...
@import './element.scss';
```
统一处理下动画时长的问题,在 `styles/variables.scss` 中,加入以下变量
```scss
$sideBarDuration: 0.28s;
```
为 `styles/sidebar.scss` 修改时长
```scss
.main-container {
transition: margin-left #{$sideBarDuration};
...
}
.sidebar-container {
transition: width #{$sideBarDuration};
...
}
```
为 `layout/index` 修改样式
```scss
.fixed-header {
...
transition: width #{$sideBarDuration};
}
```
## 4-18:全新 vue 能力:组件状态驱动的动态 CSS 值
在 [vue 3.2](https://blog.vuejs.org/posts/vue-3.2.html) 最新更新中,除了之前我们介绍的 **响应式变化** 之外,还有另外一个很重要的更新,那就是 **组件状态驱动的动态 `CSS` 值** ,对应的文档也已经公布,大家可以 [点击这里](https://v3.vuejs.org/api/sfc-style.html#state-driven-dynamic-css) 查看
那么下面我们就使用下最新的特性,来为 `logo-container` 指定下高度:
```vue
<template>
...
<el-avatar
:size="logoHeight"
...
</template>
<script setup>
...
const logoHeight = 44
</script>
<style lang="scss" scoped>
.logo-container {
height: v-bind(logoHeight) + 'px';
...
}
</style>
```
## 4-19:动态面包屑方案分析
到目前位置,本章中还剩下最后一个功能就是 **面包屑导航**,分为:
1. 静态面包屑
2. 动态面包屑
**静态面包屑:**
指的是:**在每个页面中写死对应的面包屑菜单**,缺点也很明显:
1. 每个页面都得写一遍
2. 页面路径结构变化了,得手动更改
简单来说就是 **不好维护,不好扩展** 。
**动态面包屑:**
**根据当前的 `url` 自动生成面包屑导航菜单**
无论之后路径发生了什么变化,**动态面包屑** 都会正确的进行计算
那么在后面的实现过程中,我们将会分成三大步来实现
1. 创建、渲染基本的面包屑组件
2. 计算面包屑结构数据
3. 根据数据渲染动态面包屑内容
## 4-20:业务落地:渲染基本的面包屑组件
完成第一步,先去创建并渲染出基本的 [面包屑](https://element-plus.org/#/zh-CN/component/breadcrumb) 组件
创建 `components/Breadcrumb/index`,并写入如下代码:
```vue
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item><a href="/">活动管理</a></el-breadcrumb-item>
<el-breadcrumb-item>活动列表</el-breadcrumb-item>
<!-- 面包屑的最后一项 -->
<el-breadcrumb-item>
<span class="no-redirect">活动详情</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped>
.breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
::v-deep .no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>
```
在 `layout/components/Navbar` 组件下导入
```vue
<template>
<div class="navbar">
<hamburger class="hamburger-container" />
<breadcrumb class="breadcrumb-container" />
...
</div>
</template>
...
<style lang="scss" scoped>
.navbar {
...
.breadcrumb-container {
float: left;
}
...
}
</style>
```
## 4-21:业务落地:动态计算面包屑结构数据
现在我们是完成了一个静态的 面包屑,接下来咱们就需要依托这个静态的菜单来完成动态的。
对于现在的静态面包屑来说,他分成了两个组件:
1. `el-breadcrumb`:包裹性质的容器
2. `el-breadcrumb-item`:每个单独项
如果我们想要完成动态的,那么就需要 **依据动态数据,渲染 `el-breadcrumb-item` **
所以说接下来我们需要做的事情就很简单了
1. 动态数据
2. 渲染 `el-breadcrumb-item`
那么这一小节咱们先来看 **动态数据如何制作**
我们希望可以制作出一个 **数组**,数组中每个 `item` 都表示一个 **路由信息**:
创建一个方法,用来生成数组数据,在这里我们要使用到 [route.match](https://next.router.vuejs.org/zh/api/#matched) 属性来:**获取与给定路由地址匹配的[标准化的路由记录](https://next.router.vuejs.org/zh/api/#routerecord)数组**
```vue
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 生成数组数据
const breadcrumbData = ref([])
const getBreadcrumbData = () => {
breadcrumbData.value = route.matched.filter(
item => item.meta && item.meta.title
)
console.log(breadcrumbData.value)
}
// 监听路由变化时触发
watch(
route,
() => {
getBreadcrumbData()
},
{
immediate: true
}
)
</script>
```
## 4-22:业务落地:依据动态数据,渲染面包屑
有了数据之后,根据数据来去渲染面包屑就比较简单了。
```vue
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<el-breadcrumb-item
v-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>
</el-breadcrumb>
</template>
<script setup>
...
// 处理点击事件
const router = useRouter()
const onLinkClick = item => {
console.log(item)
router.push(item.path)
}
// 将来需要进行主题替换,所以这里获取下动态样式
const store = useStore()
// eslint-disable-next-line
const linkHoverColor = ref(store.getters.cssVar.menuBg)
</script>
<style lang="scss" scoped>
.breadcrumb {
...
.redirect {
color: #666;
font-weight: 600;
}
.redirect:hover {
// 将来需要进行主题替换,所以这里不去写死样式
color: v-bind(linkHoverColor);
}
}
</style>
```
## 4-23:vue3 动画处理
vue3对 [动画](https://v3.cn.vuejs.org/guide/transitions-overview.html#%E5%9F%BA%E4%BA%8E-class-%E7%9A%84%E5%8A%A8%E7%94%BB%E5%92%8C%E8%BF%87%E6%B8%A1) 进行了一些修改([vue 动画迁移文档](https://v3.cn.vuejs.org/guide/migration/transition.html#%E6%A6%82%E8%A7%88))
主要的修改其实只有两个:
1. 过渡类名 `v-enter` 修改为 `v-enter-from`
2. 过渡类名 `v-leave` 修改为 `v-leave-from`
那么依据修改之后的动画,我们来为面包屑增加一些动画样式:
1. 在 `Breadcrumb/index` 中增加 `transition-group`
```vue
<template>
<el-breadcrumb class="breadcrumb" separator="/">
<transition-group name="breadcrumb">
...
</transition-group>
</el-breadcrumb>
</template>
```
2. 新建 `styles/transition` 样式文件
```scss
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.5s;
}
.breadcrumb-enter-from,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-leave-active {
position: absolute;
}
```
3. 在 `styles/index` 中导入
```scss
@import './transition.scss';
```
## 4-24:总结
到这里我们本章的内容就算是完成了,本章围绕着 `layout` 为核心,主要实现了三个大的业务方案:
1. 用户退出方案
2. 动态侧边栏方案
3. 动态面包屑方案
除了这三块大的方案之后,还有一些小的功能,比如:
1. 退出的通用逻辑封装
2. 伸缩侧边栏动画
3. `vue3` 动画
4. 组件状态驱动的动态 `CSS` 值等等
那么这些方案的实现逻辑,就不在这里在跟大家重复了。
这些方案在企业后台项目开发中,整体的覆盖率还是很高的
那么在下一章节中,我们会去讲解一些通用的功能方案,相信这些功能方案大家一定都或多或少的遇到过,并且给大家带来过一定的麻烦。
那么具体这样方案都有什么呢?我们一起期待吧!
四、国际化、主题变化等解决方案
# 第五章:后台项目前端综合解决方案之通用功能开发
## 5-01:开篇
在后台项目的前端开发之中,存在着很多的通用业务功能,并且存在着一定的技术难度。
所以说就有很多同学在面临这些功能的时候,大多数时都是采用 `ctrl + c || v` 的形式来进行实现。这就导致了虽然做过类似的功能,但是对这些功能的实现原理一知半解。
那么针对于这样的问题,就有了咱们这一章。
在本章中我们列举出了常见的一些通用功能,具体如下:
1. 国际化
2. 动态换肤
3. `screenfull`
4. `headerSearch`
5. `tagView`
6. `guide`
来为大家进行讲解。
相信大家完成了本章的学习之后,对于这些功能无论是从 **原理上** 还是从 **实现上** 都可以做到 **了然于胸** 的目标
## 5-02:国际化实现原理
先来看一个需求:
> 我们有一个变量 `msg` ,但是这个 `msg` 有且只能有两个值:
>
> 1. hello world
> 2. 你好世界
>
> 要求:根据需要切换 `msg` 的值
这样的一个需求就是 国际化 的需求,那么我们可以通过以下代码来实现这个需求
```js
<script>
// 1. 定义 msg 值的数据源
const messages = {
en: {
msg: 'hello world'
},
zh: {
msg: '你好世界'
}
}
// 2. 定义切换变量
let locale = 'en'
// 3. 定义赋值函数
function t(key) {
return messages[locale][key]
}
// 4. 为 msg 赋值
let msg = t('msg')
console.log(msg);
// 修改 locale, 重新执行 t 方法,获取不同语言环境下的值
</script>
```
总结:
1. 通过一个变量来 **控制** 语言环境
2. 所有语言环境下的数据源要 **预先** 定义好
3. 通过一个方法来获取 **当前语言** 下 **指定属性** 的值
4. 该值即为国际化下展示值
## 5-03:基于 vue-i18n V9 的国际化实现方案分析
在 `vue` 的项目中,我们不需要手写这么复杂的一些基础代码,可以直接使用 [vue-i18n](https://vue-i18n.intlify.dev/) 进行实现(注意:**`vue3` 下需要使用 `V 9.x` 的 `i18n`**)
[vue-i18n](https://vue-i18n.intlify.dev/guide/) 的使用可以分为四个部分:
1. 创建 `messages` 数据源
2. 创建 `locale` 语言变量
3. 初始化 `i18n` 实例
4. 注册 `i18n` 实例
那么接下来我们就去实现以下:
1. 安装 `vue-i18n`
```
npm install vue-i18n@next
```
2. 创建 `i18n/index.js` 文件
3. 创建 `messages` 数据源
```js
const messages = {
en: {
msg: {
test: 'hello world'
}
},
zh: {
msg: {
test: '你好世界'
}
}
}
```
4. 创建 `locale` 语言变量
```js
const locale = 'en'
```
5. 初始化 `i18n` 实例
```js
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
// 使用 Composition API 模式,则需要将其设置为false
legacy: false,
// 全局注入 $t 函数
globalInjection: true,
locale,
messages
})
```
6. 把 `i18n` 注册到 `vue` 实例
```js
export default i18n
```
7. 在 `main.js` 中导入
```js
// i18n (PS:导入放到 APP.vue 导入之前,因为后面我们会在 app.vue 中使用国际化内容)
import i18n from '@/i18n'
...
app.use(i18n)
```
8. 在 `layout/components/Sidebar/index.vue` 中使用 `i18n`
```html
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">
{{ $t('msg.test') }}
</h1>
```
9. 修改 `locale` 的值,即可改变展示的内容
截止到现在我们已经实现了 `i18n` 的最基础用法,那么解下来我们就可以在项目中使用 `i18n` 完成国际化。
项目中完成国际化分成以下几步进行:
1. 封装 `langSelect` 组件用于修改 `locale`
2. 导入 `el-locale` 语言包
3. 创建自定义语言包
## 5-04:方案落地:封装 langSelect 组件
1. 定义 `store/app.js`
```js
import { LANG } from '@/constant'
import { getItem, setItem } from '@/utils/storage'
export default {
namespaced: true,
state: () => ({
...
language: getItem(LANG) || 'zh'
}),
mutations: {
...
/**
* 设置国际化
*/
setLanguage(state, lang) {
setItem(LANG, lang)
state.language = lang
}
},
actions: {}
}
```
2. 在 `constant` 中定义常量
```js
// 国际化
export const LANG = 'language'
```
3. 创建 `components/LangSelect/index`
```vue
<template>
<el-dropdown
trigger="click"
class="international"
@command="handleSetLanguage"
>
<div>
<el-tooltip content="国际化" :effect="effect">
<svg-icon icon="language" />
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :disabled="language === 'zh'" command="zh">
中文
</el-dropdown-item>
<el-dropdown-item :disabled="language === 'en'" command="en">
English
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { defineProps, computed } from 'vue'
import { useStore } from 'vuex'
import { ElMessage } from 'element-plus'
defineProps({
effect: {
type: String,
default: 'dark',
validator: function(value) {
// 这个值必须匹配下列字符串中的一个
return ['dark', 'light'].indexOf(value) !== -1
}
}
})
const store = useStore()
const language = computed(() => store.getters.language)
// 切换语言的方法
const i18n = useI18n()
const handleSetLanguage = lang => {
i18n.locale.value = lang
store.commit('app/setLanguage', lang)
ElMessage.success('更新成功')
}
</script>
```
4. 在 `navbar` 中导入 `LangSelect`
```vue
<template>
<div class="navbar">
...
<div class="right-menu">
<lang-select class="right-menu-item hover-effect" />
<!-- 头像 -->
...
</div>
</div>
</template>
<script setup>
import LangSelect from '@/components/LangSelect'
...
</script>
<style lang="scss" scoped>
.navbar {
...
.right-menu {
...
::v-deep .right-menu-item {
display: inline-block;
padding: 0 18px 0 0;
font-size: 24px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
}
}
...
}
</style>
```
## 5-05:方案落地:element-plus 国际化处理
截止到目前,我们的国际化内容已经基本功能已经处理完成了。接下来需要处理的就是对应的语言包,有了语言包就可以实现整个项目中的所有国际化处理了。
那么对于语言包来说,我们整个项目中会分成两部分:
1. `element-plus` 语言包:用来处理 `element` 组件的国际化功能
2. 自定义语言包:用来处理 **非** `element` 组件的国际化功能
那么首先我们先来处理 `element-plus` 语言包:
**按照正常的逻辑,我们是可以通过 `element-ui` 配合 `vue-i18n`来实现国际化功能的,但是目前的 `element-plus` 尚未提供配合 `vue-i18n` 实现国际化的方式! **
所以说,我们暂时只能先去做临时处理,等到 `element-plus` 支持 `vue-i18n` 功能之后,我们再进行对接实现
那么临时处理我们怎么去做呢?
1. 升级 `element-plus` 到最新版本
```
npm i element-plus
```
目前课程中使用的最新版本为:`^1.1.0-beta.15`
2. 升级版本之后,左侧 `menu` 菜单无法正常显示,这是因为 `element-plus` 修改了 `el-submenu` 的组件名称
3. 到 `layout/components/Sidebar/SidebarItem` 中,修改 `el-submenu` 为 `el-sub-menu`
4. 接下来实现国际化
5. 在 `plugins/index` 中导入 `element` 的中文、英文语言包:
```js
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/lib/locale/lang/en'
```
6. 注册 `element` 时,根据当前语言选择使用哪种语言包
```js
import store from '@/store'
export default app => {
app.use(ElementPlus, {
locale: store.getters.language === 'en' ? en : zhCn
})
}
```
## 5-06:方案落地:自定义语言包国际化处理
处理完 `element` 的国际化内容之后,接下来我们来处理 **自定义语言包**。
自定义语言包我们使用了 `commonJS` 导出了一个对象,这个对象就是所有的 **自定义语言对象**
> 大家可以在 **资料/lang** 中获取到所有的语言包
1. 复制 `lang` 文件夹到 `i18n` 中
2. 在 `lang/index` 中,导入语言包
```js
import mZhLocale from './lang/zh'
import mEnLocale from './lang/en'
```
3. 在 `messages` 中注册到语言包
```js
const messages = {
en: {
msg: {
...mEnLocale
}
},
zh: {
msg: {
...mZhLocale
}
}
}
```
## 5-07:方案落地:处理项目国际化内容
在处理好了国际化的语言包之后,接下来我们就可以应用国际化功能到我们的项目中
对于我们目前的项目而言,需要进行国际化处理的地方主要分为:
1. 登录页面
2. `navbar` 区域
3. `sidebar` 区域
4. 面包屑区域
那么这一小节,我们先来处理前两个
**登录页面:**
`login/index`
```vue
<template>
<div class="login-container">
...
<div class="title-container">
<h3 class="title">{{ $t('msg.login.title') }}</h3>
<lang-select class="lang-select" effect="light"></lang-select>
</div>
...
<el-button
type="primary"
style="width: 100%; margin-bottom: 30px"
:loading="loading"
@click="handleLogin"
>{{ $t('msg.login.loginBtn') }}</el-button
>
<div class="tips" v-html="$t('msg.login.desc')"></div>
</el-form>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
...
// 验证规则
const i18n = useI18n()
const loginRules = ref({
username: [
{
...
message: i18n.t('msg.login.usernameRule')
}
],
...
})
...
</script>
```
`login/rules`
```js
import i18n from '@/i18n'
export const validatePassword = () => {
return (rule, value, callback) => {
if (value.length < 6) {
callback(new Error(i18n.global.t('msg.login.passwordRule')))
} else {
callback()
}
}
}
```
**`navbar` 区域**
`layout/components/navbar`
```vue
<template>
<div class="navbar">
...
<template #dropdown>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> {{ $t('msg.navBar.home') }} </el-dropdown-item>
</router-link>
<a target="_blank" href="">
<el-dropdown-item>{{ $t('msg.navBar.course') }}</el-dropdown-item>
</a>
<el-dropdown-item divided @click="logout">
{{ $t('msg.navBar.logout') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
```
`components/LangSelect/index`
```vue
<el-tooltip :content="$t('msg.navBar.lang')" :effect="effect">
...
const handleSetLanguage = lang => {
...
ElMessage.success(i18n.t('msg.toast.switchLangSuccess'))
}
```
## 5-08:方案落地:sidebar 与 面包屑 区域的国际化处理
**sidebar 区域**
目前对于 `sidebar` 而言,显示的文本是我们在定义路由表时的 `title`
```html
<span>{{ title }}</span>
```
我们可以 **把 `title` 作为语言包内容的 `key` 进行处理**
创建 `utils/i18n` 工具模块,用于 **将 `title` 转化为国际化内容**
```js
import i18n from '@/i18n'
export function generateTitle(title) {
return i18n.global.t('msg.route.' + title)
}
```
在 `layout/components/Sidebar/MenuItem.vue` 中导入该方法:
```vue
<template>
...
<span>{{ generateTitle(title) }}</span>
</template>
<script setup>
import { generateTitle } from '@/utils/i18n'
...
</script>
```
最后修改下 `sidebarHeader` 的内容
```php+HTML
<h1 class="logo-title" v-if="$store.getters.sidebarOpened">
imooc-admin
</h1>
```
**面包屑区域:**
在 `components/Breadcrumb/index`
```vue
<template>
...
<!-- 不可点击项 -->
<span v-if="index === breadcrumbData.length - 1" class="no-redirect">{{
generateTitle(item.meta.title)
}}</span>
<!-- 可点击项 -->
<a v-else class="redirect" @click.prevent="onLinkClick(item)">{{
generateTitle(item.meta.title)
}}</a>
...
</template>
<script setup>
import { generateTitle } from '@/utils/i18n'
...
</script>
```
## 5-09:方案落地:国际化缓存处理
我们希望在 **刷新页面后,当前的国际化选择可以被保留**,所以想要实现这个功能,那么就需要进行 **国际化的缓存处理**
此处的缓存,我们依然通过两个方面进行:
1. `vuex` 缓存
2. `LocalStorage` 缓存
只不过这里的缓存,我们已经在处理 **`langSelect` 组件时** 处理完成了,所以此时我们只需要使用缓存下来的数据即可。
在 `i18n/index` 中,创建 `getLanguage` 方法:
```js
import store from '@/store'
/**
* 返回当前 lang
*/
function getLanguage() {
return store && store.getters && store.getters.language
}
```
修改 `createI18n` 的 `locale` 为 `getLanguage()`
```js
const i18n = createI18n({
...
locale: getLanguage()
})
```
## 5-10:国际化方案总结
国际化是前端项目中的一个非常常见的功能,那么在前端项目中实现国际化主要依靠的就是 `vue-i18n` 这个第三方的包。
关于国际化的实现原理大家可以参照 **国际化实现原理** 这一小节,这里我们就不再赘述了。
而 `i18n` 的使用,整体来说就分为这么四步:
1. 创建 `messages` 数据源
2. 创建 `locale` 语言变量
3. 初始化 `i18n` 实例
4. 注册 `i18n` 实例
核心的内容其实就是 数据源的部分,但是大家需要注意,如果你的项目中使用了 **第三方组件库** ,那么不要忘记 **第三方组件库的数据源** 需要 **单独** 进行处理!
接下来我们来处理 **动态换肤** 功能。
关于 **动态换肤** 我们之前已经提到过了,在 `layout/components/Sidebar/SidebarMenu.vue` 中,我们实现 `el-menu` 的背景色时,说过:**此处将来会实现换肤功能,所以我们不能直接写死,而需要通过一个动态的值,来进行指定**
```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
>x'z
...
</el-menu>
```
那么换句话而言,想要实现 **动态换肤** 的一个前置条件就是:**色值不可以写死!**
那么为什么会有这个前置条件呢?动态换肤又是如何来去实现的呢?这一小节我们来看一下这个问题。
首先我们先来说一下动态换肤的实现方式。
在 `scss` 中,我们可以通过 `$变量名:变量值` 的方式定义 `css 变量`,然后通过该 `css 变量` 来去指定某一块 `DOM` 对应的颜色。
那么大家可以想一下,如果我此时改变了该 `css 变量` 的值,那么所对应的 `DOM` 颜色是不是也会同步发生变化?
当大量的 `DOM` 都依赖于这个 `css 变量` 设置颜色时,我们是不是只需要改变这个 `css 变量`,那么所有 `DOM` 的颜色是不是都会发生变化,所谓的 **动态换肤** 是不是就可以实现了!
这个就是实现 **动态换肤** 的原理。
而在我们的项目中想要实现动态换肤,需要同时处理两个方面的内容:
1. `element-plus` 主题
2. 非 `element-plus` 主题
那么下面我们就分别来去处理这两块主题对应的内容
## 5-11:动态换肤原理分析
接下来我们来处理 **动态换肤** 功能
关于 **动态换肤** 我们之前已经提到过了,在 `layout/components/SidebarMenu.vue` 中,我们实现 `el-menu` 的背景色时,说过 **此处将来会实现换肤功能,所以我们不能直接写死,而需要通过一个动态的值进行指定**
```html
<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` 的颜色是不是都会发生变化,所谓的 **动态换肤** 是不是就可以实现了!
这个就是 **动态换肤** 的实现原理
而在我们的项目中想要实现动态换肤,需要同时处理两个方面的内容:
1. `element-plus` 主题
2. 非 `element-plus` 主题
那么下面我们就分别来去处理这两块主题对应的内容
## 5-12:动态换肤实现方案分析
明确好了原理之后,接下来我们就来理一下咱们的实现思路。
从原理中我们可以得到以下两个关键信息:
1. 动态换肤的关键是修改 `css 变量` 的值
2. 换肤需要同时兼顾
1. `element-plus`
2. 非 `element-plus`
那么根据以上关键信息,我们就可以得出对应的实现方案
1. 创建一个组件 `ThemeSelect` 用来处理修改之后的 `css 变量` 的值
2. 根据新值修改 `element-plus` 主题色
3. 根据新值修改非 `element-plus` 主题色
## 5-13:方案落地:创建 ThemeSelect 组件
查看完成之后的项目我们可以发现,`ThemeSelect` 组件将由两部分组成:
1. `navbar` 中的展示图标
2. 选择颜色的弹出层
那么本小节我们就先来处理第一个 **`navbar` 中的展示图标**
创建 `components/ThemeSelect/index` 组件
```js
<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` 中进行引用
```vue
<div class="right-menu">
<theme-picker class="right-menu-item hover-effect"></theme-picker>
import ThemePicker from '@/components/ThemeSelect/index'
```
## 5-14:方案落地:创建 SelectColor 组件
在有了 `ThemeSelect ` 之后,接下来我们来去处理颜色选择的组件 `SelectColor`,在这里我们会用到 `element` 中的 `el-color-picker` 组件
对于 `SelectColor` 的处理,我们需要分成两步进行:
1. 完成 `SelectColor` 弹窗展示的双向数据绑定
2. 把选中的色值进行本地缓存
那么下面咱们先来看第一步:**完成 `SelectColor` 弹窗展示的双向数据绑定**
创建 `components/ThemePicker/components/SelectColor.vue`
```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 { defineProps, defineEmits, 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` 中使用该组件
```vue
<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>
```
完成双向数据绑定之后,我们来处理第二步:**把选中的色值进行本地缓存**
缓存的方式分为两种:
1. `vuex`
2. 本地存储
在 `constants/index` 下新建常量值
```js
// 主题色保存的 key
export const MAIN_COLOR = 'mainColor'
// 默认色值
export const DEFAULT_COLOR = '#409eff'
```
创建 `store/modules/theme` 模块,用来处理 **主题色** 相关内容
```js
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` 下指定快捷访问
```js
mainColor: state => state.theme.mainColor
```
在 `store/index` 中导入 `theme`
```js
...
import theme from './modules/theme.js'
export default createStore({
getters,
modules: {
...
theme
}
})
```
在 `selectColor` 中,设置初始色值 和 缓存色值
```vue
...
<script setup>
import { defineProps, defineEmits, 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>
```
## 5-15:方案落地:处理 element-plus 主题变更原理与步骤分析
对于 `element-plus` 的主题变更,相对比较复杂,所以说整个过程我们会分为三部分:
1. 实现原理
2. 实现步骤
3. 实现过程
**实现原理:**
在之前我们分析主题变更的实现原理时,我们说过,核心的原理是:**通过修改 `scss` 变量 ** 的形式修改主题色完成主题变更
但是对于 `element-plus` 而言,我们怎么去修改这样的主题色呢?
其实整体的原理非常简单,分为三步:
1. 获取当前 `element-plus` 的所有样式
2. 找到我们想要替换的样式部分,通过正则完成替换
3. 把替换后的样式写入到 `style` 标签中,利用样式优先级的特性,替代固有样式
**实现步骤:**
那么明确了原理之后,我们的实现步骤也就呼之欲出了,对应原理总体可分为四步:
1. 获取当前 `element-plus` 的所有样式
2. 定义我们要替换之后的样式
3. 在原样式中,利用正则替换新样式
4. 把替换后的样式写入到 `style` 标签中
## 5-16:方案落地:处理 element-plus 主题变更
创建 `utils/theme` 工具类,写入两个方法
```js
/**
* 写入新样式到 style
* @param {*} elNewStyle element-plus 的新样式
* @param {*} isNewStyleTag 是否生成新的 style 标签
*/
export const writeNewStyle = elNewStyle => {
}
/**
* 根据主色值,生成最新的样式表
*/
export const generateNewStyle = primaryColor => {
}
```
那么接下来我们先实现第一个方法 `generateNewStyle`,在实现的过程中,我们需要安装两个工具类:
1. [rgb-hex](https://www.npmjs.com/package/rgb-hex):转换RGB(A)颜色为十六进制
2. [css-color-function](https://www.npmjs.com/package/css-color-function):在CSS中提出的颜色函数的解析器和转换器
然后还需要写入一个 **颜色转化计算器 `formula.json`**
创建 `constants/formula.json` (https://gist.github.com/benfrain/7545629)
```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)"
}
```
准备就绪后,我们来实现 `generateNewStyle` 方法:
```js
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` 方法:
```js
/**
* 写入新样式到 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` 中导入这两个方法:
```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` 中通过一些代码进行测试:
```html
<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>
```
## 5-17:方案落地:element-plus 新主题的立即生效
到目前我们已经完成了 `element-plus` 的主题变更,但是当前的主题变更还有一个小问题,那就是:**在刷新页面后,新主题会失效**
那么出现这个问题的原因,非常简单:**因为没有写入新的 `style`**
所以我们只需要在 **应用加载后,写入 `style` 即可**
那么写入的时机,我们可以放入到 `app.vue` 中
```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>
```
## 5-18:方案落地:自定义主题变更
自定义主题变更相对来说比较简单,因为 **自己的代码更加可控**。
目前在我们的代码中,需要进行 **自定义主题变更** 为 **`menu` 菜单背景色**
而目前指定 `menu` 菜单背景色的位置在 `layout/components/sidebar/SidebarMenu.vue` 中
```js
<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` 为:
```js
cssVar: state => variables,
```
所以,我们想要修改 **自定义主题** ,只需要从这里入手即可。
**根据当前保存的 `mainColor` 覆盖原有的默认色值**
```js
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
```
但是我们这样设定之后,整个自定义主题变更,还存在两个问题:
1. `menuBg` 背景颜色没有变化
`<img src="第五章:后台项目前端综合解决方案之通用功能开发.assets/image-20210925203000626.png" alt="image-20210925203000626" style="zoom:33%;" />`
这个问题是因为咱们的 `sidebar` 的背景色未被替换,所以我们可以在 `layout/index` 中设置 `sidebar` 的 `backgroundColor`
```html
<sidebar
id="guide-sidebar"
class="sidebar-container"
:style="{ backgroundColor: $store.getters.cssVar.menuBg }"
/>
```
2. 主题色替换之后,需要刷新页面才可响应
这个是因为 `getters` 中没有监听到 **依赖值的响应变化**,所以我们希望修改依赖值
在 `store/modules/theme` 中
```js
...
import variables from '@/styles/variables.scss'
export default {
namespaced: true,
state: () => ({
...
variables
}),
mutations: {
/**
* 设置主题色
*/
setMainColor(state, newColor) {
...
state.variables.menuBg = newColor
...
}
}
}
```
在 `getters` 中
```js
....
const getters = {
...
cssVar: state => {
return {
...state.theme.variables,
...generateColors(getItem(MAIN_COLOR))
}
},
...
}
export default getters
```
## 5-19:自定义主题方案总结
那么到这里整个自定义主题我们就处理完成了。
对于 **自定义主题而言**,核心的原理其实就是 **修改 `scss`变量来进行实现主题色变化**
明确好了原理之后,对后续实现的步骤就具体情况具体分析了。
1. 对于 `element-plus`:因为 `element-plus` 是第三方的包,所以它 **不是完全可控** 的,那么对于这种最简单直白的方案,就是直接拿到它编译后的 `css` 进行色值替换,利用 `style` **内部样式表** 优先级高于 **外部样式表** 的特性,来进行主题替换
2. 对于自定义主题:因为自定义主题是 **完全可控** 的,所以我们实现起来就轻松很多,只需要修改对应的 `scss`变量即可
那么在之后大家遇到 **自定义主题** 的处理时,就可以按照我们所梳理的方案进行处理了。
## 5-20:screenfull 原理及方案分析
接下来我们来看 `screenfull (全屏)` 功能实现
对于 `screenfull ` 和之前一样 ,我们还是先分析它的原理,然后在制定对应的方案实现
**原理:**
对于 `screenfull ` 而言,浏览器本身已经提供了对用的 `API`,[点击这里即可查看](https://developer.mozilla.org/zh-CN/docs/Web/API/Fullscreen_API),这个 `API` 中,主要提供了两个方法:
1. [`Document.exitFullscreen()`](https://developer.mozilla.org/zh-CN/docs/Web/API/Document/exitFullscreen):该方法用于请求从全屏模式切换到窗口模式
2. [`Element.requestFullscreen()`](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/requestFullScreen):该方法用于请求浏览器(user agent)将特定元素(甚至延伸到它的后代元素)置为全屏模式
1. 比如我们可以通过 `document.getElementById('app').requestFullscreen()` 在获取 `id=app` 的 `DOM` 之后,把该区域置为全屏
但是该方法存在一定的小问题,比如:
1. `appmain` 区域背景颜色为黑色
所以通常情况下我们不会直接使用该 `API` 来去实现全屏效果,而是会使用它的包装库 [screenfull](https://www.npmjs.com/package/screenfull)
**方案:**
那么明确好了原理之后,接下来实现方案就比较容易了。
整体的方案实现分为两步:
1. 封装 `screenfull` 组件
1. 展示切换按钮
2. 基于 [screenfull](https://www.npmjs.com/package/screenfull) 实现切换功能
2. 在 `navbar` 中引入该组件
## 5-21:方案落地:screenfull
明确好了方案之后,接下来我们就落地该方案
**封装 `screenfull` 组件:**
1. 下来依赖包 [screenfull](https://www.npmjs.com/package/screenfull)
```
npm i screenfull@5.1.0
```
2. 创建 `components/Screenfull/index`
```vue
<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` 中引入该组件:**
```
<screenfull class="right-menu-item hover-effect" />
import Screenfull from '@/components/Screenfull'
```
## 5-22:headerSearch 原理及方案分析
> 所谓 `headerSearch` 指 **页面搜索**
**原理:**
`headerSearch` 是复杂后台系统中非常常见的一个功能,它可以:**在指定搜索框中对当前应用中所有页面进行检索,以 `select` 的形式展示出被检索的页面,以达到快速进入的目的**
那么明确好了 `headerSearch` 的作用之后,接下来我们来看一下对应的实现原理
根据前面的目的我们可以发现,整个 `headerSearch` 其实可以分为三个核心的功能点:
1. 根据指定内容对所有页面进行检索
2. 以 `select` 形式展示检索出的页面
3. 通过检索页面可快速进入对应页面
那么围绕着这三个核心的功能点,我们想要分析它的原理就非常简单了:**根据指定内容检索所有页面,把检索出的页面以 `select` 展示,点击对应 `option` 可进入**
**方案:**
对照着三个核心功能点和原理,想要指定对应的实现方案是非常简单的一件事情了
1. 创建 `headerSearch` 组件,用作样式展示和用户输入内容获取
2. 获取所有的页面数据,用作被检索的数据源
3. 根据用户输入内容在数据源中进行 [模糊搜索](https://fusejs.io/)
4. 把搜索到的内容以 `select` 进行展示
5. 监听 `select` 的 `change` 事件,完成对应跳转
## 5-23:方案落地:创建 headerSearch 组件
创建 `components/headerSearch/index` 组件:
```vue
<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` 中导入该组件
```
<header-search class="right-menu-item hover-effect"></header-search>
import HeaderSearch from '@/components/HeaderSearch'
```
在有了 `headerSearch` 之后,接下来就可以来处理对应的 **检索数据源了**
**检索数据源** 表示:**有哪些页面希望检索**
那么对于我们当前的业务而言,我们希望被检索的页面其实就是左侧菜单中的页面,那么我们检索数据源即为:**左侧菜单对应的数据源**
根据以上原理,我们可以得出以下代码:
```vue
<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>
```
## 5-25:方案落地:对检索数据源进行模糊搜索
如果我们想要进行 [模糊搜索](https://fusejs.io/) 的话,那么需要依赖一个第三方的库 [fuse.js](https://fusejs.io/)
1. 安装 [fuse.js](https://fusejs.io/)
```
npm install --save fuse.js@6.4.6
```
2. 初始化 `Fuse`,更多初始化配置项 [可点击这里](https://fusejs.io/api/options.html)
```js
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
}
]
})
```
3. 参考 [Fuse Demo](https://fusejs.io/demo.html) 与 最终效果,可以得出,我们最终期望得到如下的检索数据源结构
```json
[
{
"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":[
"文章",
"创建文章"
]
}
]
```
4. 所以我们之前处理了的数据源并不符合我们的需要,所以我们需要对数据源进行重新处理
## 5-26:方案落地:数据源重处理,生成 searchPool
在上一小节,我们明确了最终我们期望得到数据源结构,那么接下来我们就对重新计算数据源,生成对应的 `searchPoll`
创建 `compositions/HeaderSearch/FuseData.js`
```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`
```vue
<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` 测试搜索结果
```js
// 搜索方法
const querySearch = query => {
console.log(fuse.search(query))
}
```
## 5-27:方案落地:渲染检索数据
数据源处理完成之后,最后我们就只需要完成:
1. 渲染检索出的数据
2. 完成对应跳转
那么下面我们按照步骤进行实现:
1. 渲染检索出的数据
```vue
<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>
```
2. 完成对应跳转
```js
// 选中回调
const onSelectChange = val => {
router.push(val.path)
}
```
## 5-28:方案落地:剩余问题处理
到这里我们的 `headerSearch` 功能基本上就已经处理完成了,但是还存在一些小 `bug` ,那么最后这一小节我们就处理下这些剩余的 `bug`
1. 在 `search` 打开时,点击 `body` 关闭 `search`
2. 在 `search` 关闭时,清理 `searchOptions`
3. `headerSearch` 应该具备国际化能力
明确好问题之后,接下来我们进行处理
首先我们先处理前前面两个问题:
```js
/**
* 关闭 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` 即可**
1. 在 `utils/i18n` 下,新建方法 `watchSwitchLang`
```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. 在 `headerSearch` 监听变化,重新赋值
```vue
<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>
```
## 5-29:headerSearch 方案总结
那么到这里整个的 `headerSearch` 我们就已经全部处理完成了,整个 `headerSearch` 我们只需要把握住三个核心的关键点
1. 根据指定内容对所有页面进行检索
2. 以 `select` 形式展示检索出的页面
3. 通过检索页面可快速进入对应页面
保证大方向没有错误,那么具体的细节处理我们具体分析就可以了。
关于细节的处理,可能比较复杂的地方有两个:
1. 模糊搜索
2. 检索数据源
对于这两块,我们依赖于 `fuse.js` 进行了实现,大大简化了我们的业务处理流程。
## 5-30:tagsView 原理及方案分析
所谓 `tagsView` 可以分成两部分来去看:
1. tags
2. view
好像和废话一样是吧。那怎么分开看呢?
首先我们先来看 `tags`:
所谓 `tgas` 指的是:**位于 `appmain` 之上的标签**
那么现在我们忽略掉 `view`,现在只有一个要求:
> 在 `view` 之上渲染这个 `tag`
仅看这一个要求,很简单吧。
**views:**
明确好了 `tags` 之后,我们来看 `views`。
脱离了 `tags` 只看 `views` 就更简单了,所谓 `views` :**指的就是一个用来渲染组件的位置**,就像我们之前的 `Appmain` 一样,只不过这里的 `views` 可能稍微复杂一点,因为它需要在渲染的基础上增加:
1. 动画
2. 缓存
这两个额外的功能。
加上这两个功能之后可能会略显复杂,但是 [官网已经帮助我们处理了这个问题](https://next.router.vuejs.org/zh/guide/advanced/transitions.html#%E5%9F%BA%E4%BA%8E%E8%B7%AF%E7%94%B1%E7%9A%84%E5%8A%A8%E6%80%81%E8%BF%87%E6%B8%A1)
所以 单看 `views` 也是一个很简单的功能。
那么接下来我们需要做的就是把 `tags` 和 `view` 合并起来而已。
那么明确好了原理之后,我们就来看 **实现方案:**
1. 创建 `tagsView` 组件:用来处理 `tags` 的展示
2. 处理基于路由的动态过渡,在 `AppMain` 中进行:用于处理 `view` 的部分
整个的方案就是这么两大部,但是其中我们还需要处理一些细节相关的,**完整的方案为**:
1. 监听路由变化,组成用于渲染 `tags` 的数据源
2. 创建 `tags` 组件,根据数据源渲染 `tag`,渲染出来的 `tags` 需要同时具备
1. 国际化 `title`
2. 路由跳转
3. 处理鼠标右键效果,根据右键处理对应数据源
4. 处理基于路由的动态过渡
那么明确好了方案之后,接下来我们根据方案进行处理即可。
## 5-31:方案落地:创建 tags 数据源
`tags` 的数据源分为两部分:
1. 保存数据:`appmain` 组件中进行
2. 展示数据:`tags` 组件中进行
所以 `tags` 的数据我们最好把它保存到 `vuex` 中。
1. 在 `constant` 中新建常量
```js
// tags
export const TAGS_VIEW = 'tagsView'
```
2. 在 `store/app` 中创建 `tagsViewList`
```js
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: {}
}
```
3. 在 `appmain` 中监听路由的变化
```vue
<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>
```
4. 创建 `utils/tags`
```js
const whiteList = ['/login', '/import', '/404', '/401']
/**
* path 是否需要被缓存
* @param {*} path
* @returns
*/
export function isTags(path) {
return !whiteList.includes(path)
}
```
## 5-32:方案落地:生成 tagsView
目前数据已经被保存到 `store` 中,那么接下来我们就依赖数据渲染 `tags`
1. 创建 `store/app` 中 `tagsViewList` 的快捷访问
```js
tagsViewList: state => state.app.tagsViewList
```
2. 创建 `components/tagsview`
```vue
<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>
```
3. 在 `layout/index` 中导入
```vue
<div class="fixed-header">
<!-- 顶部的 navbar -->
<navbar />
<!-- tags -->
<tags-view></tags-view>
</div>
import TagsView from '@/components/TagsView'
```
## 5-33:方案落地:tagsView 国际化处理
`tagsView` 的国际化处理可以理解为修改现有 `tags` 的 `title`。
所以我们只需要:
1. 监听到语言变化
2. 国际化对应的 `title` 即可
根据方案,可生成如下代码:
1. 在 `store/app` 中,创建修改 `ttile` 的 `mutations`
```js
/**
* 为指定的 tag 修改 title
*/
changeTagsView(state, { index, tag }) {
state.tagsViewList[index] = tag
setItem(TAGS_VIEW, state.tagsViewList)
}
```
2. 在 `appmain` 中监听语言变化
```js
import { generateTitle, watchSwitchLang } from '@/utils/i18n'
/**
* 国际化 tags
*/
watchSwitchLang(() => {
store.getters.tagsViewList.forEach((route, index) => {
store.commit('app/changeTagsView', {
index,
tag: {
...route,
title: getTitle(route)
}
})
})
})
```
## 5-34:方案落地:contextMenu 展示处理
> [contextMenu](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/contextmenu_event) 为 鼠标右键事件
[contextMenu](https://developer.mozilla.org/zh-CN/docs/Web/API/Element/contextmenu_event) 事件的处理分为两部分:
1. `contextMenu` 的展示
2. 右键项对应逻辑处理
那么这一小节我们先处理第一部分:`contextMenu` 的展示:
1. 创建 `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'
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>
```
2. 在 `tagsview ` 中控制 `contextMenu` 的展示
```vue
<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>
```
## 5-35:方案落地:contextMenu 事件处理
对于 `contextMenu` 的事件一共分为三个:
1. 刷新
2. 关闭右侧
3. 关闭所有
但是不要忘记,我们之前 **关闭单个 `tags`** 的事件还没有进行处理,所以这一小节我们一共需要处理 4 个对应的事件
1. 刷新事件
```js
const router = useRouter()
const onRefreshClick = () => {
router.go(0)
}
```
2. 在 `store/app` 中,创建删除 `tags` 的 `mutations`,该 `mutations` 需要同时具备以下三个能力:
1. 删除 “右侧”
2. 删除 “其他”
3. 删除 “当前”
3. 根据以上理论得出以下代码:
```js
/**
* 删除 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)
},
```
4. 关闭右侧事件
```js
const store = useStore()
const onCloseRightClick = () => {
store.commit('app/removeTagsView', {
type: 'right',
index: props.index
})
}
```
5. 关闭其他
```js
const onCloseOtherClick = () => {
store.commit('app/removeTagsView', {
type: 'other',
index: props.index
})
}
```
6. 关闭当前(`tagsview`)
```js
/**
* 关闭 tag 的点击事件
*/
const store = useStore()
const onCloseClick = index => {
store.commit('app/removeTagsView', {
type: 'index',
index: index
})
}
```
## 5-36:方案落地:处理 contextMenu 的关闭行为
```js
/**
* 关闭 menu
*/
const closeMenu = () => {
visible.value = false
}
/**
* 监听变化
*/
watch(visible, val => {
if (val) {
document.body.addEventListener('click', closeMenu)
} else {
document.body.removeEventListener('click', closeMenu)
}
})
```
## 5-37:方案落地:处理基于路由的动态过渡
[处理基于路由的动态过渡](https://next.router.vuejs.org/zh/guide/advanced/transitions.html#%E5%9F%BA%E4%BA%8E%E8%B7%AF%E7%94%B1%E7%9A%84%E5%8A%A8%E6%80%81%E8%BF%87%E6%B8%A1) 官方已经给出了示例代码,结合 `router-view` 和 `transition` 我们可以非常方便的实现这个功能
1. 在 `appmain` 中处理对应代码逻辑
```vue
<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>
```
2. 增加了 `tags` 之后,`app-main` 的位置需要进行以下处理
```vue
<style lang="scss" scoped>
.app-main {
min-height: calc(100vh - 50px - 43px);
...
padding: 104px 20px 20px 20px;
...
}
</style>
```
3. 在 `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);
}
```
## 5-38:tagsView 方案总结
那么到这里关于 `tagsView` 的内容我们就已经处理完成了。
整个 `tagsView` 就像我们之前说的,拆开来看之后,会显得明确很多。
整个 `tagsView` 整体来看就是三块大的内容:
1. `tags`:`tagsView` 组件
2. `contextMenu`:`contextMenu` 组件
3. `view`:`appmain` 组件
再加上一部分的数据处理即可。
最后关于 `tags` 的国际化部分,其实处理的方案有非常多,大家也可以在后面的 **讨论题** 中探讨一下关于 **此处国家化** 的实现,相信会有很多新的思路被打开的。
## 5-39:guide 原理及方案分析
所谓 `guide` 指的就是 **引导页**
引导页是软件中经常见到的一个功能,无论是在后台项目还是前台或者是移动端项目中。
那么对于引导页而言,它是如何实现的呢?我们来分析一下。
通常情况下引导页是通过 **聚焦** 的方式,高亮一块视图,然后通过文字解释的形式来告知用户该功能的作用。
所以说对于引导页而言,它的实现其实就是:**页面样式** 的实现。
我们只需要可以做到:
1. 高亮某一块指定的样式
2. 在高亮的样式处通过文本展示内容
3. 用户可以进行下一次高亮或者关闭事件
那么就可以实现对应的引导功能。
**方案:**
对于引导页来说,市面上有很多现成的轮子,所以我们不需要手动的去进行以上内容的处理,我们这里可以直接使用 [driver.js](https://kamranahmed.info/driver.js/) 进行引导页处理。
基于 [driver.js](https://kamranahmed.info/driver.js/) 我们的实现方案如下:
1. 创建 `Guide` 组件:用于处理 `icon` 展示
2. 初始化 [driver.js](https://kamranahmed.info/driver.js/)
3. 指定 [driver.js](https://kamranahmed.info/driver.js/) 的 `steps`
## 5-40:方案落地:生成 Guide
1. 创建 `components/Guide`
```vue
<template>
<div>
<el-tooltip :content="$t('msg.navBar.guide')">
<svg-icon icon="guide" />
</el-tooltip>
</div>
</template>
<script setup></script>
<style scoped></style>
```
2. 在 `navbar` 中导入该组件
```vue
<guide class="right-menu-item hover-effect" />
import Guide from '@/components/Guide'
```
## 5-41:方案落地:Guide 业务逻辑处理
1. 导入 [driver.js](https://kamranahmed.info/driver.js/)
```
npm i driver.js@0.9.8
```
2. 在 `guide.vue` 中初始化 `driiver`
```vue
<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>
```
3. 创建 **步骤** `steps.js`
```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
```
4. 在 `guide` 中导入“步骤”
```vue
<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>
```
5. 为 **引导高亮区域增加 ID**
6. 在 `components/Guide/index` 中增加
```html
<svg-icon id="guide-start" icon="guide" @click="onClick" />
```
7. 在 `components/Hamburger/index` 增加
```html
<svg-icon id="guide-hamburger" class="hamburger" :icon="icon"></svg-icon>
```
8. 在 `src/layout/components` 增加
```html
<breadcrumb id="guide-breadcrumb" class="breadcrumb-container" />
```
9. 在 `components/HeaderSearch/index` 增加
```html
<svg-icon
id="guide-search"
class-name="search-icon"
icon="search"
@click.stop="onShowClick"
/>
```
10. 在 `components/Screenfull/index` 增加
```html
<svg-icon
id="guide-full"
:icon="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
@click="onToggle"
/>
```
11. 在 `components/ThemePicker/index` 增加
```html
<svg-icon id="guide-theme" icon="change-theme" />
```
12. 在 `components/LangSelect/index` 增加
```html
<svg-icon id="guide-lang" icon="language" />
```
13. 在 `layout/index` 增加
```html
<tags-view id="guide-tags"></tags-view>
```
14. 在 `layout/index` 增加
```html
<sidebar
id="guide-sidebar"
class="sidebar-container"
:style="{ backgroundColor: $store.getters.cssVar.menuBg }"
/>
```
## 5-42:总结
那么到这里我们整个的 **后台项目前端综合解决方案之通用功能开发** 这一章节就算是处理完成了。
在本章中我们对以下通用功能进行了处理:
1. 国际化
2. 动态换肤
3. `screenfull`
4. `headerSearch`
5. `tagView`
6. `guide`
其中除了 `screenfull` 和 `guide` 之外其他的功能都是具备一定的复杂度的。
但是只要我们可以根据功能分析出对应原理,就可以根据原理实现对应方案,有了方案就可以制定出对应的实现步骤。
只要大的步骤没有错误,那么具体的细节功能实现只需要具体情况具体分析即可。
不过大家要注意,对于这些实现方案而言,**并非** 只有我们课程中的这一种实现方式。大家也可以针对这些实现方案在咱们的 **群里** 或者 **讨论区** 中,和我们一起多多发言或者讨论。
五、导入导出、打印等
# 第七章:权限架构处理之用户权限处理
## 7-01:开篇
在处理完成了 **个人中心**之后, 那么接下来我们就需要来处理 **用户** 相关的模块了
整个用户相关的模块分为三部分:
1. 员工管理
2. 角色列表
3. 权限列表
这三部分的内容我们会分成两个大章来进行处理。
那么这一大章我们要来处理的就是 **员工管理** 模块的内容,整个 **员工管理** 模块可以分为以下功能:
1. 用户列表分页展示
2. `excel` 导入用户
3. 用户列表导出为 `excel`
4. 用户详情的表格展示
5. 用户详情表格打印
6. 用户删除
7. 用户角色分配(需要在完成角色列表之后处理)
那么明确好了这样的内容之后,接下来我们就进入到 **员工管理** 模块的开发之中
## 7-02:用户列表分页展示
首先我们先来处理最基础的 **用户列表分页展示** 功能,整个功能大体可以分为两步:
1. 获取分页数据
2. 利用 [el-table](https://element-plus.org/zh-CN/component/table.html) 和 [el-pagination](https://element-plus.org/zh-CN/component/pagination.html) 渲染数据
那么下面我们就根据这个步骤进行一个实现即可:
1. 创建 `api/user-manage` 文件,用于定义接口
```js
import request from '@/utils/request'
/**
* 获取用户列表数据
*/
export const getUserManageList = data => {
return request({
url: '/user-manage/list',
params: data
})
}
```
2. 在 `user-manage` 中获取对应数据
```vue
<script setup>
import { ref } from 'vue'
import { getUserManageList } from '@/api/user-manage'
import { watchSwitchLang } from '@/utils/i18n'
// 数据相关
const tableData = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(2)
// 获取数据的方法
const getListData = async () => {
const result = await getUserManageList({
page: page.value,
size: size.value
})
tableData.value = result.list
total.value = result.total
}
getListData()
// 监听语言切换
watchSwitchLang(getListData)
</script>
```
3. 根据数据利用 [el-table](https://element-plus.org/zh-CN/component/table.html) 和 [el-pagination](https://element-plus.org/zh-CN/component/pagination.html) 渲染视图
```vue
<template>
<div class="user-manage-container">
<el-card class="header">
<div>
<el-button type="primary"> {{ $t('msg.excel.importExcel') }}</el-button>
<el-button type="success">
{{ $t('msg.excel.exportExcel') }}
</el-button>
</div>
</el-card>
<el-card>
<el-table :data="tableData" border style="width: 100%">
<el-table-column label="#" type="index" />
<el-table-column prop="username" :label="$t('msg.excel.name')">
</el-table-column>
<el-table-column prop="mobile" :label="$t('msg.excel.mobile')">
</el-table-column>
<el-table-column :label="$t('msg.excel.avatar')" align="center">
<template v-slot="{ row }">
<el-image
class="avatar"
:src="row.avatar"
:preview-src-list="[row.avatar]"
></el-image>
</template>
</el-table-column>
<el-table-column :label="$t('msg.excel.role')">
<template #default="{ row }">
<div v-if="row.role && row.role.length > 0">
<el-tag v-for="item in row.role" :key="item.id" size="mini">{{
item.title
}}</el-tag>
</div>
<div v-else>
<el-tag size="mini">{{ $t('msg.excel.defaultRole') }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="openTime" :label="$t('msg.excel.openTime')">
</el-table-column>
<el-table-column
:label="$t('msg.excel.action')"
fixed="right"
width="260"
>
<template #default>
<el-button type="primary" size="mini">{{
$t('msg.excel.show')
}}</el-button>
<el-button type="info" size="mini">{{
$t('msg.excel.showRole')
}}</el-button>
<el-button type="danger" size="mini">{{
$t('msg.excel.remove')
}}</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="page"
:page-sizes="[2, 5, 10, 20]"
:page-size="size"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
>
</el-pagination>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { getUserManageList } from '@/api/user-manage'
import { watchSwitchLang } from '@/utils/i18n'
// 数据相关
const tableData = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(2)
// 获取数据的方法
const getListData = async () => {
const result = await getUserManageList({
page: page.value,
size: size.value
})
tableData.value = result.list
total.value = result.total
}
getListData()
// 监听语言切换
watchSwitchLang(getListData)
// 分页相关
/**
* size 改变触发
*/
const handleSizeChange = currentSize => {
size.value = currentSize
getListData()
}
/**
* 页码改变触发
*/
const handleCurrentChange = currentPage => {
page.value = currentPage
getListData()
}
</script>
<style lang="scss" scoped>
.user-manage-container {
.header {
margin-bottom: 22px;
text-align: right;
}
::v-deep .avatar {
width: 60px;
height: 60px;
border-radius: 50%;
}
::v-deep .el-tag {
margin-right: 6px;
}
.pagination {
margin-top: 20px;
text-align: center;
}
}
</style>
```
## 7-03:全局属性处理时间展示问题
在 `Vue3`中取消了 [过滤器的概念](https://v3.cn.vuejs.org/guide/migration/filters.html),其中:
1. 局部过滤器被完全删除
2. 全局过滤器虽然被移除,但是可以使用 [全局属性](https://v3.cn.vuejs.org/api/application-config.html#globalproperties) 进行替代
那么在列表中的时间处理部分,在 `vue2` 时代通常我们都是通过 **全局过滤器** 来进行实现的,所以在 `vue3` 中我们就顺理成章的通过 [全局属性](https://v3.cn.vuejs.org/api/application-config.html#globalproperties) 替代实现
1. 时间处理部分我们通过 [Day.js](https://day.js.org/) 进行处理
2. 下载 [Day.js](https://day.js.org/)
```
npm i dayjs@1.10.6
```
3. 创建 `src/filter` 文件夹,用于定义 [全局属性](https://v3.cn.vuejs.org/api/application-config.html#globalproperties)
```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
}
}
```
4. 在 `main.js` 中导入
```js
// filter
import installFilter from '@/filters'
installFilter(app)
```
5. 在 `user-manage` 中使用全局属性处理时间解析
```html
<el-table-column :label="$t('msg.excel.openTime')">
<template #default="{ row }">
{{ $filters.dateFilter(row.openTime) }}
</template>
</el-table-column>
```
## 7-04:excel 导入原理与实现分析
在处理完成这些基础的内容展示之后,接下来我们来看 **excel 导入** 功能
对于 **excel 导入** 首先我们先来明确一下它的业务流程:
1. 点击 **excel 导入** 按钮进入 **excel 导入页面**
2. 页面提供了两种导入形式
1. 点击按钮上传 `excel`
2. 把 `excel` 拖入指定区域
3. 选中文件,进行两步操作
1. 解析 `excel` 数据
2. 上传解析之后的数据
4. 上传成功之后,返回 **员工管理(用户列表)** 页面,进行数据展示
所以根据这个业务我们可以看出,整个 `excel` 导入核心的原理部分在于 **选中文件之后,上传成功之前** 的操作,即:
1. 解析 `excel` 数据(**最重要**)
2. 上传解析之后的数据
对于解析部分,我们回头再去详细说明,在这里我们只需要明确大的实现流程即可。
根据上面所说,整个的实现流程我们也可以很轻松得出:
1. 创建 `excel` 导入页面
2. 点击 `excel` 导入按钮,进入该页面
3. 该页面提供两种文件导入形式
4. 选中文件之后,解析 `excel` 数据(核心)
5. 上传解析之后的数据
6. 返回 员工管理(用户列表) 页面
那么明确好了这样的流程之后,接下来我们就可以实现对应的代码了。
## 7-05:业务落地:提供两种文件导入形式
`excel` 页面我们在之前已经创建过了,就是 `views/import/index` 。
所以此处,我们只需要在按钮处完成页面跳转即可,在 `user-manage` 中:
```js
<el-button type="primary" @click="onImportExcelClick">
{{ $t('msg.excel.importExcel') }}</el-button
>
const router = useRouter()
/**
* excel 导入点击事件
*/
const onImportExcelClick = () => {
router.push('/user/import')
}
```
这样我们就已经完成了前面两步,那么接下来我们就来实现 **提供两种文件导入形式**
1. 创建 `components/UploadExcel` 组件,用于处理上传 `excel` 相关的问题
2. 在 `import` 中导入该组件
```vue
<template>
<upload-excel></upload-excel>
</template>
<script setup>
import UploadExcel from '@/components/UploadExcel'
</script>
```
3. 整个 `UploadExcel` 组件的内容可以分成两部分:
1. 样式
2. 逻辑
4. 那么首先我们先处理样式内容
```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>
<input
ref="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 -->
<div
class="drop"
@drop.stop.prevent="handleDrop"
@dragover.stop.prevent="handleDragover"
@dragenter.stop.prevent="handleDragover"
>
<i class="el-icon-upload" />
<span>{{ $t('msg.uploadExcel.drop') }}</span>
</div>
</div>
</template>
<script setup>
import {} from 'vue'
</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;
i {
font-size: 60px;
display: block;
}
}
}
</style>
```
## 7-06:业务落地:文件选择之后的数据解析处理
那么接下来我们来处理整个业务中最核心的一块内容 **选中文件之后,解析 `excel` 数据**
解析的方式根据我们的导入形式的不同也可以分为两种:
1. 文件选择(选择隐藏域)导入
2. 文件拖拽导入
那么这一小节,我们先来处理第一种。
处理之前我们需要先来做一件事情:
1. 解析 `excel` 数据我们需要使用 [xlsx](https://www.npmjs.com/package/xlsx) ,所以我们需要先下载它
```
npm i xlsx@0.17.0
```
[xlsx](https://www.npmjs.com/package/xlsx) 安装完成之后,接下来我们就可以来去实现对应代码了:
```vue
<script setup>
import XLSX from 'xlsx'
import { defineProps, ref } from 'vue'
import { getHeaderRow } 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.files
const rawFile = files[0] // only use files[0]
if (!rawFile) return
upload(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 = true
return new Promise((resolve, reject) => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader
const reader = new FileReader()
// 该事件在读取操作完成时触发
// https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader/onload
reader.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)
}
</script>
```
`getHeaderRow` 为 `xlsx` 解析表头数据的通用方法,直接使用即可
```js
import XLSX from 'xlsx'
/**
* 获取表头(通用方式)
*/
export const getHeaderRow = sheet => {
const headers = []
const range = XLSX.utils.decode_range(sheet['!ref'])
let C
const 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 default
if (cell && cell.t) hdr = XLSX.utils.format_cell(cell)
headers.push(hdr)
}
return headers
}
```
在 `import` 组件中传入 `onSuccess` 事件,获取解析成功之后的 `excel` 数据
```vue
<template>
<upload-excel :onSuccess="onSuccess"></upload-excel>
</template>
<script setup>
import UploadExcel from '@/components/UploadExcel'
/**
* 数据解析成功之后的回调
*/
const onSuccess = excelData => {
console.log(excelData)
}
</script>、
```
## 7-07:业务落地:文件拖入之后的数据解析处理
想要了解 **文件拖入**,那么我们就必须要先能够了解 [HTML_Drag_and_Drop(HTML 拖放 API)](https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API) 事件,我们这里主要使用到其中三个事件:
1. [drop (en-US)](https://developer.mozilla.org/en-US/docs/Web/API/Document/drop_event):当元素或选中的文本在可释放目标上被释放时触发
2. [dragover (en-US)](https://developer.mozilla.org/en-US/docs/Web/API/Document/dragover_event):当元素或选中的文本被拖到一个可释放目标上时触发
3. [dragenter (en-US)](https://developer.mozilla.org/en-US/docs/Web/API/Document/dragenter_event):当拖拽元素或选中的文本到一个可释放目标时触发
那么明确好了这三个事件之后,我们就可以实现对应的拖入代码逻辑了
```vue
<script setup>
...
import { getHeaderRow, isExcel } from './utils'
import { ElMessage } from 'element-plus'
...
/**
* 拖拽文本释放时触发
*/
const handleDrop = e => {
// 上传中跳过
if (loading.value) return
const files = e.dataTransfer.files
if (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>
```
在 `utils` 中生成 `isExcel` 方法
```js
export const isExcel = file => {
return /\.(xlsx|xls|csv)$/.test(file.name)
}
```
## 7-08:业务落地:传递解析后的 excel 数据
那么到现在我们已经处理好了 `excel` 的数据解析操作。
接下来就可以实现对应的数据上传,完成 `excel` 导入功能了
1. 定义 `api/user-manage` 上传接口
```js
/**
* 批量导入
*/
export const userBatchImport = (data) => {
return request({
url: '/user-manage/batch/import',
method: 'POST',
data
})
}
```
2. 在 `onSuccess` 中调用接口上传数据,但是此处大家要注意两点内容:
1. `header` 头不需要上传
2. `results` 中 `key` 为中文,我们必须要按照接口要求进行上传
3. 所以我们需要处理 `results` 中的数据结构
4. 创建 `import/utils` 文件
```js
/**
* 导入数据对应表
*/
export const USER_RELATIONS = {
姓名: 'username',
联系方式: 'mobile',
角色: 'role',
开通时间: 'openTime'
}
```
5. 创建数据解析方法,生成新数组
```js
/**
* 筛选数据
*/
const generateData = results => {
const arr = []
results.forEach(item => {
const userInfo = {}
Object.keys(item).forEach(key => {
userInfo[USER_RELATIONS[key]] = item[key]
})
arr.push(userInfo)
})
return arr
}
```
6. 完成数据上传即可
```js
/**
* 数据解析成功之后的回调
*/
const onSuccess = async ({ header, results }) => {
const updateData = generateData(results)
await userBatchImport(updateData)
ElMessage.success({
message: results.length + i18n.t('msg.excel.importSuccess'),
type: 'success'
})
router.push('/user/manage')
}
```
## 7-09:业务落地:处理剩余 bug
截止到目前整个 `excel` 上传我们就已经处理完成了,只不过目前还存在两个小 bug 需要处理:
1. 上传之后的时间解析错误
2. 返回用户列表之后,数据不会自动刷新
那么这一小节我们就针对这两个问题进行分别处理
**上传之后的时间解析错误:**
导致该问题出现的原因是因为 **excel 导入解析时间会出现错误,** 处理的方案也很简单,是一个固定方案,我们只需要进行固定的时间解析处理即可:
1. 在 `import/utils` 中新增事件处理方法(固定方式直接使用即可)
```js
/**
* 解析 excel 导入的时间格式
*/
export const formatDate = (numb) => {
const time = new Date((numb - 1) * 24 * 3600000 + 1)
time.setYear(time.getFullYear() - 70)
const year = time.getFullYear() + ''
const month = time.getMonth() + 1 + ''
const date = time.getDate() - 1 + ''
return (
year +
'-' +
(month < 10 ? '0' + month : month) +
'-' +
(date < 10 ? '0' + date : date)
)
}
```
2. 在 `generateData` 中针对 `openTime` 进行单独处理
```js
/**
* 筛选数据
*/
const generateData = results => {
...
Object.keys(item).forEach(key => {
if (USER_RELATIONS[key] === 'openTime') {
userInfo[USER_RELATIONS[key]] = formatDate(item[key])
return
}
userInfo[USER_RELATIONS[key]] = item[key]
})
...
})
return arr
}
```
**返回用户列表之后,数据不会自动刷新:**
出现该问题的原因是因为:**`appmain` 中使用 `keepAlive` 进行了组件缓存**。
解决的方案也很简单,只需要:**监听 [onActivated](https://v3.cn.vuejs.org/api/options-lifecycle-hooks.html#activated) 事件,重新获取数据即可**
在 `user-manage` 中:
```js
import { ref, onActivated } from 'vue'
// 处理导入用户后数据不重新加载的问题
onActivated(getListData)
```
## 7-10:excel 导入功能总结
那么到这里我们的 `excel` 导入功能我们就已经实现完成了,再来回顾一下我们整体的流程:
1. 创建 `excel` 导入页面
2. 点击 `excel` 导入按钮,进入该页面
3. 该页面提供两种文件导入形式
4. 选中文件之后,解析 `excel` 数据(核心)
5. 上传解析之后的数据
6. 返回 员工管理(用户列表) 页面
游离于这些流程之外的,还包括额外的两个小 bug 的处理,特别是 **`excel` 的时间格式问题,** 大家要格外注意,因为这是一个必然会出现的错误,当然处理方案也是固定的。
## 7-11:辅助业务之用户删除
完成了 `excel` 的用户导入之后,那么我们肯定会产生很多的无用数据,所以说接下来我们来完成一个辅助功能:**删除用户(希望大家都可以在完成 `excel` 导入功能之后,删除掉无用数据,以方便其他的同学进行功能测试)**
删除用户的功能比较简单,我们只需要 **调用对应的接口即可**
1. 在 `api/user-manage` 中指定删除接口
```js
/**
* 删除指定数据
*/
export const deleteUser = (id) => {
return request({
url: `/user-manage/detele/${id}`
})
}
```
2. 在 `views/user-manage` 中调用删除接口接口
```html
<el-button type="danger" size="mini" @click="onRemoveClick(row)">{{
$t('msg.excel.remove')
}}</el-button>
```
```js
/**
* 删除按钮点击事件
*/
const i18n = useI18n()
const onRemoveClick = row => {
ElMessageBox.confirm(
i18n.t('msg.excel.dialogTitle1') +
row.username +
i18n.t('msg.excel.dialogTitle2'),
{
type: 'warning'
}
).then(async () => {
await deleteUser(row._id)
ElMessage.success(i18n.t('msg.excel.removeSuccess'))
// 重新渲染数据
getListData()
})
}
```
## 7-12:excel 导出原理与实现分析
对于 `excel` 导出而言我们还是先来分析一下它的业务逻辑:
1. 点击 `excel` 导出按钮
2. 展示 `dialog` 弹出层
3. 确定导出的 `excel` 文件名称
4. 点击导出按钮
5. 获取 **所有用户列表数据**
6. 将 `json` 结构数据转化为 `excel` 数据,并下载
有了 `excel` 导入的经验之后,再来看这样的一套业务逻辑,相信大家应该可以直接根据这样的一套业务逻辑得出 `excel` 导出的核心原理了:**将 `json` 结构数据转化为 `excel` 数据,并下载**
那么我们对应的实现方案也可以直接得出了:
1. 创建 `excel` 导出弹出层
2. 处理弹出层相关的业务
3. 点击导出按钮,将 `json` 结构数据转化为 `excel` 数据,并下载(核心)
## 7-13:业务落地:Export2Excel 组件
那么首先我们先去创建 `excel` 弹出层组件 `Export2Excel `
1. 创建 `views/user-manage/components/Export2Excel `
```vue
<template>
<el-dialog
:title="$t('msg.excel.title')"
:model-value="modelValue"
@close="closed"
width="30%"
>
<el-input
:placeholder="$t('msg.excel.placeholder')"
></el-input>
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{ $t('msg.excel.close') }}</el-button>
<el-button type="primary" @click="onConfirm">{{
$t('msg.excel.confirm')
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
/**
* 导出按钮点击事件
*/
const onConfirm = async () => {
closed()
}
/**
* 关闭
*/
const closed = () => {
emits('update:modelValue', false)
}
</script>
```
2. 在 `user-manage` 中进行导入 `dialog` 组件
1. 指定 `excel`按钮 点击事件
```html
<el-button type="success" @click="onToExcelClick">
{{ $t('msg.excel.exportExcel') }}
</el-button>
```
2. 导入 `ExportToExcel` 组件
```vue
<export-to-excel v-model="exportToExcelVisible"></export-to-excel>
import ExportToExcel from './components/Export2Excel.vue'
```
3. 点击事件处理函数
```js
/**
* excel 导出点击事件
*/
const exportToExcelVisible = ref(false)
const onToExcelClick = () => {
exportToExcelVisible.value = true
}
```
## 7-14:业务落地:导出前置业务处理
那么这一小节我们来处理一些实现 `excel` 导出时的前置任务,具体有:
1. 指定 `input` 默认导出文件名称
2. 定义 **获取全部用户** 列表接口,并调用
那么下面我们先来处理第一步:**指定 `input` 默认导出文件名称**
1. 指定 `input` 的双向绑定
```html
<el-input
v-model="excelName"
:placeholder="$t('msg.excel.placeholder')"
></el-input>
```
2. 指定默认文件名
```js
const i18n = useI18n()
let exportDefaultName = i18n.t('msg.excel.defaultName')
const excelName = ref('')
excelName.value = exportDefaultName
watchSwitchLang(() => {
exportDefaultName = i18n.t('msg.excel.defaultName')
excelName.value = exportDefaultName
})
```
**定义获取全部用户列表接口,并调用:**
1. 在 `user-manage` 中定义获取全部数据接口
```js
/**
* 获取所有用户列表数据
*/
export const getUserManageAllList = () => {
return request({
url: '/user-manage/all-list'
})
}
```
2. 调用接口数据,并指定 `loading`
```html
<el-button type="primary" @click="onConfirm" :loading="loading">{{
$t('msg.excel.confirm')
}}</el-button>
```
```js
import { getUserManageAllList } from '@/api/user-manage'
/**
* 导出按钮点击事件
*/
const loading = ref(false)
const onConfirm = async () => {
loading.value = true
const allUser = (await getUserManageAllList()).list
closed()
}
/**
* 关闭
*/
const closed = () => {
loading.value = false
emits('update:modelValue', false)
}
```
## 7-15:业务落地:实现 excel 导出逻辑
那么万事俱备,到此时我们就可以来实现整个业务逻辑的最后步骤:
1. 将 `json` 结构数据转化为 `excel` 数据
2. 下载对应的 `excel` 数据
对于这两步的逻辑而言,最复杂的莫过于 **将 `json` 结构数据转化为 `excel` 数据** 这一步的功能,不过万幸的是对于该操作的逻辑是 **通用处理逻辑**,搜索 **Export2Excel** 我们可以得到巨多的解决方案,所以此处我们 **没有必要** 手写对应的转换逻辑
该转化逻辑我已经把它放置到 `课程资料/Export2Excel.js` 文件中,大家可以直接把该代码复制到 `utils` 文件夹下
> PS:如果大家想要了解该代码的话,那么对应的业务逻辑我们也已经全部标出,大家可以直接查看
那么有了 `Export2Excel.js` 的代码之后 ,接下来我们还需要导入两个依赖库:
1. [xlsx](https://www.npmjs.com/package/xlsx) (已下载):`excel` 解析器和编译器
2. [file-saver](https://www.npmjs.com/package/file-saver):文件下载工具,通过 `npm i file-saver@2.0.5` 下载
那么一切准备就绪,我们去实现 `excel` 导出功能:
1. 动态导入 `Export2Excel.js`
```js
// 导入工具包
const excel = await import('@/utils/Export2Excel')
```
2. 因为从服务端获取到的为 `json 数组对象` 结构,但是导出时的数据需要为 **二维数组**,所以我们需要有一个方法来把 **`json` 结构转化为 二维数组**
3. 创建转化方法
1. 创建 `views/user-manage/components/Export2ExcelConstants.js` 中英文对照表
```js
/**
* 导入数据对应表
*/
export const USER_RELATIONS = {
姓名: 'username',
联系方式: 'mobile',
角色: 'role',
开通时间: 'openTime'
}
```
2. 创建数据解析方法
```js
// 该方法负责将数组转化成二维数组
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]]
})
})
}
```
4. 调用该方法,获取导出的二维数组数据
```js
import { USER_RELATIONS } from './Export2ExcelConstants'
const data = formatJson(USER_RELATIONS, allUser)
```
5. 调用 `export_json_to_excel` 方法,完成 `excel` 导出
```js
excel.export_json_to_excel({
// excel 表头
header: Object.keys(USER_RELATIONS),
// excel 数据(二维数组结构)
data,
// 文件名称
filename: excelName.value || exportDefaultName,
// 是否自动列宽
autoWidth: true,
// 文件类型
bookType: 'xlsx'
})
```
## 7-16:业务落地:excel 导出时的时间逻辑处理
因为服务端返回的 `openTime` 格式问题,所以我们需要在 `excel` 导出时对时间格式进行单独处理
2. 导入时间格式处理工具
```js
import { dateFormat } from '@/filters'
```
3. 对时间格式进行单独处理
```js
// 时间特殊处理
if (headers[key] === 'openTime') {
return dateFormat(item[headers[key]])
}
```
## 7-17:excel 导出功能总结
那么到这里我们的整个 `excel` 导出就算是实现完成了。
整个 `excel` 导出遵循以下业务逻辑:
1. 创建 `excel` 导出弹出层
2. 处理弹出层相关的业务
3. 点击导出按钮,将 `json` 结构数据转化为 `excel` 数据
1. `json` 数据转化为 **二维数组**
2. 时间处理
3. 角色数组处理
4. 下载 `excel` 数据
其中 **将 `json` 结构数据转化为 `excel` 数据** 部分因为有通用的实现方式,所以我们没有必要进行手动的代码书写,毕竟 **“程序猿是最懒的群体嘛”**
但是如果大家想要了解一下这个业务逻辑中所进行的事情,我们也对代码进行了完整的备注,大家可以直接进行查看
## 7-18:局部打印详情原理与实现分析
那么接下来就是我们本章中最后一个功能 **员工详情打印**
整个员工详情的打印逻辑分为两部分:
1. 以表格的形式展示员工详情
2. 打印详情表格
其中 **以表格的形式展示员工详情** 部分我们需要使用到 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件,并且想要利用该组件实现详情的表格效果还需要一些小的技巧,这个具体的我们到时候再去说
而 **打印详情表格** 的功能就是建立在展示详情页面之上的
大家知道,当我们在浏览器右键时,其实可以直接看到对应的 **打印** 选项,但是这个打印选项是直接打印整个页面,不能指定打印页面中的某一部分的。
所以说 **打印是浏览器本身的功能**,但是这个功能存在一定的小缺陷,那就是 **只能打印整个页面**
而我们想要实现 **详情打印**,那么就需要在这个功能的基础之上做到指定打印具体的某一块视图,而这个功能已经有一个第三方的包 [vue-print-nb](https://github.com/Power-kxLee/vue-print-nb#vue3-version) 帮助我们进行了实现,所以我们只需要使用这个包即可完成打印功能
那么明确好了原理之后,接下来步骤就呼之欲出了:
1. 获取员工详情数据
2. 在员工详情页面,渲染详情数据
3. 利用 [vue-print-nb](https://github.com/Power-kxLee/vue-print-nb#vue3-version) 进行局部打印
## 7-19:业务落地:获取展示数据
首先我们来获取对应的展示数据
1. 在 `api/user-manage` 中定义获取用户详情接口
```js
/**
* 获取用户详情
*/
export const userDetail = (id) => {
return request({
url: `/user-manage/detail/${id}`
})
}
```
2. 在 `views/user-info` 中根据 `id` 获取接口详情数据,并进行国际化处理
```vue
<script setup>
import { userDetail } from '@/api/user-manage'
import { watchSwitchLang } from '@/utils/i18n'
import { defineProps, ref } from 'vue'
const props = defineProps({
id: {
type: String,
required: true
}
})
// 数据相关
const detailData = ref({})
const getUserDetail = async () => {
detailData.value = await userDetail(props.id)
}
getUserDetail()
// 语言切换
watchSwitchLang(getUserDetail)
</script>
```
3. 因为用户详情可以会以组件的形式进行呈现,所以对于此处我们需要得到的 `id` ,可以通过 [vue-router Props 传参](https://next.router.vuejs.org/zh/guide/essentials/passing-props.html#%E5%B8%83%E5%B0%94%E6%A8%A1%E5%BC%8F) 的形式进行
4. 指定路由表
```js
{
path: '/user/info/:id',
name: 'userInfo',
component: () => import('@/views/user-info/index'),
props: true,
meta: {
title: 'userInfo'
}
}
```
5. 在 `views/user-manage` 中传递用户 `id`
```vue
<el-button
type="primary"
size="mini"
@click="onShowClick(row._id)"
>
{{ $t('msg.excel.show') }}
</el-button>
/**
* 查看按钮点击事件
*/
const onShowClick = id => {
router.push(`/user/info/${id}`)
}
```
## 7-20:业务落地:渲染详情结构
渲染用户详情结构我们需要借助 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件,只不过使用该组件时我们需要一些小的技巧
因为 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件作用为:渲染描述列表。但是我们想要的包含头像的用户详情样式,直接利用一个 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件并无法进行渲染,所以此时我们需要对多个 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件 与 `img` 标签进行配合使用

如果得出渲染代码
```vue
<template>
<div class="user-info-container">
<el-card class="print-box">
<el-button type="primary">{{ $t('msg.userInfo.print') }}</el-button>
</el-card>
<el-card>
<div class="user-info-box">
<!-- 标题 -->
<h2 class="title">{{ $t('msg.userInfo.title') }}</h2>
<div class="header">
<!-- 头部渲染表格 -->
<el-descriptions :column="2" border>
<el-descriptions-item :label="$t('msg.userInfo.name')">{{
detailData.username
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.sex')">{{
detailData.gender
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.nation')">{{
detailData.nationality
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.mobile')">{{
detailData.mobile
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.province')">{{
detailData.province
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.date')">{{
$filters.dateFilter(detailData.openTime)
}}</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.remark')" :span="2">
<el-tag
class="remark"
size="small"
v-for="(item, index) in detailData.remark"
:key="index"
>{{ item }}</el-tag
>
</el-descriptions-item>
<el-descriptions-item
:label="$t('msg.userInfo.address')"
:span="2"
>{{ detailData.address }}</el-descriptions-item
>
</el-descriptions>
<!-- 头像渲染 -->
<el-image
class="avatar"
:src="detailData.avatar"
:preview-src-list="[detailData.avatar]"
></el-image>
</div>
<div class="body">
<!-- 内容渲染表格 -->
<el-descriptions direction="vertical" :column="1" border>
<el-descriptions-item :label="$t('msg.userInfo.experience')">
<ul>
<li v-for="(item, index) in detailData.experience" :key="index">
<span>
{{ $filters.dateFilter(item.startTime, 'YYYY/MM') }}
----
{{ $filters.dateFilter(item.endTime, 'YYYY/MM') }}</span
>
<span>{{ item.title }}</span>
<span>{{ item.desc }}</span>
</li>
</ul>
</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.major')">
{{ detailData.major }}
</el-descriptions-item>
<el-descriptions-item :label="$t('msg.userInfo.glory')">
{{ detailData.glory }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 尾部签名 -->
<div class="foot">{{ $t('msg.userInfo.foot') }}</div>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.print-box {
margin-bottom: 20px;
text-align: right;
}
.user-info-box {
width: 1024px;
margin: 0 auto;
.title {
text-align: center;
margin-bottom: 18px;
}
.header {
display: flex;
::v-deep .el-descriptions {
flex-grow: 1;
}
.avatar {
width: 187px;
box-sizing: border-box;
padding: 30px 20px;
border: 1px solid #ebeef5;
border-left: none;
}
.remark {
margin-right: 12px;
}
}
.body {
ul {
list-style: none;
li {
span {
margin-right: 62px;
}
}
}
}
.foot {
margin-top: 42px;
text-align: right;
}
}
</style>
```
## 7-21:业务落地:局部打印功能实现
局部详情打印功能我们需要借助 [vue-print-nb](https://github.com/Power-kxLee/vue-print-nb#vue3-version),所以首先我们需要下载该插件
```
npm i vue3-print-nb@0.1.4
```
然后利用该工具完成下载功能:
1. 指定 `printLoading`
```
<el-button type="primary" :loading="printLoading">{{
$t('msg.userInfo.print')
}}</el-button>
// 打印相关
const printLoading = ref(false)
```
2. 创建打印对象
```js
const printObj = {
// 打印区域
id: 'userInfoBox',
// 打印标题
popTitle: 'imooc-vue-element-admin',
// 打印前
beforeOpenCallback(vue) {
printLoading.value = true
},
// 执行打印
openCallback(vue) {
printLoading.value = false
}
}
```
3. 指定打印区域 `id` 匹配
```html
<div id="userInfoBox" class="user-info-box">
```
4. [vue-print-nb](https://github.com/Power-kxLee/vue-print-nb#vue3-version) 以指令的形式存在,所以我们需要创建对应指令
5. 新建 `directives` 文件夹,创建 `index.js`
6. 写入如下代码
```js
import print from 'vue3-print-nb'
export default app => {
app.use(print)
}
```
7. 在 `main.js` 中导入该指令
```js
import installDirective from '@/directives'
installDirective(app)
```
8. 将打印指令挂载到 `el-button` 中
```html
<el-button type="primary" v-print="printObj" :loading="printLoading">{{
$t('msg.userInfo.print')
}}</el-button>
```
## 7-22:局部打印功能总结
整个局部打印详情功能,整体的核心逻辑就是这么两块:
1. 以表格的形式展示员工详情
2. 打印详情表格
其中第一部分使用 [el-descriptions](https://element-plus.org/zh-CN/component/descriptions.html) 组件配合一些小技巧即可实现
而局部打印功能则需要借助 [vue-print-nb](https://github.com/Power-kxLee/vue-print-nb#vue3-version) 这个第三方库进行实现
所以整个局部打印功能应该并不算复杂,掌握这两部分即可轻松实现
## 7-23:总结
那么到这里我们整个章节就全部完成了,最后的 **为用户分配角色** 功能需要配合 **角色列表** 进行实现,所以我们需要等到后面进行
那么整个章节所实现的功能有:
1. 用户列表分页展示
2. `excel` 导入用户
3. 用户列表导出为 `excel`
4. 用户详情的表格展示
5. 用户详情表格打印
6. 用户删除
这么六个
其中比较复杂的应该就是 **`excel` 导入 & 导出** 了,所以针对这两个功能我们花费了最多的篇幅进行讲解
但是这里有一点大家不要忘记,我们在本章开篇的时候说过,**员工管理** 是 **用户权限中的一个前置!** 比如我们的分配角色功能就需要配合其他的业务实现,那么具体的整个用户权限都包含了哪些内容呢?
想要知道快来看下一章节吧!
六、权限
# 第八章:权限受控解决方案之分级分控权限管理
## 8-01:开篇
那么从这一章开始我们就来解决我们的权限控制问题。
本章以权限控制为主,整个章节会分成三部分来去讲解:
1. 权限理论:明确什么是 `RBAC` 权限控制体现
2. 辅助业务:完善 用户、角色、权限 三个页面功能
3. 核心功能:落地实现 `RBAC` 权限控制系统
列举出来这三部分的目的是为了让大家能够对本章的内容有个清楚的认知,那么接下来我们就先来看第一部分 **权限理论**
## 8-02:权限理论:RBAC 权限控制体系
权限控制在开发中一直是一个比较复杂的问题,甚至有很多同学对什么是权限控制还不是很了解。所以我们需要先来统一一下认知,明确项目中的权限控制系统。
在我们当前的项目中,我们可以通过:
1. 员工管理为用户指定角色
2. 通过角色列表为角色指定权限
3. 通过权限列表查看当前项目所有权限
那么换句话而言,以上三条就制定了一个用户由:**用户 -> 角色 -> 权限** 的一个分配关系。
当我们通过角色为某一个用户指定到不同的权限之后,那么该用户就会在 **项目中体会到不同权限的功能**
那么这样的一套关系就是我们的 **RBAC 权限控制体系**,也就是 **基于 角色的权限 控制 用户的访问**
通过以下图片可以很好的说明这种权限控制体系的含义:

## 8-03:辅助业务:角色列表展示
那么明确好了 `RBAC` 的概念之后,接下来我们就可以来去实现我们的辅助业务了,所谓辅助业务具体指的就是:
1. 员工管理(用户列表)
1. 为用户分配角色
2. 角色列表
1. 角色列表展示
2. 为角色分配权限
3. 权限列表
1. 权限列表展示
那么这一小节我们就先来实现其中的 **角色列表展示**
1. 创建 `api/role` 接口文件:
```js
import request from '@/utils/request'
/**
* 获取所有角色
*/
export const roleList = () => {
return request({
url: '/role/list'
})
}
```
2. 在 `views/role-list` 中获取数据
```js
import { roleList } from '@/api/role'
import { watchSwitchLang } from '@/utils/i18n'
import { ref } from 'vue'
const allRoles = ref([])
const getRoleList = async () => {
allRoles.value = await roleList()
}
getRoleList()
watchSwitchLang(getRoleList)
```
3. 通过 [el-table](https://element-plus.org/zh-CN/component/table.html) 进行数据展示
```vue
<template>
<div class="">
<el-card>
<el-table :data="allRoles" border style="width: 100%">
<el-table-column :label="$t('msg.role.index')" type="index" width="120">
</el-table-column>
<el-table-column :label="$t('msg.role.name')" prop="title">
</el-table-column>
<el-table-column :label="$t('msg.role.desc')" prop="describe">
</el-table-column>
<el-table-column
:label="$t('msg.role.action')"
prop="action"
width="260"
>
<el-button type="primary" size="mini">
{{ $t('msg.role.assignPermissions') }}
</el-button>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
```
## 8-04:辅助业务:权限列表展示
1. 创建 `api/permission` 文件
```js
import request from '@/utils/request'
/**
* 获取所有权限
*/
export const permissionList = () => {
return request({
url: '/permission/list'
})
}
```
2. 在 `views/permission-list` 获取数据
```vue
<script setup>
import { permissionList } from '@/api/permission'
import { watchSwitchLang } from '@/utils/i18n'
import { ref } from 'vue'
/**
* 权限分级:
* 1. 一级权限为页面权限
* permissionMark 对应 路由名称
* 2. 二级权限为功能权限
* permissionMark 对应 功能权限表
*/
// 所有权限
const allPermission = ref([])
const getPermissionList = async () => {
allPermission.value = await permissionList()
}
getPermissionList()
watchSwitchLang(getPermissionList)
</script>
```
3. 通过 [el-table](https://element-plus.org/zh-CN/component/table.html) 进行数据展示
```vue
<template>
<div class="">
<el-card>
<el-table
:data="allPermission"
style="width: 100%; margin-bottom: 20px"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column
prop="permissionName"
:label="$t('msg.permission.name')"
width="180"
>
</el-table-column>
<el-table-column
prop="permissionMark"
:label="$t('msg.permission.mark')"
width="180"
>
</el-table-column>
<el-table-column
prop="permissionDesc"
:label="$t('msg.permission.desc')"
>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
```
## 8-05:辅助业务:为用户分配角色
1. 创建为用户分配角色弹出层 `views/user-manage/components/roles`
```vue
<template>
<el-dialog
:title="$t('msg.excel.roleDialogTitle')"
:model-value="modelValue"
@close="closed"
>
内容
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{ $t('msg.universal.cancel') }}</el-button>
<el-button type="primary" @click="onConfirm">{{
$t('msg.universal.confirm')
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
/**
确定按钮点击事件
*/
const onConfirm = async () => {
closed()
}
/**
* 关闭
*/
const closed = () => {
emits('update:modelValue', false)
}
</script>
<style lang="scss" scoped></style>
```
2. 在 `user-manage` 中点击查看,展示弹出层
```vue
<roles-dialog v-model="roleDialogVisible"></roles-dialog>
import RolesDialog from './components/roles.vue'
/**
* 查看角色的点击事件
*/
const roleDialogVisible = ref(false)
const onShowRoleClick = row => {
roleDialogVisible.value = true
}
```
3. 在弹出层中我们需要利用 [el-checkbox](https://element-plus.org/zh-CN/component/checkbox.html) 进行数据展示,此时数据分为两种:
1. 所有角色(已存在)
2. 用户当前角色
4. 所以我们需要先获取对应数据
5. 在 `api/user-manage` 中定义获取用户当前角色接口
```js
/**
* 获取指定用户角色
*/
export const userRoles = (id) => {
return request({
url: `/user-manage/role/${id}`
})
}
```
6. 在 `roles` 组件中获取所有角色数据
```js
import { defineProps, defineEmits, ref } from 'vue'
import { roleList } from '@/api/role'
import { watchSwitchLang } from '@/utils/i18n'
...
// 所有角色
const allRoleList = ref([])
// 获取所有角色数据的方法
const getListData = async () => {
allRoleList.value = await roleList()
}
getListData()
watchSwitchLang(getListData)
// 当前用户角色
const userRoleTitleList = ref([])
```
7. 利用 [el-checkbox](https://element-plus.org/zh-CN/component/checkbox.html) 渲染所有角色
```html
<el-checkbox-group v-model="userRoleTitleList">
<el-checkbox
v-for="item in allRoleList"
:key="item.id"
:label="item.title"
></el-checkbox>
</el-checkbox-group>
```
8. 接下来渲染选中项,即:用户当前角色
9. 调用 `userRoles` 接口需要 **当前用户 ID**,所以我们需要定义对应的 `props`
```vue
const props = defineProps({
...
userId: {
type: String,
required: true
}
})
```
10. 接下来我们可以根据 `userId` 获取数据,但是这里大家要注意:**因为该 `userId` 需要在 `user-manage` 用户点击之后获取当前点击行的 `id`。所以在 `roles` 组件的初始状态下,获取到的 `userId` 为 `null` 。** 因此我们想要根据 `userId` 获取用户当前角色数据,我们需要 `watch userId` 在 `userId` 有值的前提下,获取数据
```js
// 当前用户角色
const userRoleTitleList = ref([])
// 获取当前用户角色
const getUserRoles = async () => {
const res = await userRoles(props.userId)
userRoleTitleList.value = res.role.map(item => item.title)
}
watch(
() => props.userId,
val => {
if (val) getUserRoles()
}
)
```
11. 在 `user-manage` 中传递数据
```vue
<roles-dialog
v-model="roleDialogVisible"
:userId="selectUserId"
></roles-dialog>
const selectUserId = ref('')
const onShowRoleClick = row => {
selectUserId.value = row._id
}
```
12. 在 `dialog` 关闭时重置 `selectUserId`
```js
// 保证每次打开重新获取用户角色数据
watch(roleDialogVisible, val => {
if (!val) selectUserId.value = ''
})
```
13. 在 `api/user-manage` 中定义分配角色接口
```js
/**
* 分用户分配角色
*/
export const updateRole = (id, roles) => {
return request({
url: `/user-manage/update-role/${id}`,
method: 'POST',
data: {
roles
}
})
}
```
14. 点击确定调用接口
```js
/**
确定按钮点击事件
*/
const i18n = useI18n()
const onConfirm = async () => {
// 处理数据结构
const roles = userRoleTitleList.value.map(title => {
return allRoleList.value.find(role => role.title === title)
})
await updateRole(props.userId, roles)
ElMessage.success(i18n.t('msg.role.updateRoleSuccess'))
closed()
}
```
15. 修改成功后,发送事件
```js
const emits = defineEmits(['update:modelValue', 'updateRole'])
const onConfirm = async () => {
...
// 角色更新成功
emits('updateRole')
}
```
16. 在 `user-manage` 中监听角色更新成功事件,重新获取数据
```html
<roles-dialog
v-model="roleDialogVisible"
:userId="selectUserId"
@updateRole="getListData"
></roles-dialog>
```
## 8-06:辅助业务:为角色指定权限
为角色指定权限通过 **弹出层中的 [树形控件](https://element-plus.org/zh-CN/component/tree.html) 处理**,整体的流程与上一小节相差无几。
1. 创建 为角色指定权限弹出层
```vue
<template>
<el-dialog
:title="$t('msg.excel.roleDialogTitle')"
:model-value="modelValue"
@close="closed"
>
内容
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{ $t('msg.universal.cancel') }}</el-button>
<el-button type="primary" @click="onConfirm">{{
$t('msg.universal.confirm')
}}</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true
}
})
const emits = defineEmits(['update:modelValue'])
/**
确定按钮点击事件
*/
const onConfirm = async () => {
closed()
}
/**
* 关闭
*/
const closed = () => {
emits('update:modelValue', false)
}
</script>
```
2. 在 `roles-list` 中点击查看,展示弹出层
```vue
<template>
<div class="">
<el-card>
<el-table :data="allRoles" border style="width: 100%">
...
<el-table-column
...
#default="{ row }"
>
<el-button
type="primary"
size="mini"
@click="onDistributePermissionClick(row)"
>
{{ $t('msg.role.assignPermissions') }}
</el-button>
</el-table-column>
</el-table>
</el-card>
<distribute-permission
v-model="distributePermissionVisible"
></distribute-permission>
</div>
</template>
<script setup>
...
import DistributePermission from './components/DistributePermission.vue'
...
/**
* 分配权限
*/
const distributePermissionVisible = ref(false)
const onDistributePermissionClick = row => {
distributePermissionVisible.value = true
}
</script>
```
3. 在弹出层中我们需要利用 [el-tree](https://element-plus.org/zh-CN/component/tree.html) 进行数据展示,此时数据分为两种:
1. 所有权限(已存在)
2. 角色对应的权限
4. 所以我们需要先获取对应数据
5. 在 `api/role` 中定义获取角色当前权限
```js
/**
* 获取指定角色的权限
*/
export const rolePermission = (roleId) => {
return request({
url: `/role/permission/${roleId}`
})
}
```
6. 在 `DistributePermission` 组件中获取所有权限数据
```vue
<script setup>
import { defineProps, defineEmits, ref } from 'vue'
import { permissionList } from '@/api/permission'
import { watchSwitchLang } from '@/utils/i18n'
...
// 所有权限
const allPermission = ref([])
const getPermissionList = async () => {
allPermission.value = await permissionList()
}
getPermissionList()
watchSwitchLang(getPermissionList)
...
</script>
```
7. 使用 [el-tree](https://element-plus.org/zh-CN/component/tree.html) 渲染权限数据
```vue
<template>
...
<el-tree
ref="treeRef"
:data="allPermission"
show-checkbox
check-strictly
node-key="id"
default-expand-all
:props="defaultProps"
>
</el-tree>
...
</template>
<script setup>
...
// 属性结构配置
const defaultProps = {
children: 'children',
label: 'permissionName'
}
...
</script>
```
8. 接下来渲染选中项,即:角色当前权限
9. 调用 `rolePermission` 接口需要 **当前角色 ID**,所以我们需要定义对应的 `props`
```js
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
roleId: {
type: String,
required: true
}
})
```
10. 在 `role-list` 中传递角色ID
```vue
<distribute-permission
v-model="distributePermissionVisible"
:roleId="selectRoleId"
></distribute-permission>
/**
* 分配权限
*/
const selectRoleId = ref('')
const onDistributePermissionClick = row => {
selectRoleId.value = row.id
}
```
11. 调用 `rolePermission` 接口获取数据
```js
import { rolePermission } from '@/api/role'
// 获取当前用户角色的权限
const getRolePermission = async () => {
const checkedKeys = await rolePermission(props.roleId)
console.log(checkedKeys)
}
watch(
() => props.roleId,
val => {
if (val) getRolePermission()
}
)
```
12. 根据获取到的数据渲染选中的 `tree`
```js
// tree 节点
const treeRef = ref(null)
// 获取当前用户角色的权限
const getRolePermission = async () => {
const checkedKeys = await rolePermission(props.roleId)
treeRef.value.setCheckedKeys(checkedKeys)
}
```
13. 在 `api/role` 中定义分配权限接口
```js
/**
* 为角色修改权限
*/
export const distributePermission = (data) => {
return request({
url: '/role/distribute-permission',
method: 'POST',
data
})
}
```
14. 点击确定调用接口
```js
import { rolePermission, distributePermission } from '@/api/role'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
/**
确定按钮点击事件
*/
const i18n = useI18n()
const onConfirm = async () => {
await distributePermission({
roleId: props.roleId,
permissions: treeRef.value.getCheckedKeys()
})
ElMessage.success(i18n.t('msg.role.updateRoleSuccess'))
closed()
}
```
## 8-07:基于 RBAC 的权限控制体系原理与实现分析
那么接下来就进入我们本章中的核心内容 **基于 RBAC 的权限控制** ,在之前我们的 **权限理论** 这一小节的时候说过 `RBAC` 是基于 **用户 -> 角色 -> 权限** 的 **基于 角色的权限 控制 用户的访问** 的体系。
在这套体系中,最基层的就是 **权限部分** 。那么这个权限部分在我们的项目中具体的呈现是什么呢?那么下面我们就来看一下:
1. 我们可以先为 **员工角色** 指定 **空权限**
2. 然后为我们的 **测试用户** 指定指定 **员工角色**
3. 此时我们重新登录 **测试用户**
4. 可以发现左侧菜单中仅存在 **个人中心** 页面
5. 然后我们重新登录 **超级管理员** 账号
6. 为 **员工角色** 指定 **员工管理 && 分配角色** 权限
7. 然后为我们的 **测试用户** 指定指定 **员工角色**
8. 此时我们重新登录 **测试用户**
9. 可以发现左侧菜单中多出 **员工管理** 页面,并且页面中仅存在指定的 **分配角色** 功能
以上就是我们权限系统中的具体呈现。
那么由此呈现我们可以看出,整个权限系统其实分成了两部分:
1. 页面权限:比如 员工管理
2. 功能权限:比如 分配角色
其中 **页面权限** 表示:当前用户可以访问的页面
**功能权限** 表示:当前用户可以访问的权限功能(PS:并非所有功能有需要权限)
那么明确好了以上内容之后,接下来我们来看下,以上功能如何进行实现呢?
首先我们先来看 **页面权限:**
所谓页面权限包含两部分内容:
1. 用户可看到的:左侧 `menu` 菜单的 `item` 展示
2. 用户看不到的:路由表配置
我们知道 **左侧 `menu` 菜单是根据路由表自动生成的。** 所以以上第一部分的内容其实就是由第二部分引起的。
那么我们就可以来看一下 **路由表配置了**。
不知道大家还记不记得,之前我们设置路由表的时候,把路由表分成了两部分:
1. 私有路由表 `privateRoutes`:依据权限进行动态配置的
2. 公开路由表 `publicRoutes`:无权限要求的
那么想要实现我们的 **页面权限** 核心的点就是在我们的 **私有路由表 `privateRoutes`**
那么在 **私有路由表 `privateRoutes`** 这里我们能做什么呢?
时刻记住我们最终的目的,我们期望的是:**不同的权限进入系统可以看到不同的路由** 。那么换句话而言是不是就是:**根据不同的权限数据,生成不同的私有路由表?**
对于 `vue-router 4` 而言,提供了 [addRoute API](https://next.router.vuejs.org/zh/api/#addroute) ,可以 **动态添加路由到路由表中**,那么我们就可以利用这个 `API` 生成不同的路由表数据。
那么现在我们来总结一下以上所说的内容:
1. 页面权限实现的核心在于 **路由表配置**
2. 路由表配置的核心在于 **私有路由表 `privateRoutes`**
3. 私有路由表 `privateRoutes` 的核心在于 **[addRoute API](https://next.router.vuejs.org/zh/api/#addroute)**
那么简单一句话总结,我们只需要:**根据不同的权限数据,利用 [addRoute API](https://next.router.vuejs.org/zh/api/#addroute) 生成不同的私有路由表 ** 即可实现 **页面权限** 功能
那么接下来我们来明确 **功能权限:**
**功能权限** 的难度低于页面权限,所谓功能权限指的只有一点:
1. 根据不同的 **权限数据**,展示不同的 **功能按钮**
那么看这一条,依据我们刚才所说的 **页面权限** 经验,估计大家就应该比较好理解了。
对于 **功能权限** 而言,我们只需要:**根据权限数据,隐藏功能按钮** 即可
那么到这里我们已经分析完了 **页面权限** 与 **功能权限**
那么接下来我们就可以分别来看一下两者的实现方案了。
首先我们来看 **页面权限:**
整个 **页面权限** 实现分为以下几步:
1. 获取 **权限数据**
2. **私有路由表** 不再被直接加入到 `routes` 中
3. 利用 [addRoute API](https://next.router.vuejs.org/zh/api/#addroute) 动态添加路由到 **路由表** 中
接下来是 **功能权限:**
整个 **功能权限** 实现分为以下几步:
1. 获取 **权限数据**
2. 定义 **隐藏按钮方式**(通过指令)
3. 依据数据隐藏按钮
## 8-08:业务落地:定义页面权限控制动作,实现页面权限受控
那么这一小节我们来实现 **页面权限**
首先我们先来明确前两步的内容:
1. 页面权限数据在 **`userInfo -> permission -> menus` 之中**
2. **私有路由表** 不再被直接加入到 `routes` 中
```js
export const privateRoutes = [...]
export const publicRoutes = [...]
const router = createRouter({
history: createWebHashHistory(),
routes: publicRoutes
})
```
最后我们来实现第三步:利用 [addRoute API](https://next.router.vuejs.org/zh/api/#addroute) 动态添加路由到 **路由表** 中
1. 定义添加的动作,该动作我们通过一个新的 `vuex` 模块进行
2. 创建 `store/modules/permission` 模块
```js
// 专门处理权限路由的模块
import { publicRoutes, privateRoutes } from '@/router'
export default {
namespaced: true,
state: {
// 路由表:初始拥有静态路由权限
routes: publicRoutes
},
mutations: {
/**
* 增加路由
*/
setRoutes(state, newRoutes) {
// 永远在静态路由的基础上增加新路由
state.routes = [...publicRoutes, ...newRoutes]
}
},
actions: {
/**
* 根据权限筛选路由
*/
filterRoutes(context, menus) {
}
}
}
```
3. 那么 `filterRoutes` 这个动作我们怎么制作呢?
4. 我们可以为每个权限路由指定一个 `name`,每个 `name` 对应一个 **页面权限**
5. 通过 `name` 与 **页面权限** 匹配的方式筛选出对应的权限路由
6. 所以我们需要对现有的私有路由表进行重制
7. 创建 `router/modules` 文件夹
8. 写入 5 个页面权限路由
9. `UserManage.js`
```js
import layout from '@/layout'
export default {
path: '/user',
component: layout,
redirect: '/user/manage',
name: 'userManage',
meta: {
title: 'user',
icon: 'personnel'
},
children: [
{
path: '/user/manage',
component: () => import('@/views/user-manage/index'),
meta: {
title: 'userManage',
icon: 'personnel-manage'
}
},
{
path: '/user/info/:id',
name: 'userInfo',
component: () => import('@/views/user-info/index'),
props: true,
meta: {
title: 'userInfo'
}
},
{
path: '/user/import',
name: 'import',
component: () => import('@/views/import/index'),
meta: {
title: 'excelImport'
}
}
]
}
```
10. `RoleList.js`
```js
import layout from '@/layout'
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'),
meta: {
title: 'roleList',
icon: 'role'
}
}
]
}
```
11. `PermissionList.js`
```js
import layout from '@/layout'
export default {
path: '/user',
component: layout,
redirect: '/user/manage',
name: 'roleList',
meta: {
title: 'user',
icon: 'personnel'
},
children: [
{
path: '/user/permission',
component: () => import('@/views/permission-list/index'),
meta: {
title: 'permissionList',
icon: 'permission'
}
}
]
}
```
12. `Article.js`
```js
import layout from '@/layout'
export default {
path: '/article',
component: layout,
redirect: '/article/ranking',
name: 'articleRanking',
meta: { title: 'article', icon: 'article' },
children: [
{
path: '/article/ranking',
component: () => import('@/views/article-ranking/index'),
meta: {
title: 'articleRanking',
icon: 'article-ranking'
}
},
{
path: '/article/:id',
component: () => import('@/views/article-detail/index'),
meta: {
title: 'articleDetail'
}
}
]
}
```
13. `ArticleCreate.js`
```js
import layout from '@/layout'
export default {
path: '/article',
component: layout,
redirect: '/article/ranking',
name: 'articleCreate',
meta: { title: 'article', icon: 'article' },
children: [
{
path: '/article/create',
component: () => import('@/views/article-create/index'),
meta: {
title: 'articleCreate',
icon: 'article-create'
}
},
{
path: '/article/editor/:id',
component: () => import('@/views/article-create/index'),
meta: {
title: 'articleEditor'
}
}
]
}
```
14. 以上内容存放于 **课程资料 -> 动态路由表** 中
15. 在 `router/index` 中合并这些路由到 `privateRoutes` 中
```js
import ArticleCreaterRouter from './modules/ArticleCreate'
import ArticleRouter from './modules/Article'
import PermissionListRouter from './modules/PermissionList'
import RoleListRouter from './modules/RoleList'
import UserManageRouter from './modules/UserManage'
export const asyncRoutes = [
RoleListRouter,
UserManageRouter,
PermissionListRouter,
ArticleCreaterRouter,
ArticleRouter
]
```
16. 此时所有的 **权限页面** 都拥有一个名字,这个名字与 **权限数据** 匹配
17. 所以我们就可以据此生成 **权限路由表数据**
```js
/**
* 根据权限筛选路由
*/
filterRoutes(context, menus) {
const routes = []
// 路由权限匹配
menus.forEach(key => {
// 权限名 与 路由的 name 匹配
routes.push(...privateRoutes.filter(item => item.name === key))
})
// 最后添加 不匹配路由进入 404
routes.push({
path: '/:catchAll(.*)',
redirect: '/404'
})
context.commit('setRoutes', routes)
return routes
}
```
18. 在 `store/index` 中设置该 `modules`
```js
...
export default createStore({
getters,
modules: {
...
permission
}
})
```
19. 在 `src/permission` 中,获取用户数据之后调用该动作
```js
// 判断用户资料是否获取
// 若不存在用户信息,则需要获取用户信息
if (!store.getters.hasUserInfo) {
// 触发获取用户信息的 action,并获取用户当前权限
const { permission } = await store.dispatch('user/getUserInfo')
// 处理用户权限,筛选出需要添加的权限
const filterRoutes = await store.dispatch(
'permission/filterRoutes',
permission.menus
)
// 利用 addRoute 循环添加
filterRoutes.forEach(item => {
router.addRoute(item)
})
// 添加完动态路由之后,需要在进行一次主动跳转
return next(to.path)
}
next()
```
20. 因为我们主动获取了 `getUserInfo` 动作的返回值,所以不要忘记在 `getUserInfo` 中 `return res`
那么到这里,当我们更换用户之后,刷新页面,路由表即可动态生成。
但是此时大家应该可以发现,如果不刷新页面得话,左侧菜单是不会自动改变的?那么这是怎么回事呢?大家可以先思考一下这个问题,然后我们下一节再来处理。
## 8-09:业务落地:重置路由表数据
在上一小节中我们遇到了一个问题:重新登录权限账户,不刷新页面,左侧菜单不会自动改变。
那么出现这个问题的原因其实非常简单:**退出登录时,添加的路由表并未被删除**
所以想要解决这个问题,我们只需要在退出登录时,删除动态添加的路由表即可。
那么删除动态添加的路由可以使用 [removeRoute](https://next.router.vuejs.org/zh/api/#removeroute) 方法进行。
1. 在 `router/index` 中定义 `resetRouter` 方法
```js
/**
* 初始化路由表
*/
export function resetRouter() {
if (
store.getters.userInfo &&
store.getters.userInfo.permission &&
store.getters.userInfo.permission.menus
) {
const menus = store.getters.userInfo.permission.menus
menus.forEach((menu) => {
router.removeRoute(menu)
})
}
```
2. 在退出登录的动作下,触发该方法
```js
import router, { resetRouter } from '@/router'
logout(context) {
resetRouter()
...
}
```
## 8-10:业务落地:创建功能受控指令
在前面分析 **功能权限** 时,我们说过,实现功能权限的核心在于 **根据数据隐藏功能按钮**,那么隐藏的方式我们可以通过指令进行。
所以首先我们先去创建这样一个指令([vue3 自定义指令](https://v3.cn.vuejs.org/guide/custom-directive.html#%E7%AE%80%E4%BB%8B))
1. 我们期望最终可以通过这样格式的指令进行功能受控 `v-permission="['importUser']"`
2. 以此创建对应的自定义指令 `directives/permission`
```js
import store from '@/store'
function checkPermission(el, binding) {
// 获取绑定的值,此处为权限
const { value } = binding
// 获取所有的功能指令
const points = store.getters.userInfo.permission.points
// 当传入的指令集为数组时
if (value && value instanceof Array) {
// 匹配对应的指令
const hasPermission = points.some(point => {
return value.includes(point)
})
// 如果无法匹配,则表示当前用户无该指令,那么删除对应的功能按钮
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
} else {
// eslint-disabled-next-line
throw new Error('v-permission value is ["admin","editor"]')
}
}
export default {
// 在绑定元素的父组件被挂载后调用
mounted(el, binding) {
checkPermission(el, binding)
},
// 在包含组件的 VNode 及其子组件的 VNode 更新后调用
update(el, binding) {
checkPermission(el, binding)
}
}
```
3. 在 `directives/index` 中绑定该指令
```js
...
import permission from './permission'
export default (app) => {
...
app.directive('permission', permission)
}
```
4. 在所有功能中,添加该指令
5. `views/role-list/index`
```html
<el-button
...
v-permission="['distributePermission']"
>
{{ $t('msg.role.assignPermissions') }}
</el-button>
```
6. `views/user-manage/index`
```html
<el-button
...
v-permission="['importUser']"
>
{{ $t('msg.excel.importExcel') }}</el-button
>
```
```html
<el-button
...
v-permission="['distributeRole']"
>{{ $t('msg.excel.showRole') }}</el-button
>
```
```html
<el-button
...
v-permission="['removeUser']"
>{{ $t('msg.excel.remove') }}</el-button
>
```
## 8-11:总结
那么到这里我们整个权限受控的章节就算是全部完成了。
整个这一大章中,核心就是 **`RBAC`的权限受控体系** 。围绕着 **用户->角色->权限** 的体系是现在在包含权限控制的系统中使用率最广的一种方式。
那么怎么针对于权限控制的方案而言,除了课程中提到的这种方案之外,其实还有很多其他的方案,大家可以在我们的话题讨论中踊跃发言,多多讨论。
七、动态表格操作
# 第九章:动态表格渲染方案之文章排名业务实现
## 9-01:开篇
对于 **文章排名** 而言,核心的内容是围绕着表格处理来进行的。对应的核心业务主要有两个:
1. 文章排名切换
2. 动态表格渲染
这两个核心业务配合着其他的一些辅助功能:
1. 文章排名页面展示
2. 文章详情页面展示
共同组成了咱们这一大章的内容
## 9-02:辅助业务:文章排名页面渲染
整个 **文章排名** 的页面渲染分成三个部分:
1. 顶部的动态展示区域
2. 中间的 `table` 列表展示区域
3. 底部的分页展示区域
那么在这一小节中,我们先去渲染第 2、3 两部分:
1. 创建 `api/article` 文件定义数据获取接口
```js
import request from '@/utils/request'
/**
* 获取列表数据
*/
export const getArticleList = data => {
return request({
url: '/article/list',
params: data
})
}
```
2. 在 `article-ranking` 中获取对应数据
```js
<script setup>
import { ref, onActivated } from 'vue'
import { getArticleList } from '@/api/article'
import { watchSwitchLang } from '@/utils/i18n'
// 数据相关
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.list
total.value = result.total
}
getListData()
// 监听语言切换
watchSwitchLang(getListData)
// 处理数据不重新加载的问题
onActivated(getListData)
</script>
```
3. 根据数据渲染视图
```vue
<template>
<div class="article-ranking-container">
<el-card>
<el-table ref="tableRef" :data="tableData" border>
<el-table-column
:label="$t('msg.article.ranking')"
prop="ranking"
></el-table-column>
<el-table-column
:label="$t('msg.article.title')"
prop="title"
></el-table-column>
<el-table-column
:label="$t('msg.article.author')"
prop="author"
></el-table-column>
<el-table-column
:label="$t('msg.article.publicDate')"
prop="publicDate"
>
</el-table-column>
<el-table-column
:label="$t('msg.article.desc')"
prop="desc"
></el-table-column>
<el-table-column :label="$t('msg.article.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>
</el-table-column>
</el-table>
<el-pagination
class="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>
...
/**
* size 改变触发
*/
const handleSizeChange = currentSize => {
size.value = currentSize
getListData()
}
/**
* 页码改变触发
*/
const handleCurrentChange = currentPage => {
page.value = currentPage
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;
}
}
}
::v-deep .el-table__row {
cursor: pointer;
}
.pagination {
margin-top: 20px;
text-align: center;
}
}
</style>
```
## 9-03:相对时间与时间国际化处理
在 **发布时间** 列中,我们希望展示相对时间,并且希望相对时间具备国际化的能力。那么我们就去需要到 `filters` 中对 `dayjs` 进行处理
1. 定义相对时间的处理方法
```js
...
import rt from 'dayjs/plugin/relativeTime'
...
// 加载相对时间插件
dayjs.extend(rt)
function relativeTime(val) {
if (!isNaN(val)) {
val = parseInt(val)
}
return dayjs().to(dayjs(val))
}
export default app => {
app.config.globalProperties.$filters = {
...
relativeTime
}
}
```
2. 在 `article-ranking` 中使用相对时间
```js
<el-table-column :label="$t('msg.article.publicDate')">
<template #default="{row}">
{{ $filters.relativeTime(row.publicDate) }}
</template>
</el-table-column>
```
3. 接下来来处理国际化内容
```js
...
// 语言包
import 'dayjs/locale/zh-cn'
import store from '@/store'
...
function relativeTime(val) {
...
return dayjs()
.locale(store.getters.language === 'zh' ? 'zh-cn' : 'en')
.to(dayjs(val))
}
```
## 9-04:动态表格原理与实现分析
所谓动态表格指的是:**根据列的勾选,动态展示表格中的列**
那么我们同样把这一句话拆开来去看:
1. 展示可勾选的列
2. 动态展示表格的列
那么我们先来看第一部分 **展示可勾选的列:**
可勾选的列通过 `el-checkbox` 来进行渲染。
所以只要我们有对应的数据,那么渲染自然也没有对应的难度。
然后我们来看 **动态展示表格的列:**
所谓 **动态展示表格的列** 指的就是 **动态的渲染 `el-table-column`** ,那么怎么进行动态渲染`el-table-column`呢?
我们来看现在的 `el-table-column` 的渲染,在页面中我们写入了大量的 `el-table-column` 组件,那么对于这样的组件,我们想一下可不可以通过 `v-for` 进行渲染?
依赖于数据,通过 `v-for` 渲染 `el-table-column` ,当数据改变时 `el-table-column` 的渲染自然也就发生了变化,这样我们是不是就完成了 **动态的渲染 `el-table-column`** 功能了?
所以以上两个功能,最核心的部分就是 **列数据的指定**,只要有了对应的数据,那么对应的渲染也就非常简单了。
所以我们总结一下对应的实现步骤:
1. 构建列数据(核心)
2. 根据数据,通过 `el-checkbox` 渲染可勾选的列
3. 根据数据,通过 `v-for` 动态渲染 `el-table-column`
## 9-05:方案落地:动态列数据构建
因为我们要在 `article-ranking` 中处理多个业务,如果我们把所有的业务处理都写到 `article-ranking` 中,那么对应的组件就过于复杂了,所以说我们把所有的 **动态列表** 相关的业务放入到 `article-ranking/dynamic` 文件夹中
1. 创建 `article-ranking/dynamic/DynamicData` 文件,用来指定初始的 **列数据**
```js
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'
}
]
```
2. 创建 `article-ranking/dynamic/index` 文件,对外暴露出
1. 动态列数据
2. 被勾选的动态列数据
3. table 的列数据
```js
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
}
)
```
## 9-06:方案落地:实现动态表格能力
那么现在有了数据之后,我们就可以实现对应的动态表格功能了
1. 在 `article-ranking` 中渲染 **动态表格的 `check`**
2. 导入动态表格的 `check` 数据
```js
import { dynamicData, selectDynamicLabel } from './dynamic'
```
3. 完成动态表格的 `check` 渲染
```html
<el-card class="header">
<div class="dynamic-box">
<span class="title">{{ $t('msg.article.dynamicTitle') }}</span>
<el-checkbox-group v-model="selectDynamicLabel">
<el-checkbox
v-for="(item, index) in dynamicData"
:label="item.label"
:key="index"
>{{ item.label }}</el-checkbox
>
</el-checkbox-group>
</div>
</el-card>
```
4. 导入动态列数据
```js
import { ... tableColumns } from './dynamic'
```
5. 完成动态列渲染
```html
<el-table ref="tableRef" :data="tableData" border>
<el-table-column
v-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>
```
## 9-07:动态表格实现总结
对于动态表格而言,没有涉及到新的技术点,主要是对现有技术的一个灵活使用。
把动态表格拆开来去看,主要就是分成了两部分:
1. 展示可勾选的列
2. 动态展示表格的列
那么对于这两部分而言,核心的就是 **数据**。只要我们可以实现对应的数据,那么想要实现这两个功能就非常的简单了。
## 9-08:拖拽排序原理与实现分析
那么接下来我们来实现 **表格拖动排序** 的功能
对于这个功能,我们需要先来分析一下它的具体业务:
1. 鼠标在某一行中按下
2. 移动鼠标位置
3. 产生对应的替换样式
4. 鼠标抬起,表格行顺序发生变化
依据以上的业务,那么实现该功能的核心就在于:**监听鼠标事件,完成对应的 UI 视图处理**
具体来说:
1. 监听鼠标的按下事件
2. 监听鼠标的移动事件
3. 生成对应的 `UI` 样式
4. 监听鼠标的抬起事件
那么对于以上的原理而言,想要落实到具体的代码中,其实还是比较复杂的。
但是在现在的前端开发中,只要有对应的需求,那么在大多数的情况下都会存在对应的轮子(并且不止一个)。所以说咱们这里依然会借助对应的轮子来去实现。
这个轮子就是 [sortablejs](https://www.npmjs.com/package/sortablejs):用于在列表中实现拖动排序
那么我们整个 **拖动排序** 的核心实现,就是围绕着 [sortablejs](https://www.npmjs.com/package/sortablejs) 来进行的
那么以此,我们得出最终的实现方案:
1. 利用 [sortablejs](https://www.npmjs.com/package/sortablejs) 实现表格拖拽功能
2. 在拖拽完成后,调用接口完成排序
## 9-09:方案落地:实现表格拖拽功能
1. 下载 sortablejs
```
npm i sortablejs@1.14.0
```
2. 创建 `article-ranking/sortable/index` 文件,完成 `sortable` 初始化
```js
import { ref } from 'vue'
import Sortable from 'sortablejs'
// 排序相关
export const tableRef = ref(null)
/**
* 初始化排序
*/
export const initSortable = (tableData, cb) => {
// 设置拖拽效果
const el = tableRef.value.$el.querySelectorAll(
'.el-table__body-wrapper > table > tbody'
)[0]
// 1. 要拖拽的元素
// 2. 配置对象
Sortable.create(el, {
// 拖拽时类名
ghostClass: 'sortable-ghost',
// 拖拽结束的回调方法
onEnd(event) {}
})
}
```
3. 在 `article-ranking` 中导入 `tableRef, initSortable`,并完成初始化
```js
import { tableRef, initSortable } from './sortable'
// 表格拖拽相关
onMounted(() => {
initSortable(tableData, getListData)
})
```
4. 指定拖拽时的样式
```css
::v-deep .sortable-ghost {
opacity: 0.6;
color: #fff !important;
background: #304156 !important;
}
```
## 9-10:方案落地:完成拖拽后的排序
完成拖拽后的排序主要是在 **拖拽结束的回调方法** 中进行。
我们需要在 拖拽结束的回调方法中调用对应的服务端接口完成持久化的排序
1. 在 `api/article` 中定义排序接口
```js
/**
* 修改排序
*/
export const articleSort = data => {
return request({
url: '/article/sort',
method: 'POST',
data
})
}
```
2. 在拖拽结束的回调方法中调用接口
```js
// 拖拽结束的回调方法
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()
}
```
## 9-11:拖拽排序方案总结
整个拖拽排序的功能我们围绕着 [sortablejs](https://www.npmjs.com/package/sortablejs) 来去进行实现。
[sortablejs](https://www.npmjs.com/package/sortablejs) 提供了对于 `table` 的一个排序能力,我们只需要利用这个能力,并且在拖拽完成之后,对数据的排序进行一个持久化的存储即可。
## 9-12:辅助业务:文章删除
1. 定义删除接口
```js
/**
* 删除文章
*/
export const deleteArticle = articleId => {
return request({
url: `/article/delete/${articleId}`
})
}
```
2. 为删除按钮添加点击事件
```html
<el-button type="danger" size="mini" @click="onRemoveClick(row)">{{
$t('msg.article.remove')
}}</el-button>
```
3. 处理删除操作
```js
// 删除用户
const i18n = useI18n()
const onRemoveClick = row => {
ElMessageBox.confirm(
i18n.t('msg.article.dialogTitle1') +
row.title +
i18n.t('msg.article.dialogTitle2'),
{
type: 'warning'
}
).then(async () => {
await deleteArticle(row._id)
ElMessage.success(i18n.t('msg.article.removeSuccess'))
// 重新渲染数据
getListData()
})
}
```
## 9-13:辅助业务:文章详情展示
对于文章详情的展示而言,主要是为了配合 **创建文章** 的功能而产生的。
文章详情中包含一个 **编辑** 按钮,用于对文章的编辑功能。与 **创建文章** 配合,达到相辅相成的目的。
但是现在 **创建文章** 尚未实现,所以 **编辑文章** 也就暂时无从谈起,所以说我们此时仅先实现 **文章详情展示** 的功能,后续在完成了 **创建文章** 之后,再去实现 **文章编辑**
1. 在 `api/article` 中定义获取文章详情接口
```js
/**
* 获取文章详情
*/
export const articleDetail = (articleId) => {
return request({
url: `/article/${articleId}`
})
}
```
2. 在 `article-detail` 中获取文章详情数据
```vue
<script setup>
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { articleDetail } from '@/api/article'
// 获取数据
const route = useRoute()
const articleId = route.params.id
const detail = ref({})
const getArticleDetail = async () => {
detail.value = await articleDetail(articleId)
}
getArticleDetail()
</script>
```
3. 根据数据渲染视图
```vue
<template>
<div class="article-detail-container">
<h2 class="title">{{ detail.title }}</h2>
<div class="header">
<span class="author"
>{{ $t('msg.article.author') }}:{{ detail.author }}</span
>
<span class="time"
>{{ $t('msg.article.publicDate') }}:{{
$filters.relativeTime(detail.publicDate)
}}</span
>
<el-button type="text" class="edit" @click="onEditClick">{{
$t('msg.article.edit')
}}</el-button>
</div>
<div class="content" v-html="detail.content"></div>
</div>
</template>
...
<style lang="scss" scoped>
.article-detail-container {
.title {
font-size: 22px;
text-align: center;
padding: 12px 0;
}
.header {
padding: 26px 0;
.author {
font-size: 14px;
color: #555666;
margin-right: 20px;
}
.time {
font-size: 14px;
color: #999aaa;
margin-right: 20px;
}
.edit {
float: right;
}
}
.content {
font-size: 14px;
padding: 20px 0;
border-top: 1px solid #d4d4d4;
}
}
</style>
```
4. 点击进入详情页面
```js
/**
* 查看按钮点击事件
*/
const router = useRouter()
const onShowClick = row => {
router.push(`/article/${row._id}`)
}
```
## 9-14:总结
那么到这里我们整个的 **动态表格** 渲染的功能就算是全部完成了,整个 **动态表格** 功能围绕着:
1. 文章排名切换
2. 动态表格渲染
这两个核心进行开发,整体的一个逻辑应该并不算复杂
八、富文本
# 第十章:富文本与markdown综合处理之创建文章
## 10-1:开篇
本章中我们的核心业务就是 **编辑文章**。
而对于 **编辑文章** 而言提供了两种编辑方式:
1. 富文本
2. `markdown`
对于这两种编辑形式在现在的前端中都拥有非常多的第三方库,那么对于我们开发者而言,我们肯定也是从中去选择出一个适合我们当前业务的库来进行使用,从而实现出对应的编辑形式。
那么对于我们本章的内容而言,主要就是分成了三个部分:
1. 辅助业务:创建文章、编辑文章
2. 富文本库:介绍 、使用
3. `markdown`:介绍、使用
那么明确好了对应的内容之后,接下来我们就去进行对应的实现即可
## 10-2:辅助业务:创建文章基本结构实现
那么首先我们先去完成 **创建文章** 的基本结构,主要分成三部分:
1. `article-create` 页面:基本结构
2. `Editor` 组件:富文本编辑器
3. `Markdown` 组件:`markdown` 编辑器
那么明确好了之后,我们就去进行对应的实现:
1. 创建 `views/article-create/components/Editor`
2. 创建 `views/article-create/components/Markdown`
3. 在 `views/article-create` 完成基本结构
```vue
<template>
<div class="article-create">
<el-card>
<el-input
class="title-input"
:placeholder="$t('msg.article.titlePlaceholder')"
v-model="title"
maxlength="20"
clearable
>
</el-input>
<el-tabs v-model="activeName">
<el-tab-pane :label="$t('msg.article.markdown')" name="markdown">
<markdown></markdown>
</el-tab-pane>
<el-tab-pane :label="$t('msg.article.richText')" name="editor">
<editor></editor>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<script setup>
import Editor from './components/Editor.vue'
import Markdown from './components/Markdown.vue'
import { ref } from 'vue'
const activeName = ref('markdown')
const title = ref('')
</script>
<style lang="scss" scoped>
.title-input {
margin-bottom: 20px;
}
</style>
```
## 10-3:编辑库选择标准
对于现在的前端编辑库(`markdown` 与 富文本)而言,如果仅从功能上来去看的话,那么其实都是相差无几的。
随便从 `github` 中挑选编辑库,只要 `star` 在 `10K(保守些)` 以上的,编辑器之上的常用功能一应俱全。
那么这样的话就会导致一个问题我们想要去选择一个编辑库的话,应该如何去进行选择呢?
如果你现在想要去选择一个编辑库,那么可以从以下几点中进行选择:
1. [开源协议](https://www.runoob.com/w3cnote/open-source-license.html):其中尽量选择 `MIT` 或者 `BSD` 协议的开源项目
<img src="第十章:富文本与markdown综合处理之创建文章.assets/image-20211006194630048.png" alt="image-20211006194630048" style="zoom:67%;" />
2. 功能:功能需要满足基本需求
3. `issue`:通过 `issue` 查看作者对该库的维护程度

4. 文档:文档越详尽越好,最好提供了中文文档(英文好的可以忽略)
5. 国产的:或许你 `朋友的朋友的朋友` 就是这个库的作者
那么根据以上几点,我选择了以下的编辑器库:
1. `markdown` 编辑器:[tui.editor](https://github.com/nhn/tui.editor)
2. 富文本编辑器:[wangEditor](https://github.com/wangeditor-team/wangEditor)
那么最后给大家推荐一些编辑器库,大家可以进行一些参考:
1. `markdown` 编辑器:
1. [tui.editor](https://github.com/nhn/tui.editor):`Markdown` 所见即所得编辑器-高效且可扩展,使用MIT开源协议。
2. [editor](https://github.com/lepture/editor):纯文本 `markdown` 编辑器
3. [editor.md](https://github.com/pandao/editor.md):开源可嵌入的在线`markdown`编辑器(组件),基于 `CodeMirror` & `jQuery` & `Marked`。国产
4. [markdown-here](https://github.com/adam-p/markdown-here):谷歌开源,但是已经 **多年不更新** 了
5. [stackedit](https://github.com/benweet/stackedit):基于`PageDown`,`Stack Overflow`和其他Stack Exchange站点使用的`Markdown`库的功能齐全的开源Markdown编辑器。**两年未更新了**
6. [markdown-it](https://github.com/markdown-it/markdown-it):可配置语法,可添加、替换规则。**挺长时间未更新了**
2. 富文本编辑器:
1. [wangEditor](https://github.com/wangeditor-team/wangEditor):国产、文档详尽、更新快速
2. [tinymce](https://github.com/tinymce/tinymce):对 `IE6+` 和 `Firefox1.5+` 都有着非常良好的支持
3. [quill](https://github.com/quilljs/quill):代码高亮功能、视频加载功能、公式处理比较强。
4. [ckeditor5](https://github.com/ckeditor/ckeditor5):编辑能力强
5. [wysiwyg-editor](https://froala.com/wysiwyg-editor/):**收费的** , 就是牛
以上列举出的编辑器,大家可以进行一些参考
## 10-4:新建文章:markdown 实现
我们通过 [tui.editor](https://github.com/nhn/tui.editor) 实现 `markdown` 的编辑功能:
1. 下载 [tui.editor](https://github.com/nhn/tui.editor)
```
npm i @toast-ui/editor@3.0.2
```
2. 渲染基本结构
```vue
<template>
<div class="markdown-container">
<!-- 渲染区 -->
<div id="markdown-box"></div>
<div class="bottom">
<el-button type="primary" @click="onSubmitClick">{{
$t('msg.article.commit')
}}</el-button>
</div>
</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped>
.markdown-container {
.bottom {
margin-top: 20px;
text-align: right;
}
}
</style>
```
3. 初始化 `editor` ,处理国际化内容
```vue
<script setup>
import MkEditor from '@toast-ui/editor'
import '@toast-ui/editor/dist/toastui-editor.css'
import '@toast-ui/editor/dist/i18n/zh-cn'
import { onMounted } from 'vue'
import { useStore } from 'vuex'
// Editor实例
let mkEditor
// 处理离开页面切换语言导致 dom 无法被获取
let el
onMounted(() => {
el = document.querySelector('#markdown-box')
initEditor()
})
const store = useStore()
const initEditor = () => {
mkEditor = new MkEditor({
el,
height: '500px',
previewStyle: 'vertical',
language: store.getters.language === 'zh' ? 'zh-CN' : 'en'
})
mkEditor.getMarkdown()
}
</script>
```
4. 在语言改变时,重置 `editor`
```js
import { watchSwitchLang } from '@/utils/i18n'
watchSwitchLang(() => {
if (!el) return
const htmlStr = mkEditor.getHTML()
mkEditor.destroy()
initEditor()
mkEditor.setHTML(htmlStr)
})
```
## 10-5:新建文章:markdown 文章提交
1. 在 `api/article` 中,定义创建文章接口
```js
/**
* 创建文章
*/
export const createArticle = (data) => {
return request({
url: '/article/create',
method: 'POST',
data
})
}
```
2. 因为 `markdown` 或者是 富文本 最终都会处理提交事件,所以我们可以把这两件事情合并到一个模块中实现:
3. 创建 `article-create/components/commit.js`
```js
import { createArticle } from '@/api/article'
import { ElMessage } from 'element-plus'
import i18n from '@/i18n'
const t = i18n.global.t
export const commitArticle = async (data) => {
const res = await createArticle(data)
ElMessage.success(t('msg.article.createSuccess'))
return res
}
```
4. 在 `markdown.vue` 中导入该方法
```js
import { commitArticle } from './commit'
```
5. 触发按钮提交事件
```js
const props = defineProps({
title: {
required: true,
type: String
}
})
const emits = defineEmits(['onSuccess'])
...
// 处理提交
const onSubmitClick = async () => {
// 创建文章
await commitArticle({
title: props.title,
content: mkEditor.getHTML()
})
mkEditor.reset()
emits('onSuccess')
}
```
6. 在 `article-create` 中传递 `title`,处理 `onSuccess` 事件
```js
// 创建成功
const onSuccess = () => {
title.value = ''
}
```
## 10-6:新建文章:markdown 文章编辑
1. 在 `article-detail` 中点击编辑按钮,进入创建文章页面
```js
// 编辑
const router = useRouter()
const onEditClick = () => {
router.push(`/article/editor/${articleId}`)
}
```
2. 在 `article-craete` 中,处理 **编辑** 相关操作
3. 获取当前文章数据
```js
// 处理编辑相关
const route = useRoute()
const articleId = route.params.id
const detail = ref({})
const getArticleDetail = async () => {
detail.value = await articleDetail(articleId)
// 标题赋值
title.value = detail.value.title
}
if (articleId) {
getArticleDetail()
}
```
4. 把获取到的数据传递给 `markdown` 组件
```html
<markdown
:title="title"
:detail="detail"
@onSuccess="onSuccess"
></markdown>
```
5. 在 `markdown` 中接收该数据
```js
const props = defineProps({
...
detail: {
type: Object
}
})
```
6. 检测数据变化,存在 `detail` 时,把 `detail` 赋值给 `mkEditor`
```js
// 编辑相关
watch(
() => props.detail,
(val) => {
if (val && val.content) {
mkEditor.setHTML(val.content)
}
},
{
immediate: true
}
)
```
7. 创建 **编辑文章** 接口
```js
/**
* 编辑文章详情
*/
export const articleEdit = (data) => {
return request({
url: '/article/edit',
method: 'POST',
data
})
}
```
8. 在 `commit.js` 中生成 **编辑文章** 方法
```js
export const editArticle = async data => {
const res = await articleEdit(data)
ElMessage.success(t('msg.article.editorSuccess'))
return res
}
```
9. 在 `markdown` 中处理提交按钮事件
```js
// 处理提交
const onSubmitClick = async () => {
if (props.detail && props.detail._id) {
// 编辑文章
await editArticle({
id: props.detail._id,
title: props.title,
content: mkEditor.getHTML()
})
} else {
// 创建文章
await commitArticle({
title: props.title,
content: mkEditor.getHTML()
})
}
mkEditor.reset()
emits('onSuccess')
}
```
## 10-7:新建文章:富文本 实现
富文本我们使用 [wangEditor](https://github.com/wangeditor-team/wangEditor),所以我们得先去下载 [wangEditor](https://github.com/wangeditor-team/wangEditor)
```
npm i wangeditor@4.7.6
```
安装完成之后,我们就去实现对应的代码逻辑:
1. 创建基本组件结构
```vue
<template>
<div class="editor-container">
<div id="editor-box"></div>
<div class="bottom">
<el-button type="primary" @click="onSubmitClick">{{
$t('msg.article.commit')
}}</el-button>
</div>
</div>
</template>
<script setup>
import {} from 'vue'
</script>
<style lang="scss" scoped>
.editor-container {
.bottom {
margin-top: 20px;
text-align: right;
}
}
</style>
```
2. 初始化 `wangEditor`
```vue
<script setup>
import E from 'wangeditor'
import { onMounted } from 'vue'
// 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 = true
editor.config.menuTooltipPosition = 'down'
editor.create()
}
</script>
```
3. `wangEditor` 的 [国际化处理](https://www.wangeditor.com/doc/pages/12-%E5%A4%9A%E8%AF%AD%E8%A8%80/),官网支持 [i18next](https://www.i18next.com/),所以想要处理 `wangEditor` 的国际化,那么我们需要安装 [i18next](https://www.i18next.com/)
```js
npm i --save i18next@20.4.0
```
4. 对 `wangEditor` 进行国际化处理
```js
import i18next from 'i18next'
import { useStore } from 'vuex'
const store = useStore()
...
const initEditor = () => {
...
// 国际化相关处理
editor.config.lang = store.getters.language === 'zh' ? 'zh-CN' : 'en'
editor.i18next = i18next
editor.create()
}
```
5. 处理提交事件
```js
import { onMounted, defineProps, defineEmits } from 'vue'
import { commitArticle } from './commit'
const props = defineProps({
title: {
required: true,
type: String
}
})
const emits = defineEmits(['onSuccess'])
...
const onSubmitClick = async () => {
// 创建文章
await commitArticle({
title: props.title,
content: editor.txt.html()
})
editor.txt.html('')
emits('onSuccess')
}
```
6. 不要忘记在 `article-create` 中处理对应事件
```html
<editor
:title="title"
:detail="detail"
@onSuccess="onSuccess"
></editor>
```
7. 最后处理编辑
```js
const props = defineProps({
...
detail: {
type: Object
}
})
// 编辑相关
watch(
() => props.detail,
(val) => {
if (val && val.content) {
editor.txt.html(val.content)
}
},
{
immediate: true
}
)
const onSubmitClick = async () => {
if (props.detail && props.detail._id) {
// 编辑文章
await editArticle({
id: props.detail._id,
title: props.title,
content: editor.txt.html()
})
} else {
// 创建文章
await commitArticle({
title: props.title,
content: editor.txt.html()
})
}
editor.txt.html('')
emits('onSuccess')
}
```
## 10-8:总结
本章节中我们的核心重点就是 **编辑库** 的选择
常用的编辑库其实主要就分成了这么两种:
1. `markdown`
2. 富文本
那么对于大家而言,不一定非要使用我们在课程中使用的这两个编辑器库。
因为对于编辑器库而言,它的使用方式都是大同小异的,大家只需要根据我们 **《编辑器库选择标准》** 来选择使用自己当前情况的编辑器库即可
九、项目部署
# 第十一章:项目部署之通用方案
## 11-1:开篇
那么到这里我们的整个课程就已经接近尾声了。
最后我们就需要来看一下项目的打包和发布功能,这两个功能也就是我们本章节的主要功能。
## 11-2:项目构建过程分析与实现
本小节我们主要围绕着三个问题来去讲:
1. 为什么需要打包项目?
2. 打包之后项目可以通过浏览器直接访问吗?
3. 为什么需要有服务?
**为什么需要打包项目:**
浏览器只能识别并运行 **`html、css、js` 文件** 。
那么换句话而言,项目中的 `.vue` 文件,浏览器是不认识的。
而打包的过程就是把 `.vue` 的 **单文件组件** 打包成 `html、css、js` 的文件,让浏览器进行识别,并展示我们的项目
**打包之后项目可以通过浏览器直接访问吗?**
我们可以测试一下。
通过 `npm run build` 打包项目之后,打包的文件会被放入到 `dist` 文件夹中,其实我们可以直接双击 `index.html` 文件,可以发现,浏览器是 **无法** 显示项目的。
打开 `F12` 可以发现,终端中抛出了很多的错误。
那么根据这些错误可以知道,无法显示的原因是因为 **一些文件找不到了**。
那么为什么找不到呢?
查看我们的 `url` 可以发现,其实我们的 `url` 是一个 `file` 协议。那么对应的文件路径就会编程 `盘符下的 xxx` ,在我们当前的盘符下没有对应的文件,那么自然是无法找到的。
而想要解决这个问题的话,就需要把我们的项目运行到一个 **服务** 中,就像我们开发时的 [devServer](https://webpack.docschina.org/configuration/dev-server/) 一样。
**为什么需要有服务?**
明确了上面的问题之后,为什么要有服务就比较好理解了。
我们需要通过一个 **服务** 托管我们的项目,从而避免出现模块无法被找到等问题。
----------
那么明确好了,以上问题之后,接下来我们就可以打包我们的项目,并且把项目部署到服务器之中。
我们可以通过 `npm run build` 打包项目,打包好项目之后,接下来我们再来看如何到服务器中部署我们的服务。
## 11-3:域名、DNS、公网IP、服务器、Nginx之间的关系
在处理我们的服务之前,我们明确一些基本的概念,这些概念有:
1. 域名:`https://imooc-admin.lgdsunday.club`
2. DNS:域名解析服务器
3. 公网IP:服务器在网络中的唯一地址
4. 服务器:服务部署的电脑
5. Nginx:网页服务

## 11-4:阿里云服务器购买指南
[云服务器 ECS 自定义购买](https://ecs-buy.aliyun.com/wizard/#/prepay/cn-beijing?fromDomain=true)
## 11-5:服务器连接方式
常见的连接服务器的方式有三种:
1. 阿里云控制台中进行远程链接
2. **通过 `SSH` 工具**([XShell](https://www.netsarang.com/en/xshell/))
3. `SSH` 指令远程登录
那么我们这里使用第二种 **通过 `SSH` 工具**([XShell](https://www.netsarang.com/en/xshell/))进行连接。
1. 新建会话
<img src="第十一章:项目部署之通用方案.assets/image-20211011203212635.png" alt="image-20211011203212635" style="zoom:50%;" />
2. 确定会话信息,协议为 `SSH`、主机为服务器 IP、端口号为 22
<img src="第十一章:项目部署之通用方案.assets/image-20211011203403394.png" alt="image-20211011203403394" style="zoom:67%;" />
3. 双击会话进行连接

4. 输入你的用户名(默认为 `root`)

5. 输入你的密码
<img src="第十一章:项目部署之通用方案.assets/image-20211011203533726.png" alt="image-20211011203533726" style="zoom:67%;" />
6. 出现此信息,表示连接成功
<img src="第十一章:项目部署之通用方案.assets/image-20211011203631626.png" alt="image-20211011203631626" style="zoom:50%;" />
## 11-6:Nginx 环境处理
1. `nginx` 编译时依赖 `gcc` 环境
```
yum -y install gcc gcc-c++
```
2. 安装 `prce`,让 `nginx` 支持重写功能
```
yum -y install pcre*
```
3. 安装 `zlib`,`nginx` 使用 `zlib` 对 `http` 包内容进行 `gzip` 压缩
```
yum -y install zlib zlib-devel
```
4. 安装 `openssl`,用于通讯加密
```
yum -y install openssl openssl-devel
```
5. 进行 `nginx` 安装
6. 创建 `nginx` 文件夹
7. 下载 `nginx` 压缩包
```
wget https://nginx.org/download/nginx-1.11.5.tar.gz
```
8. 解压 `nginx`
```
tar -zxvf nginx-1.11.5.tar.gz
```
9. 进入 `nginx-1.11.5` 目录
```
cd nginx-1.11.5
```
10. 检查平台安装环境
```
./configure --prefix=/usr/local/nginx
```
11. 进行源码编译
```
make
```
12. 安装 `nginx`
```
make install
```
13. 查看 `nginx` 配置
```
/usr/local/nginx/sbin/nginx -t
```
14. 制作 `nginx` 软连接
15. 进入 `usr/bin` 目录
```
cd /usr/bin
```
16. 制作软连接
```
ln -s /usr/local/nginx/sbin/nginx nginx
```
17. 接下来制作配置文件
18. 首先进入到 `nginx` 的默认配置文件中
```
vim /usr/local/nginx/conf/nginx.conf
```
19. 在最底部增加配置项(按下 `i` 进入 输入模式)
```
include /nginx/*.conf;
```
20. 按下 `esc` 键,通过 `:wq!` 保存并退出
21. 创建新的配置文件
```
touch /nginx/nginx.conf
```
22. 进入到 `/root/nginx/nginx.conf` 文件
```
vim /nginx/nginx.conf
```
23. 写入如下配置
```js
# imooc-admin
server {
# 端口
listen 80;
# 域名
server_name localhost;
# 资源地址
root /nginx/dist/;
# 目录浏览
autoindex on;
# 缓存处理
add_header Cache-Control "no-cache, must-revalidate";
# 请求配置
location / {
# 跨域
add_header Access-Control-Allow-Origin *;
# 返回 index.html
try_files $uri $uri/ /index.html;
}
}
```
24. 通过 `:wq!` 保存退出
25. 在 `root/nginx` 中创建 `dist` 文件夹
```
mkdir /nginx/dist
```
26. 在 `nginx/dist` 中写入 `index.html` 进行测试
27. 通过 `nginx -s reload` 重启服务
28. 在 浏览器中通过,`IP` 测试访问
## 11-7:项目发布
可以通过 [XFTP](https://www.netsarang.com/en/xftp/) 进行数据传输
## 11-8:总结
本章节主要讲解了一些基础的 **部署** 相关的知识,这些内容并不复杂,属于通用性内容。
那么现在我们的项目就已经可以部署到我们的服务器中了,大家就可以根据自己的域名进行对应的访问了
十、总结
# 第十二章:课程总结
## 12-1:课程总结
那么到这里我们整个的课程就算是全部结束了
也感谢大家能看到这里,坚持下来看完不容易。
那么回顾一下整个课程,整个课程我们分成了10个章节进行讲解:
1. 编程规范
2. 登陆处理
3. `Layout` 处理
4. 后台综合方案
5. `ElementPlus` 组件
6. 权限处理
7. 动态表格处理
8. 编辑器处理
9. 部署方案
通过这10大块内容吧,咱们算是把一个后台系统的高频场景都进行了解析,那么也希望大家可以在学习完本课程的内容之后,可以获取到 **升职、加薪、换工作** 等现实的提升
那么最后,我是 `Sunday` ,咱们下次课程再见!