cwg-file-picker-wrapper.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  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 innerFileList" :key="index" class="file-item readonly-item"
  6. @click="previewFile(file, index)">
  7. <image v-if="isImage(file)" :src="file.url || file.path" mode="aspectFill" class="file-thumb" :style="imgStyle" />
  8. <view v-else class="file-icon" :class="getFileClass(file.name)">
  9. <text class="file-icon-text">{{ getFileIcon(file.name) }}</text>
  10. </view>
  11. <text class="file-name">{{ file.name }}</text>
  12. </view>
  13. <view v-if="!innerFileList?.length" class="empty-text">暂无文件</view>
  14. </view>
  15. <!-- 正常模式:宫格上传(完全对齐官方 upload-image 样式) -->
  16. <view v-else class="uni-file-picker__container">
  17. <view class="file-picker__box" v-for="(item, index) in innerFileList" :key="index" :style="typeof boxStyle === 'object' ? boxStyle : { cssText: boxStyle }">
  18. <view class="file-picker__box-content" :style="borderStyle">
  19. <!-- 图片 -->
  20. <image v-if="isImage(item)" class="file-image" :src="item.url || item.path" mode="aspectFill" :style="imgStyle"
  21. @click.stop="previewFile(item, index)" />
  22. <!-- 视频 → 显示第一帧 + 播放图标 -->
  23. <view v-else-if="isVideo(item)" class="file-cover video-box" @click.stop="previewFile(item, index)">
  24. <image :src="item.url || item.path" class="file-image" mode="aspectFill" :style="imgStyle" />
  25. <view class="video-play-icon">▶</view>
  26. </view>
  27. <!-- 其他文件(PDF/Word/Excel) -->
  28. <view v-else class="file-cover file-box" :class="getFileClass(item.name)"
  29. @click.stop="previewFile(item, index)">
  30. <text class="file-big-icon">{{ getFileIcon(item.name) }}</text>
  31. <text class="file-ext-name">{{ getFileExt(item.name) }}</text>
  32. </view>
  33. <!-- 删除 -->
  34. <view v-if="delIcon" class="icon-del-box" @click.stop="deleteFile(index)">
  35. <view class="icon-del"></view>
  36. <view class="icon-del rotate"></view>
  37. </view>
  38. <!-- 进度 -->
  39. <view v-if="item.status === 'uploading'" class="file-picker__progress">
  40. <progress class="file-picker__progress-item" :percent="item.progress" stroke-width="4"
  41. backgroundColor="#EBEBEB" />
  42. </view>
  43. <!-- 失败重试 -->
  44. <view v-if="item.status === 'error'" class="file-picker__mask" @click.stop="reUploadFile(index)">
  45. 点击重试
  46. </view>
  47. </view>
  48. </view>
  49. <!-- 添加按钮 -->
  50. <view v-if="innerFileList?.length < limit" class="file-picker__box" :style="typeof boxStyle === 'object' ? boxStyle : { cssText: boxStyle }">
  51. <view class="file-picker__box-content is-add" :style="borderStyle" @click="handleChoose">
  52. <cwg-icon name="icon_add" class="upload-icon" :size="24" />
  53. </view>
  54. </view>
  55. </view>
  56. </view>
  57. </template>
  58. <script setup>
  59. import { ref, watch, nextTick,computed } from 'vue'
  60. import config from '@/config'
  61. import { userToken } from '@/composables/config'
  62. // === Vue3 v-model 标准写法 + 多类型兼容 ===
  63. const props = defineProps({
  64. modelValue: {
  65. type: [Array, String, Object],
  66. default: () => []
  67. },
  68. readonly: {
  69. type: Boolean,
  70. default: false
  71. },
  72. disabled: {
  73. type: Boolean,
  74. default: false
  75. },
  76. limit: {
  77. type: Number,
  78. default: 9
  79. },
  80. fileMediatype: {
  81. type: String,
  82. default: 'all'
  83. },
  84. delIcon: {
  85. type: Boolean,
  86. default: true
  87. },
  88. disablePreview: {
  89. type: Boolean,
  90. default: false
  91. },
  92. autoUpload: {
  93. type: Boolean,
  94. default: true
  95. },
  96. action: {
  97. type: String,
  98. default: ''
  99. },
  100. imageWidth: {
  101. type: String,
  102. default: ''
  103. },
  104. imageHeight: {
  105. type: String,
  106. default: ''
  107. },
  108. uploadUrl: {
  109. type: String,
  110. default: '/custom/bank/upload'
  111. },
  112. uploadHeaders: {
  113. type: Object,
  114. default: () => ({})
  115. },
  116. uploadName: {
  117. type: String,
  118. default: 'file'
  119. },
  120. uploadData: {
  121. type: Object,
  122. default: () => ({})
  123. },
  124. responseHandler: {
  125. type: Function,
  126. default: (res) => {
  127. try {
  128. const data = typeof res === 'string' ? JSON.parse(res) : res
  129. return {
  130. success: data.code === 200,
  131. path: data.data?.path || data.data,
  132. message: data.msg || '上传成功'
  133. }
  134. } catch (e) {
  135. return { success: false, message: '解析失败' }
  136. }
  137. }
  138. }
  139. })
  140. const emit = defineEmits([
  141. 'update:modelValue',
  142. 'change',
  143. 'delete',
  144. 'success',
  145. 'fail',
  146. 'progress',
  147. 'select'
  148. ])
  149. // 内部数据
  150. const innerFileList = ref([])
  151. const tempFileQueue = ref([])
  152. const originalType = ref('array') // 'string' | 'object' | 'array'
  153. const borderStyle = 'border:1px #eee solid;border-radius:5px;'
  154. const boxStyle = computed(() => {
  155. if (props.imageWidth || props.imageHeight) {
  156. const width = typeof props.imageWidth === 'number' ? `${props.imageWidth}px` : props.imageWidth || 'auto'
  157. const height = typeof props.imageHeight === 'number' ? `${props.imageHeight}px` : props.imageHeight || 'auto'
  158. return { width, height }
  159. }
  160. return 'width:33.3%;height:0;'
  161. })
  162. // ==============================================
  163. // 统一格式化函数(修复递归核心)
  164. // ==============================================
  165. function formatValue(val) {
  166. if (!val) return []
  167. // 字符串
  168. if (typeof val === 'string') {
  169. return [{
  170. path: val,
  171. url: val.startsWith('http') ? val : config.Host80 + val,
  172. name: val.split('/').pop(),
  173. status: 'success'
  174. }]
  175. }
  176. // 对象
  177. if (typeof val === 'object' && !Array.isArray(val)) {
  178. const path = val.path || val.url || ''
  179. return [{
  180. ...val,
  181. path,
  182. url: val.url || (path.startsWith('http') ? path : config.Host80 + path),
  183. name: val.name || path.split('/').pop(),
  184. status: 'success'
  185. }]
  186. }
  187. // 数组
  188. if (Array.isArray(val)) {
  189. return val.map(item => {
  190. if (typeof item === 'string') {
  191. return {
  192. path: item,
  193. url: item.startsWith('http') ? item : config.Host80 + item,
  194. name: item.split('/').pop(),
  195. status: 'success'
  196. }
  197. } else {
  198. const path = item?.path || item?.url || ''
  199. return {
  200. ...item,
  201. path,
  202. url: item?.url || (path.startsWith('http') ? path : config.Host80 + path),
  203. name: item?.name || path.split('/').pop(),
  204. status: 'success'
  205. }
  206. }
  207. })
  208. }
  209. return []
  210. }
  211. // ==============================================
  212. // 监听外部传入:防重复赋值 → 无递归
  213. // ==============================================
  214. watch(
  215. () => props.modelValue,
  216. (val) => {
  217. const formatted = formatValue(val)
  218. // 值相同不更新,防止死循环
  219. if (JSON.stringify(innerFileList.value) === JSON.stringify(formatted)) return
  220. if (!val) {
  221. innerFileList.value = []
  222. originalType.value = 'array'
  223. return
  224. }
  225. // 记录原始类型
  226. if (typeof val === 'string') originalType.value = 'string'
  227. else if (typeof val === 'object' && !Array.isArray(val)) originalType.value = 'object'
  228. else originalType.value = 'array'
  229. innerFileList.value = formatted
  230. },
  231. { immediate: true, deep: true }
  232. )
  233. // ==============================================
  234. // 内部变化同步外部:nextTick → 无递归
  235. // ==============================================
  236. watch(
  237. innerFileList,
  238. (list) => {
  239. nextTick(() => {
  240. let returnValue = []
  241. if (!list || list.length === 0) {
  242. returnValue = originalType.value === 'string' ? '' :
  243. originalType.value === 'object' ? {} : []
  244. emitUpdate(returnValue)
  245. return
  246. }
  247. // 只返回干净的 path 数据
  248. const cleanList = list.map(item => item.path || item.url || '')
  249. console.log(originalType);
  250. // 按原始类型返回
  251. if (props.limit === 1) {
  252. returnValue = cleanList[0] || ''
  253. } else if (originalType.value === 'string') returnValue = cleanList[0] || ''
  254. else if (originalType.value === 'object') returnValue = { path: cleanList[0] || '' }
  255. else returnValue = cleanList
  256. emitUpdate(returnValue)
  257. })
  258. },
  259. { deep: true }
  260. )
  261. // 统一触发更新
  262. function emitUpdate(val) {
  263. emit('update:modelValue', val)
  264. emit('change', val)
  265. }
  266. // ==============================================
  267. // 文件类型判断
  268. // ==============================================
  269. const isImage = (file) => {
  270. const ext = (file.path || file.name || '').split('.').pop()?.toLowerCase()
  271. return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext)
  272. }
  273. const isVideo = (file) => {
  274. const ext = (file.path || file.name || '').split('.').pop()?.toLowerCase()
  275. return ['mp4', 'mov', 'avi', 'flv', 'wmv', 'rmvb'].includes(ext)
  276. }
  277. const getFileExt = (name) => {
  278. if (!name) return ''
  279. return name.split('.').pop()?.toUpperCase()
  280. }
  281. const getFileIcon = (name) => {
  282. const ext = getFileExt(name)
  283. if (ext === 'PDF') return 'PDF'
  284. if (['DOC', 'DOCX'].includes(ext)) return 'WORD'
  285. if (['XLS', 'XLSX'].includes(ext)) return 'EXCEL'
  286. if (isVideo({ name })) return '▶'
  287. if (isImage({ name })) return 'IMG'
  288. return 'FILE'
  289. }
  290. const getFileClass = (name) => {
  291. const ext = getFileExt(name)
  292. if (ext === 'PDF') return 'file-pdf'
  293. if (['DOC', 'DOCX'].includes(ext)) return 'file-word'
  294. if (['XLS', 'XLSX'].includes(ext)) return 'file-excel'
  295. if (isVideo({ name })) return 'file-video'
  296. return 'file-other'
  297. }
  298. // ==============================================
  299. // 预览
  300. // ==============================================
  301. const previewFile = (file, index) => {
  302. if (props.disablePreview || file.status === 'uploading' || file.status === 'error') return
  303. const url = file.url || file.path
  304. const name = file.name || '文件'
  305. if (isImage(file)) {
  306. const successImages = innerFileList.value.filter(item => isImage(item) && item.status === 'success')
  307. const realIndex = successImages.findIndex(item => item.url === file.url && item.path === file.path)
  308. const urls = successImages.map(item => item.url || item.path)
  309. uni.previewImage({ current: realIndex, urls, loop: true })
  310. return
  311. }
  312. uni.navigateTo({
  313. url: `/pages/common/webview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(name)}`
  314. })
  315. }
  316. // ==============================================
  317. // 上传 / 删除 / 重试
  318. // ==============================================
  319. const deleteFile = (index) => {
  320. const item = innerFileList.value[index]
  321. innerFileList.value.splice(index, 1)
  322. emit('delete', item, index)
  323. }
  324. const handleChoose = () => {
  325. if (props.disabled || props.readonly) return
  326. const count = props.limit - innerFileList.value.length
  327. uni.chooseFile({
  328. type: props.fileMediatype,
  329. count,
  330. success: (res) => {
  331. const files = res.tempFiles || res.tempFilePaths.map((p, i) => ({
  332. path: p, name: `file_${i}`
  333. }))
  334. emit('select', { tempFiles: files })
  335. tempFileQueue.value = files
  336. if (props.autoUpload) startUpload()
  337. }
  338. })
  339. }
  340. const startUpload = async () => {
  341. const files = tempFileQueue.value
  342. tempFileQueue.value = []
  343. for (const file of files) await uploadFile(file)
  344. }
  345. const uploadFile = (fileItem) => {
  346. return new Promise((resolve) => {
  347. innerFileList.value.push({ ...fileItem, status: 'uploading', progress: 0 })
  348. const index = innerFileList.value.length - 1
  349. const url = props.action || config.Host80 + props.uploadUrl
  350. const task = uni.uploadFile({
  351. url,
  352. filePath: fileItem.path,
  353. name: props.uploadName,
  354. header: { 'Access-Token': userToken.value, ...props.uploadHeaders },
  355. formData: props.uploadData,
  356. success: (res) => {
  357. const result = props.responseHandler(res.data)
  358. if (result.success) {
  359. innerFileList.value[index].progress = 100
  360. innerFileList.value[index].status = 'success'
  361. innerFileList.value[index].url = config.Host80 + result.path
  362. innerFileList.value[index].path = result.path
  363. emit('success', innerFileList.value[index])
  364. } else {
  365. innerFileList.value[index].status = 'error'
  366. uni.showToast({ title: result.message, icon: 'error' })
  367. emit('fail', result.message)
  368. }
  369. resolve(result)
  370. },
  371. fail: () => {
  372. innerFileList.value[index].status = 'error'
  373. uni.showToast({ title: '上传失败', icon: 'error' })
  374. emit('fail', '网络异常')
  375. resolve(null)
  376. }
  377. })
  378. task.onProgressUpdate((p) => {
  379. innerFileList.value[index].progress = p.progress
  380. emit('progress', p, index)
  381. })
  382. })
  383. }
  384. const reUploadFile = (index) => {
  385. const file = innerFileList.value[index]
  386. uploadFile(file)
  387. }
  388. const imgStyle = computed(() => {
  389. let style = {}
  390. if (props.imageWidth) {
  391. style.width = typeof props.imageWidth === 'number' ? `${props.imageWidth}px` : `${props.imageWidth}`
  392. }
  393. if (props.imageHeight) {
  394. style.height = typeof props.imageHeight === 'number' ? `${props.imageHeight}px` : `${props.imageHeight}`
  395. }
  396. console.log(style)
  397. return style
  398. })
  399. </script>
  400. <style scoped>
  401. /* 布局 */
  402. .uni-file-picker__container {
  403. display: flex;
  404. flex-wrap: wrap;
  405. margin: -5px;
  406. width: 100%;
  407. }
  408. .file-picker__box {
  409. position: relative;
  410. width: 33.3%;
  411. height: 0;
  412. //padding-top: 33.3%;
  413. box-sizing: border-box;
  414. }
  415. .file-picker__box-content {
  416. position: absolute;
  417. top: 0;
  418. right: 0;
  419. bottom: 0;
  420. left: 0;
  421. //margin: 5px;
  422. border-radius: 5px;
  423. overflow: hidden;
  424. background: #f7f7f7;
  425. }
  426. /* 图片 */
  427. .file-image {
  428. width: 100%;
  429. height: 100%;
  430. }
  431. /* 文件封面 */
  432. .file-cover {
  433. width: 100%;
  434. height: 100%;
  435. display: flex;
  436. align-items: center;
  437. justify-content: center;
  438. flex-direction: column;
  439. color: var(--bs-emphasis-color);
  440. }
  441. /* 视频 */
  442. .video-box {
  443. position: relative;
  444. }
  445. .video-play-icon {
  446. position: absolute;
  447. font-size: 30px;
  448. color: var(--bs-emphasis-color);
  449. background: rgba(0, 0, 0, 0.5);
  450. width: 50px;
  451. height: 50px;
  452. border-radius: 50%;
  453. display: flex;
  454. align-items: center;
  455. justify-content: center;
  456. }
  457. /* 文件类型颜色 */
  458. .file-pdf {
  459. background: #ff4f4f;
  460. }
  461. .file-word {
  462. background: #3a7ff5;
  463. }
  464. .file-excel {
  465. background: #24a148;
  466. }
  467. .file-video {
  468. background: #000;
  469. }
  470. .file-other {
  471. background: #f7f7f7;
  472. }
  473. .file-big-icon {
  474. font-size: 16px;
  475. font-weight: bold;
  476. margin-bottom: 4px;
  477. }
  478. .file-ext-name {
  479. font-size: 12px;
  480. opacity: 0.9;
  481. }
  482. /* 添加按钮 */
  483. .is-add {
  484. display: flex;
  485. align-items: center;
  486. justify-content: center;
  487. background: #f7f7f7;
  488. }
  489. .upload-icon {
  490. color: var(--bs-heading-color);
  491. }
  492. /* 删除按钮 */
  493. .icon-del-box {
  494. position: absolute;
  495. top: 3px;
  496. right: 3px;
  497. width: 26px;
  498. height: 26px;
  499. border-radius: 50%;
  500. background: rgba(0, 0, 0, 0.5);
  501. display: flex;
  502. align-items: center;
  503. justify-content: center;
  504. z-index: 10;
  505. transform: rotate(-45deg);
  506. }
  507. .icon-del {
  508. width: 15px;
  509. height: 2px;
  510. background: #fff;
  511. border-radius: 2px;
  512. }
  513. .rotate {
  514. position: absolute;
  515. transform: rotate(90deg);
  516. }
  517. /* 进度 */
  518. .file-picker__progress {
  519. position: absolute;
  520. bottom: 0;
  521. left: 0;
  522. right: 0;
  523. z-index: 2;
  524. }
  525. /* 失败遮罩 */
  526. .file-picker__mask {
  527. position: absolute;
  528. top: 0;
  529. left: 0;
  530. right: 0;
  531. bottom: 0;
  532. background: rgba(0, 0, 0, 0.4);
  533. color: var(--bs-emphasis-color);
  534. display: flex;
  535. align-items: center;
  536. justify-content: center;
  537. font-size: 12px;
  538. }
  539. /* 只读模式 */
  540. .file-list {
  541. padding: 5px;
  542. }
  543. .file-item {
  544. display: flex;
  545. align-items: center;
  546. gap: 10px;
  547. padding: 10px;
  548. background: #f8f8f8;
  549. border-radius: 8px;
  550. margin-bottom: 10px;
  551. }
  552. .file-thumb,
  553. .file-icon {
  554. width: 60px;
  555. height: 60px;
  556. border-radius: 6px;
  557. }
  558. .file-icon {
  559. background: #eee;
  560. display: flex;
  561. align-items: center;
  562. justify-content: center;
  563. }
  564. .file-icon-text {
  565. font-size: 12px;
  566. font-weight: bold;
  567. }
  568. .file-name {
  569. flex: 1;
  570. font-size: 14px;
  571. color: var(--bs-heading-color);
  572. }
  573. .empty-text {
  574. text-align: center;
  575. color: var(--bs-heading-color);
  576. padding: 20px 0;
  577. }
  578. </style>