「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
- 📢欢迎点赞 :👍 收藏 ⭐留言 📝 如有错误敬请指正,赐人玫瑰,手留余香!
- 📢本文作者:由webmote 原创,首发于 【掘金】
- 📢作者格言: 生活在于折腾,当你不折腾生活时,生活就开始折腾你,让我们一起加油!💪💪💪
🎏 序言
掘友们,大家好,我又来了。🕺🕺🕺
大家在工作中最烦恼的是什么? 是不是重复做类似的工作啊?你有过设计报告做到吐的感受吗?
是的,最近我碰上了个大麻烦🥺,Ctrl +C、V键快被我敲掉了。
它就是制作价值XX w💰💰💰(据说具体数字容易被举报,这里用XX替换)的某医院用报告单。 该项目主要做心理问卷,然后根据问卷、经后台算法后解析出报告。由于处理的是各种类型的心理体检报告单,所以花样繁多,总共有100+ 的不同报告需要展示和打印。接手项目的时候,对这些报告单算是懵懂无知,看了几个感觉大同小异,就误以为差不多都类似。
还好报酬足够丰厚,要不然对不起我这快要敲坏的手,看我二指禅✌️。
经过叮叮当当一阵响的脚手架、环境的准备,我以我最快的速度搞定了基本数据的增删改查工作(感谢 vue-element-admin项目),是时候表演制作报告的拿手绝活了。
🎏 01.等等,让我炫个技
最最核心💕的也就是图文混排报告了,先秀秀效果。
💫第一方队是问卷调查报告和艾森克个性测验报告。
💫第二方队是明尼苏达多相人格调查表评估报告。
💫第三方队是多项人格调查表评估报告。 希望能给新手以启迪💏,让老手有东西吐槽💏。
💫第四方队是…
打住!后面的方队都回去吧,领导不审阅了,都擦球不多的样子。
🎏 02.使用三方技术大汇总
一篇图文混排可打印报告单的技术实现,主要涉及到的技术是表格🏢、各类图📊、📈、各类报告块📃、打印🖨️。
轮子虽然也要造,但我们选择站在巨人的肩膀上造轮子,毕竟站得高看得远,能省一点是一点。
下面列下使用的三方库或包:
- 效果好到爆的 EChart
- 封装后在vue内直接和Echart交互: vue-echarts
- 可打印Echart图的print.js 脚本,具体谁写的也不知道了,没有留版权信息,有需要的童鞋可以留言。
很强大的打印脚本 print-js,我并没用,据说最新版也支持 echart; 其核心思想是把打印的dom输出到iframe内,并枚举canvas,转换成image。
getHtml: function () {
... //这里仅贴部分代码
//canvass echars图表转为图片
for (var k4 = 0; k4 < canvass.length; k4++) {
var imageURL = canvass[k4].toDataURL("image/png");
var img = document.createElement("img");
img.src = imageURL;
img.setAttribute('style', 'max-width: 100%;');
img.className = 'isNeedRemove'
// canvass[k4].style.display = 'none'
// canvass[k4].parentNode.style.width = '100%'
// canvass[k4].parentNode.style.textAlign = 'center'
canvass[k4].parentNode.insertBefore(img,canvass[k4].nextElementSibling);
}
//做分页
//style="page-break-after: always"
var pages = document.querySelectorAll('.result');
for (var k5 = 0; k5 < pages.length; k5++) {
pages[k5].setAttribute('style', 'page-break-after: always');
}
return this.dom.outerHTML;
},
🎏 03.你的报告实现思路?
“小伙子,来,姨给你社(说)句话…”
住过西安城中村(吉祥村)的娃都应该听过这个段子。
现在活来了,⚡你摊上事了⚡。
需求: 制作报告,每种报告都需要处理不同的数据,展示不同的格式;
往下看之前,不妨留给自己5分钟⏱️思考时间,看看我们的实现有哪些差异?
金樽清酒斗十千,玉盘珍羞直万钱。🥂🥂🥂
停杯投箸不能食,拔剑四顾心茫然。🤺🤺🤺
欲渡黄河冰塞川,将登太行雪满山。🚶♀️🚶♀️🚶
闲来垂钓碧溪上,忽复乘舟梦日边。🎣🎣🎣
行路难,行路难,多歧路,今安在?🚶♂️🚶♂️🚶
长风破浪会有时,直挂云帆济沧海。🏄🏄🏄
🎏 03.1 动态模板方案
所谓“动态模板方案”,就是按照报告类型定制该类型的模板组件。
我们只需要判断模板类型,然后加载相应模板进行渲染,就搞定了这个需求,是不是超简单?
看下代码组织形式:
- WQReport/index.vue 是报告的父组件,利用 slot加载模板
- WQReport/reportTemplate.vue 负责加载动态模板
- templates 文件夹内就是100个模板定义
- templates/default 为默认模板,用来兜底,万一找不到模板就用它
03.1.1 WQReport/index.vue 内容
<template>
<div ref="wrap" class="form-wrap">
<div class="form-content-wrap">
<div ref="print" class="reportBorder">
<div id="print" class="reportBlock">
<slot name="print" />
</div>
<div class="footer" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'WqPageReport',
data() {
return {
}
},
}
</script>
03.1.2 WQReport/reportTemplate.vue 内容
这里利用vue的 动态组件 component
技术进行加载动态模板。
并且利用计算属性 loader 来返回加载组件的 Promise。 注意需要使用 require(
./templates/${this.type}).default
完成载入。
载入失败了,就返回 this.rptType = () => import(
./templates/default)
默认模板。
当然数据需要赋值给模板组件的属性data。
<template>
<div class="theRpt">
<component :is="rptType" v-if="rptType" ref="theRpt" :data="rptData" :type="type" />
</div>
</template>
<script>
export default {
name: 'ReportTemplate',
props: ['rptData', 'type'],
data() {
return {
rptType: null
}
},
computed: {
loader() {
if (!this.type) {
return null
}
return () => Promise.resolve(require(`./templates/${this.type}`).default)
}
},
mounted() {
this.loader()
.then(() => {
console.log('load template:' + this.type)
this.rptType = () => this.loader()
})
.catch(() => {
console.log('load template failed.' + this.type)
this.rptType = () => import(`./templates/default`)
})
}
}
</script>
03.1.3 templates/t0-000 内容
报告模板的内容较多,这里会简化一部分html代码。
<template>
<div :id="id" class="template">
<div
style="
width: 100%;
"
>
{{ data.SCALE_NAME }}评估报告单
</div>
<div
style="
width: 100%;
"
>
<div style="width: 90%;">{{ data.REPORT_ID }}</div>
</div>
<div style="width: 100%; text-align: center; margin: 30px 0;">
<table
style="
width: 90%;
"
>
<tr>
<td style="width: 12%; text-align: right; font-weight: 800;">姓名:</td>
<td style="width: 12%; text-align: left;">{{ data.USER_REAL_NAME }}</td>
<td style="width: 12%; text-align: right; font-weight: 800;">性别:</td>
<td style="width: 12%; text-align: left;">{{ data.USER_SEX }}</td>
<td style="width: 12%; text-align: right; font-weight: 800;">年龄:</td>
</tr>
</table>
</div>
<div style="width: 100%; text-align: center;">
<div
style="
width: 90%;
"
>
{{ data.SCALE_EXPLAIN }}
</div>
</div>
... ...
本评定表最终解释权由临床医师和心理测评专家作出。
</div>
</template>
<script>
export default {
name: 't0000',
props: {
data: {
type: Object,
default: () => {
return {}
}
},
type: String
},
data() {
return {
id: `template-${this.type}`
}
},
created() {
console.log('subcom:' + this.type)
}
}
</script>
03.1.4 使用报告组件
使用动态模板报告组件,就很容易了。
import wqPageReport from "@/components/WQReport/index";
import rptTemplate from '@/components/WQReport/reportTemplate'
//增加组件引用
components: { wqPageReport, rptTemplate },
//增加模板代码
<wq-page-report ref="form">
<div slot="print" class="printContent">
<rpt-template
:type="template"
:rpt-data="rptData"
/>
</div>
</wq-page-report>
03.1.5 有啥不妥吗?
100个模板我已经Ctrl+C、V完了,命名也都改了一遍。
只等按照报告类型,逐一修改每个模板的html定义,以及渲染显示实现了。
天,还有渲染显示的逻辑呢!!!✨这,真的要把手敲断啊?✨
每个报告有一部分是相似的,比如个人资料,签名提示等,这些就算都做成组件,我也得100个模板一个个复制过去啊!
😂我已经哭晕在厕所了😂,钱真尼玛不好挣~~ 我退出好不好?
我感觉自己已经上了梁山,下不来了。
并且我感觉打包速度有点慢,利用 webpack-bundle-analyzer
插件扫描了下代码,templates模板文件夹所占性能比重超大! 100个模板组件不是盖的~~
报告类型太多了,必须换方案,要不这重复的报告拷贝来拷贝去烦都烦死了。🥺
🎏 03.2 动态配置方案
喝杯白开水,🧺闭目养神10分钟。
好了,冷静过后,加油, webmote!
重要的时刻需要冷静下来,然后再开动脑筋。
先绘制下图。
抽象一下: 每个报告都由不同的组件按照顺序结构排列而成。
顺序结构可以看数组
,不同的组件可能会有不同的属性定义,那么如果使用配置来定义一个报告,可以定义如下结构:
't0-000': [{},{},{}],
't0-001': [{},{},{}],
't0-002': [{},{},{}],
...
先看看能不能解决方案1的问题🔥?
如果t0-100
的报告格式和t0-002
的报告格式相似,则可以复制配置,看起来这个工作量是可控的。
那{}
,组件的属性是什么鬼东西呢?
嗯,我们暂且不要抽象,用到一个具体组件时在定义不迟。
既然已经由了初步的构思,那让我们先实现默认报告配置吧!
03.2.1 改造1方案
- 复用 WQReport/index.vue ,因其模板再slot内,因此无需改动代码
- 改造 WQReport/reportTemplate.vue 按照配置方案依次渲染相应的组件
报告使用代码:
<wq-page-report ref="form">
<div slot="print" class="printContent">
<rpt-template
:type="template"
:rpt-data="rptData"
:report="report"
:st="theSt"
:config="theConfig"
/>
</div>
</wq-page-report>
这里我们增加了属性 theConfig,表示某类型报告的配置; theSt,某类型报告配置相关联的数据, report,报告的详细原始数据,rptData,报告的个人信息。
03.2.1 reportTemplate 代码
该类负责按照报告类型绘制各类报告组件。
由于 rptTitle、rptTail、rptPersonalInfo、rptResult几乎每个报告都有,因此就按照固定方式配置在组件内。
<template>
<div class="rptTemplate">
<vue-lazy-component :timeout="1000">
<rpt-title :data="rptData" />
<rpt-personal-info :data="rptData" />
<div v-for="(com,index) in config" :key="index">
<rpt-total-table v-if="totalTable(com)" :data="st" :config="com" />
<rpt-guage v-if="guage(com)" :data="st" :config="com" />
<rpt-single-line v-if="singleLine(com)" :data="st" :config="com" />
</div>
<rpt-result :data="report" :config="rptData" />
<rpt-tail :data="rptData" />
</vue-lazy-component>
</div>
</template>
<script>
import rptTitle from '../rptTitle'
import rptTail from '../rptTail'
import rptResult from '../rptResult'
import rptPersonalInfo from '../rptPersonalInfo'
import rptTotalTable from '../rptTotalTable'
import rptGuage from '../rptGuage'
import rptSingleLine from '../rptSingleLine'
export default {
name: 'RptTemplate',
components: { rptTitle, rptTail, rptResult, rptPersonalInfo, rptTotalTable, rptGuage, rptSingleLine },
props: {
rptData: {
type: Object,
default: () => {
return {}
},
},
report: {
type: Object,
default: () => {
return {}
},
},
st: {
type: Object,
default: () => {
return null
},
},
config: {
type: Array,
default: () => {
return []
},
},
},
data() {
return {
id: `${this.type}`,
}
},
computed: {
},
created() {
console.log('subcom:' + this.type)
},
methods: {
totalTable(config) {
return this.getConfigValue(config, 'rptTotalTable')
},
stackLine(config) {
return this.getConfigValue(config, 'rptStackLine')
},
guage(config) {
return this.getConfigValue(config, 'rptGuage')
},
singleLine(config) {
return this.getConfigValue(config, 'rptSingleLine')
},
getConfigValue(config, key) {
if (config && 'type' in config && config.type == key) {
return config
} else {
return null
}
},
},
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.rptTemplate{
width:100%;
padding: 0 15px;
}
</style>
03.2.2 rptTitle等组件 代码
按可复用的粒度,切分报告的各个部分为组件,忽然发现组件实现超级简单了。
比如标题切分成组件后,只需要关心怎么显示标题、图片等。
<template>
<div class="titleSpan">
<table class="printTable">
<tr v-if="logo && !data.hiddenTitle">
<td valign="top" align="center">
<img :src="logo" style="max-height: 100px" />
</td>
</tr>
<tr v-if="!data.hiddenTitle">
<td align="center">
<!-- margin-top: 60px; -->
<div style="text-align: center; font-size: 38px; height: 60px">
{{ data.SYSTEM_NAME }}
</div>
</td>
</tr>
<tr>
<td align="center">
<div :class="data.hiddenTitle ? 'Bigtitle' : 'title'">
{{ data.SCALE_NAME }}评估报告单
</div>
</td>
</tr>
<tr>
<td>
<div style="text-align: right; font-size: 18px; ">
<div style="line-height: auto">
{{ data.REPORT_ID
}}
</div>
</div>
</td>
</tr>
</table>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "RptTitle",
props: {
data: {
type: Object,
default: () => {
return {};
}
}
},
data() {
return {};
},
computed: {
...mapGetters(["sysConfig"]),
styleObject() {
return {
color: this.$options.filters["statusColor3"](this.data.alertValue)
};
},
logo() {
return this.sysConfig && this.sysConfig["report.logo"]
? `/api/tools/download/${this.sysConfig["report.logo"]}`
: "";
}
},
};
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.inline {
display: inline;
width: 15px;
height: 15px;
}
.printTable {
width: 100%;
}
.Bigtitle {
text-align: center;
font-size: 32px;
height: 60px;
margin-top: 50px;
}
.title {
text-align: center;
font-size: 28px;
height: 40px;
}
</style>
03.2.3 仪表盘组件 代码
仪表盘组件按照每行4个显示,并且为了打印美观,设定该组件整体换页page-break-inside: avoid;
。
根据需要,还可以设定配置属性,以便配置仪表盘的最大值,切分几块,分区颜色等。
<template>
<div class="printBlock">
<div v-if="config.title" class="title">
{{ this.$t("report." + config.title) }}
</div>
<table style="width:100%;border:1px solid #000">
<tr v-for="(g, x) in chartData" :key="x">
<td v-for="(item, y) in g" :key="y" align="center">
<v-chart
ref="line"
class="chart"
:theme="theme"
:autoresize="true"
:init-options="initOptions"
:option="options[4 * x + y]"
/>
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name: "RptGuage",
props: {
data: {
type: Object,
default: () => {
return null;
}
},
config: {
type: Object,
default: () => {
return {};
}
}
},
data() {
return {
initOptions: {
renderer: "canvas",
locale: this.$i18n.locale
},
theme: "default", // default\light\dark
option: {
series: [
{
type: "gauge",
min: 0,
max: 5,
splitNumber: 5,
axisLine: {
lineStyle: {
width: 15,
color: [
[0.25, "#7CFFB2"],
[0.5, "#0eb83a"],
[0.75, "#FDDD60"],
[1, "#FF6E76"]
]
}
},
pointer: {
itemStyle: {
color: "auto"
}
},
axisTick: {
distance: -5,
length: 10,
lineStyle: {
color: "#fff",
width: 2
}
},
splitLine: {
distance: -10,
length: 20,
lineStyle: {
color: "#fff",
width: 4
}
},
axisLabel: {
color: "auto",
distance: 10,
fontSize: 14
},
detail: {
valueAnimation: true,
formatter: "{value}",
// offsetCenter: [0, '0%'],
color: "auto",
fontSize: "16"
},
title: {
show: true,
offsetCenter: [0, "95%"]
},
data: [
{
value: 70,
name: "人际关系敏感"
}
]
}
]
},
options: [],
chartData: []
};
},
created() {
this.chartData = [];
this.options = [];
const arr = this.config.formatData(this.data);
for (let i = 0; i < arr.length; i += 4) {
const len = Math.min(4, arr.length - i);
if (arr.length < 4) len = arr.length;
this.chartData.push(arr.slice(i, i + len));
for (let j = 0; j < len; j++) {
const opt = JSON.parse(JSON.stringify(this.option));
if (this.config.scale) {
if (this.config.scale.length > i + j) {
opt.series[0].max = this.config.scale[i + j].max || 5;
opt.series[0].splitNumber =
this.config.scale[i + j].splitNumber || 5;
opt.series[0].axisLine.lineStyle.color = this.config.scale[
i + j
].color;
} else {
opt.series[0].max = this.config.scale[0].max || 5;
opt.series[0].splitNumber = this.config.scale[0].splitNumber || 5;
opt.series[0].axisLine.lineStyle.color = this.config.scale[0].color;
}
}
opt.series[0].data[0] = {
title: { width: 160, overflow: "break" },
...arr[i + j]
};
this.options.push(opt);
}
}
},
};
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.chart {
width: 160px; //100%打印有bug
height: 160px;
border: 0px solid #000;
}
.title {
width: 100%;
text-align: center;
font-weight: 800;
font-size: 22px;
margin: 20px auto;
}
.printBlock {
page-break-inside: avoid;
}
</style>
03.2.4 折线图代码
注意: 因为data内无法使用计算属性跟踪变化,因此如果需要初始化数据后显示的化,应该在组件属性赋值前处理。
而我因为是后期才有类似需求,因此被逼在 created时初始化数据,并通过对echart的option属性修改,触发Echart的重绘,有点笨拙。
<template>
<div>
<div v-if="config.title" class="title">
{{ this.$t("report." + config.title) }}
</div>
<v-chart
ref="line"
class="chart"
:theme="theme"
:autoresize="true"
:init-options="initOptions"
:option="option"
/>
</div>
</template>
<script>
export default {
name: "RptSingleLine",
props: {
data: {
// scoresTool
type: Object,
default: () => {
return null;
}
},
config: {
type: Object,
default: () => {
return {};
}
}
}, // 因线图
created() {
if (this.config.init) {
this.config.init(this.data);
this.option.legend.data = this.config.keys;
this.option.xAxis.data = this.config.keys;
this.option.series[0].data = this.chartData();
}
},
data() {
return {
initOptions: {
renderer: "canvas",
locale: this.$i18n.locale
},
theme: "default", // default\light\dark
option: {
title: {
text: "",
show: true,
subtext: "西安西京医院-by webmote",
// textAlign:'center',
left: "right",
top: "-10"
},
tooltip: {
trigger: "axis"
},
legend: {
width: 580,
data: this.config.keys
},
grid: {
left: "5%",
right: "5%",
bottom: "5",
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: true,
data: this.config.keys,
axisTick: { interval: 0, alignWithLabel: true },
axisLabel: {
interval: 0,
rotate: this.config.keys.length > 6 ? 30 : 0
}
},
yAxis: {
name: this.$t("report." + this.config.yAxis),
nameLocation: "middle",
nameGap: 40,
type: "value",
min: 0,
max: 100
},
series: [
{
data: this.chartData(),
type: "line",
smooth: true
}
]
}
};
},
methods: {
chartData() {
return this.config.formatData(this.data);
}
}
};
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.chart {
width: 700px; //100%打印有bug
height: 300px;
border: 1px solid #000;
}
.title {
width: 100%;
text-align: center;
font-weight: 800;
font-size: 22px;
margin: 20px auto;
}
</style>
03.2.5 报告配置文件定义
配置很多了,这里展示了默认的报告配置。 st
是来自报告的相关数据,为了绘制图和仪表盘,总需要相关数据的。
🐢🐢🐢按着我的龟速算,不包含组件编写的话,基本3个小时可以完成20-30个配置的编写。
这查看和编写拷贝,已经让我烦不胜烦了。
做完后,我后悔了。
哎,先做个报告编辑器就好了,又可以涨一波技能了。
export default {
default: [
{
type: 'rptGuage',
title: 'factorImage',
formatData: function(st) {
const keys = st.getScoreCols()
if (!st) return []
const arr = []
keys.forEach(name => {
if (name) {
const data = st.getRaw(name)
arr.push({
name: name,
value: data,
})
}
})
return arr
},
},
{
type: 'rptFactorTable',
title: '',
cols: [
{
name: 'factor',
width: '15%',
},
{
name: 'scoreValue',
width: '10%',
},
{
name: 'reducingRate',
width: '10%',
},
{
name: '',
width: '15%',
},
{
name: 'factor',
width: '15%',
},
{
name: 'scoreValue',
width: '10%',
},
{
name: 'reducingRate',
width: '10%',
},
{
name: '',
width: '15%',
}],
formatData: function(st) {
const keys = st.getScoreCols()
if (!st) return []
const arr = []
for (let i = 0; i < keys.length; i += 2) {
arr.push([
keys[i], st.getRawString(keys[i]), st.getRawReducingRate(keys[i]), '',
keys[i + 1], st.getRawString(keys[i + 1]), st.getRawReducingRate(keys[i + 1]), '',
])
}
return arr
},
},
{
type: 'rptStackLine',
title: 'historyReducingRate',
formatData: function(st) {
const keys = st.getScoreCols()
if (!st) return []
const arr = []
keys.forEach(name => {
if (name) {
const data = st.getAllRawReducingRate(name)
arr.push({
name: name,
type: 'line',
data: data,
})
}
})
return arr
},
},
],
... //可以增加各个报告类型的配置
}
🎏 04.再看看效果?
做完问卷调查,就是报告列表了。
我们处的这个时代,内卷太厉害,不过不管你是抑郁还是焦虑,本系统都能给你测一测。
查看报告~~
除了报告,本系统的算法也是很值钱的。
🎏 05.打印的缺陷——页眉页脚
利用脚本打印的报告整体是OK的,但页眉页脚显示出来比较难看,会显示网页链接等信息。
仅有2个方法能搞定它:
- 利用打印选项,勾选掉页眉页脚选项,需要教导客户
- 设置打印上下页边距为 3mm
// 去除页眉页脚
@page {
size: auto A4 landscape;
margin: 3mm;
}
html{
background-color: #FFFFFF;
margin: 0; /* this affects the margin on the html before sending to printer */
}
body{
border: solid 1px blue ;
margin: 10mm 15mm 10mm 15mm;
}
注意: 不要考虑定制页眉页脚,仅仅通过js方案是搞不定的,无数大牛已经证明这一点,别再浪费时间了! (我浪费了很多时间在这上面…)
有定制页眉页脚硬需求的:
-
请在服务端生成pdf,然后打印。
-
或者安装打印插件…这个我没用过。
🎏 06. 结语
报告前前后后搞了有1周? 因为上班期间大概持续了有大半个月吧,只算纯时间,估计有1周,最后总算顺利搞定了,唯一的遗憾就是没有报告设计器。
先把功能搞定,这也是做项目的基本原则。
下一个版本再增加报告设计器!
年少不识前端香,🕺🕺🕺 错把后端当个宝!
例行小结,理性看待!
结的是啥啊,结的是我想你点赞而不可得的寂寞。😳😳😳
👓都看到这了,还在乎点个赞吗?
👓都点赞了,还在乎一个收藏吗?
👓都收藏了,还在乎一个评论吗?
还有系列前端文章,客官,你不瞧瞧? 👉关于微前端(阿里QianKun)的那点事——上线一个“微前端”逼走了2位90后
👉前端项目,看我在这里管理全局后台初始化的数据,就问你飒不飒?
👉十分钟手把手教你设计简单易用的组件级考试题(单选、多选、填空、图片),建议收藏
👉解放前端工程师——手把手教你开发自己的自定义列表和自定义表单系列之一缘起
👉解放前端工程师——手把手教你开发自己的自定义列表和自定义表单系列之二接口
👉解放前端工程师——手把手教你开发自己的自定义列表和自定义表单系列之三表格
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: webmote33 原文链接:https://juejin.im/post/6983510277576720415