af-mobile-client-vue3 1.4.36 → 1.4.38

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.
@@ -1,846 +1,846 @@
1
- <script setup lang="ts">
2
- import type { FileItem } from './FileUploader.vue'
3
- import CardContainer from '@af-mobile-client-vue3/components/data/CardContainer/CardContainer.vue'
4
- import CardHeader from '@af-mobile-client-vue3/components/data/CardContainer/CardHeader.vue'
5
- import { mobileUtil } from '@af-mobile-client-vue3/utils/mobileUtil'
6
- import {
7
- showFailToast,
8
- showToast,
9
- Button as VanButton,
10
- Icon as VanIcon,
11
- ImagePreview as VanImagePreview,
12
- } from 'vant'
13
- import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
14
-
15
- export interface FileTypeConfig {
16
- userType: string
17
- picMinNum: number
18
- icon?: string
19
- description?: string
20
- }
21
-
22
- interface Props {
23
- fileTypes: FileTypeConfig[]
24
- fileList?: FileItem[]
25
- title?: string
26
- maxSize?: number
27
- }
28
-
29
- const props = withDefaults(defineProps<Props>(), {
30
- fileList: () => [],
31
- title: '上传附件',
32
- maxSize: 10 * 1024 * 1024, // 10MB
33
- })
34
-
35
- const emit = defineEmits<{
36
- 'update:fileList': [value: FileItem[]]
37
- }>()
38
-
39
- // 文件存储,按类型分组
40
- const filesByType = reactive<Record<string, FileItem[]>>({})
41
-
42
- // 浮动按钮状态管理
43
- const activeTypeKey = ref<string>('')
44
- const showFloatingButtons = ref(false)
45
- // 定时器 ref 用于管理自动隐藏
46
- const hideTimer = ref<number | null>(null)
47
-
48
- // 图片预览状态管理
49
- const showPreview = ref(false)
50
- const previewImages = ref<string[]>([])
51
- const previewIndex = ref(0)
52
- const currentPreviewType = ref<string>('')
53
- const currentPreviewFiles = ref<FileItem[]>([])
54
-
55
- // 判断是否为开发环境
56
- const isDev = import.meta.env.MODE === 'development'
57
-
58
- // 模拟图片的base64数据 - 四色块图片(红绿蓝紫)
59
- const mockImageBase64 = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8IS0tIOW3puS4iiAoUmVkKSAtLT4KICA8cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNGRjQ0NDQiLz4KICA8IS0tIOWPs+S4iiAoR3JlZW4pIC0tPgogIDxyZWN0IHg9IjUwIiB5PSIwIiB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiM0NEZGNDQiLz4KICA8IS0tIOW3puS4iyAoQmx1ZSkgLS0+CiAgPHJlY3QgeD0iMCIgeT0iNTAiIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgZmlsbD0iIzQ0NDRGRiIvPgogIDwhLS0g57Sr5LiKIChQdXJwbGUpIC0tPgogIDxyZWN0IHg9IjUwIiB5PSI1MCIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiBmaWxsPSIjRkY0NEZGIi8+Cjwvc3ZnPgo='
60
-
61
- // 所有文件的扁平列表
62
- const allFiles = computed<FileItem[]>(() => {
63
- const files: FileItem[] = []
64
- Object.values(filesByType).forEach((typeFiles) => {
65
- files.push(...typeFiles)
66
- })
67
- return files
68
- })
69
-
70
- // 转换为标准FileItem格式并发送给父组件
71
- function emitFileListUpdate(): void {
72
- emit('update:fileList', allFiles.value)
73
- }
74
-
75
- // 获取图标
76
- function getIcon(config: FileTypeConfig): string {
77
- return config.icon || 'fluent-emoji:file-folder'
78
- }
79
-
80
- // 初始化文件存储
81
- onMounted(() => {
82
- props.fileTypes.forEach((type) => {
83
- if (!filesByType[type.userType]) {
84
- filesByType[type.userType] = []
85
- }
86
- })
87
- })
88
-
89
- // 组件卸载时清理定时器
90
- onUnmounted(() => {
91
- if (hideTimer.value) {
92
- clearTimeout(hideTimer.value)
93
- hideTimer.value = null
94
- }
95
- })
96
-
97
- // 触发文件上传
98
- function triggerFileUpload(typeKey: string): void {
99
- console.log('点击上传事件 - 类型:', typeKey)
100
-
101
- // 判断是否为开发环境
102
- if (isDev) {
103
- console.log('开发环境,使用模拟上传并调用真实接口')
104
- uploadFileInDevMode(typeKey)
105
- return
106
- }
107
-
108
- // 非开发环境,使用原生功能
109
- mobileUtil.execute({
110
- funcName: 'takePicture',
111
- param: {},
112
- callbackFunc: (result: any) => {
113
- if (result.status === 'success') {
114
- uploadFileInProdMode(typeKey, result.data)
115
- }
116
- },
117
- })
118
- }
119
-
120
- // 开发环境上传文件
121
- function uploadFileInDevMode(typeKey: string): void {
122
- // 基本信息
123
- const filename = `${typeKey}_test.png`
124
- const fileSize = 0.0003 // MB
125
- const operator = '测试管理员'
126
-
127
- // 创建临时文件对象(SVG转换为文件)
128
- const uid = Date.now() + Math.random().toString(36).substr(2, 5)
129
- const byteString = atob(mockImageBase64.split(',')[1])
130
- const ab = new ArrayBuffer(byteString.length)
131
- const ia = new Uint8Array(ab)
132
- for (let i = 0; i < byteString.length; i++) {
133
- ia[i] = byteString.charCodeAt(i)
134
- }
135
- const blob = new Blob([ab], { type: 'image/png' })
136
- const file = new File([blob], filename, { type: 'image/png' })
137
-
138
- // 创建文件项
139
- const fileItem: FileItem = {
140
- uid,
141
- name: filename,
142
- size: file.size,
143
- type: 'image/svg+xml',
144
- userType: typeKey,
145
- status: 'uploading',
146
- url: mockImageBase64,
147
- }
148
-
149
- // 添加到对应类型的文件列表
150
- if (!filesByType[typeKey]) {
151
- filesByType[typeKey] = []
152
- }
153
- filesByType[typeKey].push(fileItem)
154
-
155
- // 创建FormData
156
- const formData = new FormData()
157
- formData.append('avatar', file) // 文件二进制数据
158
- formData.append('resUploadMode', 'server')
159
- formData.append('formType', 'file')
160
- formData.append('useType', typeKey)
161
- formData.append('resUploadStock', '1')
162
- formData.append('filename', filename)
163
- formData.append('filesize', fileSize.toString())
164
- formData.append('f_operator', operator)
165
-
166
- // 调用接口上传
167
- fetch('/api/af-revenue/resource/upload', {
168
- method: 'POST',
169
- body: formData,
170
- })
171
- .then(response => response.json())
172
- .then((result) => {
173
- console.log('上传成功:', result)
174
- // 找到对应文件项 并替换
175
- const fileItem = filesByType[typeKey].find(f => f.uid === uid)
176
- if (fileItem) {
177
- fileItem.status = 'success'
178
- fileItem.result = result.data.data
179
- }
180
- emitFileListUpdate()
181
- })
182
- .catch((error) => {
183
- console.error('上传失败:', error)
184
- fileItem.status = 'error'
185
- showFailToast(`上传文件失败: ${error.message || error.msg || '请稍后重试'}`)
186
- emitFileListUpdate()
187
- })
188
- }
189
-
190
- // 生产环境上传文件
191
- function uploadFileInProdMode(typeKey: string, file: any): void {
192
- // 创建文件项
193
- const fileItem: FileItem = {
194
- uid: Date.now() + Math.random().toString(36).substr(2, 5),
195
- name: file.name,
196
- size: file.size,
197
- type: 'image/jpeg',
198
- userType: typeKey,
199
- status: 'uploading',
200
- url: `data:image/png;base64,${file.content}`,
201
- filePath: file?.filePath,
202
- }
203
-
204
- // 添加到对应类型的文件列表
205
- if (!filesByType[typeKey]) {
206
- filesByType[typeKey] = []
207
- }
208
- filesByType[typeKey].push(fileItem)
209
-
210
- mobileUtil.execute({
211
- funcName: 'uploadResource',
212
- param: {
213
- resUploadMode: 'server',
214
- pathKey: 'Default',
215
- formType: 'image',
216
- useType: typeKey,
217
- resUploadStock: '1',
218
- filename: file?.name,
219
- filesize: file?.size,
220
- f_operator: 'server',
221
- imgPath: file?.filePath,
222
- urlPath: '/api/af-revenue/resource/upload',
223
- },
224
- callbackFunc: (result: any) => {
225
- if (result.status === 'success') {
226
- fileItem.status = 'success'
227
- fileItem.result = result.data
228
- emitFileListUpdate()
229
- }
230
- else {
231
- fileItem.status = 'error'
232
- showFailToast(`上传图片失败,${result.message}`)
233
- emitFileListUpdate()
234
- }
235
- },
236
- })
237
- }
238
-
239
- // 删除文件
240
- function removeFile(typeKey: string, fileId: string | number): void {
241
- const typeFiles = filesByType[typeKey]
242
- if (!typeFiles)
243
- return
244
-
245
- const index = typeFiles.findIndex(f => f.uid === fileId)
246
- if (index > -1) {
247
- const removedFile = typeFiles.splice(index, 1)[0]
248
- if (removedFile.url) {
249
- URL.revokeObjectURL(removedFile.url)
250
- }
251
-
252
- emitFileListUpdate()
253
- }
254
- }
255
-
256
- // 显示浮动按钮
257
- function showFloatingMenu(typeKey: string): void {
258
- // 清除之前的定时器
259
- if (hideTimer.value) {
260
- clearTimeout(hideTimer.value)
261
- hideTimer.value = null
262
- }
263
-
264
- activeTypeKey.value = typeKey
265
- showFloatingButtons.value = true
266
-
267
- hideTimer.value = window.setTimeout(() => {
268
- hideFloatingMenu()
269
- }, 1200)
270
- }
271
-
272
- // 隐藏浮动按钮
273
- function hideFloatingMenu(): void {
274
- // 清除定时器
275
- if (hideTimer.value) {
276
- clearTimeout(hideTimer.value)
277
- hideTimer.value = null
278
- }
279
-
280
- showFloatingButtons.value = false
281
- activeTypeKey.value = ''
282
- }
283
-
284
- // 查看已上传的文件
285
- function viewFiles(typeKey: string): void {
286
- const typeFiles = filesByType[typeKey]
287
- if (!typeFiles?.length) {
288
- showToast('暂无已上传的文件')
289
- return
290
- }
291
-
292
- // 过滤出图片文件
293
- const imageFiles = typeFiles.filter(f => f.url && f.type.startsWith('image/'))
294
- if (!imageFiles.length) {
295
- showToast('暂无图片文件')
296
- return
297
- }
298
-
299
- // 设置预览状态
300
- currentPreviewType.value = typeKey
301
- currentPreviewFiles.value = imageFiles
302
- previewImages.value = imageFiles.map(f => f.url!).filter(Boolean)
303
- previewIndex.value = 0
304
- showPreview.value = true
305
-
306
- hideFloatingMenu()
307
- }
308
-
309
- // 上传文件(原有逻辑)
310
- function uploadFiles(typeKey: string): void {
311
- triggerFileUpload(typeKey)
312
- hideFloatingMenu()
313
- }
314
-
315
- // 预览组件事件处理
316
- function onPreviewChange(index: number): void {
317
- previewIndex.value = index
318
- }
319
-
320
- // 删除当前预览的图片
321
- function deleteCurrentPreviewImage(): void {
322
- const currentFile = currentPreviewFiles.value[previewIndex.value]
323
- if (!currentFile)
324
- return
325
-
326
- // 从文件列表中删除
327
- removeFile(currentPreviewType.value, currentFile.uid)
328
-
329
- // 更新预览状态
330
- currentPreviewFiles.value.splice(previewIndex.value, 1)
331
- previewImages.value.splice(previewIndex.value, 1)
332
-
333
- // 如果没有图片了,关闭预览
334
- if (previewImages.value.length === 0) {
335
- showPreview.value = false
336
- return
337
- }
338
-
339
- // 调整索引
340
- if (previewIndex.value >= previewImages.value.length) {
341
- previewIndex.value = previewImages.value.length - 1
342
- }
343
- }
344
-
345
- // 获取类型的已上传文件数量
346
- function getUploadedCount(typeKey: string): number {
347
- return filesByType[typeKey]?.length || 0
348
- }
349
-
350
- // 检查是否必填且未满足最小要求
351
- function isRequired(config: FileTypeConfig): boolean {
352
- return config.picMinNum > 0
353
- }
354
-
355
- function isRequirementMet(config: FileTypeConfig): boolean {
356
- const uploadedCount = getUploadedCount(config.userType)
357
- return uploadedCount >= config.picMinNum
358
- }
359
-
360
- // 验证所有必填项是否满足
361
- function validateAll(): boolean {
362
- for (const config of props.fileTypes) {
363
- if (isRequired(config) && !isRequirementMet(config)) {
364
- showToast(`${config.userType}至少需要上传${config.picMinNum}张照片`)
365
- return false
366
- }
367
- }
368
- return true
369
- }
370
-
371
- const minLength = computed(() => {
372
- return props.fileTypes.reduce((acc, config) => acc + config.picMinNum, 0)
373
- })
374
-
375
- // 暴露验证方法给父组件
376
- defineExpose({
377
- validateAll,
378
- getAllFiles: () => allFiles.value,
379
- triggerFileUpload,
380
- })
381
- </script>
382
-
383
- <template>
384
- <CardContainer class="grid-file-uploader">
385
- <CardHeader :title="title">
386
- <template #extra>
387
- <div class="grid-file-uploader__progress">
388
- 上传进度: {{ allFiles.filter(f => f.status === 'success').length }}/{{ minLength }} 已完成
389
- </div>
390
- </template>
391
- </CardHeader>
392
-
393
- <div class="grid-file-uploader__description">
394
- 点击对应类别上传相关照片和文件
395
- </div>
396
-
397
- <div class="grid-file-uploader__grid">
398
- <div
399
- v-for="config in fileTypes"
400
- :key="config.userType"
401
- class="grid-file-uploader__item"
402
- :class="{
403
- 'grid-file-uploader__item--required': isRequired(config),
404
- 'grid-file-uploader__item--completed': isRequirementMet(config),
405
- 'grid-file-uploader__item--pending': isRequired(config) && !isRequirementMet(config),
406
- }"
407
- >
408
- <!-- 必填标识 -->
409
- <div
410
- v-if="isRequired(config) && !isRequirementMet(config)"
411
- class="grid-file-uploader__item-required"
412
- >
413
-
414
- </div>
415
- <VanIcon
416
- v-else-if="isRequirementMet(config)"
417
- name="success"
418
- class="grid-file-uploader__item-success"
419
- />
420
-
421
- <!-- 文件上传区域 -->
422
- <div class="grid-file-uploader__item-content" @click="showFloatingMenu(config.userType)">
423
- <!-- 图标 -->
424
- <div class="grid-file-uploader__item-icon">
425
- <VanIcon
426
- :icon="getIcon(config)"
427
- class="grid-file-uploader__icon"
428
- />
429
- </div>
430
-
431
- <!-- 标题 -->
432
- <div class="grid-file-uploader__item-title">
433
- {{ config.userType }}
434
- </div>
435
-
436
- <!-- 描述文本 -->
437
- <div class="grid-file-uploader__item-desc">
438
- {{ config.description || `${config.userType}照片` }}
439
- </div>
440
-
441
- <!-- 文件数量显示 -->
442
- <div class="grid-file-uploader__item-count">
443
- {{ getUploadedCount(config.userType) }}
444
- <span v-if="config.picMinNum > 0" class="grid-file-uploader__item-count-min">
445
- /{{ config.picMinNum }}
446
- </span>
447
- </div>
448
- </div>
449
-
450
- <!-- 浮动按钮 -->
451
- <div
452
- v-if="showFloatingButtons && activeTypeKey === config.userType"
453
- class="grid-file-uploader__floating-buttons"
454
- @click.stop
455
- >
456
- <VanButton
457
- v-if="getUploadedCount(config.userType) > 0"
458
- type="primary"
459
- size="small"
460
- icon="eye-o"
461
- class="grid-file-uploader__floating-btn"
462
- @click="viewFiles(config.userType)"
463
- >
464
- 查看
465
- </VanButton>
466
- <VanButton
467
- type="success"
468
- size="small"
469
- :icon="isDev ? 'photograph' : 'camera-o'"
470
- class="grid-file-uploader__floating-btn"
471
- @click="uploadFiles(config.userType)"
472
- >
473
- {{ isDev ? '上传' : '拍照' }}
474
- </VanButton>
475
- </div>
476
- </div>
477
- </div>
478
-
479
- <!-- 图片预览组件 -->
480
- <VanImagePreview
481
- v-model:show="showPreview"
482
- :images="previewImages"
483
- :start-position="previewIndex"
484
- teleport="body"
485
- @change="onPreviewChange"
486
- >
487
- <template #index>
488
- <div class="preview-footer" style="text-align: center;">
489
- <span class="preview-index">第{{ previewIndex + 1 }}页</span>
490
- <br>
491
- <VanButton
492
- type="danger"
493
- size="small"
494
- icon="delete-o"
495
- class="preview-delete-btn"
496
- @click="deleteCurrentPreviewImage"
497
- >
498
- 删除
499
- </VanButton>
500
- </div>
501
- </template>
502
- </VanImagePreview>
503
- </CardContainer>
504
- </template>
505
-
506
- <style lang="less" scoped>
507
- .grid-file-uploader {
508
- &__description {
509
- font-size: 14px;
510
- color: #666;
511
- margin: 0 0 16px 0;
512
- padding: 0 16px;
513
- }
514
-
515
- &__progress {
516
- font-size: 12px;
517
- color: #999;
518
- margin: 0;
519
- }
520
-
521
- &__grid {
522
- display: grid;
523
- grid-template-columns: repeat(2, 1fr);
524
- gap: 16px;
525
- padding: 0 16px 16px 16px;
526
-
527
- @media screen and (max-width: 768px) {
528
- grid-template-columns: repeat(2, 1fr);
529
- gap: 12px;
530
- padding: 0 12px 12px 12px;
531
- }
532
-
533
- @media screen and (max-width: 480px) {
534
- grid-template-columns: repeat(2, 1fr);
535
- gap: 10px;
536
- padding: 0 10px 10px 10px;
537
- }
538
- }
539
-
540
- &__item {
541
- position: relative;
542
- border: 2px dashed #d9d9d9;
543
- border-radius: 8px;
544
- background: #fafafa;
545
- transition: all 0.3s ease;
546
- min-height: 120px;
547
- height: 120px;
548
-
549
- &:hover {
550
- border-color: #40a9ff;
551
- background: #f0f7ff;
552
- }
553
-
554
- &--required {
555
- border-color: #ff7875;
556
- background: #fff2f0;
557
- }
558
-
559
- &--completed {
560
- border-color: #52c41a;
561
- background: #f6ffed;
562
- }
563
-
564
- &--pending {
565
- border-color: #faad14;
566
- background: #fffbe6;
567
- }
568
-
569
- @media screen and (max-width: 768px) {
570
- min-height: 110px;
571
- height: 110px;
572
- }
573
-
574
- @media screen and (max-width: 480px) {
575
- min-height: 100px;
576
- height: 100px;
577
- }
578
- }
579
-
580
- &__item-required {
581
- position: absolute;
582
- top: -8px;
583
- right: -8px;
584
- background: #ff9500;
585
- color: #fff;
586
- font-size: 12px;
587
- font-weight: bold;
588
- width: 22px;
589
- height: 22px;
590
- border-radius: 50%;
591
- display: flex;
592
- align-items: center;
593
- justify-content: center;
594
- z-index: 2;
595
- line-height: 1;
596
- box-shadow: 0 2px 4px rgba(255, 149, 0, 0.3);
597
-
598
- @media screen and (max-width: 480px) {
599
- font-size: 11px;
600
- width: 20px;
601
- height: 20px;
602
- top: -6px;
603
- right: -6px;
604
- }
605
- }
606
-
607
- &__item-success {
608
- position: absolute;
609
- top: 4px;
610
- right: 4px;
611
- color: #52c41a;
612
- font-size: 16px;
613
- z-index: 2;
614
- }
615
-
616
- &__item-content {
617
- display: flex;
618
- flex-direction: column;
619
- align-items: center;
620
- justify-content: center;
621
- padding: 16px 12px;
622
- height: 100%;
623
- cursor: pointer;
624
- position: relative;
625
-
626
- @media screen and (max-width: 768px) {
627
- padding: 14px 10px;
628
- }
629
-
630
- @media screen and (max-width: 480px) {
631
- padding: 12px 8px;
632
- }
633
- }
634
-
635
- &__item-input {
636
- position: absolute;
637
- opacity: 0;
638
- pointer-events: none;
639
- }
640
-
641
- &__item-icon {
642
- display: flex;
643
- align-items: center;
644
- justify-content: center;
645
- margin-bottom: 8px;
646
-
647
- @media screen and (max-width: 768px) {
648
- margin-bottom: 6px;
649
- }
650
-
651
- @media screen and (max-width: 480px) {
652
- margin-bottom: 5px;
653
- }
654
- }
655
-
656
- &__icon {
657
- width: 36px;
658
- height: 36px;
659
- display: block;
660
-
661
- @media screen and (max-width: 768px) {
662
- width: 32px;
663
- height: 32px;
664
- }
665
-
666
- @media screen and (max-width: 480px) {
667
- width: 28px;
668
- height: 28px;
669
- }
670
- }
671
-
672
- &__item-title {
673
- font-size: 14px;
674
- font-weight: 500;
675
- color: #333;
676
- margin-bottom: 4px;
677
- text-align: center;
678
- line-height: 1.2;
679
-
680
- @media screen and (max-width: 768px) {
681
- font-size: 13px;
682
- margin-bottom: 3px;
683
- }
684
-
685
- @media screen and (max-width: 480px) {
686
- font-size: 12px;
687
- margin-bottom: 3px;
688
- }
689
- }
690
-
691
- &__item-desc {
692
- font-size: 12px;
693
- color: #666;
694
- text-align: center;
695
- margin-bottom: 8px;
696
- line-height: 1.2;
697
-
698
- @media screen and (max-width: 768px) {
699
- font-size: 11px;
700
- margin-bottom: 6px;
701
- }
702
-
703
- @media screen and (max-width: 480px) {
704
- font-size: 10px;
705
- margin-bottom: 5px;
706
- }
707
- }
708
-
709
- &__item-count {
710
- font-size: 14px;
711
- font-weight: 600;
712
- color: #1890ff;
713
-
714
- &-min {
715
- color: #999;
716
- font-weight: normal;
717
- }
718
- }
719
-
720
- &__item-files {
721
- position: absolute;
722
- bottom: 8px;
723
- left: 8px;
724
- right: 8px;
725
- display: flex;
726
- flex-wrap: wrap;
727
- gap: 4px;
728
- max-height: 40px;
729
- overflow: hidden;
730
- }
731
-
732
- &__file-preview {
733
- position: relative;
734
- width: 32px;
735
- height: 32px;
736
- border-radius: 4px;
737
- overflow: hidden;
738
- border: 1px solid #d9d9d9;
739
- }
740
-
741
- &__file-image {
742
- width: 100%;
743
- height: 100%;
744
- object-fit: cover;
745
- }
746
-
747
- &__file-icon {
748
- width: 100%;
749
- height: 100%;
750
- display: flex;
751
- align-items: center;
752
- justify-content: center;
753
- font-size: 16px;
754
- background: #f5f5f5;
755
- }
756
-
757
- &__file-remove {
758
- position: absolute;
759
- top: -4px;
760
- right: -4px;
761
- background: #ff4d4f;
762
- color: #fff;
763
- border-radius: 50%;
764
- font-size: 12px;
765
- width: 16px;
766
- height: 16px;
767
- display: flex;
768
- align-items: center;
769
- justify-content: center;
770
- cursor: pointer;
771
- z-index: 3;
772
-
773
- &:hover {
774
- background: #ff7875;
775
- }
776
- }
777
-
778
- &__file-loading {
779
- position: absolute;
780
- top: 0;
781
- left: 0;
782
- right: 0;
783
- bottom: 0;
784
- background: rgba(0, 0, 0, 0.5);
785
- display: flex;
786
- align-items: center;
787
- justify-content: center;
788
- color: #fff;
789
- }
790
-
791
- // 浮动按钮样式
792
- &__floating-buttons {
793
- position: absolute;
794
- top: 50%;
795
- left: 50%;
796
- transform: translate(-50%, -50%);
797
- display: flex;
798
- gap: 12px;
799
- background: rgba(255, 255, 255, 0.95);
800
- padding: 16px;
801
- border-radius: 12px;
802
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
803
- backdrop-filter: blur(10px);
804
- z-index: 10;
805
- animation: fadeInScale 0.3s ease-out;
806
-
807
- @media screen and (max-width: 480px) {
808
- gap: 8px;
809
- padding: 12px;
810
- border-radius: 10px;
811
- }
812
- }
813
-
814
- &__floating-btn {
815
- min-width: 60px;
816
- height: 36px;
817
- border-radius: 8px;
818
- font-size: 13px;
819
- font-weight: 500;
820
- transition: all 0.2s ease;
821
-
822
- &:hover {
823
- transform: translateY(-1px);
824
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
825
- }
826
-
827
- @media screen and (max-width: 480px) {
828
- min-width: 50px;
829
- height: 32px;
830
- font-size: 12px;
831
- }
832
- }
833
-
834
- // 动画效果
835
- @keyframes fadeInScale {
836
- from {
837
- opacity: 0;
838
- transform: translate(-50%, -50%) scale(0.8);
839
- }
840
- to {
841
- opacity: 1;
842
- transform: translate(-50%, -50%) scale(1);
843
- }
844
- }
845
- }
846
- </style>
1
+ <script setup lang="ts">
2
+ import type { FileItem } from './FileUploader.vue'
3
+ import CardContainer from '@af-mobile-client-vue3/components/data/CardContainer/CardContainer.vue'
4
+ import CardHeader from '@af-mobile-client-vue3/components/data/CardContainer/CardHeader.vue'
5
+ import { mobileUtil } from '@af-mobile-client-vue3/utils/mobileUtil'
6
+ import {
7
+ showFailToast,
8
+ showToast,
9
+ Button as VanButton,
10
+ Icon as VanIcon,
11
+ ImagePreview as VanImagePreview,
12
+ } from 'vant'
13
+ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
14
+
15
+ export interface FileTypeConfig {
16
+ userType: string
17
+ picMinNum: number
18
+ icon?: string
19
+ description?: string
20
+ }
21
+
22
+ interface Props {
23
+ fileTypes: FileTypeConfig[]
24
+ fileList?: FileItem[]
25
+ title?: string
26
+ maxSize?: number
27
+ }
28
+
29
+ const props = withDefaults(defineProps<Props>(), {
30
+ fileList: () => [],
31
+ title: '上传附件',
32
+ maxSize: 10 * 1024 * 1024, // 10MB
33
+ })
34
+
35
+ const emit = defineEmits<{
36
+ 'update:fileList': [value: FileItem[]]
37
+ }>()
38
+
39
+ // 文件存储,按类型分组
40
+ const filesByType = reactive<Record<string, FileItem[]>>({})
41
+
42
+ // 浮动按钮状态管理
43
+ const activeTypeKey = ref<string>('')
44
+ const showFloatingButtons = ref(false)
45
+ // 定时器 ref 用于管理自动隐藏
46
+ const hideTimer = ref<number | null>(null)
47
+
48
+ // 图片预览状态管理
49
+ const showPreview = ref(false)
50
+ const previewImages = ref<string[]>([])
51
+ const previewIndex = ref(0)
52
+ const currentPreviewType = ref<string>('')
53
+ const currentPreviewFiles = ref<FileItem[]>([])
54
+
55
+ // 判断是否为开发环境
56
+ const isDev = import.meta.env.MODE === 'development'
57
+
58
+ // 模拟图片的base64数据 - 四色块图片(红绿蓝紫)
59
+ const mockImageBase64 = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8IS0tIOW3puS4iiAoUmVkKSAtLT4KICA8cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNGRjQ0NDQiLz4KICA8IS0tIOWPs+S4iiAoR3JlZW4pIC0tPgogIDxyZWN0IHg9IjUwIiB5PSIwIiB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiM0NEZGNDQiLz4KICA8IS0tIOW3puS4iyAoQmx1ZSkgLS0+CiAgPHJlY3QgeD0iMCIgeT0iNTAiIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgZmlsbD0iIzQ0NDRGRiIvPgogIDwhLS0g57Sr5LiKIChQdXJwbGUpIC0tPgogIDxyZWN0IHg9IjUwIiB5PSI1MCIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiBmaWxsPSIjRkY0NEZGIi8+Cjwvc3ZnPgo='
60
+
61
+ // 所有文件的扁平列表
62
+ const allFiles = computed<FileItem[]>(() => {
63
+ const files: FileItem[] = []
64
+ Object.values(filesByType).forEach((typeFiles) => {
65
+ files.push(...typeFiles)
66
+ })
67
+ return files
68
+ })
69
+
70
+ // 转换为标准FileItem格式并发送给父组件
71
+ function emitFileListUpdate(): void {
72
+ emit('update:fileList', allFiles.value)
73
+ }
74
+
75
+ // 获取图标
76
+ function getIcon(config: FileTypeConfig): string {
77
+ return config.icon || 'fluent-emoji:file-folder'
78
+ }
79
+
80
+ // 初始化文件存储
81
+ onMounted(() => {
82
+ props.fileTypes.forEach((type) => {
83
+ if (!filesByType[type.userType]) {
84
+ filesByType[type.userType] = []
85
+ }
86
+ })
87
+ })
88
+
89
+ // 组件卸载时清理定时器
90
+ onUnmounted(() => {
91
+ if (hideTimer.value) {
92
+ clearTimeout(hideTimer.value)
93
+ hideTimer.value = null
94
+ }
95
+ })
96
+
97
+ // 触发文件上传
98
+ function triggerFileUpload(typeKey: string): void {
99
+ console.log('点击上传事件 - 类型:', typeKey)
100
+
101
+ // 判断是否为开发环境
102
+ if (isDev) {
103
+ console.log('开发环境,使用模拟上传并调用真实接口')
104
+ uploadFileInDevMode(typeKey)
105
+ return
106
+ }
107
+
108
+ // 非开发环境,使用原生功能
109
+ mobileUtil.execute({
110
+ funcName: 'takePicture',
111
+ param: {},
112
+ callbackFunc: (result: any) => {
113
+ if (result.status === 'success') {
114
+ uploadFileInProdMode(typeKey, result.data)
115
+ }
116
+ },
117
+ })
118
+ }
119
+
120
+ // 开发环境上传文件
121
+ function uploadFileInDevMode(typeKey: string): void {
122
+ // 基本信息
123
+ const filename = `${typeKey}_test.png`
124
+ const fileSize = 0.0003 // MB
125
+ const operator = '测试管理员'
126
+
127
+ // 创建临时文件对象(SVG转换为文件)
128
+ const uid = Date.now() + Math.random().toString(36).substr(2, 5)
129
+ const byteString = atob(mockImageBase64.split(',')[1])
130
+ const ab = new ArrayBuffer(byteString.length)
131
+ const ia = new Uint8Array(ab)
132
+ for (let i = 0; i < byteString.length; i++) {
133
+ ia[i] = byteString.charCodeAt(i)
134
+ }
135
+ const blob = new Blob([ab], { type: 'image/png' })
136
+ const file = new File([blob], filename, { type: 'image/png' })
137
+
138
+ // 创建文件项
139
+ const fileItem: FileItem = {
140
+ uid,
141
+ name: filename,
142
+ size: file.size,
143
+ type: 'image/svg+xml',
144
+ userType: typeKey,
145
+ status: 'uploading',
146
+ url: mockImageBase64,
147
+ }
148
+
149
+ // 添加到对应类型的文件列表
150
+ if (!filesByType[typeKey]) {
151
+ filesByType[typeKey] = []
152
+ }
153
+ filesByType[typeKey].push(fileItem)
154
+
155
+ // 创建FormData
156
+ const formData = new FormData()
157
+ formData.append('avatar', file) // 文件二进制数据
158
+ formData.append('resUploadMode', 'server')
159
+ formData.append('formType', 'file')
160
+ formData.append('useType', typeKey)
161
+ formData.append('resUploadStock', '1')
162
+ formData.append('filename', filename)
163
+ formData.append('filesize', fileSize.toString())
164
+ formData.append('f_operator', operator)
165
+
166
+ // 调用接口上传
167
+ fetch('/api/af-revenue/resource/upload', {
168
+ method: 'POST',
169
+ body: formData,
170
+ })
171
+ .then(response => response.json())
172
+ .then((result) => {
173
+ console.log('上传成功:', result)
174
+ // 找到对应文件项 并替换
175
+ const fileItem = filesByType[typeKey].find(f => f.uid === uid)
176
+ if (fileItem) {
177
+ fileItem.status = 'success'
178
+ fileItem.result = result.data.data
179
+ }
180
+ emitFileListUpdate()
181
+ })
182
+ .catch((error) => {
183
+ console.error('上传失败:', error)
184
+ fileItem.status = 'error'
185
+ showFailToast(`上传文件失败: ${error.message || error.msg || '请稍后重试'}`)
186
+ emitFileListUpdate()
187
+ })
188
+ }
189
+
190
+ // 生产环境上传文件
191
+ function uploadFileInProdMode(typeKey: string, file: any): void {
192
+ // 创建文件项
193
+ const fileItem: FileItem = {
194
+ uid: Date.now() + Math.random().toString(36).substr(2, 5),
195
+ name: file.name,
196
+ size: file.size,
197
+ type: 'image/jpeg',
198
+ userType: typeKey,
199
+ status: 'uploading',
200
+ url: `data:image/png;base64,${file.content}`,
201
+ filePath: file?.filePath,
202
+ }
203
+
204
+ // 添加到对应类型的文件列表
205
+ if (!filesByType[typeKey]) {
206
+ filesByType[typeKey] = []
207
+ }
208
+ filesByType[typeKey].push(fileItem)
209
+
210
+ mobileUtil.execute({
211
+ funcName: 'uploadResource',
212
+ param: {
213
+ resUploadMode: 'server',
214
+ pathKey: 'Default',
215
+ formType: 'image',
216
+ useType: typeKey,
217
+ resUploadStock: '1',
218
+ filename: file?.name,
219
+ filesize: file?.size,
220
+ f_operator: 'server',
221
+ imgPath: file?.filePath,
222
+ urlPath: '/api/af-revenue/resource/upload',
223
+ },
224
+ callbackFunc: (result: any) => {
225
+ if (result.status === 'success') {
226
+ fileItem.status = 'success'
227
+ fileItem.result = result.data
228
+ emitFileListUpdate()
229
+ }
230
+ else {
231
+ fileItem.status = 'error'
232
+ showFailToast(`上传图片失败,${result.message}`)
233
+ emitFileListUpdate()
234
+ }
235
+ },
236
+ })
237
+ }
238
+
239
+ // 删除文件
240
+ function removeFile(typeKey: string, fileId: string | number): void {
241
+ const typeFiles = filesByType[typeKey]
242
+ if (!typeFiles)
243
+ return
244
+
245
+ const index = typeFiles.findIndex(f => f.uid === fileId)
246
+ if (index > -1) {
247
+ const removedFile = typeFiles.splice(index, 1)[0]
248
+ if (removedFile.url) {
249
+ URL.revokeObjectURL(removedFile.url)
250
+ }
251
+
252
+ emitFileListUpdate()
253
+ }
254
+ }
255
+
256
+ // 显示浮动按钮
257
+ function showFloatingMenu(typeKey: string): void {
258
+ // 清除之前的定时器
259
+ if (hideTimer.value) {
260
+ clearTimeout(hideTimer.value)
261
+ hideTimer.value = null
262
+ }
263
+
264
+ activeTypeKey.value = typeKey
265
+ showFloatingButtons.value = true
266
+
267
+ hideTimer.value = window.setTimeout(() => {
268
+ hideFloatingMenu()
269
+ }, 1200)
270
+ }
271
+
272
+ // 隐藏浮动按钮
273
+ function hideFloatingMenu(): void {
274
+ // 清除定时器
275
+ if (hideTimer.value) {
276
+ clearTimeout(hideTimer.value)
277
+ hideTimer.value = null
278
+ }
279
+
280
+ showFloatingButtons.value = false
281
+ activeTypeKey.value = ''
282
+ }
283
+
284
+ // 查看已上传的文件
285
+ function viewFiles(typeKey: string): void {
286
+ const typeFiles = filesByType[typeKey]
287
+ if (!typeFiles?.length) {
288
+ showToast('暂无已上传的文件')
289
+ return
290
+ }
291
+
292
+ // 过滤出图片文件
293
+ const imageFiles = typeFiles.filter(f => f.url && f.type.startsWith('image/'))
294
+ if (!imageFiles.length) {
295
+ showToast('暂无图片文件')
296
+ return
297
+ }
298
+
299
+ // 设置预览状态
300
+ currentPreviewType.value = typeKey
301
+ currentPreviewFiles.value = imageFiles
302
+ previewImages.value = imageFiles.map(f => f.url!).filter(Boolean)
303
+ previewIndex.value = 0
304
+ showPreview.value = true
305
+
306
+ hideFloatingMenu()
307
+ }
308
+
309
+ // 上传文件(原有逻辑)
310
+ function uploadFiles(typeKey: string): void {
311
+ triggerFileUpload(typeKey)
312
+ hideFloatingMenu()
313
+ }
314
+
315
+ // 预览组件事件处理
316
+ function onPreviewChange(index: number): void {
317
+ previewIndex.value = index
318
+ }
319
+
320
+ // 删除当前预览的图片
321
+ function deleteCurrentPreviewImage(): void {
322
+ const currentFile = currentPreviewFiles.value[previewIndex.value]
323
+ if (!currentFile)
324
+ return
325
+
326
+ // 从文件列表中删除
327
+ removeFile(currentPreviewType.value, currentFile.uid)
328
+
329
+ // 更新预览状态
330
+ currentPreviewFiles.value.splice(previewIndex.value, 1)
331
+ previewImages.value.splice(previewIndex.value, 1)
332
+
333
+ // 如果没有图片了,关闭预览
334
+ if (previewImages.value.length === 0) {
335
+ showPreview.value = false
336
+ return
337
+ }
338
+
339
+ // 调整索引
340
+ if (previewIndex.value >= previewImages.value.length) {
341
+ previewIndex.value = previewImages.value.length - 1
342
+ }
343
+ }
344
+
345
+ // 获取类型的已上传文件数量
346
+ function getUploadedCount(typeKey: string): number {
347
+ return filesByType[typeKey]?.length || 0
348
+ }
349
+
350
+ // 检查是否必填且未满足最小要求
351
+ function isRequired(config: FileTypeConfig): boolean {
352
+ return config.picMinNum > 0
353
+ }
354
+
355
+ function isRequirementMet(config: FileTypeConfig): boolean {
356
+ const uploadedCount = getUploadedCount(config.userType)
357
+ return uploadedCount >= config.picMinNum
358
+ }
359
+
360
+ // 验证所有必填项是否满足
361
+ function validateAll(): boolean {
362
+ for (const config of props.fileTypes) {
363
+ if (isRequired(config) && !isRequirementMet(config)) {
364
+ showToast(`${config.userType}至少需要上传${config.picMinNum}张照片`)
365
+ return false
366
+ }
367
+ }
368
+ return true
369
+ }
370
+
371
+ const minLength = computed(() => {
372
+ return props.fileTypes.reduce((acc, config) => acc + config.picMinNum, 0)
373
+ })
374
+
375
+ // 暴露验证方法给父组件
376
+ defineExpose({
377
+ validateAll,
378
+ getAllFiles: () => allFiles.value,
379
+ triggerFileUpload,
380
+ })
381
+ </script>
382
+
383
+ <template>
384
+ <CardContainer class="grid-file-uploader">
385
+ <CardHeader :title="title">
386
+ <template #extra>
387
+ <div class="grid-file-uploader__progress">
388
+ 上传进度: {{ allFiles.filter(f => f.status === 'success').length }}/{{ minLength }} 已完成
389
+ </div>
390
+ </template>
391
+ </CardHeader>
392
+
393
+ <div class="grid-file-uploader__description">
394
+ 点击对应类别上传相关照片和文件
395
+ </div>
396
+
397
+ <div class="grid-file-uploader__grid">
398
+ <div
399
+ v-for="config in fileTypes"
400
+ :key="config.userType"
401
+ class="grid-file-uploader__item"
402
+ :class="{
403
+ 'grid-file-uploader__item--required': isRequired(config),
404
+ 'grid-file-uploader__item--completed': isRequirementMet(config),
405
+ 'grid-file-uploader__item--pending': isRequired(config) && !isRequirementMet(config),
406
+ }"
407
+ >
408
+ <!-- 必填标识 -->
409
+ <div
410
+ v-if="isRequired(config) && !isRequirementMet(config)"
411
+ class="grid-file-uploader__item-required"
412
+ >
413
+
414
+ </div>
415
+ <VanIcon
416
+ v-else-if="isRequirementMet(config)"
417
+ name="success"
418
+ class="grid-file-uploader__item-success"
419
+ />
420
+
421
+ <!-- 文件上传区域 -->
422
+ <div class="grid-file-uploader__item-content" @click="showFloatingMenu(config.userType)">
423
+ <!-- 图标 -->
424
+ <div class="grid-file-uploader__item-icon">
425
+ <VanIcon
426
+ :icon="getIcon(config)"
427
+ class="grid-file-uploader__icon"
428
+ />
429
+ </div>
430
+
431
+ <!-- 标题 -->
432
+ <div class="grid-file-uploader__item-title">
433
+ {{ config.userType }}
434
+ </div>
435
+
436
+ <!-- 描述文本 -->
437
+ <div class="grid-file-uploader__item-desc">
438
+ {{ config.description || `${config.userType}照片` }}
439
+ </div>
440
+
441
+ <!-- 文件数量显示 -->
442
+ <div class="grid-file-uploader__item-count">
443
+ {{ getUploadedCount(config.userType) }}
444
+ <span v-if="config.picMinNum > 0" class="grid-file-uploader__item-count-min">
445
+ /{{ config.picMinNum }}
446
+ </span>
447
+ </div>
448
+ </div>
449
+
450
+ <!-- 浮动按钮 -->
451
+ <div
452
+ v-if="showFloatingButtons && activeTypeKey === config.userType"
453
+ class="grid-file-uploader__floating-buttons"
454
+ @click.stop
455
+ >
456
+ <VanButton
457
+ v-if="getUploadedCount(config.userType) > 0"
458
+ type="primary"
459
+ size="small"
460
+ icon="eye-o"
461
+ class="grid-file-uploader__floating-btn"
462
+ @click="viewFiles(config.userType)"
463
+ >
464
+ 查看
465
+ </VanButton>
466
+ <VanButton
467
+ type="success"
468
+ size="small"
469
+ :icon="isDev ? 'photograph' : 'camera-o'"
470
+ class="grid-file-uploader__floating-btn"
471
+ @click="uploadFiles(config.userType)"
472
+ >
473
+ {{ isDev ? '上传' : '拍照' }}
474
+ </VanButton>
475
+ </div>
476
+ </div>
477
+ </div>
478
+
479
+ <!-- 图片预览组件 -->
480
+ <VanImagePreview
481
+ v-model:show="showPreview"
482
+ :images="previewImages"
483
+ :start-position="previewIndex"
484
+ teleport="body"
485
+ @change="onPreviewChange"
486
+ >
487
+ <template #index>
488
+ <div class="preview-footer" style="text-align: center;">
489
+ <span class="preview-index">第{{ previewIndex + 1 }}页</span>
490
+ <br>
491
+ <VanButton
492
+ type="danger"
493
+ size="small"
494
+ icon="delete-o"
495
+ class="preview-delete-btn"
496
+ @click="deleteCurrentPreviewImage"
497
+ >
498
+ 删除
499
+ </VanButton>
500
+ </div>
501
+ </template>
502
+ </VanImagePreview>
503
+ </CardContainer>
504
+ </template>
505
+
506
+ <style lang="less" scoped>
507
+ .grid-file-uploader {
508
+ &__description {
509
+ font-size: 14px;
510
+ color: #666;
511
+ margin: 0 0 16px 0;
512
+ padding: 0 16px;
513
+ }
514
+
515
+ &__progress {
516
+ font-size: 12px;
517
+ color: #999;
518
+ margin: 0;
519
+ }
520
+
521
+ &__grid {
522
+ display: grid;
523
+ grid-template-columns: repeat(2, 1fr);
524
+ gap: 16px;
525
+ padding: 0 16px 16px 16px;
526
+
527
+ @media screen and (max-width: 768px) {
528
+ grid-template-columns: repeat(2, 1fr);
529
+ gap: 12px;
530
+ padding: 0 12px 12px 12px;
531
+ }
532
+
533
+ @media screen and (max-width: 480px) {
534
+ grid-template-columns: repeat(2, 1fr);
535
+ gap: 10px;
536
+ padding: 0 10px 10px 10px;
537
+ }
538
+ }
539
+
540
+ &__item {
541
+ position: relative;
542
+ border: 2px dashed #d9d9d9;
543
+ border-radius: 8px;
544
+ background: #fafafa;
545
+ transition: all 0.3s ease;
546
+ min-height: 120px;
547
+ height: 120px;
548
+
549
+ &:hover {
550
+ border-color: #40a9ff;
551
+ background: #f0f7ff;
552
+ }
553
+
554
+ &--required {
555
+ border-color: #ff7875;
556
+ background: #fff2f0;
557
+ }
558
+
559
+ &--completed {
560
+ border-color: #52c41a;
561
+ background: #f6ffed;
562
+ }
563
+
564
+ &--pending {
565
+ border-color: #faad14;
566
+ background: #fffbe6;
567
+ }
568
+
569
+ @media screen and (max-width: 768px) {
570
+ min-height: 110px;
571
+ height: 110px;
572
+ }
573
+
574
+ @media screen and (max-width: 480px) {
575
+ min-height: 100px;
576
+ height: 100px;
577
+ }
578
+ }
579
+
580
+ &__item-required {
581
+ position: absolute;
582
+ top: -8px;
583
+ right: -8px;
584
+ background: #ff9500;
585
+ color: #fff;
586
+ font-size: 12px;
587
+ font-weight: bold;
588
+ width: 22px;
589
+ height: 22px;
590
+ border-radius: 50%;
591
+ display: flex;
592
+ align-items: center;
593
+ justify-content: center;
594
+ z-index: 2;
595
+ line-height: 1;
596
+ box-shadow: 0 2px 4px rgba(255, 149, 0, 0.3);
597
+
598
+ @media screen and (max-width: 480px) {
599
+ font-size: 11px;
600
+ width: 20px;
601
+ height: 20px;
602
+ top: -6px;
603
+ right: -6px;
604
+ }
605
+ }
606
+
607
+ &__item-success {
608
+ position: absolute;
609
+ top: 4px;
610
+ right: 4px;
611
+ color: #52c41a;
612
+ font-size: 16px;
613
+ z-index: 2;
614
+ }
615
+
616
+ &__item-content {
617
+ display: flex;
618
+ flex-direction: column;
619
+ align-items: center;
620
+ justify-content: center;
621
+ padding: 16px 12px;
622
+ height: 100%;
623
+ cursor: pointer;
624
+ position: relative;
625
+
626
+ @media screen and (max-width: 768px) {
627
+ padding: 14px 10px;
628
+ }
629
+
630
+ @media screen and (max-width: 480px) {
631
+ padding: 12px 8px;
632
+ }
633
+ }
634
+
635
+ &__item-input {
636
+ position: absolute;
637
+ opacity: 0;
638
+ pointer-events: none;
639
+ }
640
+
641
+ &__item-icon {
642
+ display: flex;
643
+ align-items: center;
644
+ justify-content: center;
645
+ margin-bottom: 8px;
646
+
647
+ @media screen and (max-width: 768px) {
648
+ margin-bottom: 6px;
649
+ }
650
+
651
+ @media screen and (max-width: 480px) {
652
+ margin-bottom: 5px;
653
+ }
654
+ }
655
+
656
+ &__icon {
657
+ width: 36px;
658
+ height: 36px;
659
+ display: block;
660
+
661
+ @media screen and (max-width: 768px) {
662
+ width: 32px;
663
+ height: 32px;
664
+ }
665
+
666
+ @media screen and (max-width: 480px) {
667
+ width: 28px;
668
+ height: 28px;
669
+ }
670
+ }
671
+
672
+ &__item-title {
673
+ font-size: 14px;
674
+ font-weight: 500;
675
+ color: #333;
676
+ margin-bottom: 4px;
677
+ text-align: center;
678
+ line-height: 1.2;
679
+
680
+ @media screen and (max-width: 768px) {
681
+ font-size: 13px;
682
+ margin-bottom: 3px;
683
+ }
684
+
685
+ @media screen and (max-width: 480px) {
686
+ font-size: 12px;
687
+ margin-bottom: 3px;
688
+ }
689
+ }
690
+
691
+ &__item-desc {
692
+ font-size: 12px;
693
+ color: #666;
694
+ text-align: center;
695
+ margin-bottom: 8px;
696
+ line-height: 1.2;
697
+
698
+ @media screen and (max-width: 768px) {
699
+ font-size: 11px;
700
+ margin-bottom: 6px;
701
+ }
702
+
703
+ @media screen and (max-width: 480px) {
704
+ font-size: 10px;
705
+ margin-bottom: 5px;
706
+ }
707
+ }
708
+
709
+ &__item-count {
710
+ font-size: 14px;
711
+ font-weight: 600;
712
+ color: #1890ff;
713
+
714
+ &-min {
715
+ color: #999;
716
+ font-weight: normal;
717
+ }
718
+ }
719
+
720
+ &__item-files {
721
+ position: absolute;
722
+ bottom: 8px;
723
+ left: 8px;
724
+ right: 8px;
725
+ display: flex;
726
+ flex-wrap: wrap;
727
+ gap: 4px;
728
+ max-height: 40px;
729
+ overflow: hidden;
730
+ }
731
+
732
+ &__file-preview {
733
+ position: relative;
734
+ width: 32px;
735
+ height: 32px;
736
+ border-radius: 4px;
737
+ overflow: hidden;
738
+ border: 1px solid #d9d9d9;
739
+ }
740
+
741
+ &__file-image {
742
+ width: 100%;
743
+ height: 100%;
744
+ object-fit: cover;
745
+ }
746
+
747
+ &__file-icon {
748
+ width: 100%;
749
+ height: 100%;
750
+ display: flex;
751
+ align-items: center;
752
+ justify-content: center;
753
+ font-size: 16px;
754
+ background: #f5f5f5;
755
+ }
756
+
757
+ &__file-remove {
758
+ position: absolute;
759
+ top: -4px;
760
+ right: -4px;
761
+ background: #ff4d4f;
762
+ color: #fff;
763
+ border-radius: 50%;
764
+ font-size: 12px;
765
+ width: 16px;
766
+ height: 16px;
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ cursor: pointer;
771
+ z-index: 3;
772
+
773
+ &:hover {
774
+ background: #ff7875;
775
+ }
776
+ }
777
+
778
+ &__file-loading {
779
+ position: absolute;
780
+ top: 0;
781
+ left: 0;
782
+ right: 0;
783
+ bottom: 0;
784
+ background: rgba(0, 0, 0, 0.5);
785
+ display: flex;
786
+ align-items: center;
787
+ justify-content: center;
788
+ color: #fff;
789
+ }
790
+
791
+ // 浮动按钮样式
792
+ &__floating-buttons {
793
+ position: absolute;
794
+ top: 50%;
795
+ left: 50%;
796
+ transform: translate(-50%, -50%);
797
+ display: flex;
798
+ gap: 12px;
799
+ background: rgba(255, 255, 255, 0.95);
800
+ padding: 16px;
801
+ border-radius: 12px;
802
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
803
+ backdrop-filter: blur(10px);
804
+ z-index: 10;
805
+ animation: fadeInScale 0.3s ease-out;
806
+
807
+ @media screen and (max-width: 480px) {
808
+ gap: 8px;
809
+ padding: 12px;
810
+ border-radius: 10px;
811
+ }
812
+ }
813
+
814
+ &__floating-btn {
815
+ min-width: 60px;
816
+ height: 36px;
817
+ border-radius: 8px;
818
+ font-size: 13px;
819
+ font-weight: 500;
820
+ transition: all 0.2s ease;
821
+
822
+ &:hover {
823
+ transform: translateY(-1px);
824
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
825
+ }
826
+
827
+ @media screen and (max-width: 480px) {
828
+ min-width: 50px;
829
+ height: 32px;
830
+ font-size: 12px;
831
+ }
832
+ }
833
+
834
+ // 动画效果
835
+ @keyframes fadeInScale {
836
+ from {
837
+ opacity: 0;
838
+ transform: translate(-50%, -50%) scale(0.8);
839
+ }
840
+ to {
841
+ opacity: 1;
842
+ transform: translate(-50%, -50%) scale(1);
843
+ }
844
+ }
845
+ }
846
+ </style>