|
|
@@ -0,0 +1,471 @@
|
|
|
+<template>
|
|
|
+ <div class="file-uploader-container">
|
|
|
+ <el-upload
|
|
|
+ ref="uploadRef"
|
|
|
+ :action="uploadUrl"
|
|
|
+ :headers="headers"
|
|
|
+ :method="method"
|
|
|
+ :multiple="multiple"
|
|
|
+ :data="uploadData"
|
|
|
+ :name="fileFieldName"
|
|
|
+ :with-credentials="withCredentials"
|
|
|
+ :show-file-list="showFileList"
|
|
|
+ :accept="accept"
|
|
|
+ :limit="limit"
|
|
|
+ :file-list="fileList"
|
|
|
+ :auto-upload="autoUpload"
|
|
|
+ :drag="drag"
|
|
|
+ :list-type="listType"
|
|
|
+ :before-upload="handleBeforeUpload"
|
|
|
+ :before-remove="handleBeforeRemove"
|
|
|
+ :on-success="handleSuccess"
|
|
|
+ :on-error="handleError"
|
|
|
+ :on-progress="handleProgress"
|
|
|
+ :on-change="handleChange"
|
|
|
+ :on-remove="handleRemove"
|
|
|
+ :on-exceed="handleExceed"
|
|
|
+ :on-preview="handlePreview"
|
|
|
+ :on-download="handleDownload"
|
|
|
+ >
|
|
|
+ <template v-if="drag">
|
|
|
+ <el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
|
|
+ <div class="el-upload__text">
|
|
|
+ 将文件拖到此处,或 <em>点击上传</em>
|
|
|
+ </div>
|
|
|
+ <template v-if="tip" class="el-upload__tip">{{ tip }}</template>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <slot name="trigger">
|
|
|
+ <el-button type="primary">
|
|
|
+ <el-icon><Plus /></el-icon>
|
|
|
+ <span class="ml10">{{ buttonText }}</span>
|
|
|
+ </el-button>
|
|
|
+ </slot>
|
|
|
+ <template v-if="tip" class="el-upload__tip">
|
|
|
+ <span class="ml10 gray999">{{ tip }}</span>
|
|
|
+ </template>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template #file-list>
|
|
|
+ <slot name="file-list" :file-list="fileList">
|
|
|
+ <el-upload-list :file-list="fileList" @remove="handleRemove">
|
|
|
+ <template #default="{ file }">
|
|
|
+ <el-upload-list-item
|
|
|
+ :file="file"
|
|
|
+ :name="fileFieldName"
|
|
|
+ @remove="handleRemove"
|
|
|
+ >
|
|
|
+ <template #actions>
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ @click="handlePreview(file)"
|
|
|
+ >
|
|
|
+ <el-icon><zoom-in /></el-icon>
|
|
|
+ 预览
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ @click="handleDownload(file)"
|
|
|
+ >
|
|
|
+ <el-icon><download /></el-icon>
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ @click="handleRemove(file)"
|
|
|
+ >
|
|
|
+ <el-icon><delete /></el-icon>
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-upload-list-item>
|
|
|
+ </template>
|
|
|
+ </el-upload-list>
|
|
|
+ </slot>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 上传进度条 -->
|
|
|
+ <template v-if="showProgress" #progress="{ file }">
|
|
|
+ <el-progress
|
|
|
+ :percentage="file.percentage"
|
|
|
+ :stroke-width="2"
|
|
|
+ :show-text="false"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+
|
|
|
+ <!-- 预览对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="previewVisible"
|
|
|
+ :title="previewTitle"
|
|
|
+ width="80%"
|
|
|
+ destroy-on-close
|
|
|
+ >
|
|
|
+ <div class="preview-container">
|
|
|
+ <template v-if="isImageFile(currentPreviewFile)">
|
|
|
+ <img :src="currentPreviewFile.url" alt="预览" class="preview-image" />
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else-if="isTextFile(currentPreviewFile)">
|
|
|
+ <pre class="preview-text">{{ currentPreviewContent }}</pre>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div class="preview-other">
|
|
|
+ <el-icon class="file-icon"><document /></el-icon>
|
|
|
+ <div class="file-info">
|
|
|
+ <div class="file-name">{{ currentPreviewFile.name }}</div>
|
|
|
+ <div class="file-size">{{ formatFileSize(currentPreviewFile.size) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, watch } from 'vue'
|
|
|
+import {
|
|
|
+ UploadFilled,
|
|
|
+ ZoomIn,
|
|
|
+ Download,
|
|
|
+ Delete,
|
|
|
+ Document,
|
|
|
+ Plus
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+
|
|
|
+// 组件属性
|
|
|
+const props = defineProps({
|
|
|
+ // 上传地址
|
|
|
+ uploadUrl: {
|
|
|
+ type: String,
|
|
|
+ required: true
|
|
|
+ },
|
|
|
+ // 请求头
|
|
|
+ headers: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ // 请求方法
|
|
|
+ method: {
|
|
|
+ type: String,
|
|
|
+ default: 'post'
|
|
|
+ },
|
|
|
+ // 是否支持多文件上传
|
|
|
+ multiple: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ // 上传时附带的额外参数
|
|
|
+ data: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ // 文件字段名
|
|
|
+ fileFieldName: {
|
|
|
+ type: String,
|
|
|
+ default: 'file'
|
|
|
+ },
|
|
|
+ // 是否携带凭证信息
|
|
|
+ withCredentials: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ // 是否显示已上传文件列表
|
|
|
+ showFileList: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ // 接受的文件类型
|
|
|
+ accept: {
|
|
|
+ type: String,
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ // 最大允许上传文件数量
|
|
|
+ limit: {
|
|
|
+ type: Number,
|
|
|
+ default: 0 // 0 表示不限制
|
|
|
+ },
|
|
|
+ // 已上传的文件列表
|
|
|
+ modelValue: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ // 是否自动上传
|
|
|
+ autoUpload: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ // 是否启用拖拽上传
|
|
|
+ drag: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ // 文件列表的类型
|
|
|
+ listType: {
|
|
|
+ type: String,
|
|
|
+ default: 'text', // text, picture, picture-card
|
|
|
+ validator: (value) => ['text', 'picture', 'picture-card'].includes(value)
|
|
|
+ },
|
|
|
+ // 上传按钮文字
|
|
|
+ buttonText: {
|
|
|
+ type: String,
|
|
|
+ default: '点击上传'
|
|
|
+ },
|
|
|
+ // 提示文字
|
|
|
+ tip: {
|
|
|
+ type: String,
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ // 最大文件大小 (MB)
|
|
|
+ maxSize: {
|
|
|
+ type: Number,
|
|
|
+ default: 0 // 0 表示不限制
|
|
|
+ },
|
|
|
+ // 是否显示进度条
|
|
|
+ showProgress: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 组件事件
|
|
|
+const emit = defineEmits([
|
|
|
+ 'update:modelValue', // 文件列表更新事件
|
|
|
+ 'before-upload', // 上传前事件
|
|
|
+ 'before-remove', // 删除前事件
|
|
|
+ 'success', // 上传成功事件
|
|
|
+ 'error', // 上传失败事件
|
|
|
+ 'progress', // 上传进度事件
|
|
|
+ 'change', // 文件状态改变事件
|
|
|
+ 'remove', // 文件移除事件
|
|
|
+ 'exceed', // 文件数量超出限制事件
|
|
|
+ 'preview', // 文件预览事件
|
|
|
+ 'download' // 文件下载事件
|
|
|
+])
|
|
|
+
|
|
|
+// 内部状态
|
|
|
+const uploadRef = ref(null)
|
|
|
+const fileList = ref([...props.modelValue])
|
|
|
+const previewVisible = ref(false)
|
|
|
+const currentPreviewFile = ref(null)
|
|
|
+const currentPreviewContent = ref('')
|
|
|
+
|
|
|
+// 计算属性
|
|
|
+const uploadData = computed(() => props.data)
|
|
|
+const previewTitle = computed(() => currentPreviewFile.value ? `预览:${currentPreviewFile.value.name}` : '文件预览')
|
|
|
+
|
|
|
+// 监听modelValue变化,同步到内部fileList
|
|
|
+watch(() => props.modelValue, (newVal) => {
|
|
|
+ fileList.value = [...newVal]
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+// 监听fileList变化,同步到modelValue
|
|
|
+watch(() => fileList.value, (newVal) => {
|
|
|
+ emit('update:modelValue', [...newVal])
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+// 上传前的校验
|
|
|
+const handleBeforeUpload = (rawFile) => {
|
|
|
+ // 文件大小校验
|
|
|
+ if (props.maxSize > 0) {
|
|
|
+ const maxSizeBytes = props.maxSize * 1024 * 1024
|
|
|
+ if (rawFile.size > maxSizeBytes) {
|
|
|
+ emit('error', {
|
|
|
+ message: `文件大小超出限制,最大允许 ${props.maxSize}MB`
|
|
|
+ })
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 调用外部before-upload钩子
|
|
|
+ return emit('before-upload', rawFile) !== false
|
|
|
+}
|
|
|
+
|
|
|
+// 上传成功处理
|
|
|
+const handleSuccess = (response, rawFile, uploadedFiles) => {
|
|
|
+ emit('success', response, rawFile, uploadedFiles)
|
|
|
+}
|
|
|
+
|
|
|
+// 上传失败处理
|
|
|
+const handleError = (error, rawFile, uploadedFiles) => {
|
|
|
+ emit('error', error, rawFile, uploadedFiles)
|
|
|
+}
|
|
|
+
|
|
|
+// 上传进度处理
|
|
|
+const handleProgress = (event, file, uploadedFiles) => {
|
|
|
+ emit('progress', event, file, uploadedFiles)
|
|
|
+}
|
|
|
+
|
|
|
+// 文件状态改变处理
|
|
|
+const handleChange = (file, fileList) => {
|
|
|
+ emit('change', file, fileList)
|
|
|
+}
|
|
|
+
|
|
|
+// 删除文件前的处理
|
|
|
+const handleBeforeRemove = (file, fileList) => {
|
|
|
+ return emit('before-remove', file, fileList) !== false
|
|
|
+}
|
|
|
+
|
|
|
+// 文件移除处理
|
|
|
+const handleRemove = (file, fileList) => {
|
|
|
+ emit('remove', file, fileList)
|
|
|
+}
|
|
|
+
|
|
|
+// 文件数量超出限制处理
|
|
|
+const handleExceed = (files, fileList) => {
|
|
|
+ emit('exceed', files, fileList)
|
|
|
+}
|
|
|
+
|
|
|
+// 文件预览处理
|
|
|
+const handlePreview = (file) => {
|
|
|
+ currentPreviewFile.value = file
|
|
|
+
|
|
|
+ // 图片文件直接预览
|
|
|
+ if (isImageFile(file)) {
|
|
|
+ previewVisible.value = true
|
|
|
+ emit('preview', file)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 文本文件读取内容后预览
|
|
|
+ if (isTextFile(file)) {
|
|
|
+ if (file.url) {
|
|
|
+ fetch(file.url)
|
|
|
+ .then(response => response.text())
|
|
|
+ .then(text => {
|
|
|
+ currentPreviewContent.value = text
|
|
|
+ previewVisible.value = true
|
|
|
+ emit('preview', file)
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ console.error('读取文件内容失败:', error)
|
|
|
+ emit('error', error, file)
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ previewVisible.value = true
|
|
|
+ emit('preview', file)
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他文件类型
|
|
|
+ previewVisible.value = true
|
|
|
+ emit('preview', file)
|
|
|
+}
|
|
|
+
|
|
|
+// 文件下载处理
|
|
|
+const handleDownload = (file) => {
|
|
|
+ emit('download', file)
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助方法:判断是否为图片文件
|
|
|
+const isImageFile = (file) => {
|
|
|
+ return file.type.startsWith('image/') ||
|
|
|
+ /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name)
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助方法:判断是否为文本文件
|
|
|
+const isTextFile = (file) => {
|
|
|
+ return file.type.startsWith('text/') ||
|
|
|
+ /\.(txt|json|yaml|yml|xml|html|htm|css|js|ts|md|markdown)$/i.test(file.name)
|
|
|
+}
|
|
|
+
|
|
|
+// 辅助方法:格式化文件大小
|
|
|
+const formatFileSize = (size) => {
|
|
|
+ if (size < 1024) {
|
|
|
+ return `${size} B`
|
|
|
+ } else if (size < 1024 * 1024) {
|
|
|
+ return `${(size / 1024).toFixed(2)} KB`
|
|
|
+ } else if (size < 1024 * 1024 * 1024) {
|
|
|
+ return `${(size / (1024 * 1024)).toFixed(2)} MB`
|
|
|
+ } else {
|
|
|
+ return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 暴露方法给父组件
|
|
|
+defineExpose({
|
|
|
+ // 手动上传文件
|
|
|
+ submitUpload: () => uploadRef.value?.submit(),
|
|
|
+ // 清除文件列表
|
|
|
+ clearFiles: () => uploadRef.value?.clearFiles(),
|
|
|
+ // 撤销上传请求
|
|
|
+ abort: (file) => uploadRef.value?.abort(file),
|
|
|
+ // 获取当前文件列表
|
|
|
+ getFileList: () => [...fileList.value],
|
|
|
+ // 手动添加文件到列表
|
|
|
+ addFile: (file) => {
|
|
|
+ fileList.value.push(file)
|
|
|
+ return fileList.value
|
|
|
+ },
|
|
|
+ // 手动移除文件
|
|
|
+ removeFile: (file) => {
|
|
|
+ const index = fileList.value.findIndex(item => item.uid === file.uid)
|
|
|
+ if (index > -1) {
|
|
|
+ fileList.value.splice(index, 1)
|
|
|
+ }
|
|
|
+ return fileList.value
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.file-uploader-container {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ .preview-container {
|
|
|
+ width: 100%;
|
|
|
+ max-height: 60vh;
|
|
|
+ overflow: auto;
|
|
|
+
|
|
|
+ .preview-image {
|
|
|
+ width: 100%;
|
|
|
+ height: auto;
|
|
|
+ object-fit: contain;
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview-text {
|
|
|
+ width: 100%;
|
|
|
+ padding: 10px;
|
|
|
+ background-color: #f5f5f5;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-family: monospace;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ }
|
|
|
+
|
|
|
+ .preview-other {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 40px 0;
|
|
|
+
|
|
|
+ .file-icon {
|
|
|
+ font-size: 64px;
|
|
|
+ color: #409eff;
|
|
|
+ margin-right: 20px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-info {
|
|
|
+ text-align: left;
|
|
|
+
|
|
|
+ .file-name {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 500;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-size {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #666;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|