UploadModal.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. <template>
  2. <!-- 上传组件 -->
  3. <uploader
  4. class="uploader-app"
  5. ref="uploaderRef"
  6. :options="options"
  7. :autoStart="false"
  8. :fileStatusText="fileStatusText"
  9. @files-added="handleFilesAdded"
  10. @file-success="handleFileSuccess"
  11. @file-error="handleFileError"
  12. @dragleave="hideUploadMask"
  13. @file-progress="handleFileProgress"
  14. >
  15. <uploader-unsupport></uploader-unsupport>
  16. <!-- 选择按钮 在这里隐藏 -->
  17. <uploader-btn class="select-file-btn" :attrs="attrs" ref="uploadBtn"> 选择文件 </uploader-btn>
  18. <uploader-btn class="select-file-btn" :attrs="attrs" :directory="true" ref="uploadDirBtn"> 选择目录 </uploader-btn>
  19. <!-- 拖拽上传 -->
  20. <uploader-drop class="drop-box" id="dropBox" @paste="handlePaste" @click="handleUpload">
  21. <span class="text"> 点击上传或将文件拖拽至此区域上传 </span>
  22. <p class="text">按住Ctrl可同时多选,支持上传PPT/excel/pdf/mp4/zip/rar,等单个文件不能超过2G</p>
  23. <UploadOutlined class="upload-icon" v-show="pasteImg.src" @click="handleUploadPasteImg" />
  24. </uploader-drop>
  25. <!-- 上传列表 -->
  26. <uploader-list v-show="true">
  27. <template #default="props">
  28. <div class="file-panel">
  29. <div class="file-title">
  30. <span class="title-span">
  31. 上传列表 <span class="count">({{ props.fileList.length }})</span>
  32. </span>
  33. </div>
  34. <transition name="collapse">
  35. <ul class="file-list" v-show="!collapse">
  36. <li
  37. v-for="file in props.fileList"
  38. :key="file.id"
  39. class="file-item"
  40. :class="{ 'custom-status-item': file.statusStr !== '' }"
  41. >
  42. <uploader-file ref="fileItem" :file="file" :list="true" />
  43. <span class="custom-status">{{ file.statusStr }}</span>
  44. <!-- 添加剩余时间显示 -->
  45. <!-- <span class="remaining-time" v-if="file.remainingTime"> 剩余: {{ file.remainingTime }} </span> -->
  46. </li>
  47. <div class="no-file" v-if="!props.fileList.length"><FileExclamationOutlined /> 暂无待上传文件</div>
  48. </ul>
  49. </transition>
  50. </div>
  51. </template>
  52. </uploader-list>
  53. </uploader>
  54. </template>
  55. <script setup>
  56. import { ref, reactive, computed, nextTick, getCurrentInstance } from 'vue'
  57. import { message } from 'ant-design-vue'
  58. import { useMyResourceStore } from '@/store/myResource'
  59. import SparkMD5 from 'spark-md5'
  60. import tool from '@/utils/tool'
  61. const props = defineProps({
  62. visible: {
  63. type: Boolean,
  64. default: true
  65. }
  66. })
  67. const emit = defineEmits(['update:visible', 'success'])
  68. const { proxy } = getCurrentInstance()
  69. const store = useMyResourceStore()
  70. // refs
  71. const formRef = ref(null)
  72. const uploaderRef = ref(null)
  73. const uploadBtn = ref(null)
  74. const uploadDirBtn = ref(null)
  75. const fileItem = ref(null)
  76. // 表单数据
  77. const formState = reactive({
  78. title: '',
  79. description: '',
  80. category: undefined,
  81. fileIds: []
  82. })
  83. const rules = {
  84. title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
  85. category: [{ required: true, message: '请选择分类', trigger: 'change' }]
  86. }
  87. // 上传相关数据
  88. const options = ref({
  89. target: `${proxy.$RESOURCE_CONFIG.baseContext}/resourceFile/uploadfile`,
  90. chunkSize: 1024 * 1024,
  91. fileParameterName: 'file',
  92. maxChunkRetries: 3,
  93. simultaneousUploads: 5, // 控制并发上传数
  94. testChunks: true,
  95. checkChunkUploadedByResponse: (chunk, message) => {
  96. let objMessage = JSON.parse(message)
  97. if (objMessage.success) {
  98. let data = objMessage.data
  99. if (data.skipUpload) {
  100. return true
  101. }
  102. return (data.uploaded || []).indexOf(chunk.offset + 1) >= 0
  103. } else {
  104. return true
  105. }
  106. },
  107. headers: {
  108. token: tool.data.get('TOKEN')
  109. },
  110. query: () => {}
  111. })
  112. const fileStatusText = ref({
  113. success: '上传成功',
  114. error: 'error',
  115. uploading: '上传中',
  116. paused: '暂停中',
  117. waiting: '等待中'
  118. })
  119. const attrs = ref({
  120. accept: '.doc,.docx,.ppt,.pptx,.xls,.xlsx,.mkv,.mp4,.wmv,.avi,.flv,.mpeg,.mpg,.rmvb,.mov'
  121. // accept: '*'
  122. })
  123. const dropBoxShow = ref(false)
  124. const pasteImg = ref({
  125. src: '',
  126. name: ''
  127. })
  128. const pasteImgObj = ref(null)
  129. const filesLength = ref(0)
  130. const uploadStatus = ref({})
  131. const submitting = ref(false)
  132. const canClose = ref(true)
  133. // 计算属性
  134. const uploaderInstance = computed(() => {
  135. return uploaderRef.value?.uploader
  136. })
  137. const remainderStorageValue = computed(() => {
  138. return store.remainderStorageValue
  139. })
  140. // 方法
  141. const hideUploadMask = (e) => {
  142. e.stopPropagation()
  143. e.preventDefault()
  144. dropBoxShow.value = false
  145. }
  146. const handleUpload = () => {
  147. if (uploadBtn.value?.$el) {
  148. uploadBtn.value.$el.click()
  149. }
  150. }
  151. const handleUploadDir = () => {
  152. if (uploadDirBtn.value?.$el) {
  153. uploadDirBtn.value.$el.click()
  154. }
  155. }
  156. const handlePasteUpload = () => {
  157. pasteImg.value.src = ''
  158. pasteImg.value.name = ''
  159. pasteImgObj.value = null
  160. dropBoxShow.value = true
  161. }
  162. const handlePaste = (event) => {
  163. let pasteItems = (event.clipboardData || window.clipboardData).items
  164. if (pasteItems && pasteItems.length) {
  165. let imgObj = pasteItems[0].getAsFile()
  166. pasteImgObj.value =
  167. imgObj !== null
  168. ? new File([imgObj], `qiwenshare_${new Date().valueOf()}.${imgObj.name.split('.')[1]}`, { type: imgObj.type })
  169. : null
  170. } else {
  171. message.error('当前浏览器不支持')
  172. return false
  173. }
  174. if (!pasteImgObj.value) {
  175. message.error('粘贴内容非图片')
  176. return false
  177. }
  178. pasteImg.value.name = pasteImgObj.value.name
  179. let reader = new FileReader()
  180. reader.onload = (event) => {
  181. pasteImg.value.src = event.target.result
  182. }
  183. reader.readAsDataURL(pasteImgObj.value)
  184. }
  185. const handleUploadPasteImg = () => {
  186. uploaderInstance.value.addFile(pasteImgObj.value)
  187. }
  188. const handleDeletePasteImg = () => {
  189. pasteImg.value.src = ''
  190. pasteImg.value.name = ''
  191. pasteImgObj.value = null
  192. }
  193. const handleFilesAdded = (filesSource) => {
  194. console.log('handleFilesAdded', filesSource)
  195. const filesTotalSize = filesSource
  196. .map((item) => {
  197. console.log(item, 'itemitemitemitem')
  198. // 为每个文件添加上传速度跟踪属性
  199. item.speed = 0
  200. item.lastLoaded = item.loaded || 0
  201. item.lastTime = Date.now()
  202. item.remainingTime = '计算中...'
  203. return item
  204. })
  205. .reduce((pre, next) => {
  206. return pre + next.size
  207. }, 0)
  208. console.log('handleFilesAdded', filesSource)
  209. if (remainderStorageValue.value < filesTotalSize) {
  210. // 批量选择的文件超出剩余存储空间
  211. message.warning(`剩余存储空间不足,请重新选择${filesSource.length > 1 ? '批量' : ''}文件`)
  212. filesSource.ignored = true // 本次选择的文件过滤掉
  213. } else {
  214. filesLength.value += filesSource.length
  215. // 手动控制上传队列,每次只处理5个文件
  216. const batchSize = 5
  217. for (let i = 0; i < filesSource.length; i += batchSize) {
  218. const batch = filesSource.slice(i, i + batchSize)
  219. batch.forEach((file) => {
  220. computeMD5(file)
  221. })
  222. }
  223. }
  224. }
  225. const handleFileProgress = (file) => {
  226. const now = Date.now()
  227. const duration = (now - file.lastTime) / 1000 // 秒
  228. const loadedDiff = file.lastTime - file.lastLoaded
  229. console.log('duration', now, file.lastTime, file.lastLoaded, duration, loadedDiff)
  230. if (duration > 0) {
  231. console.log('进来了', file)
  232. // 计算当前上传速度 (bytes/sec)
  233. file.speed = loadedDiff / duration
  234. // 计算剩余时间
  235. if (file.speed > 0) {
  236. const remainingBytes = file.size - file.loaded
  237. const remainingSeconds = remainingBytes / file.speed
  238. console.log('进来了计算剩余时间', remainingBytes, remainingSeconds)
  239. // 格式化剩余时间显示
  240. if (remainingSeconds < 60) {
  241. console.log('进来了', 60)
  242. file.remainingTime = `${Math.round(remainingSeconds)}秒`
  243. } else if (remainingSeconds < 3600) {
  244. console.log('进来了', 600)
  245. file.remainingTime = `${Math.round(remainingSeconds / 60)}分钟`
  246. } else {
  247. console.log('进来了', 3600)
  248. file.remainingTime = `${Math.round(remainingSeconds / 3600)}小时`
  249. }
  250. }
  251. }
  252. // 更新最后记录的时间和已上传量
  253. file.lastLoaded = file.loaded
  254. file.lastTime = now
  255. }
  256. // const handleFileProgress = (file) => {
  257. // console.log(file, '进来了filefilefile')
  258. // const now = Date.now()
  259. // // 确保文件对象有必要的属性
  260. // if (!file.lastTime) file.lastTime = now
  261. // if (file.lastLoaded === undefined) file.lastLoaded = 0
  262. // const duration = (now - file.lastTime) / 1000 // 转换为秒
  263. // console.log(duration, '进来了duration')
  264. // // 只有当时间间隔足够大时才计算速度(至少0.1秒)
  265. // if (duration > 0.1) {
  266. // console.log(duration, '进来了duration')
  267. // const loadedDiff = file.loaded - file.lastLoaded
  268. // // 只有当有新的数据上传时才计算
  269. // if (loadedDiff > 0) {
  270. // console.log(loadedDiff, '进来了loadedDiff')
  271. // file.speed = loadedDiff / duration
  272. // // 计算剩余时间(确保speed是有效数字)
  273. // if (file.speed > 0 && !isNaN(file.speed)) {
  274. // const remainingBytes = file.size - file.loaded
  275. // const remainingSeconds = remainingBytes / file.speed
  276. // // 格式化剩余时间
  277. // if (remainingSeconds < 60) {
  278. // file.remainingTime = `${Math.round(remainingSeconds)}秒`
  279. // } else if (remainingSeconds < 3600) {
  280. // const mins = Math.floor(remainingSeconds / 60)
  281. // const secs = Math.round(remainingSeconds % 60)
  282. // file.remainingTime = `${mins}分${secs}秒`
  283. // } else {
  284. // const hours = Math.floor(remainingSeconds / 3600)
  285. // const mins = Math.round((remainingSeconds % 3600) / 60)
  286. // file.remainingTime = `${hours}小时${mins}分`
  287. // }
  288. // } else {
  289. // file.remainingTime = '计算中...'
  290. // }
  291. // // 更新最后记录的时间和已上传量
  292. // file.lastLoaded = file.loaded
  293. // file.lastTime = now
  294. // }
  295. // }
  296. // }
  297. const handleFileSuccess = (rootFile, file, response) => {
  298. if (response === '') {
  299. uploadStatus.value[file.id] = '上传失败'
  300. return
  301. }
  302. const result = JSON.parse(response)
  303. if (result.success) {
  304. file.statusStr = ''
  305. // 将上传成功的文件ID添加到表单数据中
  306. formState.fileIds.push(result.data.userFileId)
  307. emit('success', formState.fileIds)
  308. } else {
  309. message.error(result.msg)
  310. uploadStatus.value[file.id] = '上传失败'
  311. }
  312. filesLength.value--
  313. // 所有文件上传完成后,允许关闭弹窗
  314. canClose.value = filesLength.value === 0
  315. // 当一个文件完成时,检查是否有待上传文件
  316. if (uploaderInstance.value.files.some((f) => !f.isComplete)) {
  317. uploaderInstance.value.upload()
  318. }
  319. console.log(formState.fileIds, response, result, 'formState.fileIdsformState.fileIdsformState.fileIds')
  320. }
  321. const handleFileError = (rootFile, file, response) => {
  322. message.error(response)
  323. filesLength.value--
  324. canClose.value = filesLength.value === 0
  325. }
  326. const computeMD5 = (file) => {
  327. let fileReader = new FileReader()
  328. let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
  329. let currentChunk = 0
  330. const chunkSize = 1024 * 1024
  331. let chunks = Math.ceil(file.size / chunkSize)
  332. let spark = new SparkMD5.ArrayBuffer()
  333. file.statusStr = '计算MD5'
  334. file.pause()
  335. const loadNext = () => {
  336. let start = currentChunk * chunkSize
  337. let end = start + chunkSize >= file.size ? file.size : start + chunkSize
  338. fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
  339. }
  340. fileReader.onload = (e) => {
  341. spark.append(e.target.result)
  342. if (currentChunk < chunks) {
  343. currentChunk++
  344. loadNext()
  345. file.statusStr = `校验MD5 ${((currentChunk / chunks) * 100).toFixed(0)}%`
  346. } else {
  347. let md5 = spark.end()
  348. calculateFileMD5End(md5, file)
  349. }
  350. }
  351. fileReader.onerror = () => {
  352. message.error({
  353. content: `文件${file.name}读取出错,请检查该文件`,
  354. duration: 2
  355. })
  356. file.cancel()
  357. filesLength.value--
  358. canClose.value = filesLength.value === 0
  359. }
  360. loadNext()
  361. }
  362. const uploadFileParams = computed(() => {
  363. return {
  364. filePath: '/',
  365. isDir: 0,
  366. funcType: 0
  367. }
  368. })
  369. const calculateFileMD5End = (md5, file) => {
  370. Object.assign(uploaderInstance.value.opts, {
  371. query: uploadFileParams.value
  372. })
  373. file.uniqueIdentifier = md5
  374. file.resume()
  375. file.statusStr = ''
  376. // 文件开始上传时,禁止关闭弹窗
  377. // canClose.value = false
  378. }
  379. const handleCancel = () => {
  380. if (!canClose.value) {
  381. message.warning('文件正在上传中,请等待上传完成后再关闭')
  382. return
  383. }
  384. // 重置表单和上传状态
  385. formRef.value?.resetFields()
  386. formState.fileIds = []
  387. if (uploaderInstance.value) {
  388. uploaderInstance.value.cancel()
  389. }
  390. emit('update:visible', false)
  391. }
  392. const handleSubmit = () => {
  393. formRef.value
  394. .validate()
  395. .then(() => {
  396. if (formState.fileIds.length === 0) {
  397. message.warning('请上传文件')
  398. return
  399. }
  400. if (filesLength.value > 0) {
  401. message.warning('文件正在上传中,请等待上传完成后再提交')
  402. return
  403. }
  404. submitting.value = true
  405. // 模拟提交数据
  406. setTimeout(() => {
  407. message.success('提交成功')
  408. submitting.value = false
  409. emit('success', formState)
  410. // 重置表单和上传状态
  411. formRef.value?.resetFields()
  412. formState.fileIds = []
  413. emit('update:visible', false)
  414. }, 1000)
  415. })
  416. .catch(() => {
  417. // 表单验证失败
  418. })
  419. }
  420. // 暴露方法给父组件
  421. defineExpose({
  422. handleUpload
  423. })
  424. </script>
  425. <style lang="less" scoped>
  426. @import '@/style/myResource/varibles.less';
  427. .upload-btn-wrapper {
  428. display: flex;
  429. align-items: center;
  430. }
  431. .uploader-file {
  432. width: 560px;
  433. }
  434. .select-file-btn {
  435. display: none;
  436. }
  437. .drop-box {
  438. position: relative;
  439. width: 100%;
  440. height: 200px;
  441. border: 2px dashed #e9e9e9;
  442. border-radius: 6px;
  443. background-color: #fafafa;
  444. display: flex;
  445. flex-direction: column;
  446. justify-content: center;
  447. align-items: center;
  448. margin-top: 10px;
  449. .text {
  450. font-size: 14px;
  451. color: #999;
  452. }
  453. .paste-img-wrapper {
  454. width: 100%;
  455. height: 100%;
  456. display: flex;
  457. flex-direction: column;
  458. justify-content: center;
  459. align-items: center;
  460. .paste-name {
  461. font-size: 14px;
  462. margin-bottom: 10px;
  463. }
  464. .paste-img {
  465. max-width: 80%;
  466. max-height: 120px;
  467. }
  468. }
  469. .upload-icon,
  470. .delete-icon {
  471. position: absolute;
  472. bottom: 10px;
  473. font-size: 20px;
  474. cursor: pointer;
  475. }
  476. .upload-icon {
  477. right: 40px;
  478. color: #1890ff;
  479. }
  480. .delete-icon {
  481. right: 10px;
  482. color: #ff4d4f;
  483. }
  484. .close-icon {
  485. position: absolute;
  486. top: 10px;
  487. right: 10px;
  488. font-size: 16px;
  489. cursor: pointer;
  490. color: #999;
  491. }
  492. }
  493. .uploader-app {
  494. width: 560px;
  495. }
  496. .file-panel {
  497. width: 100%;
  498. margin-top: 15px;
  499. border: 1px solid #e9e9e9;
  500. border-radius: 4px;
  501. overflow: hidden;
  502. .file-title {
  503. display: flex;
  504. justify-content: space-between;
  505. align-items: center;
  506. padding: 8px 12px;
  507. background-color: #f5f5f5;
  508. border-bottom: 1px solid #e9e9e9;
  509. .title-span {
  510. font-size: 14px;
  511. font-weight: 500;
  512. .count {
  513. color: #999;
  514. font-weight: normal;
  515. }
  516. }
  517. }
  518. .file-list {
  519. position: relative;
  520. height: 240px;
  521. overflow-x: hidden;
  522. overflow-y: auto;
  523. background-color: #fff;
  524. font-size: 12px;
  525. list-style: none;
  526. &::-webkit-scrollbar {
  527. width: 6px;
  528. }
  529. &::-webkit-scrollbar-thumb {
  530. background: @scrollbar-thumb-color;
  531. border-radius: 4px;
  532. }
  533. &::-webkit-scrollbar-track {
  534. background: @scrollbar-track-color;
  535. }
  536. .file-item {
  537. position: relative;
  538. background-color: #fff;
  539. :deep(.uploader-file) {
  540. height: 40px;
  541. line-height: 40px;
  542. .uploader-file-progress {
  543. border: 1px solid @success-color;
  544. border-right: none;
  545. border-left: none;
  546. background: #e1f3d8;
  547. }
  548. .uploader-file-name {
  549. width: 44%;
  550. }
  551. .uploader-file-size {
  552. width: 16%;
  553. }
  554. .uploader-file-meta {
  555. display: none;
  556. }
  557. .uploader-file-status {
  558. width: 30%;
  559. text-indent: 0;
  560. }
  561. .uploader-file-actions>span {
  562. margin-top: 12px;
  563. }
  564. }
  565. :deep(.uploader-file[status='success']) {
  566. .uploader-file-progress {
  567. border: none;
  568. }
  569. }
  570. }
  571. .file-item.custom-status-item {
  572. :deep(.uploader-file-status) {
  573. visibility: hidden;
  574. }
  575. .custom-status {
  576. position: absolute;
  577. top: 0;
  578. right: 10%;
  579. width: 24%;
  580. height: 40px;
  581. line-height: 40px;
  582. }
  583. }
  584. .no-file {
  585. position: absolute;
  586. top: 50%;
  587. left: 50%;
  588. transform: translate(-50%, -50%);
  589. font-size: 14px;
  590. }
  591. :deep(.uploader-file-icon) {
  592. display: none;
  593. }
  594. :deep(.uploader-file-actions > span) {
  595. margin-right: 6px;
  596. }
  597. }
  598. }
  599. .collapse-enter-active,
  600. .collapse-leave-active {
  601. transition: all 0.3s;
  602. max-height: 300px;
  603. overflow: hidden;
  604. }
  605. .collapse-enter-from,
  606. .collapse-leave-to {
  607. max-height: 0;
  608. }
  609. .upload-area {
  610. border: 2px dashed #3ca9f5;
  611. padding: 40px;
  612. text-align: center;
  613. }
  614. .upload-area p {
  615. margin: 10px 0;
  616. }
  617. .file-item {
  618. display: flex;
  619. align-items: center;
  620. margin: 10px 0;
  621. }
  622. .file-item .ant-progress {
  623. flex: 1;
  624. margin: 0 10px;
  625. }
  626. /* 新增表单样式 */
  627. .ant-form-item {
  628. margin-bottom: 16px;
  629. }
  630. .public-status-buttons {
  631. display: flex;
  632. }
  633. .status-button {
  634. padding: 5px 10px;
  635. /* margin-right: 10px; */
  636. border: 1px solid #ccc;
  637. /* border-radius: 3px; */
  638. cursor: pointer;
  639. background-color: #fff;
  640. }
  641. .status-button.active {
  642. background-color: #40a9ff;
  643. color: #fff;
  644. border-color: #40a9ff;
  645. }
  646. .upload-area {
  647. border: 2px dashed #3ca9f5;
  648. padding: 40px;
  649. text-align: center;
  650. transition: border-color 0.3s;
  651. /* 平滑过渡效果 */
  652. }
  653. .upload-area.drag-over {
  654. border-color: #1890ff;
  655. background-color: rgba(24, 144, 255, 0.05);
  656. }
  657. .remaining-time {
  658. position: absolute;
  659. right: 120px;
  660. top: 50%;
  661. transform: translateY(-50%);
  662. color: #666;
  663. font-size: 12px;
  664. }
  665. </style>