cwg-file-picker-wrapper.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. <template>
  2. <view class="file-picker-wrapper">
  3. <!-- 只读模式:仅展示文件列表 -->
  4. <view v-if="readonly" class="file-list readonly-list">
  5. <view v-for="(file, index) in value" :key="index" class="file-item readonly-item" @click="previewFile(file)">
  6. <image v-if="isImage(file)" :src="file.url || file.path" mode="aspectFill" class="file-thumb" />
  7. <view v-else class="file-icon">
  8. <text class="iconfont icon-file">📄</text>
  9. </view>
  10. <text class="file-name">{{ file.name }}</text>
  11. </view>
  12. <view v-if="!value.length" class="empty-text">暂无文件</view>
  13. </view>
  14. <!-- 非只读模式:完整功能 -->
  15. <view v-else>
  16. <view class="upload-area">
  17. <uni-file-picker ref="filePicker" v-bind="$attrs" :limit="999" :show-file-list="false" :auto-upload="false"
  18. mode="grid" @select="handleSelect" @fail="handleFail" @error="handleError">
  19. <cwg-icon name="icon_add" class="upload-icon" :size="24" />
  20. </uni-file-picker>
  21. </view>
  22. </view>
  23. </view>
  24. </template>
  25. <script setup>
  26. import { computed, ref, watch } from 'vue'
  27. import config from '@/config'
  28. import { userToken } from '@/composables/config'
  29. // 定义 props
  30. const props = defineProps({
  31. // 支持 v-model 的文件列表
  32. value: {
  33. type: Array,
  34. default: () => []
  35. },
  36. // 只读模式
  37. readonly: {
  38. type: Boolean,
  39. default: false
  40. },
  41. // 最大文件数量
  42. limit: {
  43. type: Number,
  44. default: 5
  45. },
  46. // 文件类型限制(image/video/all)
  47. fileMediatype: {
  48. type: String,
  49. default: 'all'
  50. },
  51. // 上传地址(必填)
  52. action: {
  53. type: String,
  54. default: ''
  55. },
  56. // 上传URL(优先使用)
  57. uploadUrl: {
  58. type: String,
  59. default: '/custom/bank/upload'
  60. },
  61. // 上传请求头
  62. uploadHeaders: {
  63. type: Object,
  64. default: () => ({})
  65. },
  66. // 上传字段名
  67. uploadName: {
  68. type: String,
  69. default: 'file'
  70. },
  71. // 上传额外数据
  72. uploadData: {
  73. type: Object,
  74. default: () => ({})
  75. },
  76. // 响应数据处理函数
  77. responseHandler: {
  78. type: Function,
  79. default: (res) => {
  80. try {
  81. const data = typeof res === 'string' ? JSON.parse(res) : res
  82. return {
  83. success: data.code === 200,
  84. path: data.data?.path || data.data,
  85. message: data.msg || '上传成功',
  86. data: data
  87. }
  88. } catch (e) {
  89. return {
  90. success: false,
  91. path: null,
  92. message: '解析响应失败',
  93. data: null
  94. }
  95. }
  96. }
  97. }
  98. })
  99. // 定义 emits
  100. const emit = defineEmits([
  101. 'input',
  102. 'update:value',
  103. 'change',
  104. 'delete',
  105. 'success',
  106. 'fail',
  107. 'error',
  108. 'progress'
  109. ])
  110. // 响应式数据
  111. const innerFileList = ref([]) // 内部文件列表
  112. const uploadProgress = ref({}) // 存储每个文件的上传进度 { fileIndex: progress }
  113. const isUploading = ref(false) // 是否正在上传
  114. // 计算属性
  115. const remainingLimit = computed(() => {
  116. return Math.max(0, props.limit - innerFileList.value.length)
  117. })
  118. // 监听外部 value 变化
  119. watch(
  120. () => props.value,
  121. (newVal) => {
  122. innerFileList.value = [...newVal]
  123. },
  124. { immediate: true, deep: true }
  125. )
  126. // 监听内部文件列表变化,同步到外部
  127. watch(
  128. innerFileList,
  129. (newVal) => {
  130. emit('input', newVal)
  131. emit('update:value', newVal)
  132. emit('change', newVal)
  133. },
  134. { deep: true }
  135. )
  136. // 方法
  137. // 判断是否为图片
  138. const isImage = (file) => {
  139. const type = file.type || file.fileType
  140. if (type) return type.startsWith('image/')
  141. const ext = (file.name || '').split('.').pop().toLowerCase()
  142. return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)
  143. }
  144. // 预览文件
  145. const previewFile = (file) => {
  146. if (isImage(file)) {
  147. const urls = innerFileList.value
  148. .filter(f => isImage(f))
  149. .map(f => f.url || f.path)
  150. const current = file.url || file.path
  151. uni.previewImage({ urls, current })
  152. } else {
  153. if (file.url) {
  154. // #ifdef H5
  155. window.open(file.url)
  156. // #endif
  157. // #ifdef APP-PLUS
  158. plus.runtime.openURL(file.url)
  159. // #endif
  160. // #ifdef MP-WEIXIN
  161. uni.downloadFile({
  162. url: file.url,
  163. success: (res) => uni.openDocument({ filePath: res.tempFilePath })
  164. })
  165. // #endif
  166. } else {
  167. uni.showToast({ title: '暂无法预览此文件', icon: 'none' })
  168. }
  169. }
  170. }
  171. // 删除文件
  172. const deleteFile = (index) => {
  173. const deleted = innerFileList.value[index]
  174. innerFileList.value.splice(index, 1)
  175. emit('delete', deleted, index)
  176. }
  177. // 处理文件选择
  178. const handleSelect = async (res) => {
  179. const { tempFiles } = res
  180. // 正常新增模式:检查数量限制
  181. const availableSlots = props.limit - innerFileList.value.length
  182. const toAddFiles = tempFiles.slice(0, availableSlots)
  183. if (toAddFiles.length < tempFiles.length) {
  184. uni.showToast({
  185. title: `最多选择${props.limit}个文件,超出部分已忽略`,
  186. icon: 'none'
  187. })
  188. }
  189. isUploading.value = true
  190. try {
  191. for (let i = 0; i < toAddFiles.length; i++) {
  192. const file = toAddFiles[i]
  193. const fileIndex = innerFileList.value.length + i
  194. await uploadFile(file, fileIndex)
  195. }
  196. } finally {
  197. isUploading.value = false
  198. }
  199. }
  200. const filePicker = ref(null)
  201. // 上传文件
  202. const uploadFile = (fileItem, fileIndex) => {
  203. return new Promise((resolve, reject) => {
  204. // 构建上传URL
  205. const uploadUrl = props.action || (config.Host80 + props.uploadUrl)
  206. // 构建上传参数
  207. const task = uni.uploadFile({
  208. url: uploadUrl,
  209. filePath: fileItem.path,
  210. name: props.uploadName,
  211. header: {
  212. 'Access-Token': userToken.value,
  213. ...props.uploadHeaders
  214. },
  215. formData: props.uploadData,
  216. success: (res) => {
  217. const result = props.responseHandler(res.data)
  218. if (result.success) {
  219. // 构造文件对象
  220. const uploadedFile = {
  221. url: config.Host80 + result.path,
  222. path: result.path,
  223. name: fileItem.name,
  224. size: fileItem.size,
  225. type: fileItem.type,
  226. response: result.data
  227. }
  228. innerFileList.value.push(uploadedFile)
  229. emit('success', uploadedFile, res)
  230. resolve(uploadedFile)
  231. } else {
  232. uni.showToast({ title: result.message || '上传失败', icon: 'error' })
  233. emit('fail', new Error(result.message), fileItem)
  234. reject(new Error(result.message))
  235. }
  236. },
  237. fail: (err) => {
  238. // 清除进度
  239. delete uploadProgress.value[fileIndex]
  240. console.error('上传失败:', err)
  241. uni.showToast({ title: '网络错误,请重试', icon: 'error' })
  242. emit('fail', err, fileItem)
  243. reject(err)
  244. }
  245. })
  246. // 监听上传进度
  247. task.onProgressUpdate((progress) => {
  248. uploadProgress.value[fileIndex] = progress.progress
  249. emit('progress', progress, fileItem, fileIndex)
  250. })
  251. })
  252. }
  253. // 处理失败事件(来自 uni-file-picker)
  254. const handleFail = (err) => {
  255. emit('fail', err)
  256. }
  257. // 处理错误事件(来自 uni-file-picker)
  258. const handleError = (err) => {
  259. emit('error', err)
  260. }
  261. // 格式化文件大小
  262. const formatFileSize = (bytes) => {
  263. if (!bytes) return ''
  264. const sizes = ['B', 'KB', 'MB', 'GB']
  265. const i = Math.floor(Math.log(bytes) / Math.log(1024))
  266. return (bytes / Math.pow(1024, i)).toFixed(2) + sizes[i]
  267. }
  268. // 暴露方法(可选,与原组件保持一致,未暴露任何方法)
  269. // defineExpose({})
  270. </script>
  271. <style scoped>
  272. .file-picker-wrapper {
  273. width: 100%;
  274. }
  275. .file-list {
  276. margin-bottom: 20rpx;
  277. }
  278. .file-item {
  279. display: flex;
  280. align-items: center;
  281. justify-content: space-between;
  282. background: #f8f8f8;
  283. border-radius: 12rpx;
  284. padding: 16rpx 20rpx;
  285. margin-bottom: 16rpx;
  286. }
  287. .file-info {
  288. display: flex;
  289. align-items: center;
  290. flex: 1;
  291. gap: 20rpx;
  292. }
  293. .file-thumb {
  294. width: 80rpx;
  295. height: 80rpx;
  296. border-radius: 8rpx;
  297. background: #e0e0e0;
  298. }
  299. .file-icon {
  300. width: 80rpx;
  301. height: 80rpx;
  302. background: #e0e0e0;
  303. border-radius: 8rpx;
  304. display: flex;
  305. align-items: center;
  306. justify-content: center;
  307. }
  308. .file-detail {
  309. flex: 1;
  310. overflow: hidden;
  311. }
  312. .file-name {
  313. font-size: 28rpx;
  314. color: #333;
  315. display: block;
  316. white-space: nowrap;
  317. overflow: hidden;
  318. text-overflow: ellipsis;
  319. }
  320. .file-size {
  321. font-size: 24rpx;
  322. color: #999;
  323. margin-top: 8rpx;
  324. display: block;
  325. }
  326. .file-actions {
  327. display: flex;
  328. gap: 16rpx;
  329. }
  330. .action-btn {
  331. padding: 0 20rpx;
  332. height: 56rpx;
  333. line-height: 56rpx;
  334. font-size: 24rpx;
  335. }
  336. .readonly-item {
  337. cursor: pointer;
  338. transition: opacity 0.2s;
  339. }
  340. .readonly-item:active {
  341. opacity: 0.7;
  342. }
  343. .empty-text {
  344. text-align: center;
  345. color: #999;
  346. font-size: 28rpx;
  347. padding: 40rpx 0;
  348. }
  349. .upload-area {
  350. margin-top: 20rpx;
  351. }
  352. .upload-btn {
  353. width: 100%;
  354. background: #007aff;
  355. color: #fff;
  356. border-radius: 8rpx;
  357. }
  358. /* 上传进度条样式 */
  359. .upload-progress {
  360. position: relative;
  361. width: 100%;
  362. height: 8rpx;
  363. background-color: #f0f0f0;
  364. border-radius: 4rpx;
  365. margin-top: 8rpx;
  366. overflow: hidden;
  367. .progress-bar {
  368. position: absolute;
  369. top: 0;
  370. left: 0;
  371. height: 100%;
  372. background-color: #007aff;
  373. border-radius: 4rpx;
  374. transition: width 0.3s ease;
  375. }
  376. .progress-text {
  377. position: absolute;
  378. top: 0;
  379. right: 0;
  380. font-size: 20rpx;
  381. color: #666;
  382. line-height: 8rpx;
  383. }
  384. }
  385. </style>