|
|
<template> <div class="embed-upload" :class="{ 'has-file': fileList.length > 0 }"> <div class="upload-header"> <span class="upload-title">原文上传</span> <span class="upload-tip">单个文件不可超过10GB</span> </div>
<div class="uploader-drop" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop"> <div class="uploader-btn"> <i class="iconfont icon-tianjiawenjian upload-icon" /> <p>{{ fileList.length > 0 ? '点击继续添加文件' : '点击或拖拽上传文件' }}</p> <!-- :disabled="!isCaValid || isCheckingCa" --> <input ref="fileInput" type="file" :multiple="true"
class="file-input" style="display: none;" @change="handleFileSelect" > </div> <div class="el-upload__tip">支持多文件上传,最大10GB/个</div> </div>
<div v-if="fileList.length > 0" class="file-list-container"> <div v-for="(fileItem, index) in fileList" :key="index" class="file-item"> <div class="file-info"> <i :class="getFileIcon(fileItem.file.name)" /> <span class="file-name">{{ fileItem.file.name }}</span> <span class="file-size">{{ formatFileSize(fileItem.file.size) }}</span> </div> <div v-if="fileItem.uploading || fileItem.merging" class="file-status"> <span v-if="fileItem.uploading" class="progress-text">上传中 {{ fileItem.progress }}%</span> <span v-else class="progress-text">合并中...</span> <div v-if="fileItem.uploading" class="progress-bar" :style="{ width: fileItem.progress + '%' }" /> </div> <p v-if="fileItem.errorMsg" class="error">{{ fileItem.errorMsg }}</p> <p v-if="fileItem.successMsg" class="success">{{ fileItem.successMsg }}</p> <i class="iconfont icon-shanchu delete-btn" @click.stop="handleDeleteFile(index)" /> </div> </div>
<div v-if="fileList.length > 0" class="upload-footer"> <span class="file-count">共 {{ fileList.length }} 个文件</span> <el-button :disabled="fileList.some(item => item.uploading || item.merging) || btnLoading" :loading="btnLoading" type="primary" @click="handleUploadConfirm" > 保存上传 </el-button> </div> </div></template>
<script>import { FetchCheckCaValidity } from '@/api/system/auth2'import { FetchInitFileCategoryView } from '@/api/collect/collect'import { mapGetters } from 'vuex'import axios from 'axios'import SparkMD5 from 'spark-md5'import { getToken } from '@/utils/auth'import { getCurrentTime } from '@/utils/index'
export default { name: 'EmbedUpload', props: { selectedCategory: { type: Object, default: () => ({}) }, arcId: { type: String, default: '' } }, data() { return { fileList: [], CHUNK_SIZE: 5 * 1024 * 1024, isCaValid: false, isCheckingCa: false, btnLoading: false, originFileData: [], repeatFileData: [], tempSelectedFiles: null } }, computed: { ...mapGetters(['baseApi']) }, mounted() { this.getCheckCaValidity() }, methods: { triggerFileInput() { this.$refs.fileInput.click() }, getFileIcon(fileName) { const ext = fileName.split('.').pop().toLowerCase() const icons = { 'jpg': 'icon-image', 'jpeg': 'icon-image', 'png': 'icon-image', 'bmp': 'icon-image', 'gif': 'icon-image', 'xlsx': 'icon-excel', 'xls': 'icon-excel', 'docx': 'icon-word', 'doc': 'icon-word', 'pdf': 'icon-pdf', 'ppt': 'icon-ppt', 'pptx': 'icon-ppt', 'zip': 'icon-zip', 'rar': 'icon-zip', 'txt': 'icon-txt' } return `fileIcon ${icons[ext] || 'icon-other'}` }, formatFileSize(bytes) { if (bytes === 0) return '0.00MB' const mb = bytes / (1024 * 1024) return mb.toFixed(2) + 'MB' }, showMessage(message, type) { this.$message({ message, type, offset: 8 }) }, async getCheckCaValidity() { try { this.isCheckingCa = true const res = await FetchCheckCaValidity() this.isCaValid = !!res if (!this.isCaValid) { this.showMessage('CA证书不在有效期内,无法进行分片上传', 'error') } } catch (err) { this.isCaValid = false this.showMessage('CA证书校验失败:' + err.message, 'error') } finally { this.isCheckingCa = false } }, async getFileList() { const params = { 'categoryId': this.selectedCategory.id, 'archivesId': this.arcId } const res = await FetchInitFileCategoryView(params) this.originFileData = res.returnlist || [] }, async handleDrop(e) { if (this.isCheckingCa) { this.showMessage('正在校验CA证书,请稍候...', 'warning') return } if (!this.isCaValid) { this.showMessage('CA证书不在有效期内,无法上传文件', 'error') return }
const selectedFiles = Array.from(e.dataTransfer.files) if (selectedFiles.length === 0) return
await this.getFileList() const existingFileNames = this.originFileData.map(file => file.file_name) this.repeatFileData = selectedFiles.filter(file => existingFileNames.includes(file.name))
if (this.repeatFileData.length > 0) { this.tempSelectedFiles = selectedFiles this.$emit('show-repeat-modal', this.repeatFileData) return }
this.addFiles(selectedFiles) }, async handleFileSelect(e) { if (this.isCheckingCa) { this.showMessage('正在校验CA证书,请稍候...', 'warning') e.target.value = '' return } if (!this.isCaValid) { this.showMessage('CA证书不在有效期内,无法上传文件', 'error') e.target.value = '' return }
const selectedFiles = Array.from(e.target.files) if (selectedFiles.length === 0) return
await this.getFileList() const existingFileNames = this.originFileData.map(file => file.file_name) this.repeatFileData = selectedFiles.filter(file => existingFileNames.includes(file.name))
if (this.repeatFileData.length > 0) { this.tempSelectedFiles = selectedFiles this.$emit('show-repeat-modal', this.repeatFileData) e.target.value = '' return }
this.addFiles(selectedFiles) e.target.value = '' }, addFiles(files) { const newFileList = files.map(file => ({ file, uploading: false, merging: false, progress: 0, errorMsg: '', successMsg: '', md5: '' })) this.fileList = [...this.fileList, ...newFileList] }, handleDeleteFile(index) { const fileItem = this.fileList[index] if (fileItem.uploading || fileItem.merging) { this.showMessage('当前文件正在上传/合并中,无法删除', 'warning') return } this.fileList.splice(index, 1) }, handleRepeatFile(type) { if (!this.tempSelectedFiles) return
let filesToUpload = [] if (type === 0) { filesToUpload = this.tempSelectedFiles } else { const existingFileNames = this.originFileData.map(file => file.file_name) filesToUpload = this.tempSelectedFiles.filter(file => !existingFileNames.includes(file.name)) if (filesToUpload.length === 0) { this.showMessage('当前所选文件去重后无可上传的文件', 'error') return } }
this.addFiles(filesToUpload) this.tempSelectedFiles = null }, calculateFileMd5(file) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer() const fileReader = new FileReader() const chunkSize = 2 * 1024 * 1024 let offset = 0
const loadNextChunk = () => { const blob = file.slice(offset, offset + chunkSize) fileReader.readAsArrayBuffer(blob) }
fileReader.onload = (e) => { spark.append(e.target.result) offset += chunkSize if (offset < file.size) { loadNextChunk() } else { resolve(spark.end()) } }
fileReader.onerror = (err) => reject(err) loadNextChunk() }) }, async checkChunkExists(fileMd5, chunkIndex) { const linkSrc = process.env.NODE_ENV === 'production' ? window.g.ApiUrl : process.env.VUE_APP_BASE_API const response = await axios.get(`${linkSrc}/api/collect/chunk`, { params: { fileMd5, chunkIndex }, headers: { 'Authorization': getToken() } }) if (response.data.code !== 200) { throw new Error('检查分片失败: ' + response.data.msg) } return response.data.data }, async uploadSingleChunk(file, fileMd5, chunkIndex) { const start = chunkIndex * this.CHUNK_SIZE const end = Math.min(start + this.CHUNK_SIZE, file.size) const chunkBlob = file.slice(start, end)
const formData = new FormData() formData.append('file', chunkBlob, `${fileMd5}_${chunkIndex}`) formData.append('fileMd5', fileMd5) formData.append('chunkIndex', chunkIndex)
const linkSrc = process.env.NODE_ENV === 'production' ? window.g.ApiUrl : process.env.VUE_APP_BASE_API const response = await axios.post(`${linkSrc}/api/collect/chunk`, formData, { headers: { 'Content-Type': 'multipart/form-data', 'Authorization': getToken() } })
if (response.data.code !== 200) { throw new Error(`分片${chunkIndex}上传失败: ` + response.data.msg) } }, async checkAllChunksExist(fileMd5, totalChunks) { for (let i = 0; i < totalChunks; i++) { const result = await this.checkChunkExists(fileMd5, i) if (!result.exists) { return false } } return true }, async uploadFileChunks(fileItem) { const file = fileItem.file fileItem.uploading = true fileItem.progress = 0 fileItem.errorMsg = '' fileItem.successMsg = ''
try { const fileMd5 = await this.calculateFileMd5(file) fileItem.md5 = fileMd5
const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE) const uploadedChunks = []
for (let i = 0; i < totalChunks; i++) { const checkResult = await this.checkChunkExists(fileMd5, i) if (!checkResult.exists) { await this.uploadSingleChunk(file, fileMd5, i) } uploadedChunks.push(i) fileItem.progress = Math.round((uploadedChunks.length / totalChunks) * 100) } fileItem.progress = 100 } catch (err) { fileItem.errorMsg = '上传失败: ' + (err.message || '未知错误') } finally { fileItem.uploading = false } }, async handleUploadConfirm() { if (this.fileList.length === 0) { this.showMessage('请先选择要上传的文件!', 'warning') return }
const pendingFiles = this.fileList.filter(item => !item.successMsg && !item.errorMsg) if (pendingFiles.length === 0) { this.showMessage('暂无待上传的文件!', 'warning') return }
this.btnLoading = true
for (const fileItem of pendingFiles) { await this.uploadFileChunks(fileItem) }
const validFiles = this.fileList.filter(item => !item.errorMsg && item.progress === 100) if (validFiles.length === 0) { this.btnLoading = false this.showMessage('无有效分片上传完成的文件!', 'error') return }
validFiles.forEach(fileItem => { fileItem.merging = true })
try { const processFiles = validFiles.map(async(fileItem) => { const file = fileItem.file const jsonString = {}
if (file.type.startsWith('image')) { const reader = new FileReader() const base64 = await new Promise((resolve) => { reader.onload = (e) => resolve(e.target.result) reader.readAsDataURL(file) }) const img = new Image() const imgRes = await new Promise((resolve) => { img.onload = () => resolve({ width: img.width, height: img.height }) img.src = base64 }) jsonString.file_dpi = `${imgRes.width}px*${imgRes.height}px` } else { jsonString.file_dpi = '' }
jsonString.file_name = file.name jsonString.file_size = file.size jsonString.file_type = file.name.split('.').pop() || '' jsonString.last_modified = file.lastModified jsonString.file_path = '' jsonString.sequence = null jsonString.archive_id = this.arcId jsonString.create_time = getCurrentTime() jsonString.id = null jsonString.file_thumbnail = ''
const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE) const chunksExist = await this.checkAllChunksExist(fileItem.md5, totalChunks) if (!chunksExist) { throw new Error(`部分分片未上传完成`) }
return { categoryId: this.selectedCategory.id, archivesId: this.arcId, identifier: fileItem.md5, filename: file.name, totalChunks: totalChunks, totalSize: file.size, fileJsonString: JSON.stringify([jsonString]) } })
const jsonArray = await Promise.all(processFiles)
const linkSrc = process.env.NODE_ENV === 'production' ? window.g.ApiUrl : process.env.VUE_APP_BASE_API const response = await axios.post(`${linkSrc}/api/collect/merge`, jsonArray, { headers: { 'Authorization': getToken(), 'Content-Type': 'application/json' } })
if (response.data.code === 200) { this.showMessage('所有文件上传并合并成功', 'success') validFiles.forEach(fileItem => { fileItem.successMsg = '上传成功!' fileItem.merging = false }) this.$emit('onUploadSuccess') } else { this.showMessage(`文件合并失败: ${response.data.message || '合并失败'}`, 'error') } } catch (err) { this.showMessage(`文件合并失败: ${err.message}`, 'error') validFiles.forEach(fileItem => { fileItem.merging = false fileItem.errorMsg = fileItem.errorMsg || `合并失败: ${err.message}` }) } finally { this.btnLoading = false } } }}</script>
<style scoped>.embed-upload { flex: 1; margin-left: 10px; border: 1px solid #e4e7ed; border-radius: 4px; background-color: #fff; display: flex; flex-direction: column;}
.upload-header { padding: 12px 15px; border-bottom: 1px solid #e4e7ed; display: flex; justify-content: space-between; align-items: center;}
.upload-title { font-weight: 500; font-size: 14px; color: #303133;}
.upload-tip { font-size: 12px; color: #ff4949;}
.uploader-drop { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 20px; cursor: pointer; min-height: 150px;}
.uploader-btn { display: flex; flex-direction: column; align-items: center; color: #606266;}
.upload-icon { font-size: 32px; color: #1F55EB; margin-bottom: 10px;}
.uploader-btn p { margin: 0; font-size: 14px;}
.el-upload__tip { font-size: 12px; color: #A6ADB6; margin-top: 10px;}
.file-list-container { max-height: 200px; overflow-y: auto; padding: 0 15px;}
.file-item { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 10px; border: 1px dashed #409eff; border-radius: 4px; margin-bottom: 8px;}
.file-info { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex: 1; line-height: 32px;}
.fileIcon { font-size: 16px;}
.file-name { flex: 1; font-size: 13px; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.file-size { font-size: 12px; color: #909399;}
.file-status { margin-top: 8px;}
.progress-text { font-size: 12px; color: #606266;}
.progress-bar { height: 6px; background-color: #42b983; transition: width 0.3s ease; border-radius: 3px; margin-top: 4px;}
.delete-btn { /* position: absolute; right: 10px; top: 10px; */ font-size: 18px; color: #909399; cursor: pointer; padding-left: 10px;}
.delete-btn:hover { color: #ff4444;}
.success { margin: 4px 0 0 0; font-size: 12px; color: #00C851;}
.error { margin: 4px 0 0 0; font-size: 12px; color: #ff4444;}
.upload-footer { padding: 12px 15px; border-top: 1px solid #e4e7ed; display: flex; justify-content: space-between; align-items: center;}
.file-count { font-size: 12px; color: #606266;}</style>
|