|
@@ -0,0 +1,369 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+<div class="rectification-upload">
|
|
|
|
|
+ <el-upload
|
|
|
|
|
+ action="#"
|
|
|
|
|
+ :file-list="uploadFileList"
|
|
|
|
|
+ :accept="accept"
|
|
|
|
|
+ :before-upload="beforeUpload"
|
|
|
|
|
+ :http-request="handlePostFile"
|
|
|
|
|
+ :show-file-list="false"
|
|
|
|
|
+ :limit="limit"
|
|
|
|
|
+ :multiple="multiple"
|
|
|
|
|
+ >
|
|
|
|
|
+ <slot name="trigger">
|
|
|
|
|
+ <el-button :disabled="disabled">
|
|
|
|
|
+ <el-icon><Upload /></el-icon>
|
|
|
|
|
+ 上传文件
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </slot>
|
|
|
|
|
+ </el-upload>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- PDF 文件列表(纯文字,无预览网格) -->
|
|
|
|
|
+ <div v-if="isPdfOnly && processedList.length > 0" class="pdf-file-list">
|
|
|
|
|
+ <div v-for="(file, index) in processedList" :key="file.uid || index" class="pdf-file-item">
|
|
|
|
|
+ <el-icon :size="18"><Document /></el-icon>
|
|
|
|
|
+ <span class="pdf-file-name" :title="displayName(file)">{{ displayName(file) }}</span>
|
|
|
|
|
+ <span class="pdf-action download" @click="handleDownload(file)">下载</span>
|
|
|
|
|
+ <span class="pdf-action" @click="handleRemove(file)">删除</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 图片/视频预览网格 -->
|
|
|
|
|
+ <div v-if="!isPdfOnly && processedList.length > 0" class="preview-grid">
|
|
|
|
|
+ <div v-for="(file, index) in processedList" :key="file.uid || index" class="preview-item">
|
|
|
|
|
+ <div class="item-body" @click="handleItemClick(file)">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ v-if="isImage(file)"
|
|
|
|
|
+ class="item-thumb"
|
|
|
|
|
+ :src="file.url"
|
|
|
|
|
+ :preview-src-list="allImageUrls"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ preview-teleported
|
|
|
|
|
+ />
|
|
|
|
|
+ <video v-else class="item-thumb" :src="file.url"></video>
|
|
|
|
|
+ <div v-if="isVideo(file)" class="play-overlay">
|
|
|
|
|
+ <el-icon :size="28"><VideoPlay /></el-icon>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="item-footer">
|
|
|
|
|
+ <span class="item-name" :title="file.name">{{ file.name }}</span>
|
|
|
|
|
+ <div class="item-actions">
|
|
|
|
|
+ <span class="action-btn" @click.stop="handleDownload(file)">下载</span>
|
|
|
|
|
+ <span class="action-btn delete" @click.stop="handleRemove(file)">删除</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 视频大屏播放弹窗 -->
|
|
|
|
|
+ <el-dialog
|
|
|
|
|
+ v-model="videoVisible"
|
|
|
|
|
+ class="preview-dialog"
|
|
|
|
|
+ width="auto"
|
|
|
|
|
+ :show-footer="false"
|
|
|
|
|
+ :show-close="false"
|
|
|
|
|
+ align-center
|
|
|
|
|
+ destroy-on-close
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="video-preview-container">
|
|
|
|
|
+ <video :src="currentVideoUrl" autoplay controls style="max-height: 75vh; max-width: 90vw;"></video>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+</div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup>
|
|
|
|
|
+import { ref, computed } from 'vue'
|
|
|
|
|
+import { Upload, Document, VideoPlay } from '@element-plus/icons-vue'
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import request from '@/config/axios'
|
|
|
|
|
+import { getAccessToken } from '@/utils/auth'
|
|
|
|
|
+import { buildFileUrl } from '@/utils'
|
|
|
|
|
+
|
|
|
|
|
+const VITE_BASE_URL = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/'
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps({
|
|
|
|
|
+ accept: { type: String, default: '' },
|
|
|
|
|
+ fileList: { type: Array, default: () => [] },
|
|
|
|
|
+ limit: { type: Number, default: 0 },
|
|
|
|
|
+ disabled: { type: Boolean, default: false },
|
|
|
|
|
+ maxSize: { type: Number, default: 0 },
|
|
|
|
|
+ multiple: { type: Boolean, default: false },
|
|
|
|
|
+ apiUrl: { type: String, required: true },
|
|
|
|
|
+ defaultFileName: { type: String, default: '' }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits(['update:fileList'])
|
|
|
|
|
+
|
|
|
|
|
+const videoVisible = ref(false)
|
|
|
|
|
+const currentVideoUrl = ref('')
|
|
|
|
|
+
|
|
|
|
|
+const isPdfOnly = computed(() => props.accept.includes('.pdf'))
|
|
|
|
|
+
|
|
|
|
|
+const displayName = (file) => {
|
|
|
|
|
+ if (isPdfOnly.value && props.defaultFileName) {
|
|
|
|
|
+ return props.defaultFileName + '.pdf'
|
|
|
|
|
+ }
|
|
|
|
|
+ return file.name
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const processedList = computed(() => {
|
|
|
|
|
+ return (props.fileList || []).map(file => ({
|
|
|
|
|
+ ...file,
|
|
|
|
|
+ url: file.url ? buildFileUrl(file.url) : (file.path ? buildFileUrl(file.path) : '')
|
|
|
|
|
+ }))
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const uploadFileList = computed(() => {
|
|
|
|
|
+ return processedList.value.map(file => ({
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ url: file.url,
|
|
|
|
|
+ uid: file.uid
|
|
|
|
|
+ }))
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const allImageUrls = computed(() => {
|
|
|
|
|
+ return processedList.value.filter(f => isImage(f)).map(f => f.url)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const isImage = (file) => {
|
|
|
|
|
+ const ext = (file.name || file.url || '').split('.').pop()?.toLowerCase()
|
|
|
|
|
+ return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const isVideo = (file) => {
|
|
|
|
|
+ const ext = (file.name || file.url || '').split('.').pop()?.toLowerCase()
|
|
|
|
|
+ return ['mp4', 'webm', 'ogg', 'mov', 'avi'].includes(ext)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleItemClick = (file) => {
|
|
|
|
|
+ if (isVideo(file)) {
|
|
|
|
|
+ currentVideoUrl.value = file.url
|
|
|
|
|
+ videoVisible.value = true
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleDownload = (file) => {
|
|
|
|
|
+ const link = document.createElement('a')
|
|
|
|
|
+ link.href = file.url
|
|
|
|
|
+ link.download = file.name
|
|
|
|
|
+ document.body.appendChild(link)
|
|
|
|
|
+ link.click()
|
|
|
|
|
+ document.body.removeChild(link)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleRemove = (file) => {
|
|
|
|
|
+ const newList = [...props.fileList]
|
|
|
|
|
+ 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 (index > -1) {
|
|
|
|
|
+ newList.splice(index, 1)
|
|
|
|
|
+ emit('update:fileList', newList)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 beforeUpload = (file) => {
|
|
|
|
|
+ if (props.limit > 0 && processedList.value.length >= props.limit) {
|
|
|
|
|
+ ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if (props.accept !== '*' && props.accept) {
|
|
|
|
|
+ const ext = '.' + (file.name || '').split('.').pop()?.toLowerCase()
|
|
|
|
|
+ const acceptExts = props.accept.split(',').map(s => s.trim().toLowerCase())
|
|
|
|
|
+ if (!acceptExts.includes(ext)) {
|
|
|
|
|
+ ElMessage.error('只能上传 ' + props.accept + ' 格式文件')
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (props.maxSize > 0 && file.size > props.maxSize) {
|
|
|
|
|
+ ElMessage.error(`文件大小不能超过 ${formatFileSize(props.maxSize)}`)
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handlePostFile = async (options) => {
|
|
|
|
|
+ const { file, onProgress, onSuccess, onError } = options
|
|
|
|
|
+ try {
|
|
|
|
|
+ const formData = new FormData()
|
|
|
|
|
+ formData.append('file', file)
|
|
|
|
|
+ const result = await request.post({
|
|
|
|
|
+ url: VITE_BASE_URL + props.apiUrl,
|
|
|
|
|
+ data: formData,
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ authorization: 'Bearer ' + getAccessToken(),
|
|
|
|
|
+ 'Content-Type': 'multipart/form-data'
|
|
|
|
|
+ },
|
|
|
|
|
+ onUploadProgress: (e) => {
|
|
|
|
|
+ const percent = Math.round((e.loaded / file.size) * 100)
|
|
|
|
|
+ onProgress({ percent })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ if (result) {
|
|
|
|
|
+ const fileObj = {
|
|
|
|
|
+ name: file.name,
|
|
|
|
|
+ url: result,
|
|
|
|
|
+ uid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
|
|
|
+ }
|
|
|
|
|
+ const newList = [...props.fileList, fileObj]
|
|
|
|
|
+ emit('update:fileList', newList)
|
|
|
|
|
+ onSuccess(result)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ onError(err)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.rectification-upload {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.pdf-file-list {
|
|
|
|
|
+ margin-top: 10px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.pdf-file-item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ background: #fafbfc;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+
|
|
|
|
|
+ .el-icon {
|
|
|
|
|
+ color: #409eff;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .pdf-file-name {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ color: #303133;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .pdf-action {
|
|
|
|
|
+ color: #f56c6c;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ &:hover { text-decoration: underline; }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ margin-top: 12px;
|
|
|
|
|
+ padding-top: 12px;
|
|
|
|
|
+ border-top: 1px dashed #e4e7ed;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-item {
|
|
|
|
|
+ border: 1px solid #ebeef5;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+
|
|
|
|
|
+ .item-body {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ aspect-ratio: 1;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+
|
|
|
|
|
+ .item-thumb {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ object-fit: cover;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .play-overlay {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.25);
|
|
|
|
|
+ color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &:hover .play-overlay {
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.45);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .item-footer {
|
|
|
|
|
+ padding: 6px 8px;
|
|
|
|
|
+ background: #fafbfc;
|
|
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+
|
|
|
|
|
+ .item-name {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #606266;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ line-height: 1.4;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .item-actions {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+
|
|
|
|
|
+ .action-btn {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #409eff;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ &:hover { text-decoration: underline; }
|
|
|
|
|
+ &.delete { color: #f56c6c; }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+:deep(.preview-dialog) {
|
|
|
|
|
+ background: transparent !important;
|
|
|
|
|
+ .el-dialog__header { display: none; }
|
|
|
|
|
+ .el-dialog__body { padding: 0; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.video-preview-container {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ background: #000;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ padding: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|