| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- <template>
- <div class="upload-container">
- <el-upload
- action="#"
- :headers="headers"
- :file-list="uploadFileList"
- :accept="realAccept"
- :before-upload="beforeUpload"
- :on-success="handleSuccess"
- :on-error="handleError"
- :on-remove="handleRemove"
- :on-exceed="handleExceed"
- :http-request="handlePostFile"
- :list-type="listType"
- :auto-upload="autoUpload"
- :limit="limit"
- :show-file-list="false"
- :multiple="multiple"
- >
- <slot name="trigger">
- <el-button :type="buttonType" :bg="false" :disabled="disabled">
- <el-icon v-if="showButtonIcon"><Upload /></el-icon>
- {{buttonText}}
- </el-button>
- </slot>
- <template #tip v-if="showTips">
- <div class="el-upload__tip">
- 支持扩展名:{{ fileExtensions }}
- <span v-if="maxSize > 0">,单个文件大小不超过 {{ formatFileSize(maxSize) }}</span>
- </div>
- </template>
- </el-upload>
- <!-- 已上传文件列表 -->
- <div v-if="processedFileList.length > 0" class="uploaded-files-section">
- <div class="files-header">
- <span class="files-title">已上传文件 ({{ processedFileList.length }})</span>
- </div>
- <div class="uploaded-files">
- <div v-for="file in processedFileList" :key="file.uid || file.url" class="custom-file-item">
- <div class="file-info">
- <!-- 文件图标或预览 -->
- <div class="file-preview" @click="handlePreview(file)">
- <el-image
- v-if="isImage(file)"
- :src="file.url"
- fit="cover"
- style="width: 40px; height: 40px; border-radius: 4px; cursor: pointer;"
- :preview-src-list="[]"
- hide-on-click-modal
- />
- <el-icon v-else-if="isVideo(file)" size="40" class="video-icon" style="color: #409EFF;">
- <VideoPlay />
- </el-icon>
- <el-icon v-else-if="isDocumentFile(file)" size="40" class="doc-icon">
- <Document />
- </el-icon>
- <el-icon v-else size="40" class="file-icon">
- <Document />
- </el-icon>
- </div>
- <!-- 文件名称和操作 -->
- <div class="file-content">
- <div class="file-name" :title="file.name">
- {{ file.name }}
- </div>
- <div class="file-actions">
- <div
- v-if="isImage(file)"
- class="detail-btn"
- @click="openImagePreview(file)"
- >
- 预览
- </div>
- <div
- v-if="isVideo(file)"
- class="detail-btn"
- @click="openVideoPreview(file)"
- >
- 播放
- </div>
- <div
- v-if="isDocumentFile(file)"
- class="detail-btn"
- @click="openDocumentPreview(file)"
- >
- 查看
- </div>
- <div
- class="detail-btn"
- @click="downloadFile(file)"
- >
- 下载
- </div>
- <div
- v-if="detailFlag == false"
- class="delete-btn"
- @click="handleRemove(file)"
- >
- 删除
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- <!-- 图片预览对话框 -->
- <el-dialog
- v-model="imagePreviewVisible"
- title="图片预览"
- width="80%"
- :before-close="closeImagePreview"
- append-to-body
- >
- <div class="image-preview-container">
- <el-image
- :src="currentPreviewImage"
- fit="cover"
- style="width: 100%;"
- :preview-src-list="[]"
- hide-on-click-modal
- />
- </div>
- </el-dialog>
- <!-- 视频预览对话框 -->
- <el-dialog
- v-model="videoPreviewVisible"
- title="视频播放"
- width="80%"
- :before-close="closeVideoPreview"
- destroy-on-close
- append-to-body
- >
- <div class="video-preview-container">
- <video
- :src="currentPreviewVideo"
- controls
- style="width: 100%; max-height: 70vh;"
- @error="handleVideoError"
- >
- 您的浏览器不支持视频播放
- </video>
- </div>
- </el-dialog>
- <!-- 文档预览对话框 -->
- <el-dialog
- v-model="documentPreviewVisible"
- title="文档预览"
- width="90%"
- :before-close="closeDocumentPreview"
- append-to-body
- center
- >
- <div class="document-preview-container">
- <iframe
- :src="currentPreviewDocument"
- width="100%"
- height="600px"
- frameborder="0"
- ></iframe>
- </div>
- <template #footer>
- <span class="dialog-footer">
- <el-button @click="closeDocumentPreview">关闭</el-button>
- <el-button type="primary" @click="openDocumentInNewWindow">在新窗口打开</el-button>
- </span>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup>
- import { Upload, Document, VideoPlay } from '@element-plus/icons-vue'
- import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
- import request from '@/config/axios'
- import SparkMD5 from 'spark-md5'
- import { getAccessToken } from '@/utils/auth'
- import { buildFileUrl } from '@/utils'
- const props = defineProps({
- apiUrl: { type: String, required: true },
- chunkSize: { type: Number, default: 5 },
- accept: { type: String, default: '' },
- headers: { type: Object, default: () => ({}) },
- fileList: { type: Array, default: () => [] },
- autoUpload: { type: Boolean, default: true },
- listType: { type: String, default: 'text' },
- showTips: { type: Boolean, default: true },
- buttonText: { type: String, default: '上传文件' },
- buttonType: { type: String, default: 'default' },
- showButtonIcon: { type: Boolean, default: true },
- limit: { type: Number, default: 0 },
- disabled: { type: Boolean, default: false },
- detailFlag: { type: Boolean, default: false },
- maxSize: { type: Number, default: 0 },
- multiple: { type: Boolean, default: false }
- })
- const emit = defineEmits(['update:fileList', 'success', 'error'])
- // 预览相关状态
- const imagePreviewVisible = ref(false)
- const videoPreviewVisible = ref(false)
- const documentPreviewVisible = ref(false)
- const currentPreviewImage = ref('')
- const currentPreviewVideo = ref('')
- const currentPreviewDocument = ref('')
- // 处理后的文件列表
- const processedFileList = computed(() => {
- return (props.fileList || [])?.map(file => ({
- ...file,
- url: file.url ? buildFileUrl(file.url) : buildFileUrl(file.name || `${file.path || ''}${file.name || ''}`)
- }))
- })
- const uploadFileList = computed(() => {
- return processedFileList.value.map(file => ({
- name: file.name,
- url: file.url,
- uid: file.uid,
- status: 'success'
- }))
- })
- const realAccept = computed(() => {
- return props.accept || '*'
- })
- const fileExtensions = computed(() => {
- return props.accept || '任意类型'
- })
- const handleExceed = (files, fileList) => {
- ElMessage.warning(`最多只能上传 ${props.limit} 个文件,请删除后再上传`)
- }
- const formatFileSize = (bytes) => {
- if (bytes === 0) return '0 B'
- const k = 1024
- const sizes = ['B', 'KB', 'MB', 'GB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
- }
- // 判断是否为图片文件
- const isImage = (file) => {
- const imageTypes = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
- const extension = (file.name || '').split('.').pop()?.toLowerCase()
- return imageTypes.includes(extension)
- }
- // 判断是否为视频文件
- const isVideo = (file) => {
- const videoTypes = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'flv', 'wmv']
- const extension = (file.name || '').split('.').pop()?.toLowerCase()
- return videoTypes.includes(extension)
- }
- // 判断是否为文档文件
- const isDocumentFile = (file) => {
- const documentTypes = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt']
- const extension = (file.name || '').split('.').pop()?.toLowerCase()
- return documentTypes.includes(extension)
- }
- // 打开图片预览
- const openImagePreview = (file) => {
- currentPreviewImage.value = file.url
- imagePreviewVisible.value = true
- }
- // 关闭图片预览
- const closeImagePreview = () => {
- imagePreviewVisible.value = false
- currentPreviewImage.value = ''
- }
- // 打开视频预览
- const openVideoPreview = (file) => {
- currentPreviewVideo.value = file.url
- videoPreviewVisible.value = true
- }
- // 关闭视频预览
- const closeVideoPreview = () => {
- videoPreviewVisible.value = false
- currentPreviewVideo.value = ''
- }
- // 视频加载错误处理
- const handleVideoError = () => {
- console.log('视频加载失败,请检查文件格式或网络连接')
- }
- // 打开文档预览
- const openDocumentPreview = (file) => {
- const extension = (file.name || '').split('.').pop()?.toLowerCase()
-
- if (extension === 'pdf') {
- currentPreviewDocument.value = file.url
- } else {
- currentPreviewDocument.value = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(file.url)}`
- }
-
- documentPreviewVisible.value = true
- }
- // 关闭文档预览
- const closeDocumentPreview = () => {
- documentPreviewVisible.value = false
- currentPreviewDocument.value = ''
- }
- // 在新窗口打开文档
- const openDocumentInNewWindow = () => {
- if (currentPreviewDocument.value) {
- window.open(currentPreviewDocument.value, '_blank')
- }
- }
- // 下载文件
- const downloadFile = (file) => {
- if (isImage(file)) {
- window.open(file.url, '_blank')
- } else {
- const link = document.createElement('a')
- link.href = file.url
- link.download = file.name
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- }
- }
- // 处理文件预览
- const handlePreview = (file) => {
- if (isImage(file)) {
- openImagePreview(file)
- } else if (isVideo(file)) {
- openVideoPreview(file)
- } else if (isDocumentFile(file)) {
- openDocumentPreview(file)
- }
- }
- // 文件上传前校验
- const beforeUpload = (file) => {
- // 手动检查 limit
- if (props.limit > 0 && processedFileList.value.length >= props.limit) {
- ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
- return false
- }
- const lastName = (file.name || '').match(/\.([^.]+)$/)?.[0] || null
- if (realAccept.value !== '*' && !realAccept.value.includes(lastName)) {
- ElMessage.error('只能上传' + realAccept.value)
- return false
- }
-
- if (props.maxSize > 0 && file.size > props.maxSize) {
- ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
- return false
- }
-
- return true
- }
- const activeUploads = ref(false)
- // 文件上传方法
- const handlePostFile = async (options) => {
- const { file, onProgress, onSuccess, onError } = options
- try {
- activeUploads.value = true
- const formData = new FormData()
- formData.append('file', file)
- const result = await request.post({
- url: props.apiUrl,
- data: formData,
- headers: {
- authorization: 'Bearer ' + getAccessToken(),
- 'Content-Type': 'multipart/form-data',
- accept: '*/*',
- ...props.headers
- },
- onUploadProgress: (progressEvent) => {
- const percent = Math.round((progressEvent.loaded / file.size) * 100)
- onProgress({ percent })
- }
- })
- if(result) {
- const fileObj = {
- ...file,
- name: file.name,
- status: 'success',
- url: result,
- uid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
- }
- updateFileList(fileObj)
- onSuccess(result)
- emit('success', result, file)
- }
- } catch (err) {
- onError(err)
- emit('error', err, file)
- } finally {
- activeUploads.value = false
- }
- }
- const handleSuccess = (response, file) => {
- activeUploads.value = false
- }
- const handleError = (err) => {
- activeUploads.value = false
- ElMessage.error('上传失败')
- }
- const handleRemove = async (file) => {
- if (file.id) {
- try {
- await request.delete(`${props.apiUrl}/${file.id}`)
- updateFileList(file, true)
- await nextTick()
- // 强制触发组件更新
- ElMessage.success('删除成功')
- } catch (err) {
- ElMessage.error('删除失败')
- }
- } else {
- updateFileList(file, true)
- await nextTick()
- }
- }
- const updateFileList = (file, isRemove = false) => {
- const newList = [...props.fileList]
-
- // 优先使用 uid 查找,避免误判
- const index = newList.findIndex(f => {
- if (file.uid && f.uid) return f.uid === file.uid
- if (file.url && f.url) return f.url === file.url
- return f.name === file.name
- })
- if (isRemove && index > -1) {
- newList.splice(index, 1)
- } else if (!isRemove && index === -1) {
- newList.push(file)
- } else if (!isRemove && index > -1) {
- newList[index] = { ...newList[index], ...file }
- }
- emit('update:fileList', newList)
- }
- const handleBeforeUnload = (e) => {
- if (activeUploads.value) {
- e.preventDefault()
- e.returnValue = '文件正在上传中,确定要离开吗?'
- return '文件正在上传中,确定要离开吗?'
- }
- }
- onMounted(() => {
- window.addEventListener('beforeunload', handleBeforeUnload)
- })
- onBeforeUnmount(() => {
- window.removeEventListener('beforeunload', handleBeforeUnload)
- })
- </script>
- <style scoped>
- .upload-container {
- width: 100%;
- }
- .uploaded-files-section {
- margin-top: 16px;
- }
- .files-header {
- margin-bottom: 12px;
- padding-bottom: 8px;
- border-bottom: 1px solid #e4e7ed;
- }
- .files-title {
- font-size: 14px;
- font-weight: 500;
- color: #303133;
- }
- .uploaded-files {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .custom-file-item {
- display: block;
- width: 100%;
- padding: 12px;
- border: 1px solid #e4e7ed;
- border-radius: 6px;
- background-color: #fafafa;
- transition: all 0.3s ease;
- }
- .custom-file-item:hover {
- background-color: #f0f9ff;
- border-color: #409eff;
- }
- .file-info {
- display: flex;
- align-items: flex-start;
- width: 100%;
- }
- .file-preview {
- margin-right: 12px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- }
- .doc-icon {
- color: #2563eb;
- cursor: pointer;
- }
- .file-icon {
- color: #718096;
- }
- .file-content {
- flex: 1;
- min-width: 0;
- }
- .file-name {
- font-size: 14px;
- color: #303133;
- /* margin-bottom: 8px; */
- word-break: break-all;
- line-height: 1.4;
- }
- .file-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 15px;
- }
- .image-preview-container {
- text-align: center;
- padding: 20px;
- }
- .document-preview-container {
- width: 100%;
- height: 600px;
- border: 1px solid #e4e7ed;
- border-radius: 4px;
- overflow: hidden;
- }
- /* 隐藏默认的文件列表 */
- :deep(.el-upload-list) {
- display: none;
- }
- .detail-btn {
- font-size: 12px;
- color: #015293;
- cursor: pointer;
- }
- .delete-btn {
- font-size: 12px;
- color: #F56c6c;
- cursor: pointer;
- }
- .video-icon {
- cursor: pointer;
- transition: transform 0.2s;
- }
- .video-icon:hover {
- transform: scale(1.1);
- }
- .video-preview-container {
- display: flex;
- justify-content: center;
- align-items: center;
- background: #000;
- border-radius: 4px;
- padding: 20px;
- }
- /* 响应式设计 */
- @media (max-width: 768px) {
- .file-actions {
- margin-top: 4px;
- }
-
- .file-actions .el-button {
- padding: 4px 8px;
- font-size: 12px;
- }
- }
- </style>
|