Fork me on GitHub

一个滚动动画的表格

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

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

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>

一个简单的后台管理类项目

一、项目概述

这是一个专门用于运营商后台发起靓号审批申请的项目。技术选型:vue3+elementplus+vite+vue-router+pinia.

用到的插件主要有:

  • axios 请求后台数据
  • crypto-js 加解密
  • dayjs 日期处理
  • nanoid 生成 id
  • vue3-print-nb 打印文件插件
  • xlsx 处理 excel 文件
  • pinia-plugin-persistedstate 配合 pinia 实现数据持久化

二、工具文件夹

1.crypto-js 加解密

因为在实际上线后,本项目主要是从另外一个项目跳转登录,在跳转时携带了用户身份标识的参数。而这个参数不能使用明文传递,因此使用 crypto-js 进行了加密,在跳转到本项目后使用 crypto-js 将加密的参数解密处理进行用户身份认证,继而实现登录。在 src/utils 下新建 encipher.js 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import CryptoJS from "crypto-js";

// 加密
export function encrypt(message = "", key = "") {
const srcs = CryptoJS.enc.Utf8.parse(message);
const encrypted = CryptoJS.AES.encrypt(srcs, key);
return encrypted.toString();
}

// 解密
export function decrypt(encrypted = "", key = "") {
const decrypt = CryptoJS.AES.decrypt(encrypted, key);
const decrypted = CryptoJS.enc.Utf8.stringify(decrypt).toString();
return decrypted;
}

2.request.js 封装 axios

在 src/utils 下新建 encipher.js 文件。

因为项目的网络请求调用了多个地方的接口,可以看到我这里封装了多个 request 实例

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
/**
* 封装 axios 请求模块
*/
import axios from "axios";
import { useUserInfoStore } from "@/store/useUserInfoStore";
import { toRefs } from "vue";
export const request = axios.create({
baseURL: "http://133.0.109.121:31455", // 上云data地址
// baseURL: 'http://192.168.137.63:8081', // 后端测试地址
// baseURL: 'http://localhost:8081', // 本机测试

timeout: 5000, // 单位毫秒
});

export const requestExpress = axios.create({
baseURL: "http://133.0.109.121:31456", // 上云express地址
timeout: 5000, // 单位毫秒
});
export const requestCRM = axios.create({
baseURL: "http://133.0.109.121:31896", // CRM地址
// baseURL: 'http://localhost:8085',
timeout: 5000, // 单位毫秒
});
// 请求拦截器,统一注入token
request.interceptors.request.use((config) => {
const { userInfo } = toRefs(useUserInfoStore());
if (userInfo.value.staffCode) {
config.headers.Authorization = userInfo.value.staffCode;
}
// 在最后必须 return config
return config;
});
// 响应拦截器,剥离一层data数据
request.interceptors.response.use((res) => {
//统一解决后端返回的错误信息
if (res.data.code !== 200) {
return Promise.reject(res.data.message);
}
return res.data;
});

三、路由配置

在 src/router 下新建 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
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
import { createRouter, createWebHashHistory } from "vue-router";
import { useUserInfoStore } from "@/store/useUserInfoStore";
import Home from "../views/home.vue";
import { toRefs } from "vue";
import { loginAPI } from "@/api/login";
import { ElMessage } from "element-plus";
import { decrypt } from "@/utils/encipher";
const routes = [
{
path: "/",
name: "Home",
component: Home,
redirect: "/NumberApplication",
children: [
// {
// path: '',
// name: 'dashboard',
// hidden: true,
// meta: {
// title: '系统首页',
// permiss: '1'
// },
// component: () =>
// import(/* webpackChunkName: "dashboard" */ '../views/dashboard.vue')
// },
//靓号申请(发起申请页)
{
path: "NumberApplication",
name: "NumberApplication",
meta: {
title: "靓号申请",
permiss: "2",
},
component: () => import("@/views/NumberApplication/index.vue"),
},
//靓号申请(填写表单页)
{
path: "NumberApplication2",
name: "NumberApplication2",
meta: {
title: "填写申请表单",
permiss: "3",
},
component: () => import("@/views/NumberApplication2/index.vue"),
},
//靓号申请3(指定号码)
{
path: "NumberApplication3",
name: "NumberApplication3",
meta: {
title: "查询指定号码",
permiss: "4",
},
component: () => import("@/views/NumberApplication3/index.vue"),
},
//靓号申请4(不指定号码)
{
path: "NumberApplication4",
name: "NumberApplication4",
meta: {
title: "不指定号码查询",
permiss: "5",
},
component: () => import("@/views/NumberApplication4/index.vue"),
},
//虚拟成本管理
{
path: "VirtualCost",
name: "VirtualCost",
meta: {
title: "虚拟成本管理",
permiss: "999",
},
component: () => import("@/views/VirtualCost.vue"),
},
//用户权限管理
{
path: "UserAccessConfig",
name: "UserAccessConfig",
meta: {
title: "用户访问权限管理",
permiss: "999",
},
component: () => import("@/views/UserAccessConfig.vue"),
},
//客户类型管理
{
path: "CustomerTypeConfig",
name: "CustomerTypeConfig",
meta: {
title: "客户类型管理",
permiss: "999",
},
component: () => import("@/views/CustomerTypeConfig.vue"),
},
//补充号码等级
{
path: "AddNumberLevel",
name: "AddNumberLevel",
meta: {
title: "补充号码等级",
permiss: "999",
},
component: () => import("@/views/AddNumberLevel.vue"),
},
],
},

{
path: "/login",
name: "Login",
meta: {
title: "登录",
},
component: () =>
import(/* webpackChunkName: "login" */ "../views/login.vue"),
},
{
path: "/403",
name: "403",
meta: {
title: "没有权限",
},
component: () => import(/* webpackChunkName: "403" */ "../views/403.vue"),
},
{
path: "/:pathMatch(.*)",
name: "404",
meta: {
title: "页面不存在",
},
component: () => import("@/views/404.vue"),
},
];

const router = createRouter({
history: createWebHashHistory(),
routes,
// scrollBehavior: () => ({ top: 0 }) 这里配置滚动条位置并不能生效,因为scrollBehavior配置的只对滚动容器是HTML,也就是整个页面才生效。而该项目的滚动容器并不是整个页面,而是home组件中的一个dom!!!
});
router.beforeEach(async (to, from, next) => {
document.title = `${to.meta.title} | 湖北电信靓号管理系统`;
const { userInfo, getUserInfo, keys } = toRefs(useUserInfoStore());
const staffCode = decrypt(to.query.staffCode);
if (staffCode) {
try {
const { data } = await loginAPI({
password: "111111",
staff_code: staffCode,
});
getUserInfo.value({ staff_code: data.staffCode });
next("/");
} catch {
ElMessage.error("登录失败");
}
}
if (!userInfo.value.staffCode && to.path !== "/login") {
next("/login");
} else if (
to.meta.permiss &&
keys.value &&
!keys.value.includes(to.meta.permiss)
) {
// 如果没有权限,则进入403
next("/403");
} else {
next();
}
});
export default router;

这里值得注意的点有:

1.页面滚动条

在路由这里我想通过 scrollBehavior 来控制页面的滚动行为,以达到无论上一个页面滚动条是否在顶部,在切换页面(路由)后,页面始终置顶的效果。

image-20240312105629967

正如我在代码注释中所写,如果我将 scrollBehavior 配置在路由中,那么它针对的将是整个页面即 app.vue(包含 header、菜单栏 sider-bar、以及主体显示部分 home),这样配置的话只有在一级路由切换时滚动条才会生效。而我想要的是只在 home 页切换时生效即可。

因此,在 home.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
<template>
<v-header />
<v-sidebar />
<div class="content-box" :class="{ 'content-collapse': sidebar.collapse }">
<v-tags></v-tags>
<!-- 这个才是真正的滚动容器,需要在这里配合路由守卫去控制滚动条的位置!!! -->
<div class="content" ref="scrollContainer">
<router-view v-slot="{ Component }" :key="key">
<transition name="move" mode="out-in">
<!-- <keep-alive :include="tags.nameList"> -->
<component :is="Component" :key="route.fullPath"></component>
<!-- </keep-alive> -->
</transition>
</router-view>
</div>
</div>
</template>
<script setup>
import { useSidebarStore } from '../store/sidebar'
// import { useTagsStore } from '../store/tags'
import vHeader from '../components/header.vue'
import vSidebar from '../components/sidebar.vue'
import vTags from '../components/tags.vue'
import { ref, computed } from 'vue'
import router from '@/router/index.js'
import { useRoute } from 'vue-router'
const sidebar = useSidebarStore()
// const tags = useTagsStore()
const scrollContainer = ref()
// 控制滚动条的位置
router.afterEach((to, from) => {
scrollContainer.value.scrollTop = 0
})
const route = useRoute()
const key = computed(() => {
return route.path + Math.random()
})
</script>

2.路由前置守卫拦截逻辑

这里的拦截逻辑是:

  • 如果是从另一个项目携带用户名跳转而来,则使用该用户名和默认密码’111111’进行登录,登录成功,获取用户信息存储到 store 中。从而实现跨一个平台免登录的效果。
  • 如果 store 中没有存储用户信息,说明用户未登录
  • 每个页面具有对应的权限等级标识,通过 meta 中的 permiss 标识。在 store 中简单的将用户角色分为了管理员和普通用户两种(当然也可以根据需求进行多样化的调整),通过 store 中存储的用户信息判断该用户是普通用户还是管理员。这样就可以在路由中判断用户是否具有要去的页面的权限了。

由此引出 store 中的 useUserInfo.js

四、store 中存放用户信息

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
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { getUserInfoAPI } from "@/api/login.js";
import { getRegion } from "@/api/NumberApplication.js";
import { ElMessage } from "element-plus";
import router from "@/router/index.js";
import { verifyPageAccessAPI } from "@/api/login.js";
export const useUserInfoStore = defineStore(
"userInfo",
() => {
const userInfo = ref({});
// 获取用户信息
const getUserInfo = async (value) => {
const userRegionInfo = {
new_region_id: "",
region_id: "",
region_name: "",
};
const { data } = await getUserInfoAPI(value);
const result = await getRegion({ staff_code: value.staff_code });
// 如果工号以hb开头,先用部门去匹配,如果匹配不到,则region_id为1000
if (value.staff_code.startsWith("HB")) {
const temp = result.data.find((item) => {
return item.region_name === data[0].dept;
});
if (!temp) {
userRegionInfo.region_id = 1000;
userRegionInfo.new_region_id = 0;
userRegionInfo.region_name = "其他";
} else {
userRegionInfo.new_region_id = temp.new_region_id
? temp.new_region_id
: 0;
userRegionInfo.region_id = temp.region_id;
userRegionInfo.region_name = temp.region_name;
}
} else {
userRegionInfo.new_region_id = result.data[0].new_region_id;
userRegionInfo.region_name = result.data[0].region_name;
userRegionInfo.region_id = result.data[0].region_id;
}
//userAccessList就是用户所拥有的权限列表
const userAccessList = await verifyPageAccessAPI({
staff_code: value.staff_code,
});
// 目前的逻辑是如果该用户同时拥有菜单配置menu_id为3031和审批人配置menu_id为3032的权限,那么他就是管理员
const hasAccess1 = userAccessList.data.some(
(item) => item.menu_id === "3031"
);
const hasAccess2 = userAccessList.data.some(
(item) => item.menu_id === "3032"
);
const isAdmin = hasAccess1 && hasAccess2;
userInfo.value = {
...data[0],
staffCode: value.staff_code,
...userRegionInfo,
isAdmin,
};
ElMessage.success("登录成功");
router.replace("/");
};
// 更改用户信息
const setUserInfo = (value) => {
userInfo.value = value;
};
// 清除用户信息
const clearUserInfo = () => {
userInfo.value = {};
};
const defaultList = ref({
admin: [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"999", //这是虚拟成本的权限标识
],
user: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
});
//在此表中的人员都是管理员
console.log(userInfo.value.isAdmin, "111");

const keys = computed(
() => defaultList.value[userInfo.value.isAdmin ? "admin" : "user"]
);
console.log(keys);
const handleSet = (val) => {
keys.value = val;
};

return {
userInfo,
getUserInfo,
setUserInfo,
clearUserInfo,
keys,
defaultList,
handleSet,
};
},
{
// persist:true
persist: {
storage: sessionStorage, //localStorage
},
}
);
  • 可以看到这里定义了获取用户信息、清除用户信息的方法。可以在登录和退出登录时调用。其中的用户信息可以在项目需要的时候随时随地方便获取,同时还定义了修改用户信息的方法,不过它一般应该只用来将一些信息添加到用户信息身上。
  • 本项目目前只有管理员和普通用户两种角色,他们被定义在 defaultList 中的 admin 和 user 中,通过用户信息的简单判断来对应 defaultList 中的 admin 和 user。再结合路由守卫,这样他们就分别有了管理员和普通用户的页面权限。当然这里的角色定义和角色判断可以结合后端灵活实现和扩展。
  • 由于使用了 pinia-plugin-persistedstate 插件,可以直接通过 persist 配置选项中的 storage 将数据持久化.注意如果直接给 true 默认使用的是 localStorage。

五、自定义指令和组件

自定义指令放在 src/directives/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useUserInfoStore } from "@/store/useUserInfoStore.js";
import { toRefs } from "vue";
// 自定义权限指令
export const permissDirective = {
install(app) {
const { keys } = toRefs(useUserInfoStore());
app.directive("permiss", {
mounted(el, binding) {
if (keys.value && !keys.value.includes(String(binding.value))) {
el["hidden"] = true;
}
},
});
},
};

自定义组件放在 src/components 中,并最终通过 index.js 进行统一导出

1
2
3
4
5
6
7
8
9
10
11
12
import TaskDeal from "./TaskDeal.vue";
import TaskDetail from "./TaskDetail.vue";
import TaskDetailDid from "./TaskDetailDid.vue";
import TaskDealNew from "./TaskDealNew.vue";
export default {
install(app) {
app.component("TaskDeal", TaskDeal);
app.component("TaskDetail", TaskDetail);
app.component("TaskDetailDid", TaskDetailDid);
app.component("TaskDealNew", TaskDealNew);
},
};

最后别忘了在 main.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
import { createApp } from "vue";
import { createPinia } from "pinia";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import App from "./App.vue";
import router from "./router/index.js";
import "element-plus/dist/index.css";
import "./assets/css/icon.css";
// 引入pinia数据持久化插件
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import { permissDirective } from "@/directives/index.js";
import Components from "@/components/index.js";
import axios from "axios";
import print from "vue3-print-nb";

const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
pinia.use(piniaPluginPersistedstate);
app.use(router);
app.use(permissDirective);
// 注册elementplus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.config.globalProperties.$http = axios;

app.use(Components).use(print);
app.mount("#app");

六、用户信息脱敏处理

​ 对于像客户的电话号码、姓名、身份证号这些敏感数据,需要进行脱敏后再显示,为此可在 src/utils 下新建 sensitiveDataHandle.js 进行数据脱敏处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 信息脱敏处理
// beginLen:开始脱敏的字符下标; endLen:结束脱敏的字符下标; str:需要脱敏的字符串; max:掩码最大的重复数量,若不传,则默认为20
export function getStr(beginLen, endLen, str, max = 20) {
// substr(begin,length),begin:需要截取的字符串的下标,必须是数值,若为负数,则从字符串最后一位向左数;
// length:需要截取的字符串长度,若不写此参数,则返回从开始位置到结束的所有字符
const firstStr = str.substr(0, beginLen);
// 结束脱敏的字符下标是否为0,为0则返回空,不为0则返回截取的字符
const lastStr = endLen == 0 ? "" : str.substr(endLen);
// Math.max(x,y,z,...,n),返回x,y,z,...,n中的最大值
// Math.abs(x),返回x的绝对值
// Math.min(x,y,z,...,n),返回x,y,z,...,n中的最小值
// 开始脱敏的下标 加上 结束脱敏的下标是否小于字符串总长度
let repeatNum = Math.max(0, str.length - (beginLen + Math.abs(endLen)));
// 若传了最大掩码重复数,则取数值小的那个
repeatNum = Math.min(max, repeatNum);
// 字符串复制指定次数,语法: string.repeat(count), count:设置需要重复的次数
const middleStr = "*".repeat(repeatNum); // 掩码要重复的数量
return firstStr + middleStr + lastStr;
}

七、客户类别互斥多选

原先客户类别选择框只是简单的多选,比如我可以在所有的选项中随意的选取几项。但是其实选项里面是有分组和互斥关系的,比如市州政府、事业单位客户和省级政府、事业单位客户和省级政府、事业单位关键人这三个应该是一组的,只能从这组中选择一个。基于此又提出了更高的需求:要求选项之间分组,选择时互斥的多选

image-20240312151003640

我将客户类型数据定义在了 src/constant/customerType.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
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
//四级手机靓号客户类型
// export const customerTypeLvl4 = [
// { label: '市州政府、事业单位客户', value: '市州政府、事业单位客户' },
// { label: '县市级别关键人', value: '县市级别关键人' },
// { label: '政企团购80户以上关键人', value: '政企团购80户以上关键人' },
// { label: '年收入超过30万客户', value: '年收入超过30万客户' },
// { label: '产数项目超100万客户', value: '产数项目超100万客户' },
// { label: '省级政府、事业单位客户', value: '省级政府、事业单位客户' },
// { label: '市州级别关键人', value: '市州级别关键人' },
// { label: '政企团购150户以上关键人', value: '政企团购150户以上关键人' },
// { label: '年收入超过100万客户', value: '年收入超过100万客户' },
// { label: '产数项目超300万客户', value: '产数项目超300万客户' },
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]
// //五级手机靓号客户类型
// export const customerTypeLvl5 = [
// { label: '省级政府、事业单位客户', value: '省级政府、事业单位客户' },
// { label: '市州级别关键人', value: '市州级别关键人' },
// { label: '政企团购150户以上关键人', value: '政企团购150户以上关键人' },
// { label: '年收入超过100万客户', value: '年收入超过100万客户' },
// { label: '产数项目超300万客户', value: '产数项目超300万客户' },
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]
// //六级手机靓号客户类型
// export const customerTypeLvl6 = [
// { label: '省级政府、事业单位关键人', value: '省级政府、事业单位关键人' },
// { label: '重点大型企业集团客户关键人', value: '重点大型企业集团客户关键人' },
// { label: '政企团购300户以上关键人', value: '政企团购300户以上关键人' },
// { label: '年收入超过200万客户', value: '年收入超过200万客户' },
// { label: '产数项目超500万客户', value: '产数项目超500万客户' }
// ]

export const customerTypeLvl4 = [
{
label: "1",
options: [
{
label: "市州政府、事业单位客户",
value: "市州政府、事业单位客户",
state: false,
},
{
label: "省级政府、事业单位客户",
value: "省级政府、事业单位客户",
state: false,
},
{
label: "省级政府、事业单位关键人",
value: "省级政府、事业单位关键人",
state: false,
},
],
},
{
label: "2",
options: [
{ label: "县市级别关键人", value: "县市级别关键人", state: false },
{ label: "市州级别关键人", value: "市州级别关键人", state: false },
{
label: "重点大型企业集团客户关键人",
value: "重点大型企业集团客户关键人",
state: false,
},
],
},
{
label: "3",
options: [
{
label: "政企团购80户以上关键人",
value: "政企团购80户以上关键人",
state: false,
},
{
label: "政企团购150户以上关键人",
value: "政企团购150户以上关键人",
state: false,
},
{
label: "政企团购300户以上关键人",
value: "政企团购300户以上关键人",
state: false,
},
],
},
{
label: "4",
options: [
{
label: "年收入超过30万客户",
value: "年收入超过30万客户",
state: false,
},
{
label: "年收入超过100万客户",
value: "年收入超过100万客户",
state: false,
},
{
label: "年收入超过200万客户",
value: "年收入超过200万客户",
state: false,
},
],
},
{
label: "5",
options: [
{
label: "产数项目超100万客户",
value: "产数项目超100万客户",
state: false,
},
{
label: "产数项目超300万客户",
value: "产数项目超300万客户",
state: false,
},
{
label: "产数项目超500万客户",
value: "产数项目超500万客户",
state: false,
},
],
},
];
//五级手机靓号客户类型
export const customerTypeLvl5 = [
{
label: "1",
options: [
{ label: "省级政府、事业单位客户", value: "省级政府、事业单位客户" },
{ label: "省级政府、事业单位关键人", value: "省级政府、事业单位关键人" },
],
},
{
label: "2",
options: [
{ label: "市州级别关键人", value: "市州级别关键人" },
{
label: "重点大型企业集团客户关键人",
value: "重点大型企业集团客户关键人",
},
],
},
{
label: "3",
options: [
{ label: "政企团购150户以上关键人", value: "政企团购150户以上关键人" },
{ label: "政企团购300户以上关键人", value: "政企团购300户以上关键人" },
],
},
{
label: "4",
options: [
{ label: "年收入超过100万客户", value: "年收入超过100万客户" },
{ label: "年收入超过200万客户", value: "年收入超过200万客户" },
],
},
{
label: "5",
options: [
{ label: "产数项目超300万客户", value: "产数项目超300万客户" },
{ label: "产数项目超500万客户", value: "产数项目超500万客户" },
],
},
];
//六级手机靓号客户类型
export const customerTypeLvl6 = [
{ label: "省级政府、事业单位关键人", value: "省级政府、事业单位关键人" },
{ label: "重点大型企业集团客户关键人", value: "重点大型企业集团客户关键人" },
{ label: "政企团购300户以上关键人", value: "政企团购300户以上关键人" },
{ label: "年收入超过200万客户", value: "年收入超过200万客户" },
{ label: "产数项目超500万客户", value: "产数项目超500万客户" },
];

在具体的业务代码中

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
<template>
<!-- 只在手机靓号四级、五级、六级时显示 -->
<el-form-item
label="客户类型"
prop="customerType"
v-if="
selectFormData.level_name === '四级手机靓号' ||
selectFormData.level_name === '五级手机靓号' ||
selectFormData.level_name === '六级手机靓号'
"
>
<el-select
style="width: 250px"
v-model="selectFormData.customerType"
placeholder="请选择"
multiple
collapse-tags
collapse-tags-tooltip
@change="selectChange"
>
<template v-if="selectFormData.level_name === '六级手机靓号'">
<el-option
v-for="item in customerTypeList"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</template>
<template v-else>
<el-option-group
v-for="group in customerTypeList"
:key="group.label"
:label="group.label"
>
<el-option
v-for="item in group.options"
:key="item.value"
:label="item.label"
:value="item.value"
:disabled="item.state"
/>
</el-option-group>
</template>
</el-select>
</el-form-item>
</template>
<script setup>
const customerTypeList = ref([])
watchEffect(() => {
if (selectFormData.value.level_name === '四级手机靓号') {
customerTypeList.value = customerTypeLvl4
} else if (selectFormData.value.level_name === '五级手机靓号') {
customerTypeList.value = customerTypeLvl5
} else if (selectFormData.value.level_name === '六级手机靓号') {
customerTypeList.value = customerTypeLvl6
} else {
customerTypeList.value = []
}
const selectChange = (val) => {
if (val.length === 0) {
customerTypeList.value.forEach((item) =>
item.options.forEach((ele) => (ele.state = false))
)
}
const tempList = ref([])
customerTypeList.value.forEach((item) => {
const tempArr = item.options.find((ele) => {
return ele.label === val[val.length - 1]
})
if (tempArr) {
tempList.value = item.options
}
})
tempList.value.forEach((item) => {
if (item.label !== val[val.length - 1]) {
item.state = true
}
})
}
</script>

这里我为了实现组内互斥效果,在选项上加了一个 state 字段控制选项的可选状态。当本组内已经有选项被选择时,则将该组内其他选项的可选状态禁用。同时不影响其他组的可选状态。具体逻辑在 selectChange 函数中实现。

八、文件上传下载功能

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
<template>
<!-- 上传附件区域 -->
<div class="container" style="margin-bottom: 10px">
<div class="title">
<span>上传附件</span>
</div>
<el-upload
v-model:file-list="fileList"
ref="uploadRef"
class="upload-demo"
:on-remove="handleRemove"
:on-change="changeFile"
:http-request="upload"
action="#"
:auto-upload="false"
multiple
style="display: flex; margin-bottom: 10px"
>
<template #trigger>
<el-button type="primary" :icon="Document" style="margin-right: 150px"
>选择文件</el-button
>
</template>
<template #tip v-if="!tempList.length">
<div class="el-upload__tip" style="margin: 10px 0 0 -216px">
未选择任何文件
</div>
</template>
<el-button
class="ml-3"
:icon="Upload"
type="success"
@click="handleUpload"
style="margin-top: 2px"
>
上传
</el-button>
</el-upload>
<span style="font-size: 14px; color: #606266">附件列表</span>
<el-table
:data="fileTableData"
border
class="table"
header-cell-class-name="table-header"
highlight-current-row
>
<el-table-column
prop="uid"
label="附件编号"
width="155"
align="center"
></el-table-column>
<el-table-column prop="filename" label="附件名称"> </el-table-column>
<el-table-column prop="size" label="附件大小"> </el-table-column>
<el-table-column label="操作" width="220" align="center">
<template #default="scope">
<el-button
type="warning"
size="small"
:icon="Delete"
@click="handleDel(scope.$index, scope.row)"
>
删除
</el-button>
<el-button
type="primary"
class="red"
size="small"
:icon="Download"
@click="handleDownload(scope.$index, scope.row)"
>
下载
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
// 文件上传
// 文件上传列表
const fileTableData = ref([]);
onMounted(() => {
if (applyForm.fileTableData) {
fileTableData.value = applyForm.fileTableData;
}
});
const fileList = ref([]);
const uploadRef = ref();
const tempList = ref([]);
// file就是要删除的file
const handleRemove = (file) => {};
// 不能够一味 的进行push 因为该函数会被多次调用 fileList其实就是当前最新的文件列表
const changeFile = (file) => {
// params参数中的file就是要上传的文件
// 文件类型不限制
// const isIMAGE = (file.raw.type === 'image/jpeg' || file.raw.type === 'image/png' || file.raw.type === 'image/gif')
const isLt25M = file.size / 1024 / 1024 < 25;
// 文件大小限制
if (!isLt25M) {
return ElMessage.error("上传文件大小不能超过25MB!");
}
const reader = new FileReader();
reader.readAsDataURL(file.raw);
reader.onload = function () {
// console.log('文件的base64数据', this.result)// 文件的base64数据

const fileInstance = {
filename: "",
size: "",
filenameHandler: "",
fileContent: "",
username: applyForm.staff_name,
nodeCode: "100",
uid: "",
};
const milliseconds = getCurrentMilliseconds();
// console.log('milliseconds', milliseconds)
fileInstance.filename = file.name;
fileInstance.size = file.size;
fileInstance.filenameHandler = milliseconds + file.name;
fileInstance.fileContent = this.result.split(",")[1];
fileInstance.uid = file.uid;
tempList.value.push(fileInstance);
};
};
// 后端要求filename加前缀New做新旧平台存储文件的区分
//tempFileListTableData是给最后提交时用的
const tempFileListTableData = computed(() => {
return fileTableData.value.map((item) => {
return {
filename: item.filename,
size: item.size,
filenameHandler: "New" + item.filenameHandler,
fileContent: item.fileContent,
username: item.username,
nodeCode: item.nodeCode,
uid: item.uid,
};
});
});
//tempFileList是给上传按钮用的
const tempFileList = computed(() => {
return tempList.value.map((item) => {
return {
filename: item.filename,
size: item.size,
filenameHandler: "New" + item.filenameHandler,
fileContent: item.fileContent,
username: item.username,
nodeCode: item.nodeCode,
uid: item.uid,
};
});
});

const upload = () => {};
const handleUpload = async () => {
try {
await uploadAPI({ fileList: tempFileList.value });
fileTableData.value.push(...tempList.value);
ElMessage.success("上传成功");
tempFileListTableData.value = [];
tempList.value = [];
fileList.value = [];
} catch {
ElMessage.error("上传失败");
}

uploadRef.value.submit();
};
</script>

九、其他

剩下的就是按照业务需求开发了

  • Copyrights © 2023-2025 congtianfeng
  • 访问人数: | 浏览次数: