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>

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
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
<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"
:fullscreen="fullscreen"
@close="handleCancel"
class="el-dialog-pro"
:top="top"
:z-index="ZIndex"
:append-to-body="appendToBody"
>
<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 || $slots.footer">
<div class="el-dialog-pro__footer">
<slot name="footer"></slot>
<el-button
v-if="showConfirm"
:loading="innerLoading"
:disabled="innerDisabled"
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, 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: false,
},
destroyOnClose: {
type: Boolean,
default: true,
},
closeOnPressEscape: {
type: Boolean,
default: true,
},
showClose: {
type: Boolean,
default: false,
},
center: {
type: Boolean,
default: false,
},
draggable: {
type: Boolean,
default: true,
},
lockScroll: {
type: Boolean,
default: true,
},
fullscreen: {
type: Boolean,
default: false,
},
top: {
type: String,
default: '3vh',
},
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,
},
disabled: {
type: Boolean,
default: false,
},
keepLoadingAfterSubmit: {
type: Boolean,
default: false,
},
ZIndex: {
type: Number,
default: null,
},
appendToBody: {
type: Boolean,
default: false,
},
});

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

const innerLoading = ref(props.loading);
const innerDisabled = ref(props.disabled);

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

// 监听对话框状态变化
watch(
() => props.modelValue,
val => {
// 当对话框关闭时,重置loading状态
if (val) {
innerLoading.value = false;
}
},
);

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

const handleSubmit = async () => {
innerLoading.value = true;
emits('update:loading', true);
/* 通知父组件,父组件可通过 done 回调关闭 loading */
emits('submit', {
done: () => {
// 根据keepLoadingAfterSubmit prop决定是否重置loading状态
if (!props.keepLoadingAfterSubmit) {
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;
flex-shrink: 0; // 防止header被压缩
align-items: center;
justify-content: space-between;
padding: 20px 24px 12px;
border-bottom: 1px solid #f0f0f0;

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

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

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

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

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

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

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

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

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

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

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

// 自定义滚动条样式
.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>

文件预览组件封装

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
<template>
<ElDialogPro
:title="title"
confirmText="下载"
cancelText="关闭"
width="60%"
body-min-height="400px"
body-max-height="70vh"
v-model:modelValue="winShow"
@submit="handleDownload"
>
<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">
<div v-if="isPdfFile" class="pdf-content">
<iframe
:src="pdfViewerUrl"
class="pdf-iframe"
frameborder="0"
@load="handleEmbeddedLoad"
@error="handleFileError"
></iframe>
<div class="pdf-actions">
<span class="pdf-hint"></span>
<el-button type="primary" link @click="openInNewTab">新窗口打开</el-button>
</div>
</div>

<div v-else-if="isImageFile" class="image-content">
<el-image
class="preview-image"
:src="fileUrl"
fit="contain"
:preview-src-list="fileUrlList"
hide-on-click-modal
:z-index="999"
show-progress
/>
</div>

<div v-else-if="isOfficeFile" class="office-content">
<iframe
:src="officeViewerUrl"
class="office-iframe"
frameborder="0"
@load="handleEmbeddedLoad"
@error="handleFileError"
></iframe>
</div>

<div v-else-if="isWebPageFile" class="web-content">
<iframe
:src="fileUrl"
class="web-iframe"
frameborder="0"
@load="handleEmbeddedLoad"
@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 lang="ts">
import type { ActionDone } from '@/types/shared/common';
import { Loading, Warning, Document } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';

defineOptions({
name: 'PreviewFile',
});

defineProps({
modelValue: Boolean,
title: {
type: String,
default: '文件预览',
},
});

const winShow = ref(false);
const fileUrl = ref('');
const fileUrlList = ref<string[]>([]);
const fileName = ref('');
const fileExtension = ref('');
const loading = ref(false);
const error = ref(false);
const errorMessage = ref('');
const textContent = ref('');
const pdfPreviewUrl = ref('');

const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
const officeExtensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'jsx', 'tsx'];

type FileSystemWritableFileStreamLike = {
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
};

type FileSystemFileHandleLike = {
createWritable: () => Promise<FileSystemWritableFileStreamLike>;
};

type SaveFilePickerOptionsLike = {
suggestedName?: string;
};

type WindowWithSaveFilePicker = Window & {
showSaveFilePicker?: (options?: SaveFilePickerOptionsLike) => Promise<FileSystemFileHandleLike>;
};

const isPdfFile = computed(() => fileExtension.value.toLowerCase() === 'pdf');
const isImageFile = computed(() => imageExtensions.includes(fileExtension.value.toLowerCase()));
const isOfficeFile = computed(() => officeExtensions.includes(fileExtension.value.toLowerCase()));
const isTextFile = computed(() => textExtensions.includes(fileExtension.value.toLowerCase()));
const isWebPageFile = computed(() => isHttpUrl(fileUrl.value) && !fileExtension.value);

const officeViewerUrl = computed(() => {
if (!isOfficeFile.value) return '';
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl.value)}`;
});

const pdfViewerUrl = computed(() => {
if (!isPdfFile.value) return '';
const sourceUrl = pdfPreviewUrl.value || fileUrl.value;
const separator = sourceUrl.includes('#') ? '&' : '#';
return `${sourceUrl}${separator}toolbar=1&navpanes=0&scrollbar=1`;
});

const isHttpUrl = (url: string) => /^https?:\/\//i.test(url);

const getFileExtension = (url: string) => {
const fallbackPath = url.split(/[?#]/)[0] || '';
let path = fallbackPath;

try {
path = new URL(url, window.location.href).pathname || fallbackPath;
} catch {
path = fallbackPath;
}

const currentFileName = path.split('/').filter(Boolean).pop() || '';
if (!currentFileName.includes('.')) return '';
return currentFileName.split('.').pop()?.toLowerCase() || '';
};

const revokePdfPreviewUrl = () => {
if (pdfPreviewUrl.value.startsWith('blob:') && typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(pdfPreviewUrl.value);
}
pdfPreviewUrl.value = '';
};

const loadPdfPreviewUrl = async (url: string) => {
revokePdfPreviewUrl();

try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`PDF 请求失败:${response.status}`);
}

const blob = await response.blob();
const pdfBlob =
blob.type === 'application/pdf' ? blob : new Blob([blob], { type: 'application/pdf' });

if (typeof URL.createObjectURL === 'function') {
pdfPreviewUrl.value = URL.createObjectURL(pdfBlob);
}
} catch (currentError) {
console.warn('PDF 转换为内嵌预览地址失败,将使用原始地址:', currentError);
pdfPreviewUrl.value = url;
}
};

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

const handleFileError = (_err?: unknown) => {
error.value = true;
errorMessage.value = '文件加载失败,请稍后重试';
ElMessage.error('文件加载失败');
};

const handleEmbeddedLoad = () => {
// 原生 iframe/object 预览交给浏览器处理,这里保留钩子方便后续扩展。
};

const retryLoad = () => {
if (fileUrl.value) {
void openWin(fileUrl.value);
}
};

const triggerBrowserDownload = (url: string, name = getFileNameFromUrl(url)) => {
const link = document.createElement('a');
link.href = url;
link.download = name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

const getFileNameFromUrl = (url: string) => {
const urlParts = url.split('/');
return urlParts[urlParts.length - 1]?.split('?')[0] || 'download';
};

const getDownloadFileName = () => fileName.value || 'download';

const pickSaveFileHandle = async () => {
const picker = (window as WindowWithSaveFilePicker).showSaveFilePicker;
if (typeof picker !== 'function') {
return null;
}

return picker({
suggestedName: getDownloadFileName(),
});
};

const downloadSingleFile = async (url: string, useSavePicker = true) => {
let saveFileHandle: FileSystemFileHandleLike | null = null;
if (useSavePicker) {
try {
saveFileHandle = await pickSaveFileHandle();
} catch (currentError) {
const errorName =
currentError && typeof currentError === 'object' && 'name' in currentError
? String((currentError as { name?: unknown }).name)
: '';
if (errorName === 'AbortError') {
return;
}
console.warn('打开保存位置选择器失败,将使用浏览器默认下载:', currentError);
}
}

try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`文件下载失败:${response.status}`);
}

const blob = await response.blob();
if (saveFileHandle) {
const writable = await saveFileHandle.createWritable();
await writable.write(blob);
await writable.close();
ElMessage.success('下载成功');
return;
}

const objectUrl = URL.createObjectURL(blob);
try {
triggerBrowserDownload(objectUrl, getFileNameFromUrl(url));
} finally {
URL.revokeObjectURL(objectUrl);
}
} catch (currentError) {
console.warn('文件下载失败,将打开原始地址:', currentError);
window.open(url, '_blank');
}
};

const downloadFile = async () => {
if (!fileUrl.value) {
ElMessage.warning('文件路径为空,无法下载');
return;
}

const urls = fileUrlList.value.length ? fileUrlList.value : [fileUrl.value];
const uniqueUrls = Array.from(new Set(urls.filter(Boolean)));
const useSavePicker = uniqueUrls.length === 1;
for (const url of uniqueUrls) {
await downloadSingleFile(url, useSavePicker);
}
};

const openInNewTab = () => {
window.open(isPdfFile.value ? pdfViewerUrl.value : fileUrl.value, '_blank');
};

const openWin = async (url: string, urlList: string[] = []) => {
let finalUrl = url;
try {
if (typeof url === 'string' && url.trim().startsWith('[') && url.trim().endsWith(']')) {
const parsed = JSON.parse(url);
if (Array.isArray(parsed) && parsed.length > 0) {
finalUrl = parsed[0];
if (!urlList || (Array.isArray(urlList) && urlList.length === 0)) {
urlList = parsed;
}
} else if (Array.isArray(parsed) && parsed.length === 0) {
finalUrl = '';
}
}
} catch (currentError) {
console.warn('URL解析为数组失败,将使用原字符串:', currentError);
}

if (!finalUrl) {
ElMessage.warning('文件路径为空,无法预览');
return;
}

winShow.value = true;
loading.value = true;
error.value = false;
errorMessage.value = '';
textContent.value = '';
revokePdfPreviewUrl();
fileUrl.value = finalUrl;
fileUrlList.value = Array.isArray(urlList) && urlList.length ? urlList : [finalUrl];

try {
fileName.value = getFileNameFromUrl(finalUrl);
fileExtension.value = getFileExtension(finalUrl);

if (isPdfFile.value) {
await loadPdfPreviewUrl(fileUrl.value);
} else if (isTextFile.value) {
await loadTextContent(fileUrl.value);
}
} catch (currentError) {
const errorLike = currentError as Error;
console.error('加载文件失败:', errorLike);
error.value = true;
errorMessage.value = errorLike.message || '文件加载失败,请稍后重试';
} finally {
loading.value = false;
}
};

const handleDownload = async ({ done }: { done: ActionDone }) => {
try {
await downloadFile();
} finally {
done();
}
};

defineExpose({
openWin,
});

onBeforeUnmount(() => {
revokePdfPreviewUrl();
});
</script>

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

.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;
font-size: 14px;
color: #606266;
}
}

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

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

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

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

.pdf-content {
display: flex;
flex-direction: column;
gap: 12px;
}

.pdf-iframe {
display: block;
width: 100%;
min-height: 70vh;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}

.pdf-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}

.pdf-hint {
font-size: 12px;
color: #909399;
}

.image-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: min(64vh, calc(100vh - 220px));
min-height: 360px;

.preview-image {
display: block;
width: 100%;
max-width: 100%;
height: 100%;

:deep(.el-image__inner) {
object-fit: contain;
}
}
}

.office-content {
width: 100%;
height: 70vh;

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

.web-content {
width: 100%;
height: 70vh;

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

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

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

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

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

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

@keyframes rotate {
from {
transform: rotate(0deg);
}

to {
transform: rotate(360deg);
}
}
</style>