@puresnake/snake-ui 0.0.0-stage → 0.0.2

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