upload.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { BASE_URL } from '../config/api.js'
  2. export const ACCEPT =
  3. 'image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
  4. export const ACCEPT_EXTENSIONS = [
  5. '.jpg',
  6. '.jpeg',
  7. '.png',
  8. '.gif',
  9. '.webp',
  10. '.bmp',
  11. '.mp4',
  12. '.mov',
  13. '.avi',
  14. '.webm',
  15. '.pdf',
  16. '.doc',
  17. '.docx',
  18. '.xls',
  19. '.xlsx',
  20. '.zip'
  21. ]
  22. const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']
  23. let _uploadSeq = 0
  24. function nextUploadId() {
  25. _uploadSeq += 1
  26. return `up_${Date.now()}_${_uploadSeq}`
  27. }
  28. export function isImageUrl(urlOrName = '') {
  29. const name = String(urlOrName).split('?')[0].toLowerCase()
  30. return IMAGE_EXTS.some((ext) => name.endsWith(ext))
  31. }
  32. export function fileNameFromUrl(url = '') {
  33. try {
  34. const path = String(url).split('?')[0]
  35. const seg = path.substring(path.lastIndexOf('/') + 1)
  36. return decodeURIComponent(seg || '附件')
  37. } catch (e) {
  38. return '附件'
  39. }
  40. }
  41. export function revokePreview(item) {
  42. const p = item && item.previewUrl
  43. if (p && String(p).startsWith('blob:')) {
  44. try {
  45. URL.revokeObjectURL(p)
  46. } catch (e) {}
  47. }
  48. }
  49. function parseUploadBody(raw) {
  50. const body = typeof raw === 'string' ? JSON.parse(raw) : raw
  51. if (body && body.code === 200) return body.data
  52. const err = body || { message: '上传失败' }
  53. uni.showToast({ title: err.message || '上传失败', icon: 'none' })
  54. throw err
  55. }
  56. export function uploadFile(filePath, folder = 'feedback', fileName, onProgress) {
  57. return new Promise((resolve, reject) => {
  58. const formData = { folder }
  59. if (fileName) formData.fileName = fileName
  60. const task = uni.uploadFile({
  61. url: `${BASE_URL}/public/attachment/upload`,
  62. filePath,
  63. name: 'file',
  64. formData,
  65. success: (res) => {
  66. try {
  67. resolve(parseUploadBody(res.data))
  68. } catch (e) {
  69. reject(e)
  70. }
  71. },
  72. fail: reject
  73. })
  74. if (task && typeof task.onProgressUpdate === 'function' && onProgress) {
  75. task.onProgressUpdate((res) => {
  76. const p = typeof res.progress === 'number' ? res.progress : 0
  77. onProgress(Math.max(0, Math.min(100, Math.round(p))))
  78. })
  79. }
  80. })
  81. }
  82. function pickFilesViaInput() {
  83. return new Promise((resolve, reject) => {
  84. // #ifdef H5
  85. const input = document.createElement('input')
  86. input.type = 'file'
  87. input.multiple = true
  88. input.accept = ACCEPT
  89. input.style.display = 'none'
  90. const cleanup = () => {
  91. if (input.parentNode) input.parentNode.removeChild(input)
  92. }
  93. input.onchange = () => {
  94. const files = Array.from(input.files || [])
  95. cleanup()
  96. resolve(files)
  97. }
  98. input.oncancel = () => {
  99. cleanup()
  100. reject(new Error('cancel'))
  101. }
  102. document.body.appendChild(input)
  103. input.click()
  104. // #endif
  105. // #ifndef H5
  106. reject(new Error('unsupported'))
  107. // #endif
  108. })
  109. }
  110. function uploadBlobAsFile(file, folder, onProgress) {
  111. return new Promise((resolve, reject) => {
  112. // #ifdef H5
  113. const url = `${BASE_URL}/public/attachment/upload`
  114. const fd = new FormData()
  115. fd.append('file', file, file.name)
  116. fd.append('folder', folder)
  117. const xhr = new XMLHttpRequest()
  118. xhr.open('POST', url)
  119. if (xhr.upload && onProgress) {
  120. xhr.upload.onprogress = (e) => {
  121. if (!e.lengthComputable) return
  122. onProgress(Math.max(0, Math.min(100, Math.round((e.loaded / e.total) * 100))))
  123. }
  124. }
  125. xhr.onload = () => {
  126. try {
  127. resolve(parseUploadBody(xhr.responseText))
  128. } catch (e) {
  129. reject(e)
  130. }
  131. }
  132. xhr.onerror = () => reject(new Error('network'))
  133. xhr.send(fd)
  134. // #endif
  135. // #ifndef H5
  136. reject(new Error('unsupported'))
  137. // #endif
  138. })
  139. }
  140. /**
  141. * 选择并上传多种类型附件,不限制个数。
  142. * @param {string} folder
  143. * @param {{
  144. * onItemAdd?: (item: object) => void,
  145. * onProgress?: (item: object, progress: number) => void,
  146. * onItemSuccess?: (item: object, data: object) => void,
  147. * onItemError?: (item: object, err: any) => void
  148. * }} handlers
  149. * @returns {Promise<Array<{previewUrl:string,url:string,fileName?:string,size?:number}>>}
  150. */
  151. export function chooseAndUploadFiles(folder = 'feedback', handlers = {}) {
  152. const { onItemAdd, onProgress, onItemSuccess, onItemError } = handlers || {}
  153. const trackUpload = async (draft, uploadFn) => {
  154. onItemAdd && onItemAdd(draft)
  155. try {
  156. const data = await uploadFn((p) => {
  157. draft.progress = p
  158. onProgress && onProgress(draft, p)
  159. })
  160. draft.uploading = false
  161. draft.progress = 100
  162. draft.url = data?.url || ''
  163. if (data?.fileName) draft.fileName = data.fileName
  164. if (data?.size != null) draft.size = data.size
  165. onItemSuccess && onItemSuccess(draft, data)
  166. return draft
  167. } catch (err) {
  168. draft.uploading = false
  169. onItemError && onItemError(draft, err)
  170. throw err
  171. }
  172. }
  173. return new Promise(async (resolve, reject) => {
  174. try {
  175. // 优先 H5 原生多选(支持任意类型、不限个数)
  176. // #ifdef H5
  177. try {
  178. const files = await pickFilesViaInput()
  179. if (!files.length) {
  180. resolve([])
  181. return
  182. }
  183. const items = []
  184. for (const file of files) {
  185. const previewUrl = URL.createObjectURL(file)
  186. const draft = {
  187. id: nextUploadId(),
  188. previewUrl,
  189. url: '',
  190. fileName: file.name,
  191. size: file.size,
  192. uploading: true,
  193. progress: 0
  194. }
  195. try {
  196. const done = await trackUpload(draft, (onP) =>
  197. uploadBlobAsFile(file, folder, onP)
  198. )
  199. if (done?.url) items.push(done)
  200. else URL.revokeObjectURL(previewUrl)
  201. } catch (err) {
  202. URL.revokeObjectURL(previewUrl)
  203. // 单项失败已 toast;继续后续文件
  204. }
  205. }
  206. resolve(items)
  207. return
  208. } catch (e) {
  209. if (e && e.message === 'cancel') {
  210. resolve([])
  211. return
  212. }
  213. }
  214. // #endif
  215. // App / 部分端:uni.chooseFile
  216. if (typeof uni.chooseFile === 'function') {
  217. uni.chooseFile({
  218. count: 99,
  219. type: 'all',
  220. extension: ACCEPT_EXTENSIONS.map((e) => e.replace(/^\./, '')),
  221. success: async (res) => {
  222. try {
  223. const items = []
  224. const paths = res.tempFilePaths || []
  225. const tempFiles = res.tempFiles || []
  226. for (let i = 0; i < paths.length; i++) {
  227. const path = paths[i]
  228. const name = tempFiles[i]?.name
  229. const draft = {
  230. id: nextUploadId(),
  231. previewUrl: path,
  232. url: '',
  233. fileName: name || fileNameFromUrl(path),
  234. size: tempFiles[i]?.size,
  235. uploading: true,
  236. progress: 0
  237. }
  238. try {
  239. const done = await trackUpload(draft, (onP) =>
  240. uploadFile(path, folder, name, onP)
  241. )
  242. if (done?.url) items.push(done)
  243. } catch (err) {
  244. // 单项失败已 toast
  245. }
  246. }
  247. resolve(items)
  248. } catch (err) {
  249. reject(err)
  250. }
  251. },
  252. fail: (err) => {
  253. if (err && (err.errMsg || '').includes('cancel')) {
  254. resolve([])
  255. return
  256. }
  257. reject(err)
  258. }
  259. })
  260. return
  261. }
  262. // 兜底:仅图片
  263. uni.chooseImage({
  264. count: 9,
  265. success: async (res) => {
  266. try {
  267. const items = []
  268. for (const path of res.tempFilePaths || []) {
  269. const draft = {
  270. id: nextUploadId(),
  271. previewUrl: path,
  272. url: '',
  273. fileName: fileNameFromUrl(path),
  274. uploading: true,
  275. progress: 0
  276. }
  277. try {
  278. const done = await trackUpload(draft, (onP) =>
  279. uploadFile(path, folder, undefined, onP)
  280. )
  281. if (done?.url) items.push(done)
  282. } catch (err) {
  283. // 单项失败已 toast
  284. }
  285. }
  286. resolve(items)
  287. } catch (err) {
  288. reject(err)
  289. }
  290. },
  291. fail: reject
  292. })
  293. } catch (e) {
  294. reject(e)
  295. }
  296. })
  297. }
  298. /** @deprecated 使用 chooseAndUploadFiles */
  299. export function chooseAndUploadImages(count = 9, folder = 'feedback') {
  300. return chooseAndUploadFiles(folder).then((items) => items.map((i) => i.url))
  301. }