FileUploader.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. <template>
  2. <div class="file-uploader-container">
  3. <el-upload
  4. ref="uploadRef"
  5. :action="uploadUrl"
  6. :headers="headers"
  7. :method="method"
  8. :multiple="multiple"
  9. :data="uploadData"
  10. :name="fileFieldName"
  11. :with-credentials="withCredentials"
  12. :show-file-list="showFileList"
  13. :accept="accept"
  14. :limit="limit"
  15. :file-list="fileList"
  16. :auto-upload="autoUpload"
  17. :drag="drag"
  18. :list-type="listType"
  19. :before-upload="handleBeforeUpload"
  20. :before-remove="handleBeforeRemove"
  21. :on-success="handleSuccess"
  22. :on-error="handleError"
  23. :on-progress="handleProgress"
  24. :on-change="handleChange"
  25. :on-remove="handleRemove"
  26. :on-exceed="handleExceed"
  27. :on-preview="handlePreview"
  28. :on-download="handleDownload"
  29. >
  30. <template v-if="drag">
  31. <el-icon class="el-icon--upload"><upload-filled /></el-icon>
  32. <div class="el-upload__text">
  33. 将文件拖到此处,或 <em>点击上传</em>
  34. </div>
  35. <template v-if="tip" class="el-upload__tip">{{ tip }}</template>
  36. </template>
  37. <template v-else-if="listType === 'picture-card'">
  38. <el-icon><Plus /></el-icon>
  39. </template>
  40. <template v-else>
  41. <slot name="trigger">
  42. <el-button type="primary" class="gradient" size="large">
  43. <el-icon><Plus /></el-icon>
  44. <span class="ml10">{{ buttonText }}</span>
  45. </el-button>
  46. </slot>
  47. <template v-if="tip" class="el-upload__tip">
  48. <span class="ml10 gray999">{{ tip }}</span>
  49. </template>
  50. </template>
  51. <template >
  52. <div v-for="(file, index) in fileList" :key="file.uid" class="el-upload-list__item">
  53. <img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
  54. <span class="el-upload-list__item-actions">
  55. <span
  56. class="el-upload-list__item-preview"
  57. @click="handlePreview(file,index)"
  58. >
  59. <el-icon><zoom-in /></el-icon>
  60. </span>
  61. <!-- <span
  62. v-if="!disabled"
  63. class="el-upload-list__item-delete"
  64. @click="handleDownload(file)"
  65. >
  66. <el-icon><Download /></el-icon>
  67. </span> -->
  68. <span
  69. v-if="!disabled"
  70. class="el-upload-list__item-delete"
  71. @click="handleRemove(file,index)"
  72. >
  73. <el-icon><Delete /></el-icon>
  74. </span>
  75. </span>
  76. </div>
  77. <!-- <slot name="file-list" :file-list="fileList">
  78. <el-upload-list :file-list="fileList" @remove="handleRemove">
  79. <template #default="{ file }">
  80. <el-upload-list-item
  81. :file="file"
  82. :name="fileFieldName"
  83. @remove="handleRemove"
  84. >
  85. <template #actions>
  86. <el-button
  87. type="text"
  88. size="small"
  89. @click="handlePreview(file)"
  90. >
  91. <el-icon><zoom-in /></el-icon>
  92. 预览
  93. </el-button>
  94. <el-button
  95. type="text"
  96. size="small"
  97. @click="handleDownload(file)"
  98. >
  99. <el-icon><download /></el-icon>
  100. 下载
  101. </el-button>
  102. <el-button
  103. type="text"
  104. size="small"
  105. @click="handleRemove(file)"
  106. >
  107. <el-icon><delete /></el-icon>
  108. 删除
  109. </el-button>
  110. </template>
  111. </el-upload-list-item>
  112. </template>
  113. </el-upload-list>
  114. </slot> -->
  115. </template>
  116. <!-- 上传进度条 -->
  117. <template v-if="showProgress" #progress="{ file }">
  118. <el-progress
  119. :percentage="file.percentage"
  120. :stroke-width="2"
  121. :show-text="false"
  122. />
  123. </template>
  124. </el-upload>
  125. <!-- 预览对话框 -->
  126. <el-dialog
  127. v-model="previewVisible"
  128. :title="previewTitle"
  129. width="50%"
  130. destroy-on-close
  131. >
  132. <div class="preview-container">
  133. <template v-if="isImageFile(currentPreviewFile)">
  134. <img :src="currentPreviewFile.url" alt="预览" class="preview-image" />
  135. </template>
  136. <template v-else-if="isTextFile(currentPreviewFile)">
  137. <pre class="preview-text">{{ currentPreviewContent }}</pre>
  138. </template>
  139. <template v-else>
  140. <div class="preview-other">
  141. <el-icon class="file-icon"><document /></el-icon>
  142. <div class="file-info">
  143. <div class="file-name">{{ currentPreviewFile.name }}</div>
  144. <div class="file-size">{{ formatFileSize(currentPreviewFile.size) }}</div>
  145. </div>
  146. </div>
  147. </template>
  148. </div>
  149. </el-dialog>
  150. </div>
  151. </template>
  152. <script setup>
  153. import { ref, computed, watch } from 'vue'
  154. import DGTMessage from '@/utils/message'
  155. import { downloadFile } from '@/utils/util'
  156. // import {
  157. // UploadFilled,
  158. // ZoomIn,
  159. // Download,
  160. // Delete,
  161. // Document,
  162. // Plus
  163. // } from '@element-plus/icons-vue'
  164. // 组件属性
  165. const props = defineProps({
  166. disabled: {
  167. type: Boolean,
  168. default: false
  169. },
  170. // 上传地址
  171. uploadUrl: {
  172. type: String,
  173. default: import.meta.env.VITE_API_BASE_URL + '/upload'
  174. },
  175. // 请求头
  176. headers: {
  177. type: Object,
  178. default: () => ({})
  179. },
  180. // 请求方法
  181. method: {
  182. type: String,
  183. default: 'post'
  184. },
  185. // 是否支持多文件上传
  186. multiple: {
  187. type: Boolean,
  188. default: false
  189. },
  190. // 上传时附带的额外参数
  191. data: {
  192. type: Object,
  193. default: () => ({
  194. //"note","工作流""workflow","工作流""video","视频""common","公共"
  195. "directory":"common"
  196. })
  197. },
  198. // 文件字段名
  199. fileFieldName: {
  200. type: String,
  201. default: 'file'
  202. },
  203. // 是否携带凭证信息
  204. withCredentials: {
  205. type: Boolean,
  206. default: false
  207. },
  208. // 是否显示已上传文件列表
  209. showFileList: {
  210. type: Boolean,
  211. default: true
  212. },
  213. // 接受的文件类型
  214. accept: {
  215. type: String,
  216. default: ''
  217. },
  218. // 最大允许上传文件数量
  219. limit: {
  220. type: Number,
  221. default: 0 // 0 表示不限制
  222. },
  223. // 已上传的文件列表
  224. modelValue: {
  225. type: Array,
  226. default: () => []
  227. },
  228. // 是否自动上传
  229. autoUpload: {
  230. type: Boolean,
  231. default: true
  232. },
  233. // 是否启用拖拽上传
  234. drag: {
  235. type: Boolean,
  236. default: false
  237. },
  238. // 文件列表的类型
  239. listType: {
  240. type: String,
  241. default: 'text', // text, picture, picture-card
  242. validator: (value) => ['text', 'picture', 'picture-card'].includes(value)
  243. },
  244. // 上传按钮文字
  245. buttonText: {
  246. type: String,
  247. default: '点击上传'
  248. },
  249. // 提示文字
  250. tip: {
  251. type: String,
  252. default: ''
  253. },
  254. // 最大文件大小 (MB)
  255. maxSize: {
  256. type: Number,
  257. default: 10 // 0 表示不限制
  258. },
  259. // 是否显示进度条
  260. showProgress: {
  261. type: Boolean,
  262. default: true
  263. }
  264. })
  265. // 组件事件
  266. const emit = defineEmits([
  267. 'update:modelValue', // 文件列表更新事件
  268. 'before-upload', // 上传前事件
  269. 'before-remove', // 删除前事件
  270. 'success', // 上传成功事件
  271. 'error', // 上传失败事件
  272. 'progress', // 上传进度事件
  273. 'change', // 文件状态改变事件
  274. 'remove', // 文件移除事件
  275. 'exceed', // 文件数量超出限制事件
  276. 'preview', // 文件预览事件
  277. 'download' // 文件下载事件
  278. ])
  279. // 内部状态
  280. const uploadRef = ref(null)
  281. const fileList = ref([...props.modelValue])
  282. const previewVisible = ref(false)
  283. const currentPreviewFile = ref(null)
  284. const currentPreviewContent = ref('')
  285. // 计算属性
  286. const uploadData = computed(() => props.data)
  287. const previewTitle = computed(() => currentPreviewFile.value ? `预览:${currentPreviewFile.value.name}` : '文件预览')
  288. // 添加一个标志位,防止循环更新
  289. const isInnerUpdate = ref(false)
  290. // 监听modelValue变化,同步到内部fileList
  291. watch(() => props.modelValue, (newVal) => {
  292. // 如果是内部更新触发的,跳过
  293. if (isInnerUpdate.value) {
  294. isInnerUpdate.value = false;
  295. return;
  296. }
  297. if (newVal && newVal.length) {
  298. fileList.value = newVal.map(item => ({
  299. ...item, // 完全保留接口返回的原始数据
  300. name: item.name || item.originalFileName || (item.url ? item.url.substring(item.url.lastIndexOf('/') + 1) : 'unknown'),
  301. url: item.url || item.fileUrl || item.path,
  302. status: item.status || 'success',
  303. uid: item.uid || Date.now() + Math.random()
  304. }));
  305. }
  306. // fileList.value = [...newVal]
  307. }, { immediate: true })
  308. // 监听fileList变化,同步到modelValue
  309. watch(() => fileList.value, (newVal) => {
  310. // 设置内部更新标志
  311. isInnerUpdate.value = true
  312. emit('update:modelValue', [...newVal])
  313. }, { deep: true })
  314. // 上传前的校验
  315. const handleBeforeUpload = (rawFile) => {
  316. // 文件大小校验
  317. if (props.maxSize > 0) {
  318. const maxSizeBytes = props.maxSize * 1024 * 1024
  319. if (rawFile.size > maxSizeBytes) {
  320. DGTMessage.error(`文件大小超出限制,最大允许 ${props.maxSize}MB`)
  321. emit('error', {
  322. message: `文件大小超出限制,最大允许 ${props.maxSize}MB`
  323. })
  324. return false
  325. }
  326. }
  327. // 调用外部before-upload钩子
  328. return emit('before-upload', rawFile) !== false
  329. }
  330. // 上传成功处理
  331. const handleSuccess = (response, rawFile, uploadedFiles) => {
  332. // 将上传成功的文件添加到fileList中
  333. // fileList.value.push({
  334. // ...response,
  335. // name: response.originalFileName || response.name,
  336. // url: response.url,
  337. // status: 'success',
  338. // uid: rawFile.uid
  339. // })
  340. emit('success', response, rawFile, uploadedFiles)
  341. }
  342. // 上传失败处理
  343. const handleError = (error, rawFile, uploadedFiles) => {
  344. DGTMessage.error(error.message || '文件上传失败')
  345. emit('error', error, rawFile, uploadedFiles)
  346. }
  347. // 上传进度处理
  348. const handleProgress = (event, file, uploadedFiles) => {
  349. emit('progress', event, file, uploadedFiles)
  350. }
  351. // 文件状态改变处理
  352. const handleChange = (file, newFileList) => {
  353. // 更新内部fileList
  354. fileList.value = newFileList.map(item => {
  355. // 如果文件已上传成功且有response,使用服务器返回的URL
  356. if (item.status === 'success' && item.response) {
  357. return {
  358. ...item.response,
  359. name: item.response.originalFileName || item.name,
  360. url: item.response.url,
  361. status: 'success',
  362. uid: item.uid
  363. }
  364. }
  365. return item
  366. })
  367. emit('change', file, fileList)
  368. }
  369. // 删除文件前的处理
  370. const handleBeforeRemove = (file, fileList) => {
  371. return emit('before-remove', file, fileList) !== false
  372. }
  373. // 文件移除处理
  374. const handleRemove = (file) => {
  375. console.log('移除文件:', file)
  376. // 从fileList中移除该文件
  377. // fileList.value = fileList.value.filter(f => f.url !== file.url)
  378. // 从fileList中移除该文件
  379. const index = fileList.value.findIndex(f =>
  380. f.uid === file.uid
  381. )
  382. console.log('index:', index,fileList.value)
  383. if (index > -1) {
  384. fileList.value.splice(index, 1)
  385. }
  386. emit('remove', file, fileList)
  387. }
  388. // 文件数量超出限制处理
  389. const handleExceed = (files, fileList) => {
  390. emit('exceed', files, fileList)
  391. }
  392. // 文件预览处理
  393. const handlePreview = (file,index) => {
  394. console.log('预览文件:', file,index)
  395. currentPreviewFile.value = file
  396. // 图片文件直接预览
  397. if (isImageFile(file)) {
  398. previewVisible.value = true
  399. emit('preview', file)
  400. return
  401. }
  402. // 文本文件读取内容后预览
  403. if (isTextFile(file)) {
  404. if (file.url) {
  405. //浏览器下载
  406. downloadFile(file.url, file.name)
  407. // fetch(file.url)
  408. // .then(response => response.text())
  409. // .then(text => {
  410. // currentPreviewContent.value = text
  411. // previewVisible.value = true
  412. // emit('preview', file)
  413. // })
  414. // .catch(error => {
  415. // console.error('读取文件内容失败:', error)
  416. // emit('error', error, file)
  417. // })
  418. } else {
  419. previewVisible.value = true
  420. emit('preview', file)
  421. }
  422. return
  423. }
  424. // 其他文件类型
  425. previewVisible.value = true
  426. emit('preview', file)
  427. }
  428. // 文件下载处理
  429. const handleDownload = (file) => {
  430. emit('download', file)
  431. }
  432. // 辅助方法:判断是否为图片文件
  433. const isImageFile = (file) => {
  434. return file.type && file.type.startsWith('image/') ||
  435. /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name)
  436. }
  437. // 辅助方法:判断是否为文本文件
  438. const isTextFile = (file) => {
  439. return file.type && file.type.startsWith('text/') ||
  440. /\.(txt|json|yaml|yml|xml|html|htm|css|js|ts|md|markdown|zip|rar)$/i.test(file.name)
  441. }
  442. // 辅助方法:格式化文件大小
  443. const formatFileSize = (size) => {
  444. if (size < 1024) {
  445. return `${size} B`
  446. } else if (size < 1024 * 1024) {
  447. return `${(size / 1024).toFixed(2)} KB`
  448. } else if (size < 1024 * 1024 * 1024) {
  449. return `${(size / (1024 * 1024)).toFixed(2)} MB`
  450. } else {
  451. return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
  452. }
  453. }
  454. // 暴露方法给父组件
  455. defineExpose({
  456. // 手动上传文件
  457. submitUpload: () => uploadRef.value?.submit(),
  458. // 清除文件列表
  459. clearFiles: () => uploadRef.value?.clearFiles(),
  460. // 撤销上传请求
  461. abort: (file) => uploadRef.value?.abort(file),
  462. // 获取当前文件列表
  463. getFileList: () => [...fileList.value],
  464. // 手动添加文件到列表
  465. addFile: (file) => {
  466. fileList.value.push(file)
  467. return fileList.value
  468. },
  469. // 手动移除文件
  470. removeFile: (file) => {
  471. const index = fileList.value.findIndex(item => item.uid === file.uid)
  472. if (index > -1) {
  473. fileList.value.splice(index, 1)
  474. }
  475. return fileList.value
  476. }
  477. })
  478. </script>
  479. <style scoped lang="scss">
  480. .file-uploader-container {
  481. width: 100%;
  482. .preview-container {
  483. width: 100%;
  484. min-height: 60vh;
  485. overflow: auto;
  486. .preview-image {
  487. width: 100%;
  488. height: auto;
  489. object-fit: contain;
  490. }
  491. .preview-text {
  492. width: 100%;
  493. padding: 10px;
  494. background-color: #f5f5f5;
  495. border-radius: 4px;
  496. font-family: monospace;
  497. white-space: pre-wrap;
  498. }
  499. .preview-other {
  500. display: flex;
  501. align-items: center;
  502. justify-content: center;
  503. padding: 40px 0;
  504. .file-icon {
  505. font-size: 64px;
  506. color: #409eff;
  507. margin-right: 20px;
  508. }
  509. .file-info {
  510. text-align: left;
  511. .file-name {
  512. font-size: 16px;
  513. font-weight: 500;
  514. margin-bottom: 8px;
  515. }
  516. .file-size {
  517. font-size: 14px;
  518. color: #666;
  519. }
  520. }
  521. }
  522. }
  523. }
  524. </style>