Fork me on GitHub

前端更新版本通知用户刷新

​ 经常我们更新了前端代码的版本,而用户却不知道。我们的功能明明已经更新了,用户却还在使用老的版本。或者 bug 已经解决了,而用户还是会反馈仍旧出现相同的 bug。我们只好一遍遍的让用户刷新。我们需要一种方法,在前端代码部署了新版本后,项目能够检测到新版本,并提醒用户刷新。

一、记录版本号

​ 通过 webpack/vite 自动生成记录版本的 json 文件。在 vue.config.js 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { defineConfig } = require('@vue/cli-service')
const fs = require('fs')
const path = require('path')
class VersionPlugin {
apply(compiler) {
compiler.hooks.done.tap('VersionPlugin', () => {
const versionData = { version: new Date().getTime() }
const outputPath = path.resolve(
compiler.options.output.path,
'version.json'
) // 写入打包输出目录
console.log(
'VersionPlugin executed, version:',
versionData.version,
'path:',
outputPath
)
fs.writeFileSync(outputPath, JSON.stringify(versionData))
})
}
}

module.exports = defineConfig({
publicPath: '/app/',
outputDir: 'app',
configureWebpack: (config) => {
if (process.env.NODE_ENV === 'production') {
config.plugins.push(new VersionPlugin()) // 在生产环境中添加自定义插件
}
},
})

​ 这样,在生产环境中,当我们执行npm run build命令打包时,就会在指定目录下生成以当前时间戳为版本号的 json 文件

二、版本比较和更新提示

​ 在@/utils/version.js 中,实现检测版本和更新提示的主要逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { ElNotification } from 'element-plus'
import axios from 'axios'
// }
/**
* 检测到版本更新后,notification会一直显示在页面上
*/
function showNotification() {
ElNotification({
showClose: true,
duration: 0,
title: '版本更新提示',
dangerouslyUseHTMLString: true,
message:
"检测到有新版本发布,请<a href='javascript:location.reload()'>刷新</a>页面",
})
}
export async function isNewVersion() {
const url = `//${window.location.host}/app/version.json`
try {
const res = await axios.get(url, {
headers: { 'Cache-Control': 'no-cache' },
})
const newVersion = res.data.version
const localVersion = +window.localStorage.getItem('version')
if (localVersion && localVersion !== newVersion) {
showNotification()
window.localStorage.setItem('version', newVersion)
} else {
window.localStorage.setItem('version', newVersion)
}
} catch (error) {
console.log('获取线上版本号失败utils/version.js', error)
}
}
// 设置定时器每分钟检查一次
setInterval(isNewVersion, 1 * 60 * 1000)

通过请求打包时生成的 version.json 文件,读取其中的版本号,并将其与本地存储的版本号对比以检测版本更新。最后用定时器每隔一段时间检测就好了。到此主要逻辑就完成了

三、在 main.js 中使用

最后在 main.js 中使用上面的功能就可以了

1
import from './utils/version'

一个滚动动画的表格

一、随时间间隔跳动滚动的表格

在大屏中经常会用到滚动的表格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
<template>
<div class="panel monitor">
<div class="inner">
<div class="content" style="display: block;">
<div class="head">
<span class="col">资源</span>
<span class="col">告警内容</span>
<span class="col">告警次数</span>
<span class="col">告警时间</span>
</div>
<div class="marquee-view">
<div
class="marquee"
:style="{ animationDuration: animationDuration + 's' }"
:class="{ 'no-scroll': dataList.length < 5 }"
>
<div class="row" v-for="(item, index) in dataList" :key="index">
<span class="col">{{ item[7] }}</span>
<span class="col">{{ item[8] }}</span>
<span class="col">{{ item[10] }}</span>
<span class="col">{{ item[6] }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 表格数据
const dataList = ref([])
const currentClickId = ref('')
const animationDuration = ref(0)
onMounted(() => {
setAnimationDuration()
})
function setAnimationDuration() {
const baseDuration = 10 // 基础动画时长(秒)
const durationPerItem = 1 // 每个数据项增加的动画时长(秒)
animationDuration.value =
baseDuration + dataList.value.length * durationPerItem
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

.monitor {
height: 520px;
overflow: hidden;
}

.monitor .inner {
padding: 10px 0;
display: flex;
flex-direction: column;
}

.monitor .content {
flex: 1;
padding-top: 20px;
position: relative;
display: none;
}

.monitor .head {
display: flex;
justify-content: space-between;
background-color: rgba(255, 255, 255, 0.1);
padding: 10px 20px;
color: #68d8fe;
font-size: 26px;
text-align: center;
}

.monitor .head .col:first-child,
.marquee .row .col:first-child {
flex: 2;
}

.monitor .head .col:nth-child(2),
.marquee .row .col:nth-child(2) {
flex: 3;
}

.monitor .head .col:nth-child(3),
.marquee .row .col:nth-child(3) {
flex: 3;
}

.monitor .head .col:nth-child(4),
.marquee .row .col:nth-child(4) {
flex: 3;
}

.monitor .marquee-view {
position: absolute;
top: 75px;
bottom: 0;
width: 100%;
height: 500px;
overflow: hidden;
}

.monitor .row {
display: flex;

justify-content: space-between;
font-size: 22px;
color: #61a8ff;
padding: 20px 20px;
text-align: center;
}

.monitor .row:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #68d8fe;
}

.monitor .col:first-child {
flex: 2;
white-space: nowrap;
/* 确保文本在一行内显示 */
overflow: hidden;
/* 隐藏溢出的内容 */
text-overflow: ellipsis;
/* 使用省略符号表示文本溢出 */
}

.monitor .col:nth-child(2) {
flex: 3;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}

.monitor .col:nth-child(3) {
flex: 1;
}

.monitor .col:nth-child(4) {
flex: 3;
}

/* 通过CSS3动画滚动marquee */
.marquee-view .marquee {
animation: move linear infinite;
}

.marquee-view .marquee.no-scroll {
animation: none;
}

@keyframes move {
0% {
}

100% {
transform: translateY(-50%);
}
}

/* 3.鼠标经过marquee 就停止动画 */
.marquee-view .marquee:hover {
animation-play-state: paused;
}
</style>

这里不滚动的判断条件写死了。其实可以更近一步,通过表格内容的总高度和单条数据的高度,动态获取表格每页的条数。

二、连续滚动的表格

在有些场景,比如工作台中的待办事项、通知消息中,我们可能需要连续滚动动画的表格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<template>
<div>
<div style="padding-left:20px;font-size: 18px;font-weight: 700;">
<i class="el-icon-s-cooperation"></i><span>待办事项</span>
</div>
<el-divider></el-divider>
<div class="panel monitor">
<div class="inner">
<div class="content" style="display: block;">
<div class="marquee-view">
<div
class="marquee"
:class="{ marquee_top: animate }"
@mouseover="closeAnimation"
@mouseleave="startAnimation"
>
<div v-for="(item, index) in dataList" :key="index" class="row">
<span class="col">{{ item.catalog }}</span>
<span class="col text-box ">{{ item.title }}</span>
<span class="tooltip">{{ item.title }}</span>
<span class="col">{{ item.starttime }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

<script>
export default {
name: '',
components: {},
props: {},
data() {
return {
animate: false,
timer: null,
dataList: [],
}
},
async created() {
await this.getTodoList()
this.timer = setInterval(this.showMarquee, 2000)
},
mounted() {},
beforeDestroy() {
clearInterval(this.timer)
},
methods: {
formatDate(val) {
const d = new Date(val)
const year = d.getFullYear()
const month = d.getMonth() + 1
const day = d.getDate()
// const hour = d.getHours();
// const min = d.getMinutes();
// const sec = d.getSeconds();
return (
year +
'-' +
(month < 10 ? '0' + month : month) +
'-' +
(day < 10 ? '0' + day : day)
)
},
showMarquee() {
if (this.dataList.length > 7) {
this.animate = true
setTimeout(() => {
this.dataList.push(this.dataList[0])
this.dataList.shift()
this.animate = false
}, 500)
}
},
closeAnimation() {
clearInterval(this.timer)
},
startAnimation() {
this.timer = setInterval(this.showMarquee, 2000)
},
getTodoList() {
this.$api
.getTodoList()
.then((res) => {
if (res && res.Status == 'OK') {
this.dataList = res.Return?.tbodyList
this.dataList.forEach((item) => {
item.starttime = this.formatDate(item.starttime)
})
}
})
.catch((res) => {
this.isLoading = false
})
},
},
computed: {},
watch: {},
}
</script>
<style scoped lang="less">
.monitor {
height: 230px;
overflow: hidden;
}

.monitor .inner {
padding: 10px 0;
display: flex;
flex-direction: column;
}

.monitor .content {
flex: 1;
padding-top: 20px;
position: relative;
display: none;
}

.marquee .row .col:first-child {
flex: 2;
}

.marquee .row .col:nth-child(2) {
flex: 3;
}

.marquee .row .col:nth-child(3) {
flex: 3;
}

.marquee .row .col:nth-child(4) {
flex: 3;
}

.monitor .marquee-view {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 500px;
overflow: hidden;
}

.monitor .row {
display: flex;
position: relative;
justify-content: space-between;
font-size: 16px;
padding: 5px 20px;
text-align: center;
}

.monitor .row:hover {
background-color: rgba(255, 255, 255, 0.1);
}

.monitor .col:first-child {
flex: 2;
white-space: nowrap;
/* 确保文本在一行内显示 */
overflow: hidden;
/* 隐藏溢出的内容 */
text-overflow: ellipsis;
/* 使用省略符号表示文本溢出 */
}

.text-box {
flex: 3;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.text-box:hover + .tooltip {
display: block;
}
.tooltip {
display: none;
position: absolute;
bottom: 100%;
left: 120px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #303133;
color: #fff;
padding: 10px;
white-space: normal;
z-index: 10;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.monitor .col:nth-child(3) {
flex: 1;
}

.monitor .col:nth-child(4) {
flex: 3;
}

/* 通过CSS3动画滚动marquee */
.marquee_top {
transition: all 0.5s ease-out;
margin-top: -32.398px;
}

/* 3.鼠标经过marquee 就停止动画 */
.marquee-view .marquee:hover {
animation-play-state: paused;
}
</style>

在第一种动画的基础上稍加改造就得到了第二种动画。可以看到,随时间间隔跳动的动画是通过@keyframes 定义关键帧实现的。而连续滚动的动画是通过 transition 过渡实现的。

使用elementUI中的el-table合并表格

一、业务场景说明

开发中遇到一个合并表格的需求,如下图

image-20250110145858077

该表格表头是固定的,末尾 3 行需要将除最后一列的其他列进行合并。中间区域则需要看情况合并行:每个指标分类下面可能有多条指标名称,对于这种具有多条指标名称的数据,其指标分类和权重/得分需要合并行,这也是该需求的难点。

二、具体实现

来看后端返回的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
{
"code": 200,
"msg": "查询成功",
"data": [
{
"id": 41,
"indexType": 126,
"indexTypeName": "服务响应性",
"indexId": 129,
"indexName": "服务投诉解决率",
"mode": 1,
"weightPercent": 100.0,
"score": 100.0,
"weightScore": 100.0,
"instId": 27,
"kpiInstId": 832,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100.0,
"secondLevelWeightPercent": 50.0,
"secondLevelWeightScore": 50.0
},
{
"id": 42,
"indexType": 157,
"indexTypeName": "服务可靠性",
"indexId": 174,
"indexName": "运维人员流失率",
"mode": 1,
"weightPercent": 100.0,
"score": 100.0,
"weightScore": 100.0,
"instId": 27,
"kpiInstId": 833,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100.0,
"secondLevelWeightPercent": 50.0,
"secondLevelWeightScore": 50.0
},
{
"id": 43,
"indexType": 157,
"indexTypeName": "服务可靠性",
"indexId": 158,
"indexName": "服务的可用性",
"mode": 1,
"weightPercent": 100.0,
"score": 100.0,
"weightScore": 100.0,
"instId": 27,
"kpiInstId": 833,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100.0,
"secondLevelWeightPercent": 50.0,
"secondLevelWeightScore": 50.0
}
],
"map": {}
}

其中 firstIndexName 表示每个表格总的类型。就是说该数据返回的可能是多个表格的数据,每个表格为一个数组。indexTypeName 对应指标分类,indexName 对应指标名称。可以看到后端返回的是完全独立的数据,我们需要自己先处理下,先按 firstIndexName 将同属于一个表格的数据归纳到一起。再将同属于一个指标分类的数据归并到一起

组件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<template>
<div class="container">
<div class="body">
<el-tabs v-model="activeName" style="width:100%">
<el-tab-pane
v-for="(item, index) in tabDataList"
:key="index"
:label="item.title"
:name="item.title"
>
<el-table
ref="detailTable"
:data="item.dataList"
:max-height="maxHeight"
:span-method="arraySpanMethod"
border
:header-cell-style="{
backgroundColor: '#EFF0F3',
color: '#8c8c8c',
fontSize: '12px',
}"
>
<el-table-column
label="指标分类"
prop="indexTypeName"
align="center"
>
</el-table-column>
<el-table-column label="权重/得分" align="center">
<template slot-scope="scope">
<span
>{{
scope.row.secondLevelWeightPercent
? scope.row.secondLevelWeightPercent + '%'
: ''
}}{{
scope.row.secondLevelWeightScore
? '(' + scope.row.secondLevelWeightScore + ')'
: ''
}}</span
>
</template>
</el-table-column>
<el-table-column label="指标名称" prop="indexName" align="center">
</el-table-column>
<el-table-column label="评估方式" prop="mode" align="center">
</el-table-column>
<el-table-column
label="评估数据来源"
prop="dataResource"
align="center"
>
</el-table-column>
<el-table-column label="权重" prop="weightPercent" align="center">
</el-table-column>
<el-table-column label="分数" prop="score" align="center">
</el-table-column>
<el-table-column label="权重得分" prop="weightScore" align="center">
<template slot-scope="scope">
<span v-if="scope.row.indexTypeName === '评级结果'"
><i
v-for="i in scope.row.weightScore"
:key="i"
class="el-icon-star-off"
:size="20"
style="color:#fcd53f"
>
</i
></span>
<span v-else>{{ scope.row.weightScore }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
<el-icon><Star /></el-icon>
</div>
</template>

<style scoped>
.container {
padding: 0 16px;
}
.header {
height: 50px;
display: flex;
justify-content: flex-start;
align-items: center;
}
.body {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: calc(100% - 66px);
}
.action-item:hover {
color: #1670f0;
cursor: pointer;
}
::v-deep .el-table__empty-text {
line-height: 38px !important;
}
::v-deep .el-table__empty-block {
height: 100%;
}
::v-deep .el-table__body-wrapper {
height: 100%;
}
::v-deep .el-table {
overflow: visible;
}
</style>

由于返回的是多个表格,所以使用 el-tabs 在多个表格之间切换

具体逻辑实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<script>
export default {
data() {
return {
tabDataList: [],
activeName: '',
}
},
created() {
this.getDetailList()
},
methods: {
getDetailList() {
this.$api
.getList()
.then((res) => {
if (!res.data.length) {
this.tabDataList.push({
title: '无数据',
dataList: [],
})
return
}
// 对数据进行处理
//按firstIndexName进行分类,firstIndexName相同的在一个tab表格
res.data.forEach((item) => {
item.rowSpan = 1
if (!this.tabDataList.length) {
this.tabDataList.push({
title: item.firstIndexName,
dataList: [item],
id: item.id,
})
}
this.tabDataList.forEach((ele) => {
if (ele.id !== item.id) {
if (ele.title === item.firstIndexName) {
// 这里再次判断一下indexTypeName是否存在,存在则添加一个合并行的标记,方便后面做合并
const isMerge = ele.dataList.find(
(i) => i.indexTypeName === item.indexTypeName
)
if (isMerge) {
isMerge.rowSpan++
item.rowSpan = 0
}
ele.dataList.push(item)
} else {
this.tabDataList.push({
title: item.firstIndexName,
dataList: [item],
id: item.id,
})
}
}
})
})
this.activeName = this.tabDataList[0].title
// 获取评等级规则
this.$api.getAllScoreRule().then((res) => {
this.tabDataList.forEach((item) => {
const totalScore = item.dataList.reduce(
(pre, cur) => pre + cur.secondLevelWeightScore,
0
)
// 根据等级规则计算等级
const level = res.data.find(
(i) => i.geValue <= totalScore && i.ltValue >= totalScore
)
item.totalScore = totalScore
item.level = level ? level.scoreLevel : ''
item.star = level ? level.star : 0
item.dataList.push(
{ indexTypeName: '总得分', weightScore: totalScore },
{ indexTypeName: '评级', weightScore: item.level },
{ indexTypeName: '评级结果', weightScore: item.star }
)
})
})
// 再次处理数据,方便后面合并
})
.catch((err) => {
this.$message.error(err.message)
})
},
arraySpanMethod({ row, column, rowIndex, columnIndex }) {
/** 注意使用该方法合并行列时,除了指明要合并的行列、合并的行列数外,还需要显式的指明被合并的行列(通过return {
*rowspan: 0,
*colspan: 0
*}方式)
*/
if (
row.indexTypeName === '总得分' ||
row.indexTypeName === '评级' ||
row.indexTypeName === '评级结果'
) {
if (columnIndex === 0) {
return {
rowspan: 1,
colspan: 7,
}
} else if (columnIndex !== 0 && columnIndex !== 7) {
return {
rowspan: 0,
colspan: 0,
}
}
} else {
if (columnIndex === 0 || columnIndex === 1) {
if (row.rowSpan > 1) {
return {
rowspan: row.rowSpan,
colspan: 1,
}
} else if (row.rowSpan === 0) {
return {
rowspan: 0,
colspan: 0,
}
}
}
}
},
},
}
</script>

总得分需要根据分项得分乘以权重计算得出。再根据总得分匹配对应的评级。最终得到一个总的 list,它包含的每一项都是一个 el-tab-pane 的表格,表格的名字为 title,表格的数据为一个 list,对应的字段名为 dataList。每一个表格数据会比原始数据多处 3 条(末尾合并列的三行),这 3 条数据的值都是经过处理的。

中间合并行区域的关键点在于这里

image-20250110162932889

找到当前这条数据是否跟之前的数据有相同的 indexTypeName(对应指标分类),有的话,那么这条数据就应该被合并,所以这时候通过 find 方法找到第一条同一个 indexTypeName 的数据,将其合并标记+1,同时当前这条数据合并标记为 0 ,表示它将会被合并。

最终处理过后的数据如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
[
{
"title": "政务云服务质量",
"dataList": [
{
"id": 41,
"indexType": 126,
"indexTypeName": "服务响应性",
"indexId": 129,
"indexName": "服务投诉解决率",
"mode": 1,
"weightPercent": 100,
"score": 100,
"weightScore": 100,
"instId": 27,
"kpiInstId": 832,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100,
"secondLevelWeightPercent": 50,
"secondLevelWeightScore": 50,
"rowSpan": 1
},
{
"id": 42,
"indexType": 157,
"indexTypeName": "服务可靠性",
"indexId": 174,
"indexName": "运维人员流失率",
"mode": 1,
"weightPercent": 100,
"score": 100,
"weightScore": 100,
"instId": 27,
"kpiInstId": 833,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100,
"secondLevelWeightPercent": 50,
"secondLevelWeightScore": 50,
"rowSpan": 2
},
{
"id": 43,
"indexType": 157,
"indexTypeName": "服务可靠性",
"indexId": 158,
"indexName": "服务的可用性",
"mode": 1,
"weightPercent": 100,
"score": 100,
"weightScore": 100,
"instId": 27,
"kpiInstId": 833,
"firstIndex": 238,
"firstIndexName": "政务云服务质量",
"dataResource": null,
"secondLevelScore": 100,
"secondLevelWeightPercent": 50,
"secondLevelWeightScore": 50,
"rowSpan": 0
},
{
"indexTypeName": "总得分",
"weightScore": 150
},
{
"indexTypeName": "评级",
"weightScore": "A"
},
{
"indexTypeName": "评级结果",
"weightScore": 5
}
],
"id": 41,
"totalScore": 150,
"level": "A",
"star": 5
}
]

电商图片显示组件和SKU组件的封装

一、电商图片显示组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
<script setup>
import { ref, watch } from 'vue'
import { useMouseInElement } from '@vueuse/core'

// 图片列表
const imageList = [
'https://yanxuan-item.nosdn.127.net/d917c92e663c5ed0bb577c7ded73e4ec.png',
'https://yanxuan-item.nosdn.127.net/e801b9572f0b0c02a52952b01adab967.jpg',
'https://yanxuan-item.nosdn.127.net/b52c447ad472d51adbdde1a83f550ac2.jpg',
'https://yanxuan-item.nosdn.127.net/f93243224dc37674dfca5874fe089c60.jpg',
'https://yanxuan-item.nosdn.127.net/f881cfe7de9a576aaeea6ee0d1d24823.jpg',
]

// 1.小图切换大图显示
const activeIndex = ref(0)

const enterhandler = (i) => {
activeIndex.value = i
}

// 2. 获取鼠标相对位置
const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)

// 3. 控制滑块跟随鼠标移动(监听elementX/Y变化,一旦变化 重新设置left/top)
const left = ref(0)
const top = ref(0)

const positionX = ref(0)
const positionY = ref(0)
watch([elementX, elementY, isOutside], () => {
// 如果鼠标没有移入到盒子里面 直接不执行后面的逻辑
if (isOutside.value) return

// 有效范围内控制滑块距离
// 横向
if (elementX.value > 100 && elementX.value < 300) {
left.value = elementX.value - 100
}
// 纵向
if (elementY.value > 100 && elementY.value < 300) {
top.value = elementY.value - 100
}

// 处理边界
if (elementX.value > 300) {
left.value = 200
}
if (elementX.value < 100) {
left.value = 0
}

if (elementY.value > 300) {
top.value = 200
}
if (elementY.value < 100) {
top.value = 0
}

// 控制大图的显示
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})
</script>

<template>
<div class="goods-image">
<!-- 左侧大图-->
<div class="middle" ref="target">
<img :src="imageList[activeIndex]" alt="" />
<!-- 蒙层小滑块 -->
<div
class="layer"
v-show="!isOutside"
:style="{ left: `${left}px`, top: `${top}px` }"
></div>
</div>
<!-- 小图列表 -->
<ul class="small">
<li
v-for="(img, i) in imageList"
:key="i"
@mouseenter="enterhandler(i)"
:class="{ active: i === activeIndex }"
>
<img :src="img" alt="" />
</li>
</ul>
<!-- 放大镜大图 -->
<div
class="large"
:style="[
{
backgroundImage: `url(${imageList[0]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},
]"
v-show="!isOutside"
></div>
</div>
</template>

<style scoped lang="scss">
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;

.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
}

.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
// 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position
background-size: 800px 800px;
background-color: #f8f8f8;
}

.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
// 绝对定位 然后跟随咱们鼠标控制left和top属性就可以让滑块移动起来
left: 0;
top: 0;
position: absolute;
}

.small {
width: 80px;

li {
width: 68px;
height: 68px;
margin-left: 12px;
margin-bottom: 15px;
cursor: pointer;

&:hover,
&.active {
border: 2px solid $xtxColor;
}
}
}
}
</style>

二、SKU 组件

此组件需要用到计算数组子集的算法

power-set.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export default function bwPowerSet(originalSet) {
const subSets = []

// We will have 2^n possible combinations (where n is a length of original set).
// It is because for every element of original set we will decide whether to include
// it or not (2 options for each set element).
const numberOfCombinations = 2 ** originalSet.length

// Each number in binary representation in a range from 0 to 2^n does exactly what we need:
// it shows by its bits (0 or 1) whether to include related element from the set or not.
// For example, for the set {1, 2, 3} the binary number of 0b010 would mean that we need to
// include only "2" to the current set.
for (
let combinationIndex = 0;
combinationIndex < numberOfCombinations;
combinationIndex += 1
) {
const subSet = []

for (
let setElementIndex = 0;
setElementIndex < originalSet.length;
setElementIndex += 1
) {
// Decide whether we need to include current element into the subset or not.
if (combinationIndex & (1 << setElementIndex)) {
subSet.push(originalSet[setElementIndex])
}
}

// Add current subset to the list of all subsets.
subSets.push(subSet)
}

return subSets
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
<template>
<div class="goods-sku">
<dl v-for="item in goods.specs" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>
<template v-for="val in item.values" :key="val.name">
<img
:class="{ selected: val.selected, disabled: val.disabled }"
@click="clickSpecs(item, val)"
v-if="val.picture"
:src="val.picture"
/>
<span
:class="{ selected: val.selected, disabled: val.disabled }"
@click="clickSpecs(item, val)"
v-else
>{{ val.name }}</span
>
</template>
</dd>
</dl>
</div>
</template>
<script>
import { watchEffect } from 'vue'
import getPowerSet from './power-set'
const spliter = '★'
// 根据skus数据得到路径字典对象
const getPathMap = (skus) => {
const pathMap = {}
if (skus && skus.length > 0) {
skus.forEach((sku) => {
// 1. 过滤出有库存有效的sku
if (sku.inventory) {
// 2. 得到sku属性值数组
const specs = sku.specs.map((spec) => spec.valueName)
// 3. 得到sku属性值数组的子集
const powerSet = getPowerSet(specs)
// 4. 设置给路径字典对象
powerSet.forEach((set) => {
const key = set.join(spliter)
// 如果没有就先初始化一个空数组
if (!pathMap[key]) {
pathMap[key] = []
}
pathMap[key].push(sku.id)
})
}
})
}
return pathMap
}

// 初始化禁用状态
function initDisabledStatus(specs, pathMap) {
if (specs && specs.length > 0) {
specs.forEach((spec) => {
spec.values.forEach((val) => {
// 设置禁用状态
val.disabled = !pathMap[val.name]
})
})
}
}

// 得到当前选中规格集合
const getSelectedArr = (specs) => {
const selectedArr = []
specs.forEach((spec, index) => {
const selectedVal = spec.values.find((val) => val.selected)
if (selectedVal) {
selectedArr[index] = selectedVal.name
} else {
selectedArr[index] = undefined
}
})
return selectedArr
}

// 更新按钮的禁用状态
const updateDisabledStatus = (specs, pathMap) => {
// 遍历每一种规格
specs.forEach((item, i) => {
// 拿到当前选择的项目
const selectedArr = getSelectedArr(specs)
// 遍历每一个按钮
item.values.forEach((val) => {
if (!val.selected) {
selectedArr[i] = val.name
// 去掉undefined之后组合成key
const key = selectedArr.filter((value) => value).join(spliter)
val.disabled = !pathMap[key]
}
})
})
}

export default {
name: 'XtxGoodSku',
props: {
// specs:所有的规格信息 skus:所有的sku组合
goods: {
type: Object,
default: () => ({ specs: [], skus: [] }),
},
},
emits: ['change'],
setup(props, { emit }) {
let pathMap = {}
watchEffect(() => {
// 得到所有字典集合
pathMap = getPathMap(props.goods.skus)
// 组件初始化的时候更新禁用状态
initDisabledStatus(props.goods.specs, pathMap)
})

const clickSpecs = (item, val) => {
if (val.disabled) return false
// 选中与取消选中逻辑
if (val.selected) {
val.selected = false
} else {
item.values.forEach((bv) => {
bv.selected = false
})
val.selected = true
}
// 点击之后再次更新选中状态
updateDisabledStatus(props.goods.specs, pathMap)
// 把选择的sku信息传出去给父组件
// 触发change事件将sku数据传递出去
const selectedArr = getSelectedArr(props.goods.specs).filter(
(value) => value
)
// 如果选中得规格数量和传入得规格总数相等则传出完整信息(都选择了)
// 否则传出空对象
if (selectedArr.length === props.goods.specs.length) {
// 从路径字典中得到skuId
const skuId = pathMap[selectedArr.join(spliter)][0]
const sku = props.goods.skus.find((sku) => sku.id === skuId)
// 传递数据给父组件
emit('change', {
skuId: sku.id,
price: sku.price,
oldPrice: sku.oldPrice,
inventory: sku.inventory,
specsText: sku.specs
.reduce((p, n) => `${p} ${n.name}:${n.valueName}`, '')
.trim(),
})
} else {
emit('change', {})
}
}
return { clickSpecs }
},
}
</script>

<style scoped lang="scss">
@mixin sku-state-mixin {
border: 1px solid #e4e4e4;
margin-right: 10px;
cursor: pointer;

&.selected {
border-color: $xtxColor;
}

&.disabled {
opacity: 0.6;
border-style: dashed;
cursor: not-allowed;
}
}

.goods-sku {
padding-left: 10px;
padding-top: 20px;

dl {
display: flex;
padding-bottom: 20px;
align-items: center;

dt {
width: 50px;
color: #999;
}

dd {
flex: 1;
color: #666;

> img {
width: 50px;
height: 50px;
margin-bottom: 4px;
@include sku-state-mixin;
}

> span {
display: inline-block;
height: 30px;
line-height: 28px;
padding: 0 20px;
margin-bottom: 4px;
@include sku-state-mixin;
}
}
}
}
</style>

使用自定义指令实现图片懒加载和默认图片

@/directives/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 定义懒加载插件
import { useIntersectionObserver } from '@vueuse/core'

export const lazyPlugin = {
install(app) {
// 懒加载指令逻辑
app.directive('img-lazy', {
mounted(el, binding) {
// el: 指令绑定的那个元素 img
// binding: binding.value 指令等于号后面绑定的表达式的值 图片url
// console.log(el, binding.value)
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
console.log(isIntersecting)
if (isIntersecting) {
// 进入视口区域
el.src = binding.value
stop()
}
})
},
})
app.directive('imagerror', {
mounted(el, binding) {
el.src = el.src || binding.value
el.onerror = function () {
// 当图片出现异常的时候 会将指令配置的默认图片设置为该图片的内容
// dom可以注册error事件
el.src = binding.value // 这里不能写死
},
updated() {
el.src = el.src || binding.value
}
},
})
},
}

main.js

1
2
3
4
5
6
import App from './App.vue'
// 引入懒加载指令插件并且注册
import { lazyPlugin } from '@/directives'
const app = createApp(App)
app.use(lazyPlugin)
app.mount('#app')

Vue2混入和Vue3中hook函数的应用

一、Vue2 的混入(Mixin)

在开发过程中,我们经常会发现一些可重复利用的代码块,于是我们将其封装成函数以供调用。这类函数包括工具函数,但又不止工具函数,因为我们可能也会封装一些重复的业务逻辑。但在以往,在前端原生开发中,我们所封装的函数,大多数是”无状态”的,不能够建立数据与视图之间的联系。那何为”有状态”的函数呢?这里的”有状态”,实际上是指是否含有响应式变量:它的变化能够引起视图的变化。这也是混入和普通函数的最大区别!

混入 (Mixin) 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个混入对象可以包含任意组件选项。当组件使用混入对象时,所有混入对象的选项将被“混合”进入该组件本身的选项。

需要注意的是:

当组件和混入对象含有同名选项时,这些选项将以恰当的方式进行“合并”。比如,数据对象在内部会进行递归合并,并在发生冲突时以组件数据优先。

1
2
3
4
var mixin = { data: function () { return { message: 'hello', foo: 'abc' } } }
new Vue({ mixins: [mixin], data: function () { return { message: 'goodbye', bar:
'def' } }, created: function () { console.log(this.$data) // => { message:
"goodbye", foo: "abc", bar: "def" } } })

同名钩子函数将合并为一个数组,因此都将被调用。另外,混入对象的钩子将在组件自身钩子之前调用。

1
2
3
var mixin = { created: function () { console.log('混入对象的钩子被调用') } } new
Vue({ mixins: [mixin], created: function () { console.log('组件钩子被调用') } })
// => "混入对象的钩子被调用" // => "组件钩子被调用"

值为对象的选项,例如 methodscomponentsdirectives,将被合并为同一个对象。两个对象键名冲突时,取组件对象的键值对。

1
2
3
4
5
var mixin = { methods: { foo: function () { console.log('foo') }, conflicting:
function () { console.log('from mixin') } } } var vm = new Vue({ mixins:
[mixin], methods: { bar: function () { console.log('bar') }, conflicting:
function () { console.log('from self') } } }) vm.foo() // => "foo" vm.bar() //
=> "bar" vm.conflicting() // => "from self"

二、使用混入实现功能权限的管控

在页面上,我们经常要检查用户是否拥有某个功能点的权限。这种带状态的功能,很适合使用混入实现

通常一个功能点,对应一个权限标识符

image-20241209144118987

一般我们已将用户信息(包括用户所拥有的权限信息)放在了状态管理工具中。我们要做的就是看看用户,是否拥有point-user-delete这个 point,有就可以让删除能用,没有就隐藏或者禁用

src/mixin/checkPermission.js

1
2
3
4
5
6
7
8
9
10
11
12
import store from '@/store'
export default {
methods: {
checkPermission(key) {
const { userInfo } = store.state.user
if (userInfo.roles.points && userInfo.roles.points.length) {
return userInfo.roles.points.some((item) => item === key)
}
return false
},
},
}

注册之后在员工组件中检查权限点

1
2
3
4
5
6
<el-button
:disabled="!checkPermission('POINT-USER-UPDATE')"
type="text"
size="small"
@click="$router.push(`/employees/detail/${obj.row.id}`)"
>查看</el-button>

三、Mixin 的不足

在 Vue 2 中,Mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:
1、Mixin 很容易发生冲突:因为每个 mixin 的 property 都被合并到同一个组件中,所以为了避免 property 名冲突,你仍然需要了解其他每个特性。也就是说 Mixin 难以追溯的方法与属性,我们根本无法获知属性来自于哪个 Mixin 文件,给后期维护带来困难
2、可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。

四、Vue3 的 Hooks

得益于 Vue3 的组合式 API,在 Vue3 中使用 Hooks 可以弥补混入的不足。也正因为如此,Vue3 中推荐使用 Hooks 代替 Mixin

五、动态计算表格高度

我们经常使用到的一种场景是,头部是一些查询条件,中间是查询结果表格,底部是分页组件(或者没有)。因为查询条件数量是不确定的,因此这一块的高度也是不确定的。此时,表格的高度(或者最大高度)需要动态计算

src/hooks/useTable.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { onMounted, ref, watch, onActivated, onDeactivated } from 'vue'
import { useWindowSize } from '@vueuse/core'

export default function useTable(tableRef, bottom) {
// // console.log('tableRef',tableRef)
// // console.log(tableRef.$el);
const bottomOptions = {
noPagination: 32,
withPagination: 32 + 52,
}

let lock = false
const tableHeight = ref()
const { height } = useWindowSize()

onMounted(() => {
calcTableHeight()
})

onActivated(() => {
// // console.log("onActivated");
lock = false
calcTableHeight()
})

onDeactivated(() => {
// // console.log("onDeactivated");
lock = true
})

watch(height, () => {
calcTableHeight()
})

function calcTableHeight() {
if (lock) return
// // console.log("hshshsh");
const { top } = tableRef.value.$el.getBoundingClientRect()
let currentBottom = 0
if (typeof bottom === 'string') {
currentBottom = bottomOptions[bottom] || 0
} else {
currentBottom = bottom
}
tableHeight.value = height.value - top - currentBottom
// // console.log(tableHeight)
}
return {
tableHeight,
calcTableHeight,
}
}

在组件中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<el-table v-loading="loading" :data="tableList" :height="tableHeight">
<el-table-column type="index" width="55" align="center" label="序号" />
<el-table-column
prop="deviceTypeName"
min-width="150"
label="设备类型"
align="center"
/>
<el-table-column
prop="deviceName"
min-width="150"
label="设备名称"
align="center"
/>
<el-table-column
prop="depict"
min-width="240"
label="设备描述"
align="center"
/>
<el-table-column
prop="deviceNum"
min-width="90"
label="设备数量"
align="center"
/>
</el-table>
</template>
import useTable from '@/hooks/useTable' const { tableHeight } =
useTable(tableRef, 'withPagination')

除此之外,我们还可以将查询字典功能(例如,一些状态、类型、原因等等的名称和 id 的对照)做成 Hooks 函数。这里只提供一下思路,不做具体实现了。在 Hooks 函数中,接收两个参数,第一个是字典的类型,第二个是字典的值(名称和 id 均可)。在字典数据中(这个可以通过 http 每次去请求,也可以一次请求回来所有数据,并将其放在状态管理工具中),根据传入的参数,筛选出对应的那条字典值,并将其交出去

六、实现付款倒计时逻辑

src/hooks/useCountDown.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 封装倒计时逻辑函数
import { computed, onUnmounted, ref } from 'vue'
import dayjs from 'dayjs'
export const useCountDown = () => {
// 1. 响应式的数据
let timer = null
const time = ref(0)
// 格式化时间 为 xx分xx秒
const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
// 2. 开启倒计时的函数
const start = (currentTime) => {
// 开始倒计时的逻辑
// 核心逻辑的编写:每隔1s就减一
time.value = currentTime
timer = setInterval(() => {
time.value--
}, 1000)
}
// 组件销毁时清除定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start,
}
}

在页面中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<script setup>
import { getOrderAPI } from '@/apis/pay'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useCountDown } from '@/composables/useCountDown'
const { formatTime, start } = useCountDown()
// 获取订单数据
const route = useRoute()
const payInfo = ref({})
const getPayInfo = async () => {
const res = await getOrderAPI(route.query.id)
payInfo.value = res.result
// 初始化倒计时秒数
start(res.result.countdown)
}
onMounted(() => getPayInfo())

// 跳转支付
// 携带订单id以及回调地址跳转到支付地址(get)
// 支付地址
const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/'
const backURL = 'http://127.0.0.1:5173/paycallback'
const redirectUrl = encodeURIComponent(backURL)
const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}`
</script>

<template>
<div class="xtx-pay-page">
<div class="container">
<!-- 付款信息 -->
<div class="pay-info">
<span class="icon iconfont icon-queren2"></span>
<div class="tip">
<p>订单提交成功!请尽快完成支付。</p>
<p>
支付还剩 <span>{{ formatTime }}</span
>, 超时后将取消订单
</p>
</div>
<div class="amount">
<span>应付总额:</span>
<span>¥{{ payInfo.payMoney?.toFixed(2) }}</span>
</div>
</div>
<!-- 付款方式 -->
<div class="pay-type">
<p class="head">选择以下支付方式付款</p>
<div class="item">
<p>支付平台</p>
<a class="btn wx" href="javascript:;"></a>
<a class="btn alipay" :href="payUrl"></a>
</div>
<div class="item">
<p>支付方式</p>
<a class="btn" href="javascript:;">招商银行</a>
<a class="btn" href="javascript:;">工商银行</a>
<a class="btn" href="javascript:;">建设银行</a>
<a class="btn" href="javascript:;">农业银行</a>
<a class="btn" href="javascript:;">交通银行</a>
</div>
</div>
</div>
</div>
</template>

一文搞懂前端文件上传中的File、FormData、Blob、Base64、ArrayBuffer

一、前置知识

1.什么是 multipart/form-data?

multipart/form-data 最初由 **《RFC 1867: Form-based File Upload in HTML》[1]**文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对 HTML 进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的 MIME 兼容表示。

总结就是原先的规范不满足啦,需要扩充规范了

2.文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867 文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非 ASCII 字符的数据。平常我们使用这个类型都是把表单数据使用 url 编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十 M 几百 M 的文件来说是速度是更慢的。

二、File

  • File 继承自 Blob,File 对象通常由用户通过文件输入控件选择,或者通过拖放操作生成。表示用户在文件输入控件中选择的文件。它是 Blob 的子类,除了 Blob 的所有属性和方法外,还包含文件名、最后修改时间等文件特有的信息

  • 可以通过 <input type="file"> 元素或 Drag and Drop API 获取 File 对象。

    1
    2
    3
    4
    5
    const fileInput = document.querySelector('input[type="file"]')
    fileInput.addEventListener('change', (event) => {
    const file = event.target.files[0]
    console.log(file)
    })

image-20241209164848375

三、FormData

  • FormData 对象用于构建一组键值对,表示表单数据。它通常用于通过 XMLHttpRequestfetch API 发送表单数据,包括文件上传

  • 你可以使用 append 方法将文件添加到 FormData 对象中。

    1
    2
    3
    4
    5
    6
    7
    8
    const formData = new FormData()
    formData.append('file', file)
    // 通过formData.get()的方式获取File
    console.log(formData.get('file'))
    fetch('/upload', {
    method: 'POST',
    body: formData,
    })

    image-20241209164907256FormData.append() 方法用于向 FormData 对象中添加一个键值对。

    1
    formData.append(name, value, filename)
    1. name(字符串):要添加的键的名称。
    2. value(字符串或 Blob 对象):要添加的值。如果是文件,可以是 BlobFile 对象。
    3. filename(可选,字符串):当 valueBlobFile 对象时,可以指定文件名。如果未指定,默认使用 BlobFile 对象的文件名。

    也就是说除了文件上传,使用 formData.append()可以添加普通的表单数据

    1
    2
    3
    4
    const formData = new FormData();
    formData.append('username', 'john_doe');
    formData.append('email', 'john@example.com');
    console.log(formData.get('username'))

    image-20241209164919174

四、Blob

  • Blob (Binary Large Object)对象表示一个不可变的、原始数据的类文件对象。它可以包含文本、二进制数据或其他类型的数据。Blob 对象通常用于处理文件或数据的片段

  • 你可以使用 new Blob() 创建一个 Blob 对象,并指定数据和类型。

    1
    2
    const blob = new Blob(['Hello, world!'], { type: 'text/plain' })
    console.log(blob)
    1
    new Blob(array, options)
    1. array(数组):一个包含 ArrayBufferArrayBufferViewBlobDOMString 等对象的数组,这些对象将会被组合成新的 Blob 对象的数据部分。

    2. options(可选,对象):一个包含属性的对象,用于指定 Blob 的配置选项。

      • type(字符串):表示 Blob 对象的 MIME 类型。默认值是空字符串。

        常见的 MIME 类型有:

        文本类型

        • text/plain:纯文本文件。
        • text/html:HTML 文件。
        • text/css:CSS 文件。
        • text/javascript:JavaScript 文件。

        图像类型

        • image/jpeg:JPEG 图像文件。
        • image/png:PNG 图像文件。
        • image/gif:GIF 图像文件。
        • image/svg+xml:SVG 图像文件。

        音频类型

        • audio/mpeg:MP3 音频文件。
        • audio/wav:WAV 音频文件。
        • audio/ogg:OGG 音频文件。

        视频类型

        • video/mp4:MP4 视频文件。
        • video/webm:WebM 视频文件。
        • video/ogg:OGG 视频文件。

        应用程序类型

        • application/json:JSON 数据文件。
        • application/xml:XML 数据文件。
        • application/pdf:PDF 文件。
        • application/zip:ZIP 压缩文件。
        • application/octet-stream:二进制数据文件(默认值)。
      • endings(字符串):表示换行符的结尾类型,可以是 "transparent""native"。默认值是 "transparent"

        创建一个简单的文本 Blob

        1
        2
        const blob = new Blob(['Hello, world!'], { type: 'text/plain' });
        console.log(blob);

        image-20241209164929959

创建一个包含二进制数据的 Blob

1
2
3
4
5
const arrayBuffer = new ArrayBuffer(8)
const view = new Uint8Array(arrayBuffer)
view[0] = 255
const blob = new Blob([view], { type: 'application/octet-stream' })
console.log(blob)

image-20241209164939501

五、Base64

  • Base64 是一种将二进制数据编码为 ASCII 字符串的方法。它通常用于在 URL、JSON 或 XML 中嵌入二进制数据。

  • 你可以使用 FileReader 对象将文件转换为 Base64 编码字符串。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <title>文件上传并转换为 Base64 链接</title>
    </head>
    <body>
    <input type="file" />
    <a id="downloadLink" href="#" target="_blank">点击这里打开文件</a>
    <script>
    const fileInput = document.querySelector('input[type="file"]')
    const downloadLink = document.getElementById('downloadLink')

    fileInput.addEventListener('change', (event) => {
    const file = event.target.files[0]
    const reader = new FileReader()

    reader.onload = () => {
    const base64String = reader.result
    console.log(base64String)
    downloadLink.href = base64String // 将 Base64 字符串设置为链接的 href
    downloadLink.download = file.name // 设置下载文件的默认名称
    downloadLink.textContent = `点击这里打开 ${file.name}` // 更新链接文本
    }

    reader.readAsDataURL(file)
    })
    </script>
    </body>
    </html>

    当文件是 txt 文本时

    image-20241209164950088

    当文件是图片时

    image-20241209164957297

    当文件是 excel 时

    image-20241209165006677

    FileReader 是一个内置的 JavaScript 对象,用于读取 BlobFile 对象的内容

    • onloadFileReader 对象的一个事件处理函数,当读取操作完成时会被触发。

    • onload 事件处理函数中,reader.result 包含了读取到的文件内容。

    • reader 在调用 readAsDataURL 方法时,会返回一个 Base64 编码的字符串,表示文件的内容。它是一个该文件的链接,可以直接将其给 a 标签的 href 属性进行文件下载,或者如果文件是图片给到 img 标签的 src 属性进行图片预览

    • reader.result 赋值给 base64String 变量,并输出到控制台。

      除了readAsDataURL,reader 还有其他的 API

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      <!DOCTYPE html>
      <html lang="en">
      <head>
      <meta charset="UTF-8" />
      <title>FileReader 示例</title>
      </head>
      <body>
      <input type="file" id="fileInput" />
      <script>
      const fileInput = document.getElementById('fileInput');

      fileInput.addEventListener('change', (event) => {
      const file = event.target.files[0];
      const reader = new FileReader();

      // 使用 readAsDataURL 读取文件
      reader.onload = () => {
      console.log('Data URL:', reader.result);
      };
      reader.readAsDataURL(file);

      // 使用 readAsArrayBuffer 读取文件
      reader.onload = () => {
      console.log('ArrayBuffer:', reader.result);
      };
      reader.readAsArrayBuffer(file);

      // 使用 readAsBinaryString 读取文件
      reader.onload = () => {
      console.log('Binary String:', reader.result);
      };
      reader.readAsBinaryString(file);

      // 使用 readAsText 读取文件
      reader.onload = () => {
      console.log('Text:', reader.result);
      };
      reader.readAsText(file, 'UTF-8');
      });
      </script>
      </body>
      </html>
      1. **readAsDataURL(file)**:
        • 将文件读取为 Base64 编码的 Data URL 字符串。
      2. **readAsArrayBuffer(file)**:
        • 将文件读取为 ArrayBuffer 对象,适用于处理二进制数据。
      3. **readAsBinaryString(file)**:
        • 将文件读取为二进制字符串(不推荐使用,因为 ArrayBuffer 更加现代和高效)。
      4. **readAsText(file, [encoding])**:
        • 将文件读取为文本字符串,第二个参数可选,用于指定文本编码(默认是 UTF-8)。

      有一个跟**readAsDataURL(file)**很像的 API 叫URL.createObjectURL

      URL.createObjectURLFileReader.readAsDataURL 都可以用于将文件转换为可以在网页中使用的 URL,但它们的工作方式和用途有所不同。

      URL.createObjectURL 方法创建一个包含文件数据的临时 URL。这个 URL 可以直接用于 <img><a> 等 HTML 元素。这个 URL 是浏览器内存中的一个引用,不会将文件数据编码为 Base64。

      特点:

      • 更高效,因为它不需要将文件数据编码为 Base64。
      • 适用于大文件,因为它不会占用额外的内存。
      • 创建的 URL 是临时的,需要手动调用URL.revokeObjectURL 释放内存

      FileReader.readAsDataURL 方法将文件读取为 Base64 编码的 Data URL。这个 Data URL 可以直接用于 <img><a> 等 HTML 元素。这个方法会将文件数据编码为 Base64,因此会占用更多的内存。

      优点:

      • 生成的 URL 可以在任何地方使用,不依赖于浏览器的内存管理。
      • 适用于需要将文件数据嵌入到 HTML 或 JSON 中的场景。
      • FileReader.readAsDataURL 创建的 Data URL 是永久的,不需要手动释放

六、ArrayBuffer

  • ArrayBuffer 对象表示一个通用的、固定长度的原始二进制数据缓冲区。它可以用来处理文件的二进制数据。

  • 你可以使用 FileReader 对象将文件读取为 ArrayBuffer

    1
    2
    3
    4
    5
    6
    const reader = new FileReader()
    reader.onload = () => {
    const arrayBuffer = reader.result
    console.log(arrayBuffer)
    }
    reader.readAsArrayBuffer(file)

七、总结

对于浏览器的文件上传,一般都是构建出 File/Blob 对象,再将其封装进 FormData 对象进行上传。而对于文件下载

若后端返回的数据是 Base64 格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 下载文件
const handleDownload = async (index, row) => {
try {
// 调用下载附件的接口,传递文件名处理器
const { data } = await downloadAttachments({
fileNameHandler: row.filenameHandler || row.appendHandler,
})

// 将 Base64 编码的文件内容解码为二进制字符串。atob 代表 "ASCII to Binary。window.atob() 只能解码 Base64 编码的字符串。window.btoa() 是 window.atob() 的逆函数,用于将二进制数据字符串编码
const binaryStr = window.atob(data.fileContent)

// 创建一个新的 ArrayBuffer,长度为二进制字符串的长度
const ab = new ArrayBuffer(binaryStr.length)

// 创建一个新的 Uint8Array 视图,指向 ArrayBuffer
const ia = new Uint8Array(ab)

// 将二进制字符串的每个字符的 Unicode 编码值存储到 Uint8Array 中
for (let i = 0; i < binaryStr.length; i++) {
ia[i] = binaryStr.charCodeAt(i)
}

// 创建一个新的 Blob 对象,包含 Uint8Array 的数据
const blob = new Blob([ia])

// 获取下载文件的名称
const fileName = row.filename

// 创建一个隐藏的 <a> 元素,用于触发下载
const alink = document.createElement('a')
alink.download = fileName
alink.style.display = 'none'

// 创建一个指向 Blob 对象的 URL,并将其设置为 <a> 元素的 href 属性
alink.href = URL.createObjectURL(blob)

// 将 <a> 元素添加到文档中
document.body.appendChild(alink)

// 触发 <a> 元素的点击事件,开始下载文件
alink.click()

// 释放 URL 对象
URL.revokeObjectURL(alink.href)

// 从文档中移除 <a> 元素
document.body.removeChild(alink)

// 显示下载成功的消息
ElMessage.success('下载成功')
} catch {
// 显示下载失败的消息
ElMessage.error('下载失败')
}
}

虽然后端返回的 Base64 可以直接作为 a 标签的 href 属性进行下载,但在实际开发中却并不经常这样做,主要是因为通用性问题:BlobURL.createObjectURL 支持所有类型的文件,而 Base64 编码的 Data URL 可能在某些浏览器或文件类型上存在兼容性问题

若后端返回的是 File,则可以直接用它创建一个下载链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 假设 downloadFile 是一个返回 File 对象的函数
const handleDownload = async () => {
try {
const file = await downloadFile() // 获取 File 对象

// 创建一个隐藏的 <a> 元素,用于触发下载
const alink = document.createElement('a')
alink.download = file.name // 设置下载文件的名称
alink.style.display = 'none'

// 创建一个指向 File 对象的 URL,并将其设置为 <a> 元素的 href 属性
alink.href = URL.createObjectURL(file)

// 将 <a> 元素添加到文档中
document.body.appendChild(alink)

// 触发 <a> 元素的点击事件,开始下载文件
alink.click()

// 释放 URL 对象
URL.revokeObjectURL(alink.href)

// 从文档中移除 <a> 元素
document.body.removeChild(alink)

console.log('下载成功')
} catch (error) {
console.error('下载失败', error)
}
}

若后端返回的是二进制数据(例如 ArrayBufferBlob),可以先将其转化为 Blob 对象,再创建下载链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 假设 downloadBinaryData 是一个返回 ArrayBuffer 或 Blob 的函数
const handleDownload = async () => {
try {
const response = await downloadBinaryData() // 获取二进制数据
const blob = new Blob([response], { type: 'application/octet-stream' }) // 创建 Blob 对象

// 创建一个隐藏的 <a> 元素,用于触发下载
const alink = document.createElement('a')
alink.download = 'downloaded_file' // 设置下载文件的名称
alink.style.display = 'none'

// 创建一个指向 Blob 对象的 URL,并将其设置为 <a> 元素的 href 属性
alink.href = URL.createObjectURL(blob)

// 将 <a> 元素添加到文档中
document.body.appendChild(alink)

// 触发 <a> 元素的点击事件,开始下载文件
alink.click()

// 释放 URL 对象
URL.revokeObjectURL(alink.href)

// 从文档中移除 <a> 元素
document.body.removeChild(alink)

console.log('下载成功')
} catch (error) {
console.error('下载失败', error)
}
}

移动端图片预览实现

思路:

1、从文章内容中获取到所有的 img DOM 节点

2、获取文章内容中所有的图片地址

3、遍历所有 img 节点,给每个节点注册点击事件

4、在 img 点击事件处理函数中,调用 vant 提供的 ImagePreview 预览

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<template>
<div
class="article-content markdown-body"
v-html="article.content"
ref="article-content"
></div>
</template>
<script>
export default {
data () {
return {
article: {}
}
}
methods: {
async loadArticle () {
try {
const { data } = await getArticleById(this.articleId)
// 数据驱动视图这件事儿不是立即的
this.article = data.data
// 初始化图片点击预览。因为vue更新dom是异步的,所以这里获取文章数据后,马上通过ref这种方式 // 是获取不到最新的dom的,除了下面这种方式,也可以通过$nextTick
setTimeout(() => {
this.previewImage()
}, 0)
} catch (err) {
if (err.response && err.response.status === 404) {
this.errStatus = 404
}
}
},
previewImage () {
// 得到所有的 img 节点
const articleContent = this.$refs['article-content']
const imgs = articleContent.querySelectorAll('img')
// 获取所有 img 地址
const images = []
imgs.forEach((img, index) => {
images.push(img.src)
// 给每个 img 注册点击事件,在处理函数中调用预览
img.onclick = () => {
ImagePreview({
// 预览的图片地址数组
images,
// 起始位置,从 0 开始
startPosition: index
})
}
})
}
}
}
</script>

文件预览功能

我这里实现了图片、excel、docx、pdf 格式的文件在浏览器中预览

一、插件安装

1
npm install @vue-office/docx @vue-office/excel @vue-office/pdf

二、路由配置

我将预览页做成了一个单独的路由页面(一级),因此需要配置下路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import { createRouter, createWebHashHistory } from "vue-router";
import Login from "../views/Login.vue";
import Home from "../views/Home.vue";
import Welcome from "../views/Welcome.vue";
const routes = [
{
path: "/",
redirect: "/login",
meta: { title: "登录页" },
},
{
path: "/login",
name: "login",
component: Login,
meta: { title: "登录页" },
},
{
path: "/home",
name: "home",
component: Home,
redirect: "welcome",
meta: { title: "欢迎页" },
children: [
{
path: "/welcome",
name: "welcome",
component: Welcome,
meta: { title: "欢迎页" },
},
{
path: "/myDoTask",
name: "myDoTask",
component: () => import("@/views/workBench/MyDoTask.vue"),
meta: { title: "我的待办" },
},
{
path: "/myDidTask",
name: "myDidTask",
component: () => import("@/views/workBench/MyDidTask.vue"),
meta: { title: "我的已办" },
},
{
path: "/myApplyTask",
name: "myApplyTask",
component: () => import("@/views/workBench/MyApplyTask.vue"),
meta: { title: "我的申请" },
},
{
path: "/myReadTask",
name: "myReadTask",
component: () => import("@/views/workBench/MyReadTask.vue"),
meta: { title: "我的阅知" },
},
{
path: "/myReadDoneTask",
name: "myReadDoneTask",
component: () => import("@/views/workBench/MyReadDoneTask.vue"),
meta: { title: "我的已阅" },
},
{
path: "/myDraftTask",
name: "myDraftTask",
component: () => import("@/views/workBench/MyDraftTask.vue"),
meta: { title: "我的草稿箱" },
},
{
path: "/marketingGeFix/:WfCode",
name: "marketingGeFix/:WfCode",
component: () => import("@/views/marketing/MarketingGeFix.vue"),
meta: { title: "营销审批-政企产品类(含高校)-固话" },
},
],
},
{
path: "/printDidTask",
name: "printDidTask",
component: () => import("@/views/print/PrintDidTask.vue"),
meta: { title: "已办打印" },
},
// 附件预览页面
{
path: "/preview",
name: "preview",
component: () => import("@/views/preview/preview.vue"),
meta: { title: "附件预览" },
},
{
path: "/403",
name: "403",
meta: {
title: "没有权限",
},
component: () => import("@/views/403.vue"),
},
{
path: "/:pathMatch(.*)",
name: "404",
meta: {
title: "页面不存在",
},
component: () => import("@/views/404.vue"),
},
];

const router = createRouter({
history: createWebHashHistory(),
routes,
base: "/approve/",
});
const whiteList = [
"/403",
"/404",
"/home",
"/welcome",
"/myDoTask",
"/myDidTask",
"/myApplyTask",
"/myReadTask",
"/myReadDoneTask",
"/myDraftTask",
"/printDidTask",
"/printDidTaskPrettyNumber",
"/preview",
];
// 挂载路由导航守卫
router.beforeEach(async (to, from, next) => {
if (to.path === "/login" || to.path === "/403" || to.path === "/404") {
return next();
}
// 获取token
const tokenStr = window.sessionStorage.getItem("token");
if (!tokenStr) {
return next("/login");
} else {
if (whiteList.includes(to.path)) return next();
// 获取to.path对应的menu_id
// const { data } = await getMenuIdByURLAPI({ menu_url: to.path.substring(1) })
// debugger
const menuInfoList = JSON.parse(window.sessionStorage.getItem("menuInfo"));
const result = menuInfoList.filter((item) => {
return item.menu_url === to.path.substring(1);
});
const menuIdList = result.map((item) => item.menu_id + "");
if (result) {
// 根据staff_code 和menu_id获取用户页面访问权限
const userMenuAccessList = JSON.parse(
window.sessionStorage.getItem("userMenuAccess")
);
const res = userMenuAccessList.find((item) =>
menuIdList.includes(item.menu_id)
);
if (res) {
next();
} else {
next("/403");
}
} else {
next("/404");
}
}
});

export default router;

三、预览组件

创建路由中对应的预览组件 preview.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
<template>
<div class="previewContainer">
<el-image
v-if="fileType === 'jpg' || fileType === 'png' || fileType === 'bmp'"
:src="fileURL"
@error="errorHandler"
></el-image>
<vue-office-docx
v-if="fileType === 'docx'"
:src="fileURL"
style="height: 100vh"
@error="errorHandler"
/>
<vue-office-excel
v-if="fileType === 'xlsx'"
:src="fileURL"
style="height: 100vh"
@error="errorHandler"
/>
<vue-office-pdf
v-if="fileType === 'pdf'"
:src="fileURL"
@error="errorHandler"
/>
<el-button type="success" round class="returnBtn" @click="$router.back()"
>返回</el-button
>
<el-button
type="success"
round
class="downloadBtn"
@click="download(appendHandler)"
>下载</el-button
>
</div>
</template>

<script setup>
import { useRoute } from "vue-router";
// 引入VueOfficeDocx组件
import VueOfficeDocx from "@vue-office/docx";
// 引入相关样式
import "@vue-office/docx/lib/index.css";
// 引入VueOfficeExcel组件
import VueOfficeExcel from "@vue-office/excel";
// 引入相关样式
import "@vue-office/excel/lib/index.css";
// 引入VueOfficePdf组件
import VueOfficePdf from "@vue-office/pdf";
import { ElMessage } from "element-plus";
import { gdownloadAPI, getDownloadUrlAPI } from "@/api/TaskDetail";
import { ref, onMounted } from "vue";
const route = useRoute();
const fileType = route.params.fileType;
const fileURL = ref(null);
const getfileURL = async () => {
if (fileType === "jpg" || fileType === "png" || fileType === "bmp") {
getDownloadUrlAPI({ fileNameHandler: route.params.appendHandler }).then(
(res) => {
// 通过row.fileName.substring(row.fileName.lastIndexOf('.') + 1)获取文件的后缀名
fileURL.value = res.data.fileDownloadUrl;
}
);
} else {
const { data } = await gdownloadAPI({
fileNameHandler: route.params.appendHandler,
});
const binaryStr = window.atob(data.fileContent);
const ab = new ArrayBuffer(binaryStr.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < binaryStr.length; i++) {
ia[i] = binaryStr.charCodeAt(i);
}
const blob = new Blob([ia]); // new Blob([res])中不加data就会返回下图中[objece objece]内容(少取一层)

const fileReader = new FileReader();
fileReader.readAsArrayBuffer(blob);
fileReader.onload = () => {
// 将 ArrayBuffer 数据转换为 Base64 字符串
fileURL.value = fileReader.result;
};
}
};

onMounted(() => {
getfileURL();
});
const appendHandler = route.params.appendHandler;
const errorHandler = () => {
ElMessage.error("文件预览失败!");
};
const download = async (item) => {
try {
const { data } = await gdownloadAPI({
fileNameHandler: item,
});
const binaryStr = window.atob(data.fileContent);
const ab = new ArrayBuffer(binaryStr.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < binaryStr.length; i++) {
ia[i] = binaryStr.charCodeAt(i);
}
const blob = new Blob([ia]); // new Blob([res])中不加data就会返回下图中[objece objece]内容(少取一层)
const fileName = route.params.filename; // 下载文件名称
const alink = document.createElement("a");
alink.download = fileName;
alink.style.display = "none";
alink.href = URL.createObjectURL(blob);
document.body.appendChild(alink);
alink.click();
URL.revokeObjectURL(alink.href); // 释放URL 对象
document.body.removeChild(alink);
ElMessage.success("下载成功");
} catch {
ElMessage.error("下载失败");
}
};
</script>
<style scoped lang="less">
.previewContainer {
margin: 0;
padding: 0 100px;
background-color: rgb(14, 14, 14);
.returnBtn {
position: fixed;
bottom: 5px;
right: 52%;
opacity: 0.8;
}
.downloadBtn {
position: fixed;
bottom: 5px;
right: 47%;
opacity: 0.8;
}
}
</style>

四、使用预览功能

当在其他地方需要预览文件时,跳转到预览组件,并通过路由传递参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<el-button type="primary" @click="preview(scope.$index, scope.row)"
>预览</el-button
>
</template>
<script setup>
const preview = async (index, row) => {
// 跳转之前先通知父组件保存页面的状态数据,这样预览返回的时候可以保持原来的状态
emit("saveStateData");
router.push({
name: "preview",
params: {
fileType: row.filename.substring(row.filename.lastIndexOf(".") + 1),
appendHandler: row.appendHandler,
filename: row.filename,
},
});
};
</script>

顺便一提,由于跳转页面之后,原页面的数据丢失,我还将原页面的数据放在了 store 中方便返回时回显。

打印功能

一、安装

1
npm install vue-print-nb --save-dev

二、注册

在 main.js 中引入并注册

1
2
3
4
5
import { createApp } from "vue";
import print from "vue3-print-nb";
import App from "./App.vue";
const app = createApp(App);
app.use(print).mount("#app");

三、组件中使用

定义一个 printObj 对象,注意局部打印必须在 printObj 对象上配置 id 属性,并将其绑定在需要打印的容器上。打印按钮通过 v-print=”printObj”实现打印效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<template>
<div id="myPrint">
<!-- 流程信息详情 -->
<el-table :data="wfData" style="margin-top: 70px">
<el-table-column type="index" width="auto" />
<el-table-column prop="node_name" label="环节名称" width="auto" />
<el-table-column prop="proc_type" label="环节类型" width="auto" />
<el-table-column prop="deal_state" label="环节状态" />
<el-table-column prop="username" label="办理人" />
<el-table-column prop="dept" label="办理人部门" />
<el-table-column prop="action_value" label="办理意见" />
<el-table-column prop="create_date" label="创建时间" />
<el-table-column prop="complete_date" label="完成时间" />
<el-table-column prop="deal_content" label="审批意见" />
</el-table>
</div>
<el-row type="flex" justify="center" style="margin-top: 10px">
<el-button v-print="printObj" type="primary">打印</el-button>
<el-button @click="$router.back()">返回</el-button>
</el-row>
</template>

<script>
import { ref, onMounted, defineComponent } from "vue";
import { useRoute } from "vue-router";

export default defineComponent({
setup() {
const route = useRoute();
const wfData = ref(null);
const printObj = ref({
id: "myPrint",
});
onMounted(() => {
wfData.value = JSON.parse(route.params.wfData);
});
return { detailPage, applyIdValue, rowData, printObj, wfData };
},
});
</script>
<style scoped lang="less"></style>
  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: