element-plus表单二次封装

为什么需要二次封装?

在使用 Element Plus 进行项目开发时,我们经常会遇到表单组件的使用场景。虽然 Element Plus 提供了丰富的表单组件和功能,但在实际项目中直接使用原生组件往往会遇到以下问题:

1. 代码重复性高

  • 每个表单都需要重复编写验证规则、错误提示、样式调整等代码
  • 相似的字段配置(如手机号、邮箱、身份证等)在多个表单中重复出现
  • 表单布局和样式代码大量重复

2. 维护成本高

  • 当需要修改表单验证逻辑时,需要在多个地方进行修改
  • 样式调整需要逐个表单进行修改
  • 新增字段类型时需要重新编写相关逻辑

3. 用户体验不一致

  • 不同开发者编写的表单可能存在交互差异
  • 错误提示的展示方式不统一
  • 表单验证的反馈机制不一致

4. 开发效率低

  • 每次创建新表单都需要从零开始
  • 缺乏统一的表单模板和最佳实践
  • 调试和测试需要重复进行

5. 业务需求定制化

  • 需要根据具体业务场景定制表单行为
  • 需要集成特定的验证规则和业务逻辑
  • 需要统一的错误处理和用户反馈机制

因此,对 Element Plus 表单组件进行二次封装,可以:

  • 提高开发效率:通过组件化减少重复代码
  • 保证代码质量:统一代码规范和最佳实践
  • 提升用户体验:保持界面和交互的一致性
  • 降低维护成本:集中管理表单逻辑和样式
  • 增强扩展性:便于添加新功能和业务逻辑

接下来,我们将详细介绍如何实现 Element Plus 部分组件的二次封装。

表单组件封装

表单封装

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
<template>
<el-form
ref="formRef"
:model="model"
:rules="rules"
:label-width="labelWidth"
:label-position="labelPosition"
:label-suffix="labelSuffix"
:inline="inline"
:size="size"
:disabled="disabled"
:scroll-to-error="scrollToError"
@submit.prevent="handleSubmit"
>
<slot></slot>
</el-form>
</template>

<script>
export default {
name: 'ElFormPro',
};
</script>

<script setup>
const props = defineProps({
// 表单数据对象
model: {
type: Object,
required: true,
},
// 验证规则
rules: {
type: Object,
default: () => ({}),
},
// 标签宽度
labelWidth: {
type: [String, Number],
default: '130',
},
// 标签位置
labelPosition: {
type: String,
default: 'right',
validator: value => ['left', 'right', 'top'].includes(value),
},
// 标签后缀
labelSuffix: {
type: String,
default: '',
},
// 行内表单
inline: {
type: Boolean,
default: false,
},
// 尺寸
size: {
type: String,
default: 'default',
validator: value => ['large', 'default', 'small'].includes(value),
},
// 禁用状态
disabled: {
type: Boolean,
default: false,
},
// 验证失败时滚动到错误字段
scrollToError: {
type: Boolean,
default: false,
},
});

const emit = defineEmits(['submit', 'validate']);

const formRef = ref(null);

// 表单验证方法
const validate = async () => {
if (!formRef.value) return false;

try {
const valid = await formRef.value.validate();
emit('validate', valid);
return valid;
} catch (error) {
console.error('表单验证失败:', error);
emit('validate', false);
return false;
}
};

// 验证指定字段
const validateField = async (props, callback) => {
if (!formRef.value) return false;

try {
await formRef.value.validateField(props, callback);
} catch (error) {
console.error('字段验证失败:', error);
}
};

// 重置表单
const resetFields = () => {
if (!formRef.value) return;
formRef.value.resetFields();
};

// 清除验证信息
const clearValidate = props => {
if (!formRef.value) return;
formRef.value.clearValidate(props);
};

// 滚动到指定字段
const scrollToField = prop => {
if (!formRef.value) return;
formRef.value.scrollToField(prop);
};

// 处理表单提交
const handleSubmit = () => {
emit('submit');
};

// 获取表单数据
const getFormData = () => {
return props.model;
};

// 设置表单数据
const setFormData = data => {
if (data && typeof data === 'object') {
Object.assign(props.model, data);
}
};

// 重置表单数据
const resetFormData = () => {
if (props.model) {
Object.keys(props.model).forEach(key => {
if (Array.isArray(props.model[key])) {
props.model[key] = [];
} else if (typeof props.model[key] === 'object' && props.model[key] !== null) {
props.model[key] = {};
} else {
props.model[key] = '';
}
});
}
};
// 暴露方法
defineExpose({
validate,
validateField,
resetFields,
clearValidate,
scrollToField,
getFormData,
setFormData,
resetFormData,
formRef,
});
</script>

<style scoped></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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
<template>
<el-col :span="colSpan">
<el-form-item
style="width: 100%"
:label="label"
:prop="prop"
:label-width="labelWidth"
:required="required"
>
<!-- 文本输入框 -->
<el-input
v-if="type == 'text'"
v-model="inputValue"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:autosize="autosize"
:rows="rows"
:name="name"
:id="id"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@clear="handleClear"
@keyup="handleKeyup"
@keydown="handleKeydown"
@keypress="handleKeypress"
/>

<!-- 数字输入框 -->
<el-input-number
v-else-if="type == 'number'"
v-model="inputValue"
:placeholder="placeholderComputed"
:min="min"
:max="max"
:step="step"
:step-strictly="stepStrictly"
:precision="precision"
:size="size"
:disabled="disabled"
:controls="controls"
:controls-position="controlsPosition"
:name="name"
:id="id"
:label="label"
@change="handleChange"
@blur="handleBlur"
@focus="handleFocus"
style="width: 100%"
/>

<!-- 下拉选择框 -->
<el-select
v-else-if="type == 'select'"
v-model="inputValue"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:multiple="multiple"
:multiple-limit="multipleLimit"
:name="name"
:id="id"
:filterable="filterable"
:allow-create="allowCreate"
:filter-method="filterMethod"
:remote="remote"
:remote-method="remoteMethod"
:loading="loading"
:loading-text="loadingText"
:no-match-text="noMatchText"
:no-data-text="noDataText"
:fit-input-width="fitInputWidth"
:suffix-icon="suffixIcon"
:tag-type="tagType"
@change="handleChange"
@visible-change="handleVisibleChange"
@remove-tag="handleRemoveTag"
@clear="handleClear"
@blur="handleBlur"
@focus="handleFocus"
style="width: 100%"
>
<!-- 使用自定义模板 -->
<template v-if="useSlot">
<slot name="option" :options="options"></slot>
</template>
<!-- 使用默认模板 -->
<template v-else>
<el-option
v-for="option in options"
:key="option[optionsProps.value] || option.value"
:label="option[optionsProps.label] || option.label"
:value="option[optionsProps.value] || option.value"
:disabled="option.disabled"
/>
</template>
</el-select>

<!-- 级联选择器 -->
<el-cascader
v-else-if="type == 'cascader'"
v-model="inputValue"
:options="cascaderOptions"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:show-all-levels="showAllLevels"
:separator="separator"
:filterable="filterable"
:filter-method="filterMethod"
:debounce="debounce"
:before-filter="beforeFilter"
:tag-type="tagType"
@change="handleChange"
@expand-change="handleExpandChange"
@blur="handleBlur"
@focus="handleFocus"
@visible-change="handleVisibleChange"
@remove-tag="handleRemoveTag"
style="width: 100%"
/>

<!-- 日期选择器 -->
<el-date-picker
v-else-if="type == 'date'"
v-model="inputValue"
:type="dateType"
:placeholder="placeholderComputed"
:format="format"
:value-format="valueFormat"
:readonly="readonly"
:disabled="disabled"
:clearable="clearable"
:size="size"
:editable="editable"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:range-separator="rangeSeparator"
:default-value="defaultValue"
:default-time="defaultTime"
:is-range="isRange"
:unlink-panels="unlinkPanels"
:prefix-icon="prefixIcon"
:clear-icon="clearIcon"
:shortcuts="shortcuts"
:disabled-date="disabledDate"
:popper-style="popperStyle"
@change="handleChange"
@blur="handleBlur"
@focus="handleFocus"
@calendar-change="handleCalendarChange"
@panel-change="handlePanelChange"
@visible-change="handleVisibleChange"
style="width: 100%"
/>

<!-- 日期时间选择器 -->
<el-date-picker
v-else-if="type == 'datetime'"
v-model="inputValue"
type="datetime"
:placeholder="placeholderComputed"
:format="format || 'YYYY-MM-DD HH:mm:ss'"
:value-format="valueFormat || 'YYYY-MM-DD HH:mm:ss'"
:readonly="readonly"
:disabled="disabled"
:clearable="clearable"
:size="size"
:editable="editable"
:default-value="defaultValue"
:default-time="defaultTime"
:prefix-icon="prefixIcon"
:clear-icon="clearIcon"
:shortcuts="shortcuts"
:disabled-date="disabledDate"
:popper-style="popperStyle"
@change="handleChange"
@blur="handleBlur"
@focus="handleFocus"
@calendar-change="handleCalendarChange"
@panel-change="handlePanelChange"
@visible-change="handleVisibleChange"
style="width: 100%"
/>

<!-- 文本域 -->
<el-input
v-else-if="type == 'textarea'"
v-model="inputValue"
type="textarea"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:show-word-limit="showWordLimit"
:autosize="autosize"
:rows="rows || 2"
:name="name"
:id="id"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@clear="handleClear"
@keyup="handleKeyup"
@keydown="handleKeydown"
@keypress="handleKeypress"
/>

<!-- 复合型输入框 -->
<div v-else-if="type == 'append'" class="append-input-container">
<!-- 前缀 -->
<div v-if="prefix" class="append-prefix">
<span v-if="typeof prefix == 'string'" class="prefix-text">{{ prefix }}</span>
<component v-else :is="prefix" />
</div>
<!-- 内容 -->
<el-input
v-model="inputValue"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:name="name"
:id="id"
:tabindex="tabindex"
class="append-main-input"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@clear="handleClear"
@keyup="handleKeyup"
@keydown="handleKeydown"
@keypress="handleKeypress"
/>
<!-- 后缀插槽 -->
<div v-if="$slots.suffix" class="append-suffix">
<slot name="suffix" />
</div>
<!-- 单位 -->
<div v-if="unit" class="append-unit">
<span class="unit-text">{{ unit }}</span>
</div>
</div>

<!-- 默认文本输入框 -->
<el-input
v-else
v-model="inputValue"
:placeholder="placeholderComputed"
:clearable="clearable"
:disabled="disabled"
:readonly="readonly"
:type="inputType"
:autosize="autosize"
:name="name"
:id="id"
:tabindex="tabindex"
@input="handleInput"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
@clear="handleClear"
@keyup="handleKeyup"
@keydown="handleKeydown"
@keypress="handleKeypress"
/>
</el-form-item>
</el-col>
</template>

<script>
export default {
name: 'ElFormItemPro',
};
</script>

<script setup>
const props = defineProps({
colSpan: {
type: Number,
default: 12,
},
// 基础属性
modelValue: {
type: [String, Number, Array, Date],
default: '',
},
label: {
type: String,
default: '',
},
prop: {
type: String,
default: '',
},
labelWidth: {
type: [String, Number],
default: '',
},
required: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'default',
},

// 输入类型
type: {
type: String,
default: 'text',
},
placeholder: {
type: String,
default: '',
},
clearable: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},

// 文本输入框属性
maxlength: {
type: [String, Number],
default: '200',
},
showWordLimit: {
type: Boolean,
default: true,
},
inputType: {
type: String,
default: 'text',
},
autosize: {
type: [Object, Boolean],
default: false,
},
rows: {
type: Number,
default: 2,
},
name: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
tabindex: {
type: [String, Number],
default: undefined,
},

// 数字输入框属性
min: {
type: Number,
default: -Infinity,
},
max: {
type: Number,
default: Infinity,
},
step: {
type: Number,
default: 1,
},
stepStrictly: {
type: Boolean,
default: false,
},
precision: {
type: Number,
default: 2,
},
controls: {
type: Boolean,
default: true,
},
controlsPosition: {
type: String,
default: '',
},

// 选择框属性
options: {
type: Array,
default: () => [],
},
optionsProps: {
type: Object,
default: () => {
return { label: 'label', value: 'value' };
},
},
useSlot: {
type: Boolean,
default: false,
},
multiple: {
type: Boolean,
default: false,
},
multipleLimit: {
type: Number,
default: 0,
},
filterable: {
type: Boolean,
default: true,
},
allowCreate: {
type: Boolean,
default: false,
},
filterMethod: {
type: Function,
default: undefined,
},
remote: {
type: Boolean,
default: false,
},
remoteMethod: {
type: Function,
default: undefined,
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: '加载中',
},
noMatchText: {
type: String,
default: '无匹配数据',
},
noDataText: {
type: String,
default: '无数据',
},
fitInputWidth: {
type: Boolean,
default: false,
},
suffixIcon: {
type: [String, Object],
default: 'ArrowDown',
},
tagType: {
type: String,
default: 'info',
},

// 级联选择器属性
cascaderOptions: {
type: Array,
default: () => [],
},
showAllLevels: {
type: Boolean,
default: true,
},
separator: {
type: String,
default: ' / ',
},
debounce: {
type: Number,
default: 300,
},
beforeFilter: {
type: Function,
default: undefined,
},

// 日期选择器属性
dateType: {
type: String,
default: 'date',
},
format: {
type: String,
default: '',
},
valueFormat: {
type: String,
default: '',
},
startPlaceholder: {
type: String,
default: '',
},
endPlaceholder: {
type: String,
default: '',
},
rangeSeparator: {
type: String,
default: '-',
},
defaultValue: {
type: Date,
default: undefined,
},
defaultTime: {
type: [Date, Array],
default: undefined,
},
isRange: {
type: Boolean,
default: false,
},
unlinkPanels: {
type: Boolean,
default: false,
},
prefixIcon: {
type: [String, Object],
default: '',
},
clearIcon: {
type: [String, Object],
default: '',
},
shortcuts: {
type: Array,
default: () => [],
},
disabledDate: {
type: Function,
default: undefined,
},
popperStyle: {
type: Object,
default: () => ({}),
},

// 复合型输入框属性
prefix: {
type: [String, Object],
default: '',
},
unit: {
type: String,
default: '',
},
});

const emit = defineEmits([
'update:modelValue',
'input',
'change',
'focus',
'blur',
'clear',
'keyup',
'keydown',
'keypress',
'visible-change',
'remove-tag',
'expand-change',
'calendar-change',
'panel-change',
]);

// 内部值
const inputValue = ref(props.modelValue);

const placeholderComputed = computed(() => {
// 如果用户自定义了 placeholder,优先使用
if (props.placeholder) {
return props.placeholder;
}
const type = props.type || 'text';
const label = props.label || '';

switch (type) {
case 'select':
case 'cascader':
return label ? `请选择${label}` : '请选择';

case 'date':
return label ? `请选择${label}` : '请选择日期';

case 'datetime':
return label ? `请选择${label}` : '请选择日期时间';

case 'number':
return label ? `请输入${label}` : '请输入数字';

case 'textarea':
case 'text':
return label ? `请输入${label}` : '请输入内容';
}
});

// 监听外部值变化
watch(
() => props.modelValue,
newVal => {
inputValue.value = newVal;
},
{ immediate: true }
);

// 监听内部值变化
watch(
inputValue,
newVal => {
emit('update:modelValue', newVal);
},
{ deep: true }
);

// 事件处理函数
const handleInput = value => {
emit('input', value);
};

const handleChange = value => {
const type = props.type;
if (type == 'select') {
emit('change', { value, dic: arguments[0].options });
} else {
emit('change', value);
}
};

const handleFocus = event => {
emit('focus', event);
};

const handleBlur = event => {
emit('blur', event);
};

const handleClear = () => {
emit('clear');
};

const handleKeyup = event => {
emit('keyup', event);
};

const handleKeydown = event => {
emit('keydown', event);
};

const handleKeypress = event => {
emit('keypress', event);
};

const handleVisibleChange = visible => {
emit('visible-change', visible);
};

const handleRemoveTag = value => {
emit('remove-tag', value);
};

const handleExpandChange = value => {
emit('expand-change', value);
};

const handleCalendarChange = value => {
emit('calendar-change', value);
};

const handlePanelChange = value => {
emit('panel-change', value);
};

defineExpose({
inputValue,
});
</script>

<style lang="scss" scoped>
.append-input-container {
width: 100%;
display: flex;
align-items: center;
.append-prefix {
margin-right: 5px;
.prefix-text {
margin-right: 4px;
}
}
.append-suffix {
margin-left: 5px;
}
.append-unit {
margin-left: 5px;
.unit-text {
margin-left: 4px;
}
}
.append-main-input {
flex: 1;
}
}
</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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
/**
* 必填验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function requiredFn(message = '此项为必填项', trigger = ['blur', 'change']) {
return {
required: true,
message,
trigger,
};
}

/**
* 长度验证
* @param {number} min 最小长度
* @param {number} max 最大长度
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function length(min, max, message, trigger = ['blur', 'change']) {
return {
min,
max,
message: message || `长度在 ${min}${max} 个字符`,
trigger,
};
}

/**
* 最小长度验证
* @param {number} min 最小长度
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function minLength(min, message, trigger = ['blur', 'change']) {
return {
min,
message: message || `长度不能少于 ${min} 个字符`,
trigger,
};
}

/**
* 最大长度验证
* @param {number} max 最大长度
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function maxLength(max, message, trigger = ['blur', 'change']) {
return {
max,
message: message || `长度不能超过 ${max} 个字符`,
trigger,
};
}

/**
* 手机号验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function mobile(message = '请输入正确的手机号码', trigger = ['blur', 'change']) {
return {
pattern: /^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$/,
message,
trigger,
};
}

/**
* 邮箱验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function email(message = '请输入正确的邮箱地址', trigger = ['blur', 'change']) {
return {
type: 'email',
message,
trigger,
};
}

/**
* 身份证号验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function idCard(message = '请输入正确的身份证号码', trigger = ['blur', 'change']) {
return {
pattern:
/^(^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$)|(^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])((\d{4})|\d{3}[Xx])$)$/,
message,
trigger,
};
}

/**
* 数字验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function number(message = '请输入数字', trigger = ['blur', 'change']) {
return {
type: 'number',
message,
trigger,
};
}

/**
* 整数验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function integer(message = '请输入整数', trigger = ['blur', 'change']) {
return {
pattern: /^-?\d+$/,
message,
trigger,
};
}

/**
* 正整数验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function positiveInteger(message = '请输入正整数', trigger = ['blur', 'change']) {
return {
pattern: /^[1-9]\d*$/,
message,
trigger,
};
}

/**
* 金额验证(保留两位小数)
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function money(message = '请输入正确的金额格式', trigger = ['blur', 'change']) {
return {
pattern: /^[1-9]\d*(\.\d{1,2})?$|^0\.\d{1,2}$/,
message,
trigger,
};
}

/**
* URL验证
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function url(message = '请输入正确的URL地址', trigger = ['blur', 'change']) {
return {
type: 'url',
message,
trigger,
};
}

/**
* 自定义正则验证
* @param {RegExp} pattern 正则表达式
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function pattern(pattern, message, trigger = ['blur', 'change']) {
return {
pattern,
message,
trigger,
};
}

/**
* 自定义验证器
* @param {Function} validator 验证函数
* @param {string} message 错误提示信息
* @param {string|array} trigger 触发方式
* @returns {object} 验证规则
*/
export function custom(validator, message, trigger = ['blur', 'change']) {
return {
validator,
message,
trigger,
};
}

/**
* 组合多个验证规则
* @param {...object} rules 验证规则数组
* @returns {array} 验证规则数组
*/
export function combine(...rules) {
return rules.filter(rule => rule !== null && rule !== undefined);
}

/**
* 常用字段验证规则预设
*/
export const presets = {
// 姓名验证
name: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入姓名'));
}
rules.push(length(1, 20, '姓名长度在1-20个字符'));
return rules;
},

// 手机号验证
phone: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入手机号码'));
}
rules.push(mobile());
return rules;
},
// 手机号验证
mobile: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入手机号码'));
}
rules.push(mobile());
return rules;
},

// 身份证验证
idCard: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入身份证号码'));
}
rules.push(idCard());
return rules;
},

// 邮箱验证
email: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入邮箱地址'));
}
rules.push(email());
return rules;
},

// 金额验证
money: (requiredFlag = true) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入金额'));
}
rules.push(money());
return rules;
},

// 备注验证
remark: (requiredFlag = false) => {
const rules = [];
if (requiredFlag) {
rules.push(requiredFn('请输入备注信息'));
}
rules.push(maxLength(500, '备注长度不能超过500个字符'));
return rules;
},
};

/**
* 快速生成表单验证规则的工具函数
* @param {object} fieldRules 字段验证规则配置
* @returns {object} 表单验证规则对象
*/
export function createFormRules(fieldRules) {
const rules = {};

Object.keys(fieldRules).forEach(field => {
const fieldConfig = fieldRules[field];
if (typeof fieldConfig === 'string') {
// 使用预设规则
rules[field] = presets[fieldConfig]();
} else if (Array.isArray(fieldConfig)) {
// 直接传入规则数组
rules[field] = fieldConfig;
} else if (typeof fieldConfig === 'object') {
// 使用预设规则并传入参数
const { preset, required = true, message, ...options } = fieldConfig;
if (presets[preset]) {
rules[field] = presets[preset](required);
} else {
rules[field] = [requiredFn(message)];
}
}
});

return rules;
}

// 向后兼容,导出所有
export default {
requiredFn,
length,
minLength,
maxLength,
mobile,
email,
idCard,
number,
integer,
positiveInteger,
money,
url,
pattern,
custom,
combine,
presets,
createFormRules,
};

用例

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
<template>
<ElCardPro title="表单组件Demo">
<ElFormPro ref="formRef" :model="formData" :rules="formRules" label-width="180">
<el-row>
<ElFormItemPro required label="手机号" prop="mobile" v-model="formData.mobile" />
<ElFormItemPro required label="文本输入框" prop="text" v-model="formData.text" />
<ElFormItemPro
required
label="数字输入框"
prop="number"
type="number"
:min="0"
:max="100"
v-model="formData.number"
/>
<ElFormItemPro
label="下拉选择器"
prop="select"
type="select"
v-model="formData.select"
:optionsProps="{ label: 'name', value: 'value' }"
:options="[
{ name: '选项1', value: 1 },
{ name: '选项2', value: 2 },
]"
/>
<ElFormItemPro
label="下拉选择器-自定义模板"
prop="selectSlot"
type="select"
:use-slot="true"
:options="options"
v-model="formData.selectSlot"
>
<template #option="{ options }">
<el-option
v-for="option in options"
:key="option.id"
:label="option.desc"
:value="option.id"
>
<div class="flex-center" style="justify-content: space-between">
<span>{{ option.desc }}</span>
<span style="color: #409eff; font-weight: bold" @click.stop="openTab(option.url)">
点击跳转
</span>
</div>
</el-option>
</template>
</ElFormItemPro>
<ElFormItemPro
required
:clearable="true"
label="日期选择框"
prop="date"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
v-model="formData.date"
:shortcuts="shortcuts"
/>
<ElFormItemPro
label="文本域"
prop="textarea"
type="textarea"
:rows="5"
v-model="formData.textarea"
:colSpan="24"
/>

<ElFormItemPro type="append" label="价格" v-model="formData.price" unit="万元" />

<!-- 带按钮的复合型输入框 -->
<ElFormItemPro
required
type="append"
label="选择用户"
prop="filename"
v-model="formData.filename"
placeholder="请选择用户"
disabled
>
<template #suffix>
<el-button type="primary" @click="() => ElMessage.error('选择用户')">
选择用户
</el-button>
</template>
</ElFormItemPro>
</el-row>
</ElFormPro>
<div style="text-align: center">
<el-button type="primary" @click="formValid">表单校验</el-button>
</div>
</ElCardPro>
</template>

<script setup>
import { ElMessage } from 'element-plus';
import { createFormRules, requiredFn } from '@/utils/formRules';
const formRef = ref(null);
const formData = reactive({
mobile: '',
text: '',
number: '',
select: '',
selectSlot: '',
date: '',
textarea: '',
filename: '',
});
const formRules = reactive(
createFormRules({
mobile: { preset: 'mobile' },
text: { message: '文本必输测试' },
number: requiredFn('数字必输测试'),
date: { message: '请选择日期' },
filename: {},
})
);
const options = [{ id: 1, url: 'https://www.baidu.com', desc: '百度' }];
const openTab = url => window.open(url);

const shortcuts = [
{
text: '今天',
value: new Date(),
},
{
text: '昨天',
value: () => {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24);
return date;
},
},
{
text: '一周前',
value: () => {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24 * 7);
return date;
},
},
];

const formValid = async () => {
const valid = await formRef.value.validate();
if (!valid) ElMessage.error('存在必输项或校验未通过,请检查输入');
};
</script>

表格组件封装

表格封装

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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
<template>
<div class="el-table-pro">
<div v-if="showSearchForm" class="el-table-pro__search-form">
<ElFormPro :model="searchFormModel" :inline="true" :label-width="'70px'">
<div class="search-form-content">
<div class="search-form-fields">
<el-row :gutter="24">
<ElFormItemPro
v-for="item in searchFormConfig"
:key="item.prop"
:label="item.label"
:prop="item.prop"
v-model="searchFormModel[item.prop]"
:type="item.type"
:options="item.options"
:colSpan="6"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-row>
</div>
<div class="search-form-buttons">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</div>
</ElFormPro>
</div>
<el-table
v-loading="loading"
:data="tableData"
:border="border"
:stripe="stripe"
:size="size"
:fit="fit"
:show-header="showHeader"
:highlight-current-row="highlightCurrentRow"
:row-key="rowKey"
:empty-text="emptyText"
:default-expand-all="defaultExpandAll"
:expand-row-keys="expandRowKeys"
:default-sort="defaultSort"
:tree-props="treeProps"
:lazy="lazy"
:load="load"
@select="handleSelect"
@select-all="handleSelectAll"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@row-dblclick="handleRowDblclick"
@sort-change="handleSortChange"
@filter-change="handleFilterChange"
@current-change="handleCurrentChange"
@expand-change="handleExpandChange"
>
<template v-if="selection">
<el-table-column
type="selection"
width="55"
align="center"
:selectable="selectable"
:reserve-selection="reserveSelection"
/>
</template>

<template v-if="expand">
<el-table-column type="expand">
<template #default="props">
<slot name="expand" :row="props.row"></slot>
</template>
</el-table-column>
</template>

<template v-if="index">
<el-table-column
type="index"
:label="indexLabel"
:index="indexMethod"
width="60"
align="center"
/>
</template>

<template v-for="column in columns" :key="column.prop">
<el-table-column
:class-name="column.className || ''"
:prop="column.prop"
:label="column.label"
:width="column.width"
:min-width="column.minWidth"
:fixed="column.fixed"
:render-header="column.renderHeader"
:sortable="column.sortable"
:formatter="column.formatter"
:show-overflow-tooltip="
typeof column.showOverflowTooltip !== 'boolean' ? true : column.showOverflowTooltip
"
:align="column.align || 'center'"
:header-align="column.headerAlign || 'center'"
:selectable="column.selectable"
:reserve-selection="column.reserveSelection"
>
<template #default="scope">
<slot
:name="column.slot || column.prop"
:row="scope.row"
:column="column"
:index="scope.$index"
>
{{ scope.row[column.prop] }}
</slot>
</template>
</el-table-column>
</template>
</el-table>

<div v-if="pagination" class="el-table-pro__pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="pageSizes"
:layout="layout"
:total="total"
:background="background"
@size-change="handleSizeChange"
@current-change="handleCurrentPageChange"
/>
</div>
</div>
</template>
<script setup>
defineOptions({ name: 'ElTablePro' });
const props = defineProps({
// 搜索表单
showSearchForm: {
type: Boolean,
default: true,
},
searchFormModel: {
type: [Object, Array],
default: () => {},
},
searchFormConfig: {
type: [Object, Array],
default: () => [],
},
searchFormLabelWidth: {
type: [String, Number],
default: '130',
},
// 数据相关
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},

// 表格样式
border: {
type: Boolean,
default: true,
},
stripe: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'default',
},
fit: {
type: Boolean,
default: true,
},
showHeader: {
type: Boolean,
default: true,
},
highlightCurrentRow: {
type: Boolean,
default: false,
},

// 展开行
rowKey: {
type: [String, Function],
default: 'id',
},
defaultExpandAll: {
type: Boolean,
default: false,
},
expandRowKeys: {
type: Array,
default: () => [],
},

// 排序
defaultSort: {
type: Object,
default: () => ({}),
},

// 树形数据
treeProps: {
type: Object,
default: () => ({
hasChildren: 'hasChildren',
children: 'children',
}),
},

// 选择列
selection: {
type: Boolean,
default: false,
},
selectable: Function,
reserveSelection: {
type: Boolean,
default: false,
},
selectOnIndeterminate: {
type: Boolean,
default: true,
},

// 展开列
expand: {
type: Boolean,
default: false,
},

// 索引列
index: {
type: Boolean,
default: false,
},
indexLabel: {
type: String,
default: '序号',
},
indexMethod: Function,

// 列配置
columns: {
type: Array,
default: () => [],
},

// 空数据
emptyText: String,

// 树形数据懒加载
lazy: {
type: Boolean,
default: false,
},
load: Function,

// 分页
pagination: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 10,
},
pageSizes: {
type: Array,
default: () => [10, 20, 30, 50, 100],
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper',
},
total: {
type: Number,
default: 0,
},
background: {
type: Boolean,
default: true,
},
});

/* 抛出事件 */
const emit = defineEmits([
'update:currentPage',
'update:pageSize',
'update:searchFormModel',
'select',
'select-all',
'selection-change',
'row-click',
'row-contextmenu',
'row-dblclick',
'header-click',
'header-contextmenu',
'sort-change',
'filter-change',
'current-change',
'expand-change',
'size-change',
'current-page-change',
'search',
'reset',
]);

// 响应式数据
const currentPage = ref(props.currentPage);
const pageSize = ref(props.pageSize);
const searchFormModel = ref(props.searchFormModel);

// 计算属性
const tableData = computed(() => {
return props.data;
});

// 监听
watch(
() => props.currentPage,
val => {
currentPage.value = val;
}
);

watch(
() => props.pageSize,
val => {
pageSize.value = val;
}
);
// 监听搜索表单模型变化
watch(
() => props.searchFormModel,
val => {
searchFormModel.value = val;
},
{ deep: true, immediate: true }
);

// 监听内部表单模型变化,同步到外部
watch(
searchFormModel,
newVal => {
emit('update:searchFormModel', newVal);
},
{ deep: true }
);
// 搜索表单相关方法
const handleSearch = () => {
emit('search', searchFormModel.value);
// 重置分页到第一页,但不触发分页变化事件
currentPage.value = 1;
emit('update:currentPage', 1);
// 移除 current-page-change 事件,避免重复触发
// emit('current-page-change', 1);
};

const handleReset = () => {
// 重置搜索表单
Object.keys(searchFormModel.value).forEach(key => {
searchFormModel.value[key] = '';
});
emit('reset');
// 重置分页到第一页,但不触发分页变化事件
currentPage.value = 1;
emit('update:currentPage', 1);
// 移除 current-page-change 事件,避免重复触发
// emit('current-page-change', 1);
};
const handleSelect = (selection, row) => {
emit('select', selection, row);
};

const handleSelectAll = selection => {
emit('select-all', selection);
};

const handleSelectionChange = selection => {
emit('selection-change', selection);
};

const handleRowClick = (row, column, event) => {
emit('row-click', row, column, event);
};

const handleRowDblclick = (row, column, event) => {
emit('row-dblclick', row, column, event);
};

const handleSortChange = column => {
emit('sort-change', column);
};

const handleFilterChange = filters => {
emit('filter-change', filters);
};

const handleCurrentChange = (currentRow, oldCurrentRow) => {
emit('current-change', currentRow, oldCurrentRow);
};

const handleExpandChange = (row, expanded) => {
emit('expand-change', row, expanded);
};

const handleSizeChange = val => {
emit('update:pageSize', val);
emit('size-change', val);
};

const handleCurrentPageChange = val => {
emit('update:currentPage', val);
emit('current-page-change', val);
};
</script>
<style lang="scss" scoped>
.el-table-pro {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;

// 表头样式
:deep(.el-table__header-wrapper) {
box-sizing: border-box;
.el-table__header {
th {
background-color: #fafafa;
color: rgba(0, 0, 0, 0.85);
font-weight: bold;
font-size: 16px;

.cell {
color: rgba(0, 0, 0, 0.85);
font-weight: bold;
font-size: 14px;
}
}
}
}

.el-table-pro__search-form {
border-bottom: 1px solid rgb(235, 238, 245);
padding: 16px;
background-color: #fff;
align-items: center;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 20;
margin-top: -20px;

.search-form-content {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;

.search-form-fields {
display: flex;
gap: 16px;
flex-wrap: wrap;
flex: 1;

.el-row {
width: 100%;
margin: 0 !important;
}
}

.search-form-buttons {
height: 100%;
display: flex;
gap: 8px;
flex-shrink: 0;
margin-bottom: 9px;
// align-self: flex-end;
margin-left: auto;
justify-content: flex-end;
}
}
}

.el-table {
flex: 1;
overflow: hidden;
min-height: 0; // 确保表格能够正确收缩
height: 0; // 强制表格收缩到可用空间

:deep(.el-table__header-wrapper) {
position: sticky;
top: 0;
z-index: 10;
background-color: #fafafa;
flex-shrink: 0;
}

:deep(.el-table__body-wrapper) {
overflow-y: auto;
height: calc(100% - 40px); // 减去表头高度
}

:deep(.table-operation) {
.cell {
display: flex;
justify-content: center;
align-items: center;

.el-dropdown {
margin-left: 12px;
color: #409eff;
cursor: pointer;
padding: 8px 0;
margin-top: -2px;
}
}
}
}

.el-table-pro__pagination {
margin-top: 16px;
margin-bottom: -20px;
display: flex;
justify-content: flex-end;
background-color: #fff;
padding: 16px 0;
border-top: 1px solid #ebeef5;
flex-shrink: 0; // 防止分页器被压缩
position: sticky; // 固定分页器
bottom: 0;
z-index: 20;
}
}
</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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
<template>
<div class="execution">
<basic-container>
<ElTablePro
selection
:data="tableData"
:columns="columns"
:loading="loading"
:total="paramsSearch.total"
v-model:current-page="paramsSearch.currentPage"
v-model:page-size="paramsSearch.pageSize"
v-model:search-form-model="searchFormModel"
:search-form-config="searchFormConfig"
@search="handleSearch"
@reset="handleReset"
@selection-change="handleSelectionChange"
@sort-change="getPage()"
@size-change="handleSizeChange"
@current-page-change="handleCurrentPageChange"
>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>

<template #operation="{ row }">
<el-button type="text" @click="handleEdit(row)">
<el-icon><EditPen /></el-icon>
编辑
</el-button>
<el-button type="text" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
<el-dropdown size="small" trigger="click" @command="handleCommand">
<span class="el-dropdown-link">
更多<el-icon>
<ArrowDown />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ action: 'test', row }">
<el-button type="text">
<Iconfont icon="icon-zijinliushui" />&nbsp;下拉测试
</el-button>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</ElTablePro>
</basic-container>
</div>
</template>

<script setup>
import Api from '@/api/recycle/logistics.js';
import { ElMessage, ElMessageBox } from 'element-plus';

const tableData = ref([]);
const loading = ref(false);
const selectedRows = ref([]);

const paramsSearch = ref({
currentPage: 1,
pageSize: 20,
total: 0,
});

// 搜索表单配置
const searchFormConfig = ref([
{ prop: 'id', label: 'ID', type: 'text', placeholder: '请输入ID' },
{ prop: 'name', label: '名称', type: 'text', placeholder: '请输入名称' },
{
prop: 'status',
label: '状态',
type: 'select',
options: [
{ label: '全部', value: '' },
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
{ prop: 'startTime', label: '开始时间', type: 'date', placeholder: '请选择开始时间' },
{ prop: 'endTime', label: '结束时间', type: 'date', placeholder: '请选择结束时间' },
]);

// 搜索表单数据
const searchFormModel = ref({
id: '',
name: '',
status: '',
startTime: '',
endTime: '',
});

const columns = ref([
{ label: 'ID', prop: 'id', width: '80' },
{ label: '名称', prop: 'name', minWidth: '120' },
{ label: '状态', prop: 'status', width: '100', slot: 'status' },
{ label: '开始时间', prop: 'startTime', width: '160' },
{ label: '结束时间', prop: 'endTime', width: '160' },
{ label: '创建时间', prop: 'createTime', width: '160' },
{ label: '更新时间', prop: 'updateTime', width: '160' },
{
label: '操作',
prop: 'operation',
width: '180',
slot: 'operation',
fixed: 'right',
showOverflowTooltip: false,
className: 'table-operation',
},
]);

// 处理搜索
const handleSearch = params => {
paramsSearch.value.currentPage = 1;
getPage();
};

// 处理重置
const handleReset = () => {
Object.keys(searchFormModel.value).forEach(key => {
searchFormModel.value[key] = '';
});
paramsSearch.value.currentPage = 1;
paramsSearch.value.pageSize = 20;
getPage();
};

// 处理选择变化
const handleSelectionChange = selection => {
selectedRows.value = selection;
};

// 处理分页大小变化
const handleSizeChange = size => {
paramsSearch.value.currentPage = 1;
paramsSearch.value.pageSize = size;
getPage();
};

// 处理当前页变化
const handleCurrentPageChange = page => {
paramsSearch.value.currentPage = page;
getPage();
};

// 获取分页数据
const getPage = async () => {
loading.value = true;

let reqParams = {
current: paramsSearch.value.currentPage,
size: paramsSearch.value.pageSize,
...searchFormModel.value,
};

// 过滤空值
Object.keys(reqParams).forEach(key => {
if (reqParams[key] === '' || reqParams[key] === null || reqParams[key] === undefined) {
delete reqParams[key];
}
});

try {
const { data } = await Api.getList(reqParams);
tableData.value = data.data.records || [];
paramsSearch.value.total = Number(data.data.total) || 0;
} catch (err) {
console.error('获取数据失败:', err);
} finally {
loading.value = false;
}
};

// 编辑操作
const handleEdit = row => {
ElMessage.info(`编辑ID为${row.id}的记录`);
};

// 删除操作
const handleDelete = async row => {
try {
await ElMessageBox.confirm(`确定要删除ID为${row.id}的记录吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});

// const { data } = await Api.delete(row.id);

getPage();
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('删除失败');
}
}
};

// 批量删除
const handleBatchDelete = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要删除的记录');
return;
}

try {
const ids = selectedRows.value.map(row => row.id);
await ElMessageBox.confirm(
`确定要删除选中的${selectedRows.value.length}条记录吗?`,
'确认批量删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);

// const { data } = await Api.batchDelete(ids);

selectedRows.value = [];
getPage();
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('批量删除失败');
}
}
};

const handleCommand = command => {
const { action, row } = command;

switch (action) {
case 'test':
ElMessage.info(`测试el-dropdown组件`);
break;
}
};

onMounted(() => {
getPage();
});
</script>

<style lang="scss" scoped></style>

dialog组件封装

弹窗封装

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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
<template>
<el-dialog
:model-value="modelValue"
:width="width"
:close-on-click-modal="closeOnClickModal"
:destroy-on-close="destroyOnClose"
:close-on-press-escape="closeOnPressEscape"
:show-close="showClose"
:center="center"
:draggable="draggable"
:lock-scroll="lockScroll"
@close="handleCancel"
class="el-dialog-pro"
top="5vh"
>
<template #header>
<div class="el-dialog-pro__header" v-if="showHeader">
<span class="el-dialog-pro__title">{{ title }}</span>
<el-icon class="el-dialog-pro__close" @click="handleCancel" v-if="!showClose">
<i-ep-close />
</el-icon>
</div>
</template>
<div
class="el-dialog-pro__body"
:style="{
'min-height': props.bodyMinHeight,
'max-height': props.bodyMaxHeight,
'overflow-y': 'auto',
'overflow-x': 'hidden',
}"
>
<slot></slot>
</div>
<template #footer v-if="showConfirm || showCancel">
<div class="el-dialog-pro__footer">
<slot name="footer"></slot>
<el-button v-if="showConfirm" :loading="innerLoading" type="primary" @click="handleSubmit">
{{ confirmText }}
</el-button>
<el-button v-if="showCancel" :loading="innerLoading" @click="handleCancel">
{{ cancelText }}
</el-button>
</div>
</template>
</el-dialog>
</template>

<script>
export default {
name: 'ElDialogPro',
};
</script>

<script setup>
import { ref, defineProps, defineEmits, watch } from 'vue';
import { Close as IEpClose } from '@element-plus/icons-vue';

const props = defineProps({
modelValue: Boolean,
title: {
type: String,
default: '标题',
},
width: {
type: [String, Number],
default: '30%',
},
bodyMinHeight: {
type: String,
default: '300px',
},
bodyMaxHeight: {
type: String,
default: '70vh',
},
closeOnClickModal: {
type: Boolean,
default: true,
},
destroyOnClose: {
type: Boolean,
default: true,
},
closeOnPressEscape: {
type: Boolean,
default: true,
},
showClose: {
type: Boolean,
default: false,
},
center: {
type: Boolean,
default: false,
},
draggable: {
type: Boolean,
default: false,
},
lockScroll: {
type: Boolean,
default: true,
},
showHeader: {
type: Boolean,
default: true,
},
showConfirm: {
type: Boolean,
default: true,
},
showCancel: {
type: Boolean,
default: true,
},
confirmText: {
type: String,
default: '确定',
},
cancelText: {
type: String,
default: '取消',
},
loading: {
type: Boolean,
default: false,
},
});

const emits = defineEmits(['update:modelValue', 'submit', 'cancel', 'update:loading']);

const innerLoading = ref(props.loading);

watch(
() => props.loading,
val => {
innerLoading.value = val;
}
);

const handleCancel = () => {
emits('update:modelValue', false);
emits('cancel');
};

const handleSubmit = async () => {
innerLoading.value = true;
emits('update:loading', true);
/* 通知父组件,父组件可通过 done 回调关闭 loading */
emits('submit', {
done: () => {
innerLoading.value = false;
emits('update:loading', false);
// emits('update:modelValue', false);
},
});
};
</script>

<style lang="scss" scoped>
.el-dialog-pro {
.el-dialog-pro__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 12px 24px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0; // 防止header被压缩

.el-dialog-pro__title {
font-weight: 900;
font-size: 18px;
color: #222;
}

.el-dialog-pro__close {
cursor: pointer;
font-size: 20px;
color: #888;
transition: color 0.2s;

&:hover {
color: #f56c6c;
}
}
}

.el-dialog-pro__body {
padding: 24px;
background: #fff;
box-sizing: border-box;
flex: 1;
overflow-y: auto;
}

.el-dialog-pro__footer {
display: flex;
justify-content: center;
padding: 8px 24px;
border-top: 1px solid #f0f0f0;
background: #fafbfc;
flex-shrink: 0; // 防止footer被压缩

.el-button + .el-button {
margin-left: 12px;
}
}
}

:deep(.el-dialog__body) {
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: auto;
}

:deep(.el-dialog__header) {
padding: 0;
display: none;
}

:deep(.el-dialog__footer) {
padding: 0;
}

// 防止滚动穿透
:deep(.el-overlay) {
overflow: hidden;
}

:deep(.el-dialog) {
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
}

// 自定义滚动条样式
.el-dialog-pro__body {
&::-webkit-scrollbar {
width: 6px;
}

&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}

&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;

&:hover {
background: #a8a8a8;
}
}
}
</style>

用法

1
2
3
4
5
6
7
8
9
<ElDialogPro
title="弹窗封装"
v-model="winShow"
width="60%"
cancelText="关闭"
bodyMinHeight="60vh"
>
<!-- 弹窗内容 -->
</ElDialogPro>

Card组件封装

组件封装

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
<template>
<el-card class="custom-card" shadow="never">
<template #header>
<div class="card-header" @click="toggleCollapse">
<div class="card-header-left">
<slot name="header-left">
<span class="title">{{ title }}</span>
</slot>
</div>
<div class="card-header-right" @click.stop>
<slot name="header-right"></slot>
<el-icon
class="collapse-icon"
:class="{ 'is-collapsed': isCollapsed }"
@click="toggleCollapse"
>
<ArrowDown v-if="!isCollapsed" />
<ArrowRight v-else />
</el-icon>
</div>
</div>
</template>
<div class="card-body" :class="{ 'is-collapsed': isCollapsed }">
<slot></slot>
</div>
<template #footer v-if="$slots.footer">
<div class="card-footer">
<slot name="footer"></slot>
</div>
</template>
</el-card>
</template>

<script>
export default {
name: 'ElCardPro',
};
</script>
<script setup>
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue';

// 定义props
const props = defineProps({
title: {
type: String,
required: true,
default: '标题',
},
defaultCollapsed: {
type: Boolean,
default: false,
},
});

// 定义emits
const emit = defineEmits(['collapse-change']);

// 响应式数据
const isCollapsed = ref(props.defaultCollapsed);

// 方法
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
emit('collapse-change', isCollapsed.value);
};
</script>

<style lang="scss" scoped>
.custom-card {
border: 1px solid #ebeef5;

:deep(.el-card__header) {
padding: 12px 20px;
border-bottom: 1px solid #e4e7ed;
cursor: pointer;
transition: background-color 0.3s ease;

&:hover {
background-color: #f5f7fa;
}
}

:deep(.el-card__body),
:deep(.el-card__footer) {
padding: 0;
}

.card-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
font-size: 14px;
}

.card-header-left {
.title {
font-weight: 600;
color: #303133;
}
}

.card-header-right {
display: flex;
align-items: center;
gap: 8px;

.collapse-icon {
font-size: 16px;
color: #909399;
cursor: pointer;
transition: all 0.3s ease;
margin-left: 8px;

&:hover {
color: #409eff;
transform: scale(1.1);
}

&.is-collapsed {
transform: rotate(-90deg);
}
}
}

.card-body {
padding: 12px 20px;
min-height: 0;
transition: all 0.3s ease;
overflow: hidden;

&.is-collapsed {
padding: 0 20px;
min-height: 0;
max-height: 0;
}
}

.card-footer {
padding: 6px 15px;
border-top: 1px solid #e4e7ed;
background-color: #fafafa;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
}
</style>

文件预览组件封装

  • 需要安装依赖 “vue-pdf-embed”: “^2.1.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
    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
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    <template>
    <ElDialogPro
    :title="title"
    :showConfirm="false"
    cancelText="关闭"
    width="60%"
    body-min-height="400px"
    body-max-height="70vh"
    v-model:modelValue="winShow"
    >
    <div class="file-preview-container">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-container">
    <el-icon class="loading-icon" :size="40">
    <Loading />
    </el-icon>
    <p class="loading-text">正在加载文件...</p>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-container">
    <el-icon class="error-icon" :size="40">
    <Warning />
    </el-icon>
    <p class="error-text">{{ errorMessage }}</p>
    <el-button type="primary" @click="retryLoad">重试</el-button>
    </div>

    <!-- 文件内容 -->
    <div v-else class="file-content">
    <!-- PDF文件 -->
    <div v-if="fileType === 'pdf'" class="pdf-content">
    <div v-for="page in numPages" :key="page" class="pdf-page-wrapper">
    <vue-pdf-embed
    ref="pdfRef"
    :source="fileUrl"
    :page="page"
    @error="handleFileError"
    class="pdf-page"
    />
    </div>
    </div>

    <!-- 图片文件 -->
    <div v-else-if="isImageFile" class="image-content">
    <el-image
    class="preview-image"
    :src="fileUrl"
    :preview-src-list="[fileUrl]"
    hide-on-click-modal
    />
    </div>

    <!-- Office文档 (使用Office Online或Google Docs) -->
    <div v-else-if="isOfficeFile" class="office-content">
    <iframe
    :src="officeViewerUrl"
    class="office-iframe"
    frameborder="0"
    @load="handleIframeLoad"
    @error="handleFileError"
    ></iframe>
    </div>

    <!-- 文本文件 -->
    <div v-else-if="isTextFile" class="text-content">
    <pre class="text-preview">{{ textContent }}</pre>
    </div>

    <!-- 不支持的文件格式 -->
    <div v-else class="unsupported-content">
    <el-icon class="unsupported-icon" :size="40">
    <Document />
    </el-icon>
    <p class="unsupported-text">不支持预览此文件格式</p>
    <p class="file-info">{{ fileName }} ({{ fileExtension }})</p>
    <el-button type="primary" @click="downloadFile">下载文件</el-button>
    </div>
    </div>
    </div>
    </ElDialogPro>
    </template>

    <script setup>
    import VuePdfEmbed from 'vue-pdf-embed';
    import { Loading, Warning, Document } from '@element-plus/icons-vue';
    import { ElMessage } from 'element-plus';
    defineOptions({
    name: 'PreviewFile'
    })
    const props = defineProps({
    modelValue: Boolean,
    title: {
    type: String,
    default: '文件预览',
    },
    });
    const emit = defineEmits(['update:modelValue']);

    const winShow = ref(false);
    const fileUrl = ref('');
    const fileName = ref('');
    const fileExtension = ref('');
    const fileType = ref('');
    const numPages = ref(1);
    const pdfRef = ref(null);
    const loading = ref(false);
    const error = ref(false);
    const errorMessage = ref('');
    const textContent = ref('');

    // 图片文件扩展名
    const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
    // Office文档扩展名
    const officeExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
    // 文本文件扩展名
    const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'jsx', 'tsx'];

    const isImageFile = computed(() => imageExtensions.includes(fileExtension.value.toLowerCase()));
    const isOfficeFile = computed(() => officeExtensions.includes(fileExtension.value.toLowerCase()));
    const isTextFile = computed(() => textExtensions.includes(fileExtension.value.toLowerCase()));

    // Office文档预览URL (使用Microsoft Office Online Viewer)
    const officeViewerUrl = computed(() => {
    if (!isOfficeFile.value) return '';
    return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl.value)}`;
    });

    // 获取文件扩展名
    const getFileExtension = url => {
    const fileName = url.split('/').pop().split('?')[0];
    return fileName.split('.').pop().toLowerCase();
    };

    // 获取PDF页数
    const getPdfPageCount = async url => {
    try {
    const response = await fetch(url);
    const arrayBuffer = await response.arrayBuffer();
    const { PDFDocument } = await import('pdf-lib');
    const pdfDoc = await PDFDocument.load(arrayBuffer);
    return pdfDoc.getPageCount();
    } catch (error) {
    console.error('解析PDF文件失败:', error);
    return 1;
    }
    };

    // 加载文本文件内容
    const loadTextContent = async url => {
    try {
    const response = await fetch(url);
    const text = await response.text();
    textContent.value = text;
    } catch (error) {
    console.error('加载文本文件失败:', error);
    throw new Error('无法加载文本文件');
    }
    };

    // 处理文件错误
    const handleFileError = err => {
    error.value = true;
    errorMessage.value = '文件加载失败,请稍后重试';
    ElMessage.error('文件加载失败');
    };

    // 处理iframe加载
    const handleIframeLoad = () => {
    ElMessage.success('文件加载成功');
    };

    // 重试加载
    const retryLoad = () => {
    if (fileUrl.value) {
    openWin(fileUrl.value);
    }
    };

    // 下载文件
    const downloadFile = () => {
    const link = document.createElement('a');
    link.href = fileUrl.value;
    link.download = fileName.value;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    };

    // 打开预览窗口
    const openWin = async url => {
    winShow.value = true;
    loading.value = true;
    error.value = false;
    errorMessage.value = '';
    fileUrl.value = url;

    try {
    // 获取文件信息
    const urlParts = url.split('/');
    fileName.value = urlParts[urlParts.length - 1].split('?')[0];
    fileExtension.value = getFileExtension(url);
    fileType.value = fileExtension.value;

    // 根据文件类型进行不同处理
    if (fileType.value === 'pdf') {
    numPages.value = await getPdfPageCount(fileUrl.value);
    } else if (isTextFile.value) {
    await loadTextContent(fileUrl.value);
    }
    // 图片和Office文档直接通过URL预览,无需额外处理
    } catch (err) {
    console.error('加载文件失败:', err);
    error.value = true;
    errorMessage.value = err.message || '文件加载失败,请稍后重试';
    } finally {
    loading.value = false;
    }
    };

    defineExpose({
    openWin,
    });
    </script>

    <style lang="scss" scoped>
    .file-preview-container {
    display: flex;
    flex-direction: column;
    gap: 20px;
    width: 100%;
    max-width: 800px;
    margin: 0 auto;
    padding-bottom: 20px;
    min-height: 400px;
    }

    .loading-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 400px;

    .loading-icon {
    color: #409eff;
    animation: rotate 1s linear infinite;
    }

    .loading-text {
    margin-top: 16px;
    color: #606266;
    font-size: 14px;
    }
    }

    .error-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 400px;

    .error-icon {
    color: #f56c6c;
    margin-bottom: 16px;
    }

    .error-text {
    color: #606266;
    font-size: 14px;
    margin-bottom: 16px;
    }
    }

    .file-content {
    display: flex;
    flex-direction: column;
    gap: 20px;
    }

    // PDF样式
    .pdf-content {
    display: flex;
    flex-direction: column;
    gap: 20px;
    }

    .pdf-page-wrapper {
    position: relative;
    }

    .pdf-page {
    border: 1px solid #ddd;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    width: 100%;
    height: auto;
    display: block;
    max-width: 100%;
    object-fit: contain;
    }

    // 图片样式
    .image-content {
    display: flex;
    justify-content: center;
    align-items: center;

    .preview-image {
    width: 60%;
    height: 60%;
    }
    }

    // Office文档样式
    .office-content {
    width: 100%;
    height: 70vh;

    .office-iframe {
    width: 100%;
    height: 100%;
    border: 1px solid #ddd;
    border-radius: 4px;
    }
    }

    // 文本文件样式
    .text-content {
    .text-preview {
    background-color: #f5f5f5;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 16px;
    font-family: 'Courier New', monospace;
    font-size: 14px;
    line-height: 1.5;
    white-space: pre-wrap;
    word-wrap: break-word;
    max-height: 70vh;
    overflow-y: auto;
    margin: 0;
    }
    }

    // 不支持的文件格式样式
    .unsupported-content {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 400px;

    .unsupported-icon {
    color: #909399;
    margin-bottom: 16px;
    }

    .unsupported-text {
    color: #606266;
    font-size: 14px;
    margin-bottom: 8px;
    }

    .file-info {
    color: #909399;
    font-size: 12px;
    margin-bottom: 16px;
    }
    }

    @keyframes rotate {
    from {
    transform: rotate(0deg);
    }
    to {
    transform: rotate(360deg);
    }
    }
    </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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
<template>
<div class="file-upload-container">
<div class="upload-header">
<span class="upload-title">{{ uploadTitle }}</span>
<el-upload
ref="uploadRef"
class="upload-btn"
:action="uploadUrl"
:headers="headers"
:data="uploadData"
:multiple="multiple"
:limit="limit"
:accept="accept"
:auto-upload="autoUpload"
:show-file-list="false"
:file-list="innerFileList"
:on-success="handleSuccess"
:on-error="handleError"
:on-exceed="handleExceed"
:on-change="handleChange"
:on-remove="handleRemove"
:before-upload="beforeUpload"
:disabled="disabled"
v-bind="useCustomUpload ? { 'http-request': customHttpRequest } : {}"
>
<el-button type="primary" :disabled="disabled">
<el-icon><upload-filled /></el-icon>
&nbsp;选择文件
</el-button>
</el-upload>
</div>
<el-divider style="margin: 10px 0 0 0" />
<div class="file-list" v-if="innerFileList.length">
<div v-for="(file, idx) in innerFileList" :key="file.uid" class="file-list-item">
<el-icon class="file-link-icon" style="margin-right: 4px"><upload-filled /></el-icon>
<!-- <a
class="file-link file-name"
:href="file.url || file.response?.url || 'javascript:void(0)'"
target="_blank"
>
<el-tooltip :content="file.name" placement="top" effect="light">
{{ file.name }}
</el-tooltip>
</a> -->
<span
class="file-link file-name"
@click="PreViewFileRef.openWin(file.url || file.response?.url || 'javascript:void(0)')"
>
<el-tooltip :content="file.name" placement="top" effect="light">
{{ file.name }}
</el-tooltip>
</span>
<span v-if="showDel" class="file-delete" @click="removeFile(idx)">删除</span>
</div>
</div>
<div class="file-list" v-else>
<el-empty class="el-empty" image-size="80" description="暂无文件" />
</div>
</div>
<!-- 文件预览 -->
<PreViewFile ref="PreViewFileRef" :title="props.uploadTitle" />
</template>

<script>
export default {
name: 'FileUpload'
}
</script>

<script setup>
import { UploadFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getStore } from '@/utils/store.js';
import { requestCommonFn } from '@/api/allList.js';
const PreViewFile = defineAsyncComponent(() => import('@/components/previewFile/index.vue'));
const PreViewFileRef = ref(null);
const isDev = import.meta.env.MODE === 'development'; // 是否开发环境
// const preFix = isDev ? '/api' : '';
const preFix = '/api';

const props = defineProps({
uploadTitle: {
type: String,
default: '上传文件',
},
action: {
type: String,
default: `/gardner-recycle/Tools/createTmpUrl`,
required: true,
},
multiple: {
type: Boolean,
default: false,
},
limit: {
type: Number,
default: 5,
},
accept: {
type: String,
default: '*',
},
autoUpload: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
data: {
type: Object,
default: () => ({}),
},
maxSize: {
type: Number,
default: 5,
},
showDel: {
type: Boolean,
default: true,
},
uploadType: {
type: String,
default: 'default',
},
fileList: {
type: Array,
default: () => [],
},
});
const emits = defineEmits([
'update:fileList',
'success',
'error',
'exceed',
'change',
'remove',
'preview',
'before-upload',
]);
const uploadRef = ref(null);
const innerFileList = computed({
get: () => props.fileList,
set: val => emits('update:fileList', val),
});
const fileContentType = ref('');
const fileName = ref('');
const uploadUrl = computed(() => preFix + props.action);
const customUploadUrl = ref('');
const headers = computed(() => {
return {
Authorization: 'Bearer ' + getStore({ name: 'token' }),
};
});
const uploadData = computed(() => ({
...props.data,
contentType: fileContentType.value,
originalFilename: fileName.value,
directory: 'recycle/',
}));
// 判断自定义上传还是默认上传事件
const useCustomUpload = computed(() => props.uploadType === 'custom');

watch(
() => props.fileList,
newVal => {
emits('update:fileList', newVal);
},
{ deep: true }
);

let isCustomUpload = false;

const handleSuccess = (response, file, list) => {
if (!isCustomUpload) {
ElMessage.success('文件上传成功');
}
emits('success', response, file, list);
};
const handleError = (error, file, list) => {
ElMessage.error('文件上传失败');
emits('error', error, file, list);
};
const handleExceed = (files, list) => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`);
emits('exceed', files, list);
};
const handleChange = (file, list) => {
innerFileList.value = list;
emits('change', file, list);
};
const handleRemove = (file, list) => {
innerFileList.value = list;
emits('remove', file, list);
};
const beforeUpload = async file => {
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize;
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`);
return false;
}
if (props.accept && props.accept !== '*') {
const acceptTypes = props.accept.split(',').map(type => type.trim());
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
const fileType = file.type;
const isValidType = acceptTypes.some(type => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase();
} else {
return fileType === type;
}
});
if (!isValidType) {
ElMessage.error(`文件类型不支持,请上传 ${props.accept} 格式的文件`);
return false;
}
}
fileContentType.value = file.type;
fileName.value = file.name;
try {
const { data } = await requestCommonFn(props.action, uploadData.value, 'post');
customUploadUrl.value = data.data.signedUrl;
} catch (err) {}
emits('before-upload', file);
return true;
};
// 自定义上传方法
const customHttpRequest = async options => {
isCustomUpload = true;
const { file, onProgress, onSuccess, onError } = options;
try {
const url = customUploadUrl.value;
const xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('Content-Type', file.type);

xhr.upload.onprogress = function (event) {
if (event.lengthComputable && onProgress) {
onProgress({ percent: (event.loaded / event.total) * 100 });
}
};

xhr.onload = function () {
if (xhr.status == 200 || xhr.status == 204) {
const response = {
url: url.split('?')[0], // 文件的访问地址
name: file.name,
};
ElMessage.success('文件上传成功');
onSuccess && onSuccess(response, file);
} else {
onError && onError(new Error('上传失败'));
}
isCustomUpload = false;
};

xhr.onerror = function () {
onError && onError(new Error('上传失败'));
isCustomUpload = false;
};

xhr.send(file);
} catch (err) {
onError && onError(err);
isCustomUpload = false;
}
};
// 删除文件
const removeFile = idx => {
const newList = innerFileList.value.slice();
const removed = newList.splice(idx, 1);
innerFileList.value = newList;
emits('remove', removed[0], newList);
};
// 暴露方法
defineExpose({ fileList: innerFileList });
</script>

<style lang="scss" scoped>
.file-upload-container {
background: #fff;
border-radius: 4px;
border: 1px solid #ebeef5;
padding: 12px 16px 8px 16px;
box-sizing: border-box;

.upload-header {
display: flex;
align-items: center;
justify-content: space-between;

.upload-title {
font-weight: 600;
font-size: 15px;
color: #222;
}

.upload-btn {
margin-left: 12px;
}
}

.file-list {
margin-top: 8px;
min-height: 160px;
max-height: 160px;
overflow-y: auto;

.file-list-item {
display: flex;
align-items: center;
height: 36px;
border-bottom: 1px solid #f2f2f2;
font-size: 14px;

.file-link-icon {
color: #409eff;
font-size: 16px;
}

.file-link {
color: #409eff;
cursor: pointer;
text-decoration: none;
margin-right: 16px;

&:hover {
text-decoration: underline;
}
}

.file-delete {
color: #409eff;
cursor: pointer;
margin-left: auto;
font-size: 14px;

&:hover {
text-decoration: underline;
}
}
}

.el-empty {
min-height: 160px;
padding: 0;
overflow-y: auto;
}
}
}
.file-name {
display: inline-block;
max-width: 200px; /* 可以根据需要调整这个值 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template #certificateImage-form>
<FileUpload
v-model:fileList="fileList"
uploadTitle="证件图片"
:limit="1"
accept=".jpg,.jpeg,.png"
uploadType="custom"
@success="uploadSuccess"
@remove="onFileRemove"
/>
</template>

<script setup>
const fileList = ref([])
const url = ref('')
const uploadSuccess = (response, file, list) => {
url.value = response?.url || '';
};
const onFileRemove = () => {
url.value = '';
};
</script>