hrp-ui-base 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,658 @@
1
+ <!--
2
+ * @description: 个人签名弹窗
3
+ * @fileName: PersonalSignDialog.vue
4
+ -->
5
+ <template>
6
+ <el-dialog
7
+ v-model="dialogVisible"
8
+ title="个人签名"
9
+ width="600"
10
+ center
11
+ show-close
12
+ close-on-press-escape
13
+ >
14
+ <div class="personal-sign-content">
15
+ <div v-if="!toSignStatus" class="signbox">
16
+ <div v-if="isSign" class="haveSign">
17
+ <div class="signImgBox">
18
+ <el-image style="width: 133px; height: 100px" :src="nowBindUrlMsg" fit="contain" v-if="nowBind" />
19
+ <!-- 未签名提示 -->
20
+ <div v-else class="no-sign-tip">
21
+ <svg class="tip-icon" viewBox="0 0 1024 1024" width="32" height="32">
22
+ <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" fill="#d9d9d9"/>
23
+ <path d="M464 336a48 48 0 1 0 96 0 48 48 0 1 0-96 0zm32 120h64c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-64c-4.4 0-8-3.6-8-8V464c0-4.4 3.6-8 8-8z" fill="#d9d9d9"/>
24
+ </svg>
25
+ <span class="tip-text">当前还未进行签字</span>
26
+ </div>
27
+ </div>
28
+ <el-button class="sign-btn" type="primary" @click="handleReSignClick">{{ nowBind ? "重签" : "去签字" }}</el-button>
29
+ </div>
30
+ <div v-else class="unHaveSign">
31
+ <div class="unHaveSignUp">
32
+ <div class="qrCodeImgBox">
33
+ <QrcodeVue :size="143" :value="codeContent" class="order-print-big-qrcode" ref="QrCodeRef" />
34
+ <!-- 已扫描状态覆盖层 -->
35
+ <div v-if="ifScanned" class="qrcode-status-overlay scanned">
36
+ <svg class="status-icon" viewBox="0 0 1024 1024" width="24" height="24">
37
+ <path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" fill="#333333"/>
38
+ </svg>
39
+ <span class="status-text">已扫描</span>
40
+ </div>
41
+ <!-- 已过期状态覆盖层 -->
42
+ <div v-if="ifExpired" class="qrcode-status-overlay expired" @click="handleReSignClick">
43
+ <svg class="status-icon" viewBox="0 0 1024 1024" width="24" height="24">
44
+ <path d="M960 416V192l-73.056 73.056a447.712 447.712 0 0 0-373.6-201.088C265.92 63.968 65.312 264.544 65.312 512S265.92 960.032 513.344 960.032a448.064 448.064 0 0 0 415.232-279.488 38.368 38.368 0 1 0-70.272-29.568 371.36 371.36 0 0 1-344.96 232.064c-205.408 0-371.36-165.984-371.36-371.04 0-205.088 165.952-371.04 371.36-371.04 108.896 0 206.752 47.2 274.464 122.016L736 314.016h224a32 32 0 0 0 32-32z" fill="#b0b0b0"/>
45
+ </svg>
46
+ <span class="status-text">二维码已过期,点击刷新</span>
47
+ </div>
48
+ </div>
49
+ <div class="qrCodeBtnBox">
50
+ <el-button type="primary" size="default" @click="handleReturnClick">取消</el-button>
51
+ </div>
52
+ </div>
53
+ <div class="uploadContent">使用钉钉扫码签名或点击上传</div>
54
+ <div class="uploadContent">为保证签名识别效果最佳,请选择纯白色背景、减少阴影的图片</div>
55
+ <div class="upload-link">
56
+ <a href="javascript:;" @click="triggerFileInput">
57
+ 已有签名?点击上传
58
+ </a>
59
+ </div>
60
+ <!-- 隐藏的 file input -->
61
+ <input
62
+ type="file"
63
+ ref="fileInput"
64
+ style="display: none;"
65
+ accept=".jpg,.jpeg,.png,.bmp,.raw,.heif,.tiff,.gif"
66
+ @change="handleFileChange"
67
+ >
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <template #footer>
72
+ <div class="dialog-footer">
73
+ <el-button @click="dialogVisible = false">取消</el-button>
74
+ <el-button type="primary" @click="handleSignSave">保存</el-button>
75
+ </div>
76
+ </template>
77
+ <!-- 图片裁剪弹框 -->
78
+ <ImageCropper ref="imageCropperRef" @success="handleCropSuccess"></ImageCropper>
79
+ </el-dialog>
80
+ </template>
81
+
82
+ <script lang="ts" setup>
83
+ import { ref, watch } from 'vue'
84
+ import { ElMessage } from 'element-plus'
85
+ import QrcodeVue from 'qrcode.vue'
86
+ import ImageCropper from './ImageCropper.vue'
87
+ import FlowSignController from '../../../api/bms/flow/FlowSignController'
88
+ import FileUploadController from '../../../api/bms/file/FileUploadController'
89
+
90
+ const dialogVisible = ref<boolean>(false)
91
+ const nowBind = ref<string>('')
92
+ const nowBindUrlMsg = ref<string>('')
93
+ const toSignStatus = ref<boolean>(false)
94
+ const isSign = ref<boolean>(true)
95
+ const currentImgId = ref<string>('')
96
+
97
+ const codeContent = ref<string>('')
98
+ const qrCodeUUID = ref<string>('')
99
+ const ifScanned = ref<boolean>(false)
100
+ const ifExpired = ref<boolean>(false)
101
+ const qrCodeTimeRecord = ref<any>()
102
+ const queryQrCodeTimeRecord = ref<any>()
103
+
104
+ const fileInput = ref<HTMLInputElement>()
105
+ const imageCropperRef = ref<any>()
106
+
107
+ // 允许的图片格式
108
+ const allowedTypes = ['image/jpeg','image/png','image/bmp','image/raw','image/heif','image/tiff','image/gif']
109
+ const allowedExtensions = ['.jpg','.jpeg','.png','.bmp','.raw','.heif','.tiff','.gif']
110
+ const maxSize = 5 * 1024 * 1024
111
+
112
+ watch(nowBind, () => {
113
+ isSign.value = true
114
+ })
115
+
116
+ watch(dialogVisible, (newVal) => {
117
+ if (newVal === false) {
118
+ dialogClose()
119
+ }
120
+ })
121
+
122
+ const pageId = ref<string>('01-01-04-001')
123
+
124
+ const uploadOss = async (file: any) => {
125
+ FileUploadController.upload(file, pageId.value).then((data: any) => {
126
+ if (data.code === 200 && data.res) {
127
+ nowBind.value = data.res.url
128
+ nowBindUrlMsg.value = data.res.url
129
+ currentImgId.value = data.res.fileId
130
+ isSign.value = true
131
+ handleReturnClick()
132
+ } else {
133
+ ElMessage.error("数据发生错误,请重新上传!")
134
+ }
135
+ }).catch((err) => {
136
+ ElMessage.error("oss上传发生错误,请重试!")
137
+ console.error(err)
138
+ })
139
+ }
140
+
141
+ const handleReSignClick = async () => {
142
+ generateQrCode()
143
+ await updateQrCodeStatus("unscanned")
144
+ startQueryStatus()
145
+ isSign.value = false
146
+ }
147
+
148
+ const handleReturnClick = async () => {
149
+ await updateQrCodeStatus("expired")
150
+ clearInterval(qrCodeTimeRecord.value)
151
+ clearInterval(queryQrCodeTimeRecord.value)
152
+ isSign.value = true
153
+ }
154
+
155
+ // 触发文件选择
156
+ const triggerFileInput = () => {
157
+ if (!fileInput.value) return
158
+ fileInput.value.click()
159
+ }
160
+
161
+ const handleFileChange = async (event: any) => {
162
+ const file = event.target.files[0]
163
+ if (!file) return
164
+
165
+ if (!allowedTypes.includes(file.type)) {
166
+ ElMessage.error(`仅支持 ${allowedExtensions.join(', ')} 格式`)
167
+ resetFileInput()
168
+ return
169
+ }
170
+ if (file.size > maxSize) {
171
+ const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1)
172
+ ElMessage.error(`文件大小不能超过 ${maxSizeMB}MB`)
173
+ resetFileInput()
174
+ return
175
+ }
176
+
177
+ try {
178
+ const processedBlob = await extractSignatureImageTransparent(file)
179
+ imageCropperRef.value?.open(processedBlob)
180
+ } catch (e) {
181
+ console.error(e)
182
+ ElMessage.error('签名图片处理失败')
183
+ } finally {
184
+ resetFileInput()
185
+ }
186
+ }
187
+
188
+ const resetFileInput = () => {
189
+ if (fileInput.value) {
190
+ fileInput.value.value = ''
191
+ }
192
+ }
193
+
194
+ // 裁剪成功回调
195
+ const handleCropSuccess = async (croppedBlob: Blob) => {
196
+ try {
197
+ const croppedFile = new File(
198
+ [croppedBlob],
199
+ '签名文件.png',
200
+ { type: 'image/png' }
201
+ )
202
+ await uploadOss(croppedFile)
203
+ } catch (e) {
204
+ console.error(e)
205
+ ElMessage.error('签名图片上传失败')
206
+ }
207
+ }
208
+
209
+ const generateUUID = () => {
210
+ const timestamp = performance.now().toString(36)
211
+ const random = Math.random().toString(36).slice(2)
212
+ const random2 = Math.random().toString(36).slice(2)
213
+ return `${timestamp}-${random}-${random2}`.replace(/\./g, '')
214
+ }
215
+
216
+ const generateQrCode = () => {
217
+ ifScanned.value = false
218
+ ifExpired.value = false
219
+ qrCodeUUID.value = generateUUID()
220
+ const urlStr = `${window.location.origin}/mobile/login/index.html?host=${localStorage.getItem('TenantHost')}&url=${'/full-screen-sign-process'}?qrCodeUUID=${qrCodeUUID.value}`
221
+ codeContent.value = urlStr
222
+ }
223
+
224
+ const startPolling = () => {
225
+ qrCodeTimeRecord.value = setInterval(async () => {
226
+ try {
227
+ const data = await FlowSignController.startPolling(qrCodeUUID.value)
228
+ if (data.code === 200 && data.res) {
229
+ clearInterval(qrCodeTimeRecord.value)
230
+ nowBind.value = data.res.url
231
+ nowBindUrlMsg.value = data.res.url
232
+ currentImgId.value = data.res.fileId
233
+ }
234
+ } catch (e) {
235
+ // ignore
236
+ }
237
+ }, 2000)
238
+ }
239
+
240
+ const handleSignSave = () => {
241
+ FlowSignController.personalSignSave(currentImgId.value).then(data => {
242
+ if (data.code === 200) {
243
+ ElMessage.success('个人签名上传成功')
244
+ }
245
+ }).catch(() => {
246
+ dialogVisible.value = false
247
+ }).finally(() => {
248
+ dialogVisible.value = false
249
+ })
250
+ }
251
+
252
+ const getPersonalSign = async () => {
253
+ await FlowSignController.gerPersonalSign().then(data => {
254
+ if (data.code === 200 && data.res) {
255
+ nowBind.value = data.res.url
256
+ nowBindUrlMsg.value = data.res.url
257
+ }
258
+ })
259
+ }
260
+
261
+ const startQueryStatus = () => {
262
+ queryQrCodeTimeRecord.value = setInterval(() => {
263
+ FlowSignController.queryQrCodeStatus(qrCodeUUID.value).then(data => {
264
+ if (data.code === 200 && data.res === "scanned") {
265
+ clearInterval(queryQrCodeTimeRecord.value)
266
+ ifScanned.value = true
267
+ startPolling()
268
+ } else if (data.code === 200 && (data.res === null || data.res === "expired")) {
269
+ clearInterval(queryQrCodeTimeRecord.value)
270
+ ifExpired.value = true
271
+ ElMessage.error("二维码已过期,请刷新")
272
+ }
273
+ })
274
+ }, 2000)
275
+ }
276
+
277
+ const updateQrCodeStatus = (status: string) => {
278
+ if (status === 'scanned') {
279
+ ifScanned.value = true
280
+ } else if (status === 'expired') {
281
+ ifExpired.value = true
282
+ }
283
+ FlowSignController.updateQrCodeStatus(status, qrCodeUUID.value).then(data => {
284
+ if (data.code === 200 && data.res) {
285
+ console.log("二维码状态更新成功")
286
+ } else if (data.code === 200 && !data.res) {
287
+ ElMessage.error(data.msg || "二维码失效请刷新后重试")
288
+ }
289
+ })
290
+ }
291
+
292
+ const dialogClose = () => {
293
+ clearInterval(qrCodeTimeRecord.value)
294
+ clearInterval(queryQrCodeTimeRecord.value)
295
+ ifScanned.value = false
296
+ ifExpired.value = false
297
+ isSign.value = true
298
+ }
299
+
300
+ /** 打开弹窗 */
301
+ const open = () => {
302
+ dialogVisible.value = true
303
+ getPersonalSign()
304
+ }
305
+
306
+ // ====== 图片处理工具函数 ======
307
+
308
+ const extractSignatureImageTransparent = (file: File): Promise<Blob> => {
309
+ return new Promise((resolve, reject) => {
310
+ const img = new Image()
311
+ img.onload = () => {
312
+ const canvas = document.createElement('canvas')
313
+ const ctx = canvas.getContext('2d')!
314
+
315
+ canvas.width = img.width
316
+ canvas.height = img.height
317
+ ctx.drawImage(img, 0, 0)
318
+
319
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
320
+ const data = imageData.data
321
+
322
+ const grays: number[] = []
323
+ let min = 255, max = 0
324
+ for (let i = 0; i < data.length; i += 4) {
325
+ const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
326
+ grays.push(gray)
327
+ min = Math.min(min, gray)
328
+ max = Math.max(max, gray)
329
+ }
330
+
331
+ const threshold = min + (max - min) * 0.35
332
+
333
+ for (let i = 0; i < data.length; i += 4) {
334
+ const gray = grays[i / 4]
335
+ if (gray > threshold) {
336
+ data[i + 3] = 0
337
+ } else {
338
+ data[i] = data[i + 1] = data[i + 2] = 0
339
+ data[i + 3] = 255
340
+ }
341
+ }
342
+
343
+ enhanceContrast(data)
344
+ for (let i = 0; i < 2; i++) {
345
+ dilateAlpha(data, canvas.width, canvas.height, 1)
346
+ fillGapsAlpha(data, canvas.width, canvas.height)
347
+ }
348
+ removeHorizontalLines(data, canvas.width, canvas.height)
349
+ ctx.putImageData(imageData, 0, 0)
350
+
351
+ canvas.toBlob(
352
+ blob => blob ? resolve(blob) : reject(),
353
+ 'image/png',
354
+ 1
355
+ )
356
+ }
357
+
358
+ img.onerror = reject
359
+ img.src = URL.createObjectURL(file)
360
+ })
361
+ }
362
+
363
+ const dilateAlpha = (data: Uint8ClampedArray, width: number, height: number, radius = 1) => {
364
+ const copy = new Uint8ClampedArray(data)
365
+ const idx = (x: number, y: number) => (y * width + x) * 4
366
+
367
+ for (let y = radius; y < height - radius; y++) {
368
+ for (let x = radius; x < width - radius; x++) {
369
+ const center = idx(x, y)
370
+ if (copy[center + 3] === 255) continue
371
+
372
+ let hasOpaqueNeighbor = false
373
+ for (let dy = -radius; dy <= radius && !hasOpaqueNeighbor; dy++) {
374
+ for (let dx = -radius; dx <= radius; dx++) {
375
+ const i = idx(x + dx, y + dy)
376
+ if (copy[i + 3] === 255) {
377
+ hasOpaqueNeighbor = true
378
+ break
379
+ }
380
+ }
381
+ }
382
+
383
+ if (hasOpaqueNeighbor) {
384
+ data[center + 3] = 255
385
+ for (let c = 0; c < 3; c++) {
386
+ data[center + c] = copy[center + c]
387
+ }
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ const fillGapsAlpha = (data: Uint8ClampedArray, width: number, height: number) => {
394
+ const copy = new Uint8ClampedArray(data)
395
+ const idx = (x: number, y: number) => (y * width + x) * 4
396
+
397
+ for (let y = 1; y < height - 1; y++) {
398
+ for (let x = 1; x < width - 1; x++) {
399
+ const i = idx(x, y)
400
+ if (copy[i + 3] === 255) continue
401
+
402
+ let opaqueCount = 0
403
+ let r = [0, 0, 0]
404
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
405
+ const neighbor = idx(x + dx, y + dy)
406
+ if (copy[neighbor + 3] === 255) {
407
+ opaqueCount++
408
+ for (let c = 0; c < 3; c++) r[c] += copy[neighbor + c]
409
+ }
410
+ }
411
+
412
+ if (opaqueCount >= 2) {
413
+ data[i + 3] = 255
414
+ for (let c = 0; c < 3; c++) data[i + c] = Math.round(r[c] / opaqueCount)
415
+ }
416
+ }
417
+ }
418
+ }
419
+
420
+ const enhanceContrast = (data: Uint8ClampedArray) => {
421
+ for (let i = 0; i < data.length; i += 4) {
422
+ if (data[i + 3] === 255) {
423
+ for (let c = 0; c < 3; c++) {
424
+ const val = data[i + c] / 255
425
+ data[i + c] = Math.min(255, Math.pow(val, 0.4) * 255)
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ const removeHorizontalLines = (data: Uint8ClampedArray, width: number, height: number) => {
432
+ const threshold = width * 0.7
433
+ const idx = (x: number, y: number) => (y * width + x) * 4
434
+
435
+ for (let y = 0; y < height; y++) {
436
+ let count = 0
437
+ let startX = 0
438
+ for (let x = 0; x < width; x++) {
439
+ if (data[idx(x, y) + 3] === 255) {
440
+ if (count === 0) startX = x
441
+ count++
442
+ } else {
443
+ if (count > threshold) {
444
+ for (let i = startX; i < x; i++) {
445
+ data[idx(i, y) + 3] = 0
446
+ }
447
+ }
448
+ count = 0
449
+ }
450
+ }
451
+ if (count > threshold) {
452
+ for (let i = startX; i < width; i++) {
453
+ data[idx(i, y) + 3] = 0
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ defineExpose({ open })
460
+ </script>
461
+
462
+ <style scoped lang="scss">
463
+ .personal-sign-content {
464
+ width: 100%;
465
+ min-height: 220px;
466
+ }
467
+
468
+ .signbox {
469
+ margin-top: 8px;
470
+ width: 100%;
471
+ display: flex;
472
+ flex-direction: row;
473
+ justify-content: flex-start;
474
+ align-items: flex-start;
475
+ flex-wrap: wrap;
476
+ }
477
+
478
+ .haveSign {
479
+ display: flex;
480
+ justify-content: center;
481
+ align-items: flex-start;
482
+ width: 100%;
483
+ margin: 0 auto;
484
+ min-height: 197px;
485
+ text-align: center;
486
+ position: relative;
487
+ }
488
+
489
+ .sign-btn {
490
+ position: absolute;
491
+ right: calc(50% - 145px);
492
+ top: 72.5px;
493
+ margin-left: 16px;
494
+ }
495
+
496
+ .unHaveSign {
497
+ width: 100%;
498
+ margin: 0 auto;
499
+ min-height: 197px;
500
+ text-align: center;
501
+ position: relative;
502
+
503
+ .uploadContent {
504
+ margin-bottom: 5px;
505
+ font-size: 11.5px;
506
+ }
507
+
508
+ .upload-link {
509
+ font-weight: 500;
510
+ font-size: 10px;
511
+ color: #409eff;
512
+ cursor: pointer;
513
+ }
514
+ }
515
+
516
+ .signImgBox, .qrCodeImgBox {
517
+ box-sizing: border-box;
518
+ width: 145px;
519
+ height: 145px;
520
+ overflow: hidden;
521
+ margin: 0 auto 15px;
522
+ position: relative;
523
+ }
524
+
525
+ .signImgBox {
526
+ width: 133px;
527
+ height: 100px;
528
+ margin-top: 50px;
529
+ display: flex;
530
+ align-items: center;
531
+ justify-content: center;
532
+
533
+ .no-sign-tip {
534
+ display: flex;
535
+ flex-direction: column;
536
+ align-items: center;
537
+ justify-content: center;
538
+ width: 100%;
539
+ height: 100%;
540
+ background: #fafafa;
541
+ border-radius: 4px;
542
+ gap: 8px;
543
+
544
+ .tip-icon {
545
+ flex-shrink: 0;
546
+ }
547
+
548
+ .tip-text {
549
+ font-size: 12px;
550
+ color: #999;
551
+ text-align: center;
552
+ line-height: 1.4;
553
+ padding: 0 8px;
554
+ }
555
+ }
556
+ }
557
+
558
+ .unHaveSignUp {
559
+ display: flex;
560
+ justify-content: center;
561
+ position: relative;
562
+
563
+ .qrCodeBtnBox {
564
+ position: absolute;
565
+ right: calc(50% - 145px);
566
+ top: 72.5px;
567
+ margin-left: 16px;
568
+ display: flex;
569
+ flex-direction: column;
570
+ gap: 8px;
571
+
572
+ .el-button + .el-button {
573
+ margin-left: 0 !important;
574
+ }
575
+
576
+ .el-button {
577
+ min-width: 60px;
578
+ }
579
+ }
580
+
581
+ .uploadContent {
582
+ margin-top: 8px;
583
+ }
584
+ }
585
+
586
+ // 二维码状态覆盖层样式
587
+ .qrcode-status-overlay {
588
+ position: absolute;
589
+ top: 0;
590
+ left: 0;
591
+ right: 0;
592
+ bottom: 0;
593
+ display: flex;
594
+ flex-direction: column;
595
+ align-items: center;
596
+ justify-content: center;
597
+ gap: 6px;
598
+ animation: fadeIn 0.25s ease-out;
599
+ z-index: 10;
600
+
601
+ &::before {
602
+ content: '';
603
+ position: absolute;
604
+ top: 0;
605
+ left: 0;
606
+ right: 0;
607
+ bottom: 0;
608
+ background-image:
609
+ repeating-linear-gradient(0deg, transparent, transparent 1px, currentColor 1px, currentColor 2px),
610
+ repeating-linear-gradient(90deg, transparent, transparent 1px, currentColor 1px, currentColor 2px);
611
+ opacity: 0.25;
612
+ z-index: 1;
613
+ }
614
+
615
+ .status-icon {
616
+ flex-shrink: 0;
617
+ position: relative;
618
+ z-index: 3;
619
+ }
620
+
621
+ .status-text {
622
+ font-size: 12px;
623
+ font-weight: 400;
624
+ letter-spacing: 0.5px;
625
+ line-height: 1;
626
+ position: relative;
627
+ z-index: 3;
628
+ }
629
+
630
+ &.scanned {
631
+ background: rgba(255, 255, 255, 0.96);
632
+ color: rgba(100, 100, 100, 0.3);
633
+
634
+ .status-text {
635
+ color: #333333;
636
+ }
637
+ }
638
+
639
+ &.expired {
640
+ background: rgba(40, 40, 40, 0.92);
641
+ color: rgba(180, 180, 180, 0.3);
642
+ cursor: pointer;
643
+
644
+ .status-text {
645
+ color: #e0e0e0;
646
+ }
647
+
648
+ .status-icon path {
649
+ fill: #b0b0b0;
650
+ }
651
+ }
652
+ }
653
+
654
+ @keyframes fadeIn {
655
+ from { opacity: 0; }
656
+ to { opacity: 1; }
657
+ }
658
+ </style>