uview-pro 0.6.18 → 0.6.19

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