UploadModal.vue 14 KB

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