index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. <template>
  2. <div class="upload-container">
  3. <el-upload
  4. action="#"
  5. :headers="headers"
  6. :file-list="uploadFileList"
  7. :accept="realAccept"
  8. :before-upload="beforeUpload"
  9. :on-success="handleSuccess"
  10. :on-error="handleError"
  11. :on-remove="handleRemove"
  12. :on-exceed="handleExceed"
  13. :http-request="handlePostFile"
  14. :list-type="listType"
  15. :auto-upload="autoUpload"
  16. :limit="limit"
  17. :show-file-list="false"
  18. :multiple="multiple"
  19. >
  20. <slot name="trigger">
  21. <el-button :type="buttonType" :bg="false" :disabled="disabled">
  22. <el-icon v-if="showButtonIcon"><Upload /></el-icon>
  23. {{buttonText}}
  24. </el-button>
  25. </slot>
  26. <template #tip v-if="showTips">
  27. <div class="el-upload__tip">
  28. 支持扩展名:{{ fileExtensions }}
  29. <span v-if="maxSize > 0">,单个文件大小不超过 {{ formatFileSize(maxSize) }}</span>
  30. </div>
  31. </template>
  32. </el-upload>
  33. <!-- 已上传文件列表 -->
  34. <div v-if="processedFileList.length > 0" class="uploaded-files-section">
  35. <div class="files-header">
  36. <span class="files-title">已上传文件 ({{ processedFileList.length }})</span>
  37. </div>
  38. <div class="uploaded-files">
  39. <div v-for="file in processedFileList" :key="file.uid || file.url" class="custom-file-item">
  40. <div class="file-info">
  41. <!-- 文件图标或预览 -->
  42. <div class="file-preview" @click="handlePreview(file)">
  43. <el-image
  44. v-if="isImage(file)"
  45. :src="file.url"
  46. fit="cover"
  47. style="width: 40px; height: 40px; border-radius: 4px; cursor: pointer;"
  48. :preview-src-list="[]"
  49. hide-on-click-modal
  50. />
  51. <el-icon v-else-if="isVideo(file)" size="40" class="video-icon" style="color: #409EFF;">
  52. <VideoPlay />
  53. </el-icon>
  54. <el-icon v-else-if="isDocumentFile(file)" size="40" class="doc-icon">
  55. <Document />
  56. </el-icon>
  57. <el-icon v-else size="40" class="file-icon">
  58. <Document />
  59. </el-icon>
  60. </div>
  61. <!-- 文件名称和操作 -->
  62. <div class="file-content">
  63. <div class="file-name" :title="file.name">
  64. {{ file.name }}
  65. </div>
  66. <div class="file-actions">
  67. <div
  68. v-if="isImage(file)"
  69. class="detail-btn"
  70. @click="openImagePreview(file)"
  71. >
  72. 预览
  73. </div>
  74. <div
  75. v-if="isVideo(file)"
  76. class="detail-btn"
  77. @click="openVideoPreview(file)"
  78. >
  79. 播放
  80. </div>
  81. <div
  82. v-if="isDocumentFile(file)"
  83. class="detail-btn"
  84. @click="openDocumentPreview(file)"
  85. >
  86. 查看
  87. </div>
  88. <div
  89. class="detail-btn"
  90. @click="downloadFile(file)"
  91. >
  92. 下载
  93. </div>
  94. <div
  95. v-if="detailFlag == false"
  96. class="delete-btn"
  97. @click="handleRemove(file)"
  98. >
  99. 删除
  100. </div>
  101. </div>
  102. </div>
  103. </div>
  104. </div>
  105. </div>
  106. </div>
  107. <!-- 图片预览对话框 -->
  108. <el-dialog
  109. v-model="imagePreviewVisible"
  110. title="图片预览"
  111. width="80%"
  112. :before-close="closeImagePreview"
  113. append-to-body
  114. >
  115. <div class="image-preview-container">
  116. <el-image
  117. :src="currentPreviewImage"
  118. fit="cover"
  119. style="width: 100%;"
  120. :preview-src-list="[]"
  121. hide-on-click-modal
  122. />
  123. </div>
  124. </el-dialog>
  125. <!-- 视频预览对话框 -->
  126. <el-dialog
  127. v-model="videoPreviewVisible"
  128. title="视频播放"
  129. width="80%"
  130. :before-close="closeVideoPreview"
  131. destroy-on-close
  132. append-to-body
  133. >
  134. <div class="video-preview-container">
  135. <video
  136. :src="currentPreviewVideo"
  137. controls
  138. style="width: 100%; max-height: 70vh;"
  139. @error="handleVideoError"
  140. >
  141. 您的浏览器不支持视频播放
  142. </video>
  143. </div>
  144. </el-dialog>
  145. <!-- 文档预览对话框 -->
  146. <el-dialog
  147. v-model="documentPreviewVisible"
  148. title="文档预览"
  149. width="90%"
  150. :before-close="closeDocumentPreview"
  151. append-to-body
  152. center
  153. >
  154. <div class="document-preview-container">
  155. <iframe
  156. :src="currentPreviewDocument"
  157. width="100%"
  158. height="600px"
  159. frameborder="0"
  160. ></iframe>
  161. </div>
  162. <template #footer>
  163. <span class="dialog-footer">
  164. <el-button @click="closeDocumentPreview">关闭</el-button>
  165. <el-button type="primary" @click="openDocumentInNewWindow">在新窗口打开</el-button>
  166. </span>
  167. </template>
  168. </el-dialog>
  169. </div>
  170. </template>
  171. <script setup>
  172. import { Upload, Document, VideoPlay } from '@element-plus/icons-vue'
  173. import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
  174. import request from '@/config/axios'
  175. import SparkMD5 from 'spark-md5'
  176. import { getAccessToken } from '@/utils/auth'
  177. import { buildFileUrl } from '@/utils'
  178. const props = defineProps({
  179. apiUrl: { type: String, required: true },
  180. chunkSize: { type: Number, default: 5 },
  181. accept: { type: String, default: '' },
  182. headers: { type: Object, default: () => ({}) },
  183. fileList: { type: Array, default: () => [] },
  184. autoUpload: { type: Boolean, default: true },
  185. listType: { type: String, default: 'text' },
  186. showTips: { type: Boolean, default: true },
  187. buttonText: { type: String, default: '上传文件' },
  188. buttonType: { type: String, default: 'default' },
  189. showButtonIcon: { type: Boolean, default: true },
  190. limit: { type: Number, default: 0 },
  191. disabled: { type: Boolean, default: false },
  192. detailFlag: { type: Boolean, default: false },
  193. maxSize: { type: Number, default: 0 },
  194. multiple: { type: Boolean, default: false }
  195. })
  196. const emit = defineEmits(['update:fileList', 'success', 'error'])
  197. // 预览相关状态
  198. const imagePreviewVisible = ref(false)
  199. const videoPreviewVisible = ref(false)
  200. const documentPreviewVisible = ref(false)
  201. const currentPreviewImage = ref('')
  202. const currentPreviewVideo = ref('')
  203. const currentPreviewDocument = ref('')
  204. // 处理后的文件列表
  205. const processedFileList = computed(() => {
  206. return (props.fileList || [])?.map(file => ({
  207. ...file,
  208. url: file.url ? buildFileUrl(file.url) : buildFileUrl(file.name || `${file.path || ''}${file.name || ''}`)
  209. }))
  210. })
  211. const uploadFileList = computed(() => {
  212. return processedFileList.value.map(file => ({
  213. name: file.name,
  214. url: file.url,
  215. uid: file.uid,
  216. status: 'success'
  217. }))
  218. })
  219. const realAccept = computed(() => {
  220. return props.accept || '*'
  221. })
  222. const fileExtensions = computed(() => {
  223. return props.accept || '任意类型'
  224. })
  225. const handleExceed = (files, fileList) => {
  226. ElMessage.warning(`最多只能上传 ${props.limit} 个文件,请删除后再上传`)
  227. }
  228. const formatFileSize = (bytes) => {
  229. if (bytes === 0) return '0 B'
  230. const k = 1024
  231. const sizes = ['B', 'KB', 'MB', 'GB']
  232. const i = Math.floor(Math.log(bytes) / Math.log(k))
  233. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  234. }
  235. // 判断是否为图片文件
  236. const isImage = (file) => {
  237. const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
  238. const extension = (file.name || '').split('.').pop()?.toLowerCase()
  239. return imageTypes.includes(extension)
  240. }
  241. // 判断是否为视频文件
  242. const isVideo = (file) => {
  243. const videoTypes = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'flv', 'wmv']
  244. const extension = (file.name || '').split('.').pop()?.toLowerCase()
  245. return videoTypes.includes(extension)
  246. }
  247. // 判断是否为文档文件
  248. const isDocumentFile = (file) => {
  249. const documentTypes = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']
  250. const extension = (file.name || '').split('.').pop()?.toLowerCase()
  251. return documentTypes.includes(extension)
  252. }
  253. // 打开图片预览
  254. const openImagePreview = (file) => {
  255. currentPreviewImage.value = file.url
  256. imagePreviewVisible.value = true
  257. }
  258. // 关闭图片预览
  259. const closeImagePreview = () => {
  260. imagePreviewVisible.value = false
  261. currentPreviewImage.value = ''
  262. }
  263. // 打开视频预览
  264. const openVideoPreview = (file) => {
  265. currentPreviewVideo.value = file.url
  266. videoPreviewVisible.value = true
  267. }
  268. // 关闭视频预览
  269. const closeVideoPreview = () => {
  270. videoPreviewVisible.value = false
  271. currentPreviewVideo.value = ''
  272. }
  273. // 视频加载错误处理
  274. const handleVideoError = () => {
  275. console.log('视频加载失败,请检查文件格式或网络连接')
  276. }
  277. // 打开文档预览
  278. const openDocumentPreview = (file) => {
  279. const extension = (file.name || '').split('.').pop()?.toLowerCase()
  280. if (extension === 'pdf') {
  281. currentPreviewDocument.value = file.url
  282. } else {
  283. currentPreviewDocument.value = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(file.url)}`
  284. }
  285. documentPreviewVisible.value = true
  286. }
  287. // 关闭文档预览
  288. const closeDocumentPreview = () => {
  289. documentPreviewVisible.value = false
  290. currentPreviewDocument.value = ''
  291. }
  292. // 在新窗口打开文档
  293. const openDocumentInNewWindow = () => {
  294. if (currentPreviewDocument.value) {
  295. window.open(currentPreviewDocument.value, '_blank')
  296. }
  297. }
  298. // 下载文件
  299. const downloadFile = (file) => {
  300. if (isImage(file)) {
  301. window.open(file.url, '_blank')
  302. } else {
  303. const link = document.createElement('a')
  304. link.href = file.url
  305. link.download = file.name
  306. document.body.appendChild(link)
  307. link.click()
  308. document.body.removeChild(link)
  309. }
  310. }
  311. // 处理文件预览
  312. const handlePreview = (file) => {
  313. if (isImage(file)) {
  314. openImagePreview(file)
  315. } else if (isVideo(file)) {
  316. openVideoPreview(file)
  317. } else if (isDocumentFile(file)) {
  318. openDocumentPreview(file)
  319. }
  320. }
  321. // 文件上传前校验
  322. const beforeUpload = (file) => {
  323. // 手动检查 limit
  324. if (props.limit > 0 && processedFileList.value.length >= props.limit) {
  325. ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
  326. return false
  327. }
  328. const lastName = (file.name || '').match(/\.([^.]+)$/)?.[0] || null
  329. if (realAccept.value !== '*' && !realAccept.value.includes(lastName)) {
  330. ElMessage.error('只能上传' + realAccept.value)
  331. return false
  332. }
  333. if (props.maxSize > 0 && file.size > props.maxSize) {
  334. ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
  335. return false
  336. }
  337. return true
  338. }
  339. const activeUploads = ref(false)
  340. // 文件上传方法
  341. const handlePostFile = async (options) => {
  342. const { file, onProgress, onSuccess, onError } = options
  343. try {
  344. activeUploads.value = true
  345. const formData = new FormData()
  346. formData.append('file', file)
  347. const result = await request.post({
  348. url: props.apiUrl,
  349. data: formData,
  350. headers: {
  351. authorization: 'Bearer ' + getAccessToken(),
  352. 'Content-Type': 'multipart/form-data',
  353. accept: '*/*',
  354. ...props.headers
  355. },
  356. onUploadProgress: (progressEvent) => {
  357. const percent = Math.round((progressEvent.loaded / file.size) * 100)
  358. onProgress({ percent })
  359. }
  360. })
  361. if(result) {
  362. const fileObj = {
  363. ...file,
  364. name: file.name,
  365. status: 'success',
  366. url: result,
  367. uid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  368. }
  369. updateFileList(fileObj)
  370. onSuccess(result)
  371. emit('success', result, file)
  372. }
  373. } catch (err) {
  374. onError(err)
  375. emit('error', err, file)
  376. } finally {
  377. activeUploads.value = false
  378. }
  379. }
  380. const handleSuccess = (response, file) => {
  381. activeUploads.value = false
  382. }
  383. const handleError = (err) => {
  384. activeUploads.value = false
  385. ElMessage.error('上传失败')
  386. }
  387. const handleRemove = async (file) => {
  388. if (file.id) {
  389. try {
  390. await request.delete(`${props.apiUrl}/${file.id}`)
  391. updateFileList(file, true)
  392. await nextTick()
  393. // 强制触发组件更新
  394. ElMessage.success('删除成功')
  395. } catch (err) {
  396. ElMessage.error('删除失败')
  397. }
  398. } else {
  399. updateFileList(file, true)
  400. await nextTick()
  401. }
  402. }
  403. const updateFileList = (file, isRemove = false) => {
  404. const newList = [...props.fileList]
  405. // 优先使用 uid 查找,避免误判
  406. const index = newList.findIndex(f => {
  407. if (file.uid && f.uid) return f.uid === file.uid
  408. if (file.url && f.url) return f.url === file.url
  409. return f.name === file.name
  410. })
  411. if (isRemove && index > -1) {
  412. newList.splice(index, 1)
  413. } else if (!isRemove && index === -1) {
  414. newList.push(file)
  415. } else if (!isRemove && index > -1) {
  416. newList[index] = { ...newList[index], ...file }
  417. }
  418. emit('update:fileList', newList)
  419. }
  420. const handleBeforeUnload = (e) => {
  421. if (activeUploads.value) {
  422. e.preventDefault()
  423. e.returnValue = '文件正在上传中,确定要离开吗?'
  424. return '文件正在上传中,确定要离开吗?'
  425. }
  426. }
  427. onMounted(() => {
  428. window.addEventListener('beforeunload', handleBeforeUnload)
  429. })
  430. onBeforeUnmount(() => {
  431. window.removeEventListener('beforeunload', handleBeforeUnload)
  432. })
  433. </script>
  434. <style scoped>
  435. .upload-container {
  436. width: 100%;
  437. }
  438. .uploaded-files-section {
  439. margin-top: 16px;
  440. }
  441. .files-header {
  442. margin-bottom: 12px;
  443. padding-bottom: 8px;
  444. border-bottom: 1px solid #e4e7ed;
  445. }
  446. .files-title {
  447. font-size: 14px;
  448. font-weight: 500;
  449. color: #303133;
  450. }
  451. .uploaded-files {
  452. display: flex;
  453. flex-direction: column;
  454. gap: 8px;
  455. }
  456. .custom-file-item {
  457. display: block;
  458. width: 100%;
  459. padding: 12px;
  460. border: 1px solid #e4e7ed;
  461. border-radius: 6px;
  462. background-color: #fafafa;
  463. transition: all 0.3s ease;
  464. }
  465. .custom-file-item:hover {
  466. background-color: #f0f9ff;
  467. border-color: #409eff;
  468. }
  469. .file-info {
  470. display: flex;
  471. align-items: flex-start;
  472. width: 100%;
  473. }
  474. .file-preview {
  475. margin-right: 12px;
  476. cursor: pointer;
  477. display: flex;
  478. align-items: center;
  479. justify-content: center;
  480. flex-shrink: 0;
  481. }
  482. .doc-icon {
  483. color: #2563eb;
  484. cursor: pointer;
  485. }
  486. .file-icon {
  487. color: #718096;
  488. }
  489. .file-content {
  490. flex: 1;
  491. min-width: 0;
  492. }
  493. .file-name {
  494. font-size: 14px;
  495. color: #303133;
  496. /* margin-bottom: 8px; */
  497. word-break: break-all;
  498. line-height: 1.4;
  499. }
  500. .file-actions {
  501. display: flex;
  502. flex-wrap: wrap;
  503. gap: 15px;
  504. }
  505. .image-preview-container {
  506. text-align: center;
  507. padding: 20px;
  508. }
  509. .document-preview-container {
  510. width: 100%;
  511. height: 600px;
  512. border: 1px solid #e4e7ed;
  513. border-radius: 4px;
  514. overflow: hidden;
  515. }
  516. /* 隐藏默认的文件列表 */
  517. :deep(.el-upload-list) {
  518. display: none;
  519. }
  520. .detail-btn {
  521. font-size: 12px;
  522. color: #015293;
  523. cursor: pointer;
  524. }
  525. .delete-btn {
  526. font-size: 12px;
  527. color: #F56c6c;
  528. cursor: pointer;
  529. }
  530. .video-icon {
  531. cursor: pointer;
  532. transition: transform 0.2s;
  533. }
  534. .video-icon:hover {
  535. transform: scale(1.1);
  536. }
  537. .video-preview-container {
  538. display: flex;
  539. justify-content: center;
  540. align-items: center;
  541. background: #000;
  542. border-radius: 4px;
  543. padding: 20px;
  544. }
  545. /* 响应式设计 */
  546. @media (max-width: 768px) {
  547. .file-actions {
  548. margin-top: 4px;
  549. }
  550. .file-actions .el-button {
  551. padding: 4px 8px;
  552. font-size: 12px;
  553. }
  554. }
  555. </style>