uview-pro 0.5.18 → 0.6.0-beta.0

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,59 +1,205 @@
1
1
  <template>
2
- <view class="u-upload" v-if="!disabled" :class="customClass" :style="$u.toStyle(customStyle)">
3
- <view
4
- v-if="showUploadList"
5
- class="u-list-item u-preview-wrap"
6
- v-for="(item, index) in lists"
7
- :key="index"
8
- :style="{
9
- width: $u.addUnit(width),
10
- height: $u.addUnit(height)
11
- }"
12
- >
13
- <view
14
- v-if="deletable"
15
- class="u-delete-icon"
16
- @tap.stop="deleteItem(index)"
17
- :style="{
18
- background: delBgColor
19
- }"
20
- >
21
- <u-icon :name="delIcon" size="20" :color="delColor"></u-icon>
2
+ <view
3
+ v-if="!disabled"
4
+ class="u-upload"
5
+ :class="[customClass, { 'u-upload--list': props.mode === 'list' }]"
6
+ :style="$u.toStyle(customStyle)"
7
+ >
8
+ <!-- 列表模式 -->
9
+ <template v-if="props.mode === 'list'">
10
+ <view v-if="showUploadList" class="u-upload-list">
11
+ <view
12
+ v-for="(item, index) in lists"
13
+ class="u-upload-list__item"
14
+ :key="index"
15
+ :class="{ 'u-upload-list__item--error': item.error }"
16
+ >
17
+ <!-- 左侧:图片缩略图或文件图标 -->
18
+ <view class="u-upload-list__left" @tap.stop="handlePreview(item, index)">
19
+ <!-- 图片类型:显示缩略图 -->
20
+ <template v-if="isImageFile(item)">
21
+ <image class="u-upload-list__thumb" :src="item.url || item.path" mode="aspectFill" />
22
+ </template>
23
+ <!-- 非图片类型:显示文件图标 -->
24
+ <template v-else>
25
+ <view class="u-upload-list__icon" :style="{ background: getFileIcon(item).bgColor }">
26
+ <u-icon :name="getFileIcon(item).name" size="32" color="var(--u-white-color)"></u-icon>
27
+ </view>
28
+ </template>
29
+ </view>
30
+
31
+ <!-- 中间:文件名和大小 -->
32
+ <view class="u-upload-list__center" @tap.stop="handlePreview(item, index)">
33
+ <text
34
+ v-if="showFileName && item.name"
35
+ class="u-upload-list__name"
36
+ :class="{ 'u-upload-list__name--error': item.error }"
37
+ >
38
+ {{ getFileName(item) }}
39
+ </text>
40
+ <text v-if="showFileSize && item.size" class="u-upload-list__size">
41
+ {{ formatFileSize(item.size) }}
42
+ </text>
43
+ <!-- 进度条 -->
44
+ <view
45
+ v-if="showProgress && item.progress > 0 && item.progress < 100 && !item.error"
46
+ class="u-upload-list__progress"
47
+ >
48
+ <u-line-progress
49
+ height="4"
50
+ :show-percent="false"
51
+ :percent="item.progress"
52
+ ></u-line-progress>
53
+ </view>
54
+ <view
55
+ v-if="item.error"
56
+ class="u-upload-list__retry"
57
+ hover-class="u-upload-list__retry--hover"
58
+ hover-stay-time="150"
59
+ @tap.stop="retry(index)"
60
+ >
61
+ <u-icon name="reload" size="24" color="var(--u-type-error)"></u-icon>
62
+ <text class="u-upload-list__retry-text">{{ t('uUpload.retry') }}</text>
63
+ </view>
64
+ </view>
65
+
66
+ <!-- 右侧:删除按钮 -->
67
+ <view
68
+ v-if="deletable"
69
+ class="u-upload-list__right"
70
+ @tap.stop="deleteItem(index)"
71
+ :style="{
72
+ backgroundColor: delBgColor
73
+ }"
74
+ >
75
+ <u-icon :name="delIcon" size="20" :color="delColor"></u-icon>
76
+ </view>
77
+ </view>
22
78
  </view>
23
- <view class="u-upload-progress">
24
- <u-line-progress
25
- v-if="showProgress && item.progress > 0 && item.progress != 100 && !item.error"
26
- :show-percent="false"
27
- height="16"
28
- :percent="item.progress"
29
- ></u-line-progress>
79
+
80
+ <!-- 列表模式:自定义文件列表插槽 -->
81
+ <slot name="file" :file="lists"></slot>
82
+
83
+ <!-- 列表模式:添加按钮 -->
84
+ <view v-if="Number(maxCount) > lists.length" class="u-upload-list__add" @tap="selectFile">
85
+ <slot name="addBtn"></slot>
86
+ <view
87
+ v-if="!customBtn"
88
+ class="u-upload-list__add-btn"
89
+ hover-class="u-upload-list__add-btn--hover"
90
+ hover-stay-time="150"
91
+ >
92
+ <u-icon name="plus" size="32" :color="$u.color.primary"></u-icon>
93
+ <text class="u-upload-list__add-text">{{ uploadBtnText }}</text>
94
+ </view>
30
95
  </view>
31
- <view @tap.stop="retry(index)" v-if="item.error" class="u-error-btn">点击重试</view>
32
- <image
33
- @tap.stop="doPreviewImage(item.url || item.path, index)"
34
- class="u-preview-image"
35
- v-if="!item.isImage"
36
- :src="item.url || item.path"
37
- :mode="imageMode"
38
- ></image>
39
- </view>
40
- <slot name="file" :file="lists"></slot>
41
- <view style="display: inline-block" @tap="selectFile" v-if="Number(maxCount) > lists.length">
42
- <slot name="addBtn"></slot>
43
- <view
44
- v-if="!customBtn"
45
- class="u-list-item u-add-wrap"
46
- hover-class="u-add-wrap__hover"
47
- hover-stay-time="150"
48
- :style="{
49
- width: $u.addUnit(width),
50
- height: $u.addUnit(height)
51
- }"
52
- >
53
- <u-icon name="plus" class="u-add-btn" size="40"></u-icon>
54
- <view class="u-add-tips">{{ uploadText }}</view>
96
+ </template>
97
+
98
+ <!-- 网格模式(默认) -->
99
+ <template v-else>
100
+ <template v-if="showUploadList">
101
+ <view
102
+ class="u-upload-grid__item u-upload-grid__preview"
103
+ v-for="(item, index) in lists"
104
+ :key="index"
105
+ :style="{
106
+ width: $u.addUnit(width),
107
+ height: $u.addUnit(height)
108
+ }"
109
+ >
110
+ <view
111
+ v-if="deletable"
112
+ class="u-upload-grid__delete"
113
+ @tap.stop="deleteItem(index)"
114
+ :style="{
115
+ backgroundColor: delBgColor
116
+ }"
117
+ >
118
+ <u-icon :name="delIcon" size="20" :color="delColor"></u-icon>
119
+ </view>
120
+ <view class="u-upload-grid__progress">
121
+ <u-line-progress
122
+ v-if="showProgress && item.progress > 0 && item.progress !== 100 && !item.error"
123
+ :show-percent="false"
124
+ height="16"
125
+ :percent="item.progress"
126
+ ></u-line-progress>
127
+ </view>
128
+ <view @tap.stop="retry(index)" v-if="item.error" class="u-upload-grid__error">{{
129
+ t('uUpload.retry')
130
+ }}</view>
131
+
132
+ <!-- 图片类型预览 -->
133
+ <template v-if="isImageFile(item)">
134
+ <image
135
+ @tap.stop="handlePreview(item, index)"
136
+ class="u-upload-grid__image"
137
+ :src="item.url || item.path"
138
+ :mode="imageMode"
139
+ ></image>
140
+ </template>
141
+
142
+ <!-- 视频类型预览 -->
143
+ <template v-else-if="isVideoFile(item)">
144
+ <view class="u-upload-grid__file" @tap.stop="doPreviewFile(item, index)">
145
+ <view class="u-upload-grid__file-icon" :style="{ background: getFileIcon(item).bgColor }">
146
+ <u-icon :name="getFileIcon(item).name" size="48" color="var(--u-white-color)"></u-icon>
147
+ </view>
148
+ <view class="u-upload-grid__file-info" v-if="showFileName || showFileSize">
149
+ <text
150
+ v-if="showFileName && item.name"
151
+ class="u-upload-grid__file-name"
152
+ :style="{ color: item.error ? 'var(--u-type-error)' : 'var(--u-content-color)' }"
153
+ >
154
+ {{ getFileName(item) }}
155
+ </text>
156
+ <text v-if="showFileSize && item.size" class="u-upload-grid__file-size">{{
157
+ formatFileSize(item.size)
158
+ }}</text>
159
+ </view>
160
+ </view>
161
+ </template>
162
+
163
+ <!-- 其他文件类型预览 -->
164
+ <template v-else>
165
+ <view class="u-upload-grid__file" @tap.stop="doPreviewFile(item, index)">
166
+ <view class="u-upload-grid__file-icon" :style="{ background: getFileIcon(item).bgColor }">
167
+ <u-icon :name="getFileIcon(item).name" size="48" color="var(--u-white-color)"></u-icon>
168
+ </view>
169
+ <view class="u-upload-grid__file-info" v-if="showFileName || showFileSize">
170
+ <text
171
+ v-if="showFileName && item.name"
172
+ class="u-upload-grid__file-name"
173
+ :style="{ color: item.error ? 'var(--u-type-error)' : 'var(--u-content-color)' }"
174
+ >
175
+ {{ getFileName(item) }}
176
+ </text>
177
+ <text v-if="showFileSize && item.size" class="u-upload-grid__file-size">
178
+ {{ formatFileSize(item.size) }}
179
+ </text>
180
+ </view>
181
+ </view>
182
+ </template>
183
+ </view>
184
+ </template>
185
+ <slot name="file" :file="lists"></slot>
186
+ <view style="display: inline-block" @tap="selectFile" v-if="Number(maxCount) > lists.length">
187
+ <slot name="addBtn"></slot>
188
+ <view
189
+ v-if="!customBtn"
190
+ class="u-upload-grid__item u-upload-grid__add"
191
+ hover-class="u-upload-grid__add--hover"
192
+ hover-stay-time="150"
193
+ :style="{
194
+ width: $u.addUnit(width),
195
+ height: $u.addUnit(height)
196
+ }"
197
+ >
198
+ <u-icon name="plus" class="u-upload-grid__add-icon" size="40"></u-icon>
199
+ <view class="u-upload-grid__add-text">{{ uploadBtnText }}</view>
200
+ </view>
55
201
  </view>
56
- </view>
202
+ </template>
57
203
  </view>
58
204
  </template>
59
205
 
@@ -71,50 +217,71 @@ export default {
71
217
  </script>
72
218
 
73
219
  <script setup lang="ts">
74
- import { ref, watch } from 'vue';
220
+ import { ref, watch, computed } from 'vue';
75
221
  import { $u, useLocale } from '../..';
76
222
  import { UploadProps } from './types';
223
+ import type { UploadFileItem } from '../../types/global';
77
224
 
78
225
  /**
79
- * upload 图片上传
80
- * @description 该组件用于上传图片场景
226
+ * upload 文件上传
227
+ * @description 该组件用于上传文件场景,支持图片、视频、文档等多种类型,支持网格和列表两种展示模式
81
228
  * @tutorial https://uviewpro.cn/zh/components/upload.html
229
+ *
82
230
  * @property {String} action 服务器上传地址
83
- * @property {String Number} max-count 最大选择图片的数量(默认99)
84
- * @property {Boolean} custom-btn 如果需要自定义选择图片的按钮,设置为true(默认false)
85
- * @property {Boolean} show-progress 是否显示进度条(默认true
86
- * @property {Boolean} disabled 是否启用(显示/移仓)组件(默认false
87
- * @property {String} image-mode 预览图片等显示模式,可选值为uni的image的mode属性值(默认aspectFill
88
- * @property {String} del-icon 右上角删除图标名称,只能为uView内置图标
89
- * @property {String} del-bg-color 右上角关闭按钮的背景颜色
231
+ * @property {String} accept 接受上传的文件类型,可选值:image|video|file|media|all,默认image
232
+ * @property {String} mode 上传组件的展示模式,可选值:grid|list,默认grid
233
+ * @property {String Number} max-count 最大选择文件的数量(默认52
234
+ * @property {String Number} max-size 选择单个文件的最大大小,单位B(byte),默认不限制(默认Number.MAX_VALUE
235
+ * @property {Boolean} multiple 是否开启文件多选(默认true
236
+ * @property {Boolean} disabled 是否禁用组件(默认false)
237
+ * @property {Boolean} auto-upload 选择完文件是否自动上传(默认true)
238
+ * @property {Boolean} deletable 是否显示删除文件的按钮(默认true)
239
+ * @property {Boolean} show-confirm 删除文件前是否显示确认弹窗(默认true)
240
+ * @property {Boolean} show-tips 特殊情况下是否自动提示toast(默认true)
241
+ * @property {Boolean} show-progress 是否显示上传进度条(默认true)
242
+ * @property {Boolean} show-upload-list 是否显示组件内部的文件预览列表(默认true)
243
+ * @property {Boolean} show-file-name 是否显示文件名(默认true)
244
+ * @property {Boolean} show-file-size 是否显示文件大小(默认false)
245
+ * @property {Boolean} preview-full-image 是否可以通过uni.previewImage预览已选择的图片(默认true)
246
+ * @property {Boolean} preview-file 是否可预览文件(非图片类型)(默认true)
247
+ * @property {Boolean} custom-btn 是否自定义选择文件的按钮,设置为true可使用addBtn插槽(默认false)
248
+ * @property {String} upload-text 选择文件按钮的提示文字(默认根据accept自动显示)
249
+ * @property {String} image-mode 预览图片的显示模式,可选值为uni的image的mode属性值(默认aspectFill)
250
+ * @property {String} del-icon 右上角删除图标名称,只能为uView内置图标(默认'close')
251
+ * @property {String} del-bg-color 右上角删除按钮的背景颜色(默认'var(--u-type-error)')
252
+ * @property {String} del-color 右上角删除按钮图标的颜色(默认'var(--u-white-color)')
90
253
  * @property {String | Number} index 在各个回调事件中的最后一个参数返回,用于区别是哪一个组件的事件
91
- * @property {String} del-color 右上角关闭按钮图标的颜色
92
- * @property {Object} header 上传携带的头信息,对象形式
254
+ * @property {Object} header 上传携带的请求头信息,对象形式
93
255
  * @property {Object} form-data 上传额外携带的参数
94
- * @property {String} name 上传文件的字段名,供后端获取使用(默认file)
256
+ * @property {String} name 上传文件的字段名,供后端获取使用(默认'file'
95
257
  * @property {Array<String>} size-type original 原图,compressed 压缩图,默认二者都有(默认['original', 'compressed'])
96
- * @property {Array<String>} source-type 选择图片的来源,album-从相册选图,camera-使用相机,默认二者都有(默认['album', 'camera'])
97
- * @property {Boolean} preview-full-image 是否可以通过uni.previewImage预览已选择的图片(默认true)
98
- * @property {Boolean} multiple 是否开启图片多选,部分安卓机型不支持(默认true)
99
- * @property {Boolean} deletable 是否显示删除图片的按钮(默认true)
100
- * @property {String Number} max-size 选择单个文件的最大大小,单位B(byte),默认不限制(默认Number.MAX_VALUE
101
- * @property {Array<Object>} file-list 默认显示的图片列表,数组元素为对象,必须提供url属性
102
- * @property {Boolean} upload-text 选择图片按钮的提示文字(默认“选择图片”)
103
- * @property {Boolean} auto-upload 选择完图片是否自动上传,见上方说明(默认true)
104
- * @property {Boolean} show-tips 特殊情况下是否自动提示toast,见上方说明(默认true)
105
- * @property {Boolean} show-upload-list 是否显示组件内部的图片预览(默认true)
106
- * @event {Function} on-oversize 图片大小超出最大允许大小
107
- * @event {Function} on-preview 全屏预览图片时触发
108
- * @event {Function} on-remove 移除图片时触发
109
- * @event {Function} on-success 图片上传成功时触发
110
- * @event {Function} on-change 图片上传后,无论成功或者失败都会触发
111
- * @event {Function} on-error 图片上传失败时触发
112
- * @event {Function} on-progress 图片上传过程中的进度变化过程触发
113
- * @event {Function} on-uploaded 所有图片上传完毕触发
114
- * @event {Function} on-choose-complete 每次选择图片后触发,只是让外部可以得知每次选择后,内部的文件列表
258
+ * @property {Array<String>} source-type 选择文件的来源,album-从相册选图,camera-使用相机,默认二者都有(默认['album', 'camera'])
259
+ * @property {Array<String>} limit-type 限制允许上传的文件后缀,如['png', 'jpg']。优先级高于accept内置的格式限制
260
+ * @property {Array<String>} extension 选择文件时的扩展名过滤,仅在H5和微信小程序accept='file'时有效,如['.pdf', '.docx']
261
+ * @property {Object} file-icon-map 文件类型图标映射配置,用于自定义不同文件类型的图标和颜色
262
+ * @property {Boolean} compressed 选择视频时是否压缩(默认true
263
+ * @property {Number} max-duration 选择视频时拍摄最长时长,单位秒(默认60)
264
+ * @property {String} camera 选择视频时摄像头方向,可选值:front|back(默认'back')
265
+ * @property {Array<Object>} file-list 默认显示的文件列表,数组元素为对象,必须提供url属性
266
+ * @property {Function} before-upload 上传前钩子,返回false或Promise.reject则跳过当前文件上传
267
+ * @property {Function} before-remove 删除前钩子,返回false或Promise.reject则阻止删除
268
+ * @property {Boolean} to-json 如果上传后的返回值为json字符串,是否自动转为json格式(默认true)
269
+ *
270
+ * @event {Function} on-oversize 文件大小超出max-size限制时触发
271
+ * @event {Function} on-exceed 文件数量超出max-count限制时触发
272
+ * @event {Function} on-choose-complete 每次选择文件后触发,返回当前文件列表
115
273
  * @event {Function} on-choose-fail 文件选择失败时触发
274
+ * @event {Function} on-uploaded 所有文件上传完毕时触发
275
+ * @event {Function} on-success 单个文件上传成功时触发
276
+ * @event {Function} on-error 单个文件上传失败时触发
277
+ * @event {Function} on-change 单个文件上传状态改变时触发(无论成功或失败)
278
+ * @event {Function} on-progress 文件上传过程中的进度变化时触发
279
+ * @event {Function} on-remove 移除文件时触发
280
+ * @event {Function} on-preview 预览文件时触发
116
281
  * @event {Function} on-list-change 文件列表发生变化时触发
117
- * @example <u-upload :action="action" :file-list="fileList" ></u-upload>
282
+ *
283
+ * @example <u-upload :action="action" :file-list="fileList" accept="image"></u-upload>
284
+ * @example <u-upload :action="action" accept="file" mode="list" :show-file-name="true"></u-upload>
118
285
  */
119
286
 
120
287
  const props = defineProps(UploadProps);
@@ -133,37 +300,280 @@ const emit = defineEmits([
133
300
  'on-change',
134
301
  'on-progress',
135
302
  'on-remove',
136
- 'on-preview'
303
+ 'on-preview',
304
+ 'update:modelValue'
137
305
  ]);
138
306
 
139
- const lists = ref<any[]>([]);
307
+ const lists = ref<UploadFileItem[]>([]);
140
308
  const uploading = ref(false);
141
309
 
142
- // 监听 fileList 变化,自动同步内部 lists
310
+ // 默认文件图标映射
311
+ const defaultFileIconMap: Record<string, { name: string; bgColor: string }> = {
312
+ image: { name: 'photo', bgColor: 'var(--u-type-primary)' },
313
+ video: { name: 'play-circle', bgColor: 'var(--u-type-error)' },
314
+ pdf: { name: 'file-text', bgColor: 'var(--u-type-error)' },
315
+ word: { name: 'file-text', bgColor: 'var(--u-type-primary)' },
316
+ excel: { name: 'file-text', bgColor: 'var(--u-type-success)' },
317
+ ppt: { name: 'file-text', bgColor: 'var(--u-type-warning)' },
318
+ zip: { name: 'folder', bgColor: 'var(--u-tips-color)' },
319
+ audio: { name: 'volume-up', bgColor: 'var(--u-type-info)' },
320
+ file: { name: 'file-text', bgColor: 'var(--u-tips-color)' }
321
+ };
322
+
323
+ // 文件扩展名到类型的映射
324
+ const extTypeMap: Record<string, string> = {
325
+ // 图片
326
+ png: 'image',
327
+ jpg: 'image',
328
+ jpeg: 'image',
329
+ gif: 'image',
330
+ bmp: 'image',
331
+ webp: 'image',
332
+ svg: 'image',
333
+ // 视频
334
+ mp4: 'video',
335
+ avi: 'video',
336
+ mov: 'video',
337
+ wmv: 'video',
338
+ flv: 'video',
339
+ mkv: 'video',
340
+ rmvb: 'video',
341
+ '3gp': 'video',
342
+ m3u8: 'video',
343
+ // PDF
344
+ pdf: 'pdf',
345
+ // Word
346
+ doc: 'word',
347
+ docx: 'word',
348
+ // Excel
349
+ xls: 'excel',
350
+ xlsx: 'excel',
351
+ csv: 'excel',
352
+ // PPT
353
+ ppt: 'ppt',
354
+ pptx: 'ppt',
355
+ // 压缩包
356
+ zip: 'zip',
357
+ rar: 'zip',
358
+ '7z': 'zip',
359
+ tar: 'zip',
360
+ gz: 'zip',
361
+ // 音频
362
+ mp3: 'audio',
363
+ wav: 'audio',
364
+ wma: 'audio',
365
+ ogg: 'audio',
366
+ aac: 'audio',
367
+ flac: 'audio'
368
+ };
369
+
370
+ /**
371
+ * 根据 accept 类型获取上传按钮文字
372
+ */
373
+ const uploadBtnText = computed(() => {
374
+ // 如果用户自定义了 uploadText,优先使用
375
+ if (props.uploadText !== t('uUpload.uploadText')) {
376
+ return props.uploadText;
377
+ }
378
+ // 根据 accept 类型返回对应的国际化文本
379
+ switch (props.accept) {
380
+ case 'image':
381
+ return t('uUpload.uploadImage');
382
+ case 'video':
383
+ return t('uUpload.uploadVideo');
384
+ case 'file':
385
+ return t('uUpload.uploadFile');
386
+ case 'media':
387
+ return t('uUpload.uploadMedia');
388
+ case 'all':
389
+ default:
390
+ return t('uUpload.uploadFile');
391
+ }
392
+ });
393
+
394
+ /**
395
+ * 同步外部列表到内部 lists
396
+ * @param val 外部传入的文件列表
397
+ * @param isModelValue 是否来自 modelValue
398
+ */
399
+ function syncToInternal(val: UploadFileItem[], isModelValue: boolean = false) {
400
+ // 只有当不是由内部变化触发时才同步(避免循环更新)
401
+ const externalList = isModelValue ? props.modelValue : props.fileList;
402
+ const externalSet = new Set(externalList.map((item: UploadFileItem) => item.url || item.path).filter(Boolean));
403
+ const internalSet = new Set(lists.value.map(item => item.url || item.path).filter(Boolean));
404
+
405
+ // 检查是否需要同步(简单比较长度和关键属性)
406
+ const needSync =
407
+ externalList.length !== lists.value.length ||
408
+ !externalList.every(
409
+ (item: UploadFileItem) => externalSet.has(item.url || item.path) && internalSet.has(item.url || item.path)
410
+ );
411
+
412
+ if (!needSync) return;
413
+
414
+ // 清空并重新填充(保持数据一致性)
415
+ lists.value = val.map((value: UploadFileItem) => {
416
+ const fileType = value.fileType || detectFileType(value);
417
+ return {
418
+ ...value,
419
+ url: value.url,
420
+ path: value.path,
421
+ name: value.name || getFileNameFromPath(value.url || value.path || ''),
422
+ size: value.size || 0,
423
+ fileType,
424
+ error: value.error || false,
425
+ progress: value.progress ?? 100
426
+ };
427
+ });
428
+ }
429
+
430
+ // 监听 modelValue 变化,自动同步内部 lists(v-model 模式,优先级更高)
431
+ watch(
432
+ () => props.modelValue,
433
+ val => {
434
+ if (val && val.length > 0) {
435
+ syncToInternal(val, true);
436
+ }
437
+ },
438
+ { immediate: true, deep: true }
439
+ );
440
+
441
+ // 监听 fileList 变化,自动同步内部 lists(向后兼容模式,仅在 modelValue 为空时生效)
143
442
  watch(
144
443
  () => props.fileList,
145
444
  val => {
146
- val.map((value: any) => {
147
- // 首先检查内部是否已经添加过这张图片,因为外部绑定了一个对象给fileList的话(对象引用),进行修改外部fileList
148
- // 时,会触发watch,导致重新把原来的图片再次添加到this.lists
149
- // 数组的some方法意思是,只要数组元素有任意一个元素条件符合,就返回true,而另一个数组的every方法的意思是数组所有元素都符合条件才返回true
150
- let tmp = lists.value.some(val2 => val2.url == value.url);
151
- // 如果内部没有这个图片(tmp为false),则添加到内部
152
- !tmp && lists.value.push({ ...value, url: value.url, error: false, progress: 100 });
153
- });
445
+ // 如果 modelValue 有值,优先使用 modelValue,忽略 fileList
446
+ if (props.modelValue && props.modelValue.length > 0) return;
447
+ syncToInternal(val, false);
154
448
  },
155
449
  { immediate: true, deep: true }
156
450
  );
157
451
 
158
- // 监听 lists 变化,发出事件
452
+ // 监听 lists 变化,自动触发事件
159
453
  watch(
160
454
  lists,
161
455
  n => {
456
+ // 触发 v-model 更新
457
+ emit('update:modelValue', n);
458
+ // 触发 on-list-change 事件(向后兼容)
162
459
  emit('on-list-change', n, props.index);
163
460
  },
164
461
  { deep: true }
165
462
  );
166
463
 
464
+ /**
465
+ * 从路径中获取文件名
466
+ */
467
+ function getFileNameFromPath(path: string): string {
468
+ if (!path) return '';
469
+ const arr = path.split('/');
470
+ return arr[arr.length - 1] || '';
471
+ }
472
+
473
+ /**
474
+ * 根据扩展名检测文件类型
475
+ */
476
+ function getFileTypeByExt(ext: string): 'image' | 'video' | 'file' {
477
+ const lowerExt = ext.toLowerCase();
478
+ if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(lowerExt)) {
479
+ return 'image';
480
+ }
481
+ if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'rmvb', '3gp', 'm3u8'].includes(lowerExt)) {
482
+ return 'video';
483
+ }
484
+ return 'file';
485
+ }
486
+
487
+ /**
488
+ * 检测文件类型
489
+ */
490
+ function detectFileType(file: UploadFileItem): 'image' | 'video' | 'file' {
491
+ return getFileTypeByExt(getFileExt(file));
492
+ }
493
+
494
+ /**
495
+ * 获取文件扩展名
496
+ */
497
+ function getFileExt(file: UploadFileItem): string {
498
+ const path = file.url || file.path || file.name || '';
499
+ if (!path) return '';
500
+ const reg = /\.([^.]+)$/;
501
+ const match = path.match(reg);
502
+ return match ? match[1].toLowerCase() : '';
503
+ }
504
+
505
+ /**
506
+ * 判断是否为图片文件
507
+ */
508
+ function isImageFile(file: UploadFileItem): boolean {
509
+ if (file.fileType) return file.fileType === 'image';
510
+ const ext = getFileExt(file);
511
+ return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'image'].includes(ext);
512
+ }
513
+
514
+ /**
515
+ * 判断是否为视频文件
516
+ */
517
+ function isVideoFile(file: UploadFileItem): boolean {
518
+ if (file.fileType) return file.fileType === 'video';
519
+ const ext = getFileExt(file);
520
+ return ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'rmvb', '3gp', 'm3u8'].includes(ext);
521
+ }
522
+
523
+ /**
524
+ * 获取文件图标配置
525
+ */
526
+ function getFileIcon(file: UploadFileItem): { name: string; bgColor: string } {
527
+ const ext = getFileExt(file);
528
+ const type = extTypeMap[ext] || 'file';
529
+
530
+ // 优先使用用户自定义配置
531
+ if (props.fileIconMap && props.fileIconMap[type]) {
532
+ const customIcon = props.fileIconMap[type];
533
+ return {
534
+ name: customIcon.name,
535
+ bgColor: customIcon.color || defaultFileIconMap[type]?.bgColor || 'var(--u-tips-color)'
536
+ };
537
+ }
538
+
539
+ return defaultFileIconMap[type] || defaultFileIconMap.file;
540
+ }
541
+
542
+ /**
543
+ * 获取文件名(处理过长的情况)
544
+ */
545
+ function getFileName(file: UploadFileItem): string {
546
+ const name = file.name || getFileNameFromPath(file.url || file.path || '');
547
+ if (!name) return '未知文件';
548
+ // 如果文件名过长,截断显示
549
+ if (name.length > 15) {
550
+ const ext = getFileExt(file);
551
+ return name.substring(0, 12) + '...' + (ext ? '.' + ext : '');
552
+ }
553
+ return name;
554
+ }
555
+
556
+ /**
557
+ * 格式化文件大小(简化版,避免 Math.log 计算)
558
+ */
559
+ function formatFileSize(size: number): string {
560
+ if (size === 0) return '0 B';
561
+
562
+ const KB = 1024;
563
+ const MB = KB * 1024;
564
+ const GB = MB * 1024;
565
+
566
+ if (size < KB) {
567
+ return size + ' B';
568
+ } else if (size < MB) {
569
+ return (size / KB).toFixed(2) + ' KB';
570
+ } else if (size < GB) {
571
+ return (size / MB).toFixed(2) + ' MB';
572
+ } else {
573
+ return (size / GB).toFixed(2) + ' GB';
574
+ }
575
+ }
576
+
167
577
  /**
168
578
  * 清除列表
169
579
  */
@@ -179,49 +589,238 @@ function reUpload() {
179
589
  }
180
590
 
181
591
  /**
182
- * 选择图片
592
+ * 选择文件
183
593
  */
184
594
  function selectFile() {
185
595
  if (props.disabled) return;
596
+
597
+ // 根据 accept 类型选择不同的文件选择方式
598
+ switch (props.accept) {
599
+ case 'image':
600
+ chooseImage();
601
+ break;
602
+ case 'video':
603
+ chooseVideo();
604
+ break;
605
+ case 'media':
606
+ chooseMedia();
607
+ break;
608
+ case 'file':
609
+ case 'all':
610
+ chooseFile();
611
+ break;
612
+ default:
613
+ chooseImage();
614
+ }
615
+ }
616
+
617
+ /**
618
+ * 选择图片
619
+ */
620
+ function chooseImage() {
186
621
  const newMaxCount = Number(props.maxCount) - lists.value.length;
187
- // 设置为只选择图片的时候使用 chooseImage 来实现
188
- let chooseFile: Promise<any>;
189
- chooseFile = new Promise((resolve, reject) => {
190
- uni.chooseImage({
191
- count: props.multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
192
- sourceType: props.sourceType as string[],
193
- sizeType: props.sizeType as string[],
194
- success: resolve,
195
- fail: reject
196
- });
622
+
623
+ uni.chooseImage({
624
+ count: props.multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
625
+ sourceType: props.sourceType,
626
+ sizeType: props.sizeType as string[],
627
+ success: (res: any) => {
628
+ handleFilesSelected(res.tempFiles, 'image');
629
+ },
630
+ fail: (error: any) => {
631
+ emit('on-choose-fail', error);
632
+ }
197
633
  });
198
- chooseFile
199
- .then((res: any) => {
200
- let listOldLength = lists.value.length;
201
- res.tempFiles.map((val: any, index: number) => {
202
- // 检查文件后缀是否允许,如果不在limitType内,就会返回false
203
- if (!checkFileExt(val)) return;
204
- // 如果是非多选,index大于等于1或者超出最大限制数量时,不处理
205
- if (!props.multiple && index >= 1) return;
206
- if (val.size > Number(props.maxSize)) {
207
- emit('on-oversize', val, lists.value, props.index);
208
- showToast(t('uUpload.overSize'));
209
- } else {
210
- if (Number(props.maxCount) <= lists.value.length) {
211
- emit('on-exceed', val, lists.value, props.index);
212
- showToast(t('uUpload.overMaxCount'));
213
- return;
214
- }
215
- lists.value.push({ url: val.path, progress: 0, error: false, file: val });
216
- }
217
- });
218
- // 每次图片选择完,抛出一个事件,并将当前内部选择的图片数组抛出去
219
- emit('on-choose-complete', lists.value, props.index);
220
- if (props.autoUpload) uploadFile(listOldLength);
221
- })
222
- .catch((error: any) => {
634
+ }
635
+
636
+ /**
637
+ * 选择视频
638
+ */
639
+ function chooseVideo() {
640
+ uni.chooseVideo({
641
+ sourceType: props.sourceType,
642
+ compressed: props.compressed,
643
+ maxDuration: props.maxDuration,
644
+ camera: props.camera as 'front' | 'back',
645
+ success: (res: any) => {
646
+ const file = {
647
+ path: res.tempFilePath,
648
+ name: res.name || getFileNameFromPath(res.tempFilePath),
649
+ size: res.size || 0,
650
+ width: res.width,
651
+ height: res.height,
652
+ duration: res.duration
653
+ };
654
+ handleFilesSelected([file], 'video');
655
+ },
656
+ fail: (error: any) => {
223
657
  emit('on-choose-fail', error);
658
+ }
659
+ });
660
+ }
661
+
662
+ /**
663
+ * 选择媒体文件(图片+视频)
664
+ */
665
+ function chooseMedia() {
666
+ // #ifdef MP-WEIXIN || MP-TOUTIAO || APP
667
+ const newMaxCount = Number(props.maxCount) - lists.value.length;
668
+ uni.chooseMedia({
669
+ count: props.multiple ? (newMaxCount > 9 ? 9 : newMaxCount) : 1,
670
+ mediaType: ['image', 'video'],
671
+ sourceType: props.sourceType,
672
+ maxDuration: props.maxDuration,
673
+ camera: props.camera as 'front' | 'back',
674
+ success: (res: any) => {
675
+ const files = res.tempFiles.map((file: any) => ({
676
+ path: file.tempFilePath,
677
+ name: file.name || getFileNameFromPath(file.tempFilePath),
678
+ size: file.size || 0,
679
+ thumb: file.thumbTempFilePath,
680
+ duration: file.duration,
681
+ width: file.width,
682
+ height: file.height
683
+ }));
684
+ handleFilesSelected(files, 'media');
685
+ },
686
+ fail: (error: any) => {
687
+ emit('on-choose-fail', error);
688
+ }
689
+ });
690
+ // #endif
691
+
692
+ // #ifndef MP-WEIXIN || MP-TOUTIAO || APP
693
+ // 非支持平台回退到选择图片
694
+ chooseImage();
695
+ // #endif
696
+ }
697
+
698
+ /**
699
+ * 选择文件
700
+ */
701
+ function chooseFile() {
702
+ // #ifdef H5 || MP-WEIXIN
703
+ const newMaxCount = Number(props.maxCount) - lists.value.length;
704
+
705
+ // #ifdef H5
706
+ // H5 使用 chooseFile
707
+ if (typeof uni.chooseFile === 'function') {
708
+ uni.chooseFile({
709
+ count: props.multiple ? (newMaxCount > 100 ? 100 : newMaxCount) : 1,
710
+ type: 'all',
711
+ extension: props.extension.length > 0 ? props.extension : undefined,
712
+ success: (res: any) => {
713
+ handleFilesSelected(res.tempFiles, 'file');
714
+ },
715
+ fail: (error: any) => {
716
+ emit('on-choose-fail', error);
717
+ }
224
718
  });
719
+ }
720
+ // #endif
721
+
722
+ // #ifdef MP-WEIXIN
723
+ // 微信小程序使用 chooseMessageFile
724
+ if (typeof uni.chooseMessageFile === 'function') {
725
+ uni.chooseMessageFile({
726
+ count: props.multiple ? (newMaxCount > 100 ? 100 : newMaxCount) : 1,
727
+ type: 'all',
728
+ extension: props.extension.length > 0 ? props.extension : undefined,
729
+ success: (res: any) => {
730
+ const files = res.tempFiles.map((file: any) => ({
731
+ path: file.path,
732
+ name: file.name,
733
+ size: file.size,
734
+ type: file.type
735
+ }));
736
+ handleFilesSelected(files, 'file');
737
+ },
738
+ fail: (error: any) => {
739
+ emit('on-choose-fail', error);
740
+ }
741
+ });
742
+ }
743
+ // #endif
744
+ // #endif
745
+
746
+ // #ifndef H5 || MP-WEIXIN
747
+ // 其他平台暂不支持文件选择,提示用户
748
+ showToast(t('uUpload.fileNotSupported') || '当前平台暂不支持文件选择');
749
+ // #endif
750
+ }
751
+
752
+ /**
753
+ * 处理选中的文件
754
+ */
755
+ function handleFilesSelected(files: any[], sourceType: 'image' | 'video' | 'media' | 'file') {
756
+ let listOldLength = lists.value.length;
757
+
758
+ files.forEach((val: any, index: number) => {
759
+ // 检查文件后缀是否允许
760
+ if (!checkFileExt(val)) return;
761
+
762
+ // 如果是非多选,index大于等于1或者超出最大限制数量时,不处理
763
+ if (!props.multiple && index >= 1) return;
764
+
765
+ if (val.size > Number(props.maxSize)) {
766
+ emit('on-oversize', val, lists.value, props.index);
767
+ showToast(t('uUpload.overSize'));
768
+ } else {
769
+ if (Number(props.maxCount) <= lists.value.length) {
770
+ emit('on-exceed', val, lists.value, props.index);
771
+ showToast(t('uUpload.overMaxCount'));
772
+ return;
773
+ }
774
+
775
+ // 根据 accept 和 sourceType 确定 fileType
776
+ let fileType: 'image' | 'video' | 'file';
777
+ if (props.accept === 'image') {
778
+ fileType = 'image';
779
+ } else if (props.accept === 'video') {
780
+ fileType = 'video';
781
+ } else if (props.accept === 'file') {
782
+ fileType = 'file';
783
+ } else {
784
+ // media 或 all 时,根据路径或 sourceType 检测
785
+ fileType = detectFileTypeFromPath(val.path || val.name || '');
786
+ }
787
+
788
+ lists.value.push({
789
+ url: val.path,
790
+ path: val.path,
791
+ name: val.name || getFileNameFromPath(val.path || ''),
792
+ size: val.size || 0,
793
+ progress: 0,
794
+ error: false,
795
+ file: val,
796
+ fileType,
797
+ thumb: val.thumb || '',
798
+ width: val.width,
799
+ height: val.height,
800
+ duration: val.duration,
801
+ type: val.type
802
+ });
803
+ }
804
+ });
805
+
806
+ // 每次文件选择完,抛出一个事件,并将当前内部选择的文件数组抛出去
807
+ emit('on-choose-complete', lists.value, props.index);
808
+ if (props.autoUpload) uploadFile(listOldLength);
809
+ }
810
+
811
+ /**
812
+ * 从路径中获取文件扩展名
813
+ */
814
+ function getExtFromPath(path: string): string {
815
+ if (!path) return '';
816
+ return path.split('.').pop() || '';
817
+ }
818
+
819
+ /**
820
+ * 根据路径检测文件类型
821
+ */
822
+ function detectFileTypeFromPath(path: string): 'image' | 'video' | 'file' {
823
+ return getFileTypeByExt(getExtFromPath(path));
225
824
  }
226
825
 
227
826
  /**
@@ -241,7 +840,7 @@ function upload() {
241
840
  }
242
841
 
243
842
  /**
244
- * 对失败的图片重新上传
843
+ * 对失败的文件重新上传
245
844
  */
246
845
  function retry(index: number) {
247
846
  lists.value[index].progress = 0;
@@ -254,31 +853,27 @@ function retry(index: number) {
254
853
  }
255
854
 
256
855
  /**
257
- * 上传图片
856
+ * 上传文件
258
857
  */
259
858
  async function uploadFile(index = 0): Promise<void> {
260
859
  if (props.disabled) return;
261
860
  if (uploading.value) return;
861
+
262
862
  // 全部上传完成
263
863
  if (index >= lists.value.length) {
264
864
  emit('on-uploaded', lists.value, props.index);
265
865
  return;
266
866
  }
867
+
267
868
  // 检查是否是已上传或者正在上传中
268
- if (lists.value[index].progress == 100) {
269
- if (props.autoUpload == false) uploadFile(index + 1);
869
+ if (lists.value[index].progress === 100) {
870
+ if (props.autoUpload === false) uploadFile(index + 1);
270
871
  return;
271
872
  }
873
+
272
874
  // 执行before-upload钩子
273
875
  if (props.beforeUpload && typeof props.beforeUpload === 'function') {
274
- // 执行回调,同时传入索引和文件列表当作参数
275
- // 在微信,支付宝等环境(H5正常),会导致父组件定义的customBack()函数体中的this变成子组件的this
276
- // 通过bind()方法,绑定父组件的this,让this.customBack()的this为父组件的上下文
277
- // 因为upload组件可能会被嵌套在其他组件内,比如u-form,这时this.$parent其实为u-form的this,
278
- // 非页面的this,所以这里需要往上历遍,一直寻找到最顶端的$parent,这里用了this.$u.$parent.call(this)
279
- // 明白意思即可,无需纠结this.$u.$parent.call(this)的细节
280
876
  let beforeResponse = props.beforeUpload(index, lists.value);
281
- // 判断是否返回了promise
282
877
  if (
283
878
  typeof beforeResponse === 'object' &&
284
879
  beforeResponse !== null &&
@@ -289,32 +884,32 @@ async function uploadFile(index = 0): Promise<void> {
289
884
  // promise返回成功,不进行动作,继续上传
290
885
  })
291
886
  .catch(() => {
292
- // 进入catch回调的话,继续下一张
887
+ // 进入catch回调的话,继续下一个
293
888
  return uploadFile(index + 1);
294
889
  });
295
890
  } else if (beforeResponse === false) {
296
- // 如果返回false,继续下一张图片的上传
297
891
  return uploadFile(index + 1);
298
- } else {
299
- // 此处为返回"true"的情形,这里不写代码,就跳过此处,继续执行当前的上传逻辑
300
892
  }
301
893
  }
894
+
302
895
  // 检查上传地址
303
896
  if (!props.action) {
304
897
  showToast(t('uUpload.noAction'), true);
305
898
  return;
306
899
  }
900
+
307
901
  lists.value[index].error = false;
308
902
  uploading.value = true;
903
+
309
904
  // 创建上传对象
310
- const task = uni.uploadFile({
905
+ const uploadTask = uni.uploadFile({
311
906
  url: props.action,
312
- filePath: lists.value[index].url,
907
+ filePath: lists.value[index].url || lists.value[index].path || '',
313
908
  name: props.name,
314
909
  formData: props.formData,
315
910
  header: props.header,
316
911
  // #ifdef MP-ALIPAY
317
- fileType: 'image',
912
+ fileType: isImageFile(lists.value[index]) ? 'image' : 'video',
318
913
  // #endif
319
914
  success: (res: any) => {
320
915
  // 判断是否json字符串,将其转为json格式
@@ -333,13 +928,19 @@ async function uploadFile(index = 0): Promise<void> {
333
928
  uploadError(index, e);
334
929
  },
335
930
  complete: (res: any) => {
931
+ // 上传完成后清除任务引用
932
+ lists.value[index].uploadTask = undefined;
336
933
  uni.hideLoading();
337
934
  uploading.value = false;
338
935
  uploadFile(index + 1);
339
936
  emit('on-change', res, index, lists.value, props.index);
340
937
  }
341
938
  });
342
- task.onProgressUpdate((res: any) => {
939
+
940
+ // 保存上传任务引用,用于后续取消上传
941
+ lists.value[index].uploadTask = uploadTask as unknown as UniApp.UploadTask;
942
+
943
+ uploadTask.onProgressUpdate((res: any) => {
343
944
  if (res.progress > 0) {
344
945
  lists.value[index].progress = res.progress;
345
946
  emit('on-progress', res, index, lists.value, props.index);
@@ -359,59 +960,64 @@ function uploadError(index: number, err: any) {
359
960
  }
360
961
 
361
962
  /**
362
- * 删除一个图片
963
+ * 删除一个文件
363
964
  */
364
965
  function deleteItem(index: number) {
365
- uni.showModal({
366
- title: t('uUpload.modalTitle'),
367
- content: t('uUpload.deleteConfirm'),
368
- cancelText: t('uUpload.modalCancelText'),
369
- confirmText: t('uUpload.modalConfirmText'),
370
- success: async (res: any) => {
371
- if (res.confirm) {
372
- // 先检查是否有定义before-remove移除前钩子
373
- // 执行before-remove钩子
374
- if (props.beforeRemove && typeof props.beforeRemove === 'function') {
375
- // 此处钩子执行 原理同before-remove参数,见上方注释
376
- let beforeResponse = props.beforeRemove(index, lists.value);
377
- // 判断是否返回了promise
378
- if (
379
- typeof beforeResponse === 'object' &&
380
- beforeResponse !== null &&
381
- typeof (beforeResponse as Promise<any>).then === 'function'
382
- ) {
383
- await (beforeResponse as Promise<any>)
384
- .then(() => {
385
- // promise返回成功,不进行动作,继续上传
386
- handlerDeleteItem(index);
387
- })
388
- .catch(() => {
389
- // 如果进入promise的reject,终止删除操作
390
- showToast(t('uUpload.terminatedRemove'));
391
- });
392
- } else if (beforeResponse === false) {
393
- // 返回false,终止删除
394
- showToast(t('uUpload.terminatedRemove'));
395
- } else {
396
- // 如果返回true,执行删除操作
397
- handlerDeleteItem(index);
398
- }
399
- } else {
400
- // 如果不存在before-remove钩子,
401
- handlerDeleteItem(index);
966
+ // 如果需要确认弹窗,显示 uni.showModal
967
+ if (props.showConfirm) {
968
+ uni.showModal({
969
+ title: t('uUpload.modalTitle'),
970
+ content: t('uUpload.deleteConfirm'),
971
+ cancelText: t('uUpload.modalCancelText'),
972
+ confirmText: t('uUpload.modalConfirmText'),
973
+ success: async (res: any) => {
974
+ if (res.confirm) {
975
+ await executeBeforeRemove(index);
402
976
  }
403
977
  }
978
+ });
979
+ } else {
980
+ // 不需要确认弹窗,直接执行删除
981
+ executeBeforeRemove(index);
982
+ }
983
+ }
984
+
985
+ /**
986
+ * 执行删除前的钩子并删除文件
987
+ */
988
+ async function executeBeforeRemove(index: number) {
989
+ // 执行before-remove钩子
990
+ if (props.beforeRemove && typeof props.beforeRemove === 'function') {
991
+ let beforeResponse = props.beforeRemove(index, lists.value);
992
+ if (
993
+ typeof beforeResponse === 'object' &&
994
+ beforeResponse !== null &&
995
+ typeof (beforeResponse as Promise<any>).then === 'function'
996
+ ) {
997
+ await (beforeResponse as Promise<any>)
998
+ .then(() => {
999
+ handlerDeleteItem(index);
1000
+ })
1001
+ .catch(() => {
1002
+ showToast(t('uUpload.terminatedRemove'));
1003
+ });
1004
+ } else if (beforeResponse === false) {
1005
+ showToast(t('uUpload.terminatedRemove'));
1006
+ } else {
1007
+ handlerDeleteItem(index);
404
1008
  }
405
- });
1009
+ } else {
1010
+ handlerDeleteItem(index);
1011
+ }
406
1012
  }
407
1013
 
408
1014
  /**
409
- * 执行移除图片的动作
1015
+ * 执行移除文件的动作
410
1016
  */
411
1017
  function handlerDeleteItem(index: number) {
412
- // 如果文件正在上传中,终止上传任务,进度在0 < progress < 100则意味着正在上传
1018
+ // 如果文件正在上传中,终止上传任务
413
1019
  if (lists.value[index].progress < 100 && lists.value[index].progress > 0) {
414
- typeof lists.value[index].uploadTask != 'undefined' && lists.value[index].uploadTask.abort();
1020
+ typeof lists.value[index].uploadTask != 'undefined' && lists.value[index].uploadTask?.abort?.();
415
1021
  }
416
1022
  lists.value.splice(index, 1);
417
1023
  emit('on-remove', index, lists.value, props.index);
@@ -419,65 +1025,169 @@ function handlerDeleteItem(index: number) {
419
1025
  }
420
1026
 
421
1027
  /**
422
- * 用户通过ref手动的形式,移除一张图片
1028
+ * 用户通过ref手动的形式,移除一个文件
423
1029
  */
424
1030
  function remove(index: number) {
425
1031
  // 判断索引的合法范围
426
1032
  if (index >= 0 && index < lists.value.length) {
427
1033
  lists.value.splice(index, 1);
428
- emit('on-list-change', lists.value, props.index);
429
1034
  }
430
1035
  }
431
1036
 
1037
+ /**
1038
+ * 预览/打开文件
1039
+ */
1040
+ function doPreviewFile(item: string | UploadFileItem, index: number) {
1041
+ // 处理字符串类型的文件
1042
+ if (typeof item === 'string') {
1043
+ item = { url: item };
1044
+ }
1045
+ // 图片文件预览
1046
+ if (isImageFile(item)) {
1047
+ doPreviewImage(item.url || item.path || '', index);
1048
+ return;
1049
+ }
1050
+ // 视频文件不支持预览
1051
+ if (isVideoFile(item)) {
1052
+ return;
1053
+ }
1054
+ // 尝试打开文档
1055
+ openDocument(item);
1056
+ }
1057
+
432
1058
  /**
433
1059
  * 预览图片
434
1060
  */
435
1061
  function doPreviewImage(url: string, index: number) {
436
- // 判断是否允许预览
437
- if (!props.previewFullImage) return;
438
1062
  // 获取所有图片的url
439
- const images = lists.value.map(item => item.url || item.path);
1063
+ const images = lists.value.filter(item => isImageFile(item)).map(item => item.url || item.path || '');
440
1064
  uni.previewImage({
441
1065
  urls: images,
442
1066
  current: url,
443
1067
  success: () => {
444
- emit('on-preview', url, lists.value, props.index);
1068
+ console.log('预览图片成功');
445
1069
  },
446
1070
  fail: () => {
447
- uni.showToast({ title: t('uUpload.previewFailed'), icon: 'none' });
1071
+ showToast(t('uUpload.previewFailed'));
448
1072
  }
449
1073
  });
450
1074
  }
451
1075
 
452
1076
  /**
453
- * 判断文件后缀是否允许
1077
+ * 打开文档
454
1078
  */
455
- function checkFileExt(file: any) {
456
- // 检查是否在允许的后缀中
457
- let noArrowExt = false;
458
- // 获取后缀名
459
- let fileExt = '';
460
- const reg = /.+\./;
461
- // 如果是H5,需要从name中判断
1079
+ function openDocument(item: UploadFileItem) {
462
1080
  // #ifdef H5
463
- // H5环境下从name中获取后缀
464
- fileExt = file.name.replace(reg, '').toLowerCase();
1081
+ // H5 环境直接在新标签页打开
1082
+ if (item.url) {
1083
+ window.open(item.url, '_blank');
1084
+ }
465
1085
  // #endif
466
1086
 
467
- // 非H5环境下从path中获取后缀
468
1087
  // #ifndef H5
469
- fileExt = file.path.replace(reg, '').toLowerCase();
1088
+ // 其他环境尝试使用 uni.openDocument
1089
+ if (item.path || item.url) {
1090
+ uni.openDocument({
1091
+ filePath: item.path || item.url || '',
1092
+ showMenu: true,
1093
+ success: () => {
1094
+ console.log('打开文档成功');
1095
+ },
1096
+ fail: err => {
1097
+ console.error('打开文档失败', err);
1098
+ showToast(t('uUpload.openFailed') || '无法打开此文件');
1099
+ }
1100
+ });
1101
+ }
470
1102
  // #endif
471
- // 使用数组的some方法,只要符合limitType中的一个,就返回true
472
- noArrowExt = (props.limitType as string[]).some((ext: string) => {
473
- // 转为小写
474
- return ext.toLowerCase() === fileExt;
475
- });
476
- if (!noArrowExt) showToast(t('uUpload.notAllowedExt', { ext: fileExt }));
477
- return noArrowExt;
478
1103
  }
479
1104
 
480
- defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImage, lists });
1105
+ /**
1106
+ * 统一处理预览
1107
+ */
1108
+ function handlePreview(item: UploadFileItem, index: number) {
1109
+ emit('on-preview', item.url || item.path, lists.value, props.index);
1110
+ // 判断是否允许预览
1111
+ if (!props.previewFile) return;
1112
+ // 图片文件预览
1113
+ if (isImageFile(item) && props.previewFullImage) {
1114
+ doPreviewImage(item.url || item.path || '', index);
1115
+ return;
1116
+ }
1117
+ // 文件预览
1118
+ doPreviewFile(item, index);
1119
+ }
1120
+
1121
+ /**
1122
+ * 从文件名或路径中提取扩展名(简化版,避免正则表达式)
1123
+ */
1124
+ function extractExt(file: { name?: string; path?: string }): string {
1125
+ let source: string;
1126
+ // #ifdef H5
1127
+ source = file.name || file.path || '';
1128
+ // #endif
1129
+ // #ifndef H5
1130
+ source = file.path || file.name || '';
1131
+ // #endif
1132
+
1133
+ if (!source) return '';
1134
+ const lastDotIndex = source.lastIndexOf('.');
1135
+ return lastDotIndex > -1 ? source.slice(lastDotIndex + 1).toLowerCase() : '';
1136
+ }
1137
+
1138
+ /**
1139
+ * 判断文件后缀是否允许
1140
+ */
1141
+ function checkFileExt(file: { name?: string; path?: string }): boolean {
1142
+ const fileExt = extractExt(file);
1143
+
1144
+ // 根据 accept 类型确定允许的后缀
1145
+ let allowedExts: string[] = [];
1146
+ if (props.limitType && props.limitType.length > 0) {
1147
+ allowedExts = props.limitType;
1148
+ } else if (props.accept === 'image') {
1149
+ allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'image'];
1150
+ } else if (props.accept === 'video') {
1151
+ allowedExts = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'rmvb', '3gp', 'm3u8'];
1152
+ } else if (props.accept === 'file') {
1153
+ // file 类型使用用户设置的 extension 或允许所有文件
1154
+ allowedExts = props.extension.length > 0 ? (props.extension as string[]) : [];
1155
+ } else if (props.accept === 'media') {
1156
+ // media 类型,合并图片和视频格式
1157
+ allowedExts = [
1158
+ 'png',
1159
+ 'jpg',
1160
+ 'jpeg',
1161
+ 'gif',
1162
+ 'webp',
1163
+ 'bmp',
1164
+ 'svg',
1165
+ 'mp4',
1166
+ 'avi',
1167
+ 'mov',
1168
+ 'wmv',
1169
+ 'flv',
1170
+ 'mkv',
1171
+ 'rmvb',
1172
+ '3gp',
1173
+ 'm3u8'
1174
+ ];
1175
+ } else {
1176
+ // all 类型,允许所有文件
1177
+ return true;
1178
+ }
1179
+
1180
+ // 如果没有限制类型(空数组),允许所有
1181
+ if (allowedExts.length === 0) return true;
1182
+
1183
+ // 使用数组的some方法,只要符合allowedExts中的一个,就返回true
1184
+ const isValid = allowedExts.some(ext => ext.toLowerCase() === fileExt);
1185
+
1186
+ if (!isValid) showToast(t('uUpload.notAllowedExt', { ext: fileExt || '未知' }));
1187
+ return isValid;
1188
+ }
1189
+
1190
+ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImage, doPreviewFile, lists });
481
1191
  </script>
482
1192
 
483
1193
  <style lang="scss" scoped>
@@ -489,7 +1199,8 @@ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImag
489
1199
  align-items: center;
490
1200
  }
491
1201
 
492
- .u-list-item {
1202
+ // ==================== Grid 模式样式 ====================
1203
+ .u-upload-grid__item {
493
1204
  width: 200rpx;
494
1205
  height: 200rpx;
495
1206
  overflow: hidden;
@@ -504,33 +1215,33 @@ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImag
504
1215
  justify-content: center;
505
1216
  }
506
1217
 
507
- .u-preview-wrap {
1218
+ .u-upload-grid__preview {
508
1219
  border: 1px solid var(--u-border-color);
509
1220
  }
510
1221
 
511
- .u-add-wrap {
1222
+ .u-upload-grid__add {
512
1223
  flex-direction: column;
513
1224
  color: $u-content-color;
514
1225
  font-size: 26rpx;
515
1226
  }
516
1227
 
517
- .u-add-tips {
1228
+ .u-upload-grid__add-text {
518
1229
  margin-top: 20rpx;
519
1230
  line-height: 40rpx;
520
1231
  }
521
1232
 
522
- .u-add-wrap__hover {
1233
+ .u-upload-grid__add--hover {
523
1234
  background-color: var(--u-bg-gray-light);
524
1235
  }
525
1236
 
526
- .u-preview-image {
1237
+ .u-upload-grid__image {
527
1238
  display: block;
528
1239
  width: 100%;
529
1240
  height: 100%;
530
1241
  border-radius: 10rpx;
531
1242
  }
532
1243
 
533
- .u-delete-icon {
1244
+ .u-upload-grid__delete {
534
1245
  position: absolute;
535
1246
  top: 10rpx;
536
1247
  right: 10rpx;
@@ -544,7 +1255,7 @@ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImag
544
1255
  justify-content: center;
545
1256
  }
546
1257
 
547
- .u-upload-progress {
1258
+ .u-upload-grid__progress {
548
1259
  position: absolute;
549
1260
  bottom: 10rpx;
550
1261
  left: 8rpx;
@@ -553,7 +1264,7 @@ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImag
553
1264
  width: auto;
554
1265
  }
555
1266
 
556
- .u-error-btn {
1267
+ .u-upload-grid__error {
557
1268
  color: var(--u-white-color);
558
1269
  background-color: $u-type-error;
559
1270
  font-size: 20rpx;
@@ -566,4 +1277,231 @@ defineExpose({ clear, reUpload, selectFile, upload, retry, remove, doPreviewImag
566
1277
  z-index: 9;
567
1278
  line-height: 1;
568
1279
  }
1280
+
1281
+ // Grid 模式:文件预览样式
1282
+ .u-upload-grid__file {
1283
+ width: 100%;
1284
+ height: 100%;
1285
+ display: flex;
1286
+ flex-direction: column;
1287
+ align-items: center;
1288
+ justify-content: center;
1289
+ padding: 16rpx;
1290
+ box-sizing: border-box;
1291
+ position: relative;
1292
+ }
1293
+
1294
+ .u-upload-grid__file-icon {
1295
+ width: 80rpx;
1296
+ height: 80rpx;
1297
+ border-radius: 16rpx;
1298
+ display: flex;
1299
+ align-items: center;
1300
+ justify-content: center;
1301
+ margin-bottom: 12rpx;
1302
+ }
1303
+
1304
+ .u-upload-grid__file-play {
1305
+ position: absolute;
1306
+ top: 50%;
1307
+ left: 50%;
1308
+ transform: translate(-50%, -50%);
1309
+ margin-top: -20rpx;
1310
+ opacity: 0.9;
1311
+ }
1312
+
1313
+ .u-upload-grid__file-info {
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ align-items: center;
1317
+ width: 100%;
1318
+ }
1319
+
1320
+ .u-upload-grid__file-name {
1321
+ font-size: 22rpx;
1322
+ line-height: 1.4;
1323
+ text-align: center;
1324
+ max-width: 100%;
1325
+ overflow: hidden;
1326
+ text-overflow: ellipsis;
1327
+ white-space: nowrap;
1328
+ }
1329
+
1330
+ .u-upload-grid__file-size {
1331
+ font-size: 20rpx;
1332
+ color: var(--u-tips-color);
1333
+ margin-top: 4rpx;
1334
+ }
1335
+
1336
+ // ==================== 列表模式样式 ====================
1337
+ .u-upload--list {
1338
+ flex-direction: column;
1339
+ align-items: stretch;
1340
+ }
1341
+
1342
+ // 列表模式容器
1343
+ .u-upload-list {
1344
+ display: flex;
1345
+ flex-direction: column;
1346
+ width: 100%;
1347
+ }
1348
+
1349
+ // 列表项
1350
+ .u-upload-list__item {
1351
+ display: flex;
1352
+ align-items: center;
1353
+ padding: 20rpx;
1354
+ background: var(--u-bg-white);
1355
+ border-radius: 12rpx;
1356
+ margin-bottom: 16rpx;
1357
+ border: 1rpx solid var(--u-border-color);
1358
+ position: relative;
1359
+ }
1360
+
1361
+ .u-upload-list__item--error {
1362
+ border-color: var(--u-type-error);
1363
+ position: relative;
1364
+ }
1365
+
1366
+ .u-upload-list__item--error::before {
1367
+ content: '';
1368
+ position: absolute;
1369
+ inset: 0;
1370
+ background-color: var(--u-type-error);
1371
+ opacity: 0.05;
1372
+ border-radius: inherit;
1373
+ pointer-events: none;
1374
+ }
1375
+
1376
+ .u-upload-list__item:last-child {
1377
+ margin-bottom: 0;
1378
+ }
1379
+
1380
+ // 左侧:缩略图/图标
1381
+ .u-upload-list__left {
1382
+ flex-shrink: 0;
1383
+ margin-right: 20rpx;
1384
+ }
1385
+
1386
+ .u-upload-list__thumb {
1387
+ width: 80rpx;
1388
+ height: 80rpx;
1389
+ border-radius: 8rpx;
1390
+ background: var(--u-bg-gray-light);
1391
+ }
1392
+
1393
+ .u-upload-list__icon {
1394
+ width: 80rpx;
1395
+ height: 80rpx;
1396
+ border-radius: 8rpx;
1397
+ display: flex;
1398
+ align-items: center;
1399
+ justify-content: center;
1400
+ }
1401
+
1402
+ // 中间:文件名信息
1403
+ .u-upload-list__center {
1404
+ flex: 1;
1405
+ display: flex;
1406
+ flex-direction: column;
1407
+ min-width: 0;
1408
+ overflow: hidden;
1409
+ }
1410
+
1411
+ .u-upload-list__name {
1412
+ font-size: 28rpx;
1413
+ color: var(--u-content-color);
1414
+ line-height: 1.4;
1415
+ overflow: hidden;
1416
+ text-overflow: ellipsis;
1417
+ white-space: nowrap;
1418
+ text-align: left;
1419
+ }
1420
+
1421
+ .u-upload-list__name--error {
1422
+ color: var(--u-type-error);
1423
+ }
1424
+
1425
+ .u-upload-list__size {
1426
+ font-size: 24rpx;
1427
+ color: var(--u-tips-color);
1428
+ margin-top: 8rpx;
1429
+ text-align: left;
1430
+ }
1431
+
1432
+ .u-upload-list__progress {
1433
+ margin-top: 12rpx;
1434
+ width: 100%;
1435
+ }
1436
+
1437
+ // 重试按钮
1438
+ .u-upload-list__retry {
1439
+ display: flex;
1440
+ align-items: center;
1441
+ margin-top: 8rpx;
1442
+ padding: 8rpx 16rpx;
1443
+ border-radius: 8rpx;
1444
+ align-self: flex-start;
1445
+ position: relative;
1446
+ }
1447
+
1448
+ .u-upload-list__retry::before {
1449
+ content: '';
1450
+ position: absolute;
1451
+ inset: 0;
1452
+ background-color: var(--u-type-error);
1453
+ opacity: 0.1;
1454
+ border-radius: inherit;
1455
+ pointer-events: none;
1456
+ }
1457
+
1458
+ .u-upload-list__retry--hover::before {
1459
+ opacity: 0.2;
1460
+ }
1461
+
1462
+ .u-upload-list__retry-text {
1463
+ font-size: 24rpx;
1464
+ color: var(--u-type-error);
1465
+ margin-left: 8rpx;
1466
+ }
1467
+
1468
+ // 右侧:删除按钮
1469
+ .u-upload-list__right {
1470
+ flex-shrink: 0;
1471
+ margin-left: 20rpx;
1472
+ background-color: $u-type-error;
1473
+ border-radius: 100rpx;
1474
+ width: 44rpx;
1475
+ height: 44rpx;
1476
+ @include vue-flex;
1477
+ align-items: center;
1478
+ justify-content: center;
1479
+ }
1480
+
1481
+ // 列表模式添加按钮
1482
+ .u-upload-list__add {
1483
+ margin-top: 16rpx;
1484
+ }
1485
+
1486
+ .u-upload-list__add-btn {
1487
+ display: flex;
1488
+ align-items: center;
1489
+ justify-content: center;
1490
+ padding: 24rpx;
1491
+ background: var(--u-bg-white);
1492
+ border: 2rpx dashed var(--u-border-color);
1493
+ border-radius: 12rpx;
1494
+ transition: all 0.3s;
1495
+ }
1496
+
1497
+ .u-upload-list__add-btn--hover {
1498
+ background: var(--u-bg-gray-light);
1499
+ border-color: var(--u-type-primary);
1500
+ }
1501
+
1502
+ .u-upload-list__add-text {
1503
+ margin-left: 16rpx;
1504
+ font-size: 28rpx;
1505
+ color: var(--u-type-primary);
1506
+ }
569
1507
  </style>