384 lines
8.5 KiB
Vue
384 lines
8.5 KiB
Vue
<template>
|
|
<div class="image-upload">
|
|
<a-upload
|
|
v-model:file-list="fileList"
|
|
list-type="picture-card"
|
|
:custom-request="customUpload"
|
|
:before-upload="beforeUpload"
|
|
:accept="accept"
|
|
:max-count="maxCount"
|
|
:disabled="disabled"
|
|
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: !disabled }"
|
|
@preview="handlePreview"
|
|
@change="handleChange"
|
|
@drop="handleDrop"
|
|
@dragenter="handleDragEnter"
|
|
@dragleave="handleDragLeave"
|
|
class="custom-upload"
|
|
:class="{ 'drag-over': isDragOver }"
|
|
>
|
|
<div v-if="fileList.length < maxCount && !disabled" class="upload-area">
|
|
<loading-outlined v-if="uploading" class="upload-icon" />
|
|
<plus-outlined v-else class="upload-icon" />
|
|
<div class="ant-upload-text">{{ uploading ? '上传中...' : uploadText }}</div>
|
|
<div v-if="tip" class="ant-upload-tip">{{ tip }}</div>
|
|
</div>
|
|
</a-upload>
|
|
<a-modal
|
|
:open="previewVisible"
|
|
:title="previewTitle"
|
|
:footer="null"
|
|
:width="800"
|
|
@cancel="handleCancel"
|
|
>
|
|
<img alt="图片预览" style="width: 100%; max-height: 600px; object-fit: contain;" :src="previewImage" />
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed } from 'vue'
|
|
import { message, Modal } from 'ant-design-vue'
|
|
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
|
|
import uploadConfig from '@/config/upload'
|
|
|
|
const props = defineProps({
|
|
// 图片列表
|
|
modelValue: {
|
|
type: [Array, String],
|
|
default: () => []
|
|
},
|
|
// 最大上传数量,默认1为单图上传
|
|
maxCount: {
|
|
type: Number,
|
|
default: 1
|
|
},
|
|
// 接受的文件类型
|
|
accept: {
|
|
type: String,
|
|
default: 'image/*'
|
|
},
|
|
// 是否禁用
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 是否返回URL字符串(单图)或URL数组(多图)
|
|
returnUrl: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
// 上传按钮文字
|
|
uploadText: {
|
|
type: String,
|
|
default: '上传图片'
|
|
},
|
|
// 提示文字
|
|
tip: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
// 最小宽度(像素)
|
|
minWidth: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
// 最大宽度(像素)
|
|
maxWidth: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
// 最小高度(像素)
|
|
minHeight: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
// 最大高度(像素)
|
|
maxHeight: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
// 是否删除前确认
|
|
confirmBeforeRemove: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
// 自定义上传按钮内容
|
|
customUploadBtn: {
|
|
type: Function,
|
|
default: null
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'change', 'preview', 'remove', 'uploadSuccess', 'uploadError'])
|
|
|
|
// 文件列表
|
|
const fileList = ref([])
|
|
|
|
// 预览相关
|
|
const previewVisible = ref(false)
|
|
const previewImage = ref('')
|
|
const previewTitle = computed(() => {
|
|
return previewImage.value ? '图片预览' : ''
|
|
})
|
|
|
|
// 上传状态
|
|
const uploading = ref(false)
|
|
|
|
// 拖拽状态
|
|
const isDragOver = ref(false)
|
|
|
|
// 初始化文件列表
|
|
const initFileList = () => {
|
|
if (props.modelValue) {
|
|
if (typeof props.modelValue === 'string') {
|
|
// 单图上传,字符串格式
|
|
fileList.value = props.modelValue
|
|
? [
|
|
{
|
|
uid: '-1',
|
|
name: 'image.png',
|
|
status: 'done',
|
|
url: props.modelValue
|
|
}
|
|
]
|
|
: []
|
|
} else if (Array.isArray(props.modelValue)) {
|
|
// 多图上传,数组格式
|
|
fileList.value = props.modelValue.map((url, index) => ({
|
|
uid: `-${index}`,
|
|
name: `image${index}.png`,
|
|
status: 'done',
|
|
url: url
|
|
}))
|
|
}
|
|
} else {
|
|
fileList.value = []
|
|
}
|
|
}
|
|
|
|
// 监听外部值变化
|
|
watch(
|
|
() => props.modelValue,
|
|
() => {
|
|
initFileList()
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
// 自定义上传
|
|
const customUpload = (options) => {
|
|
const { file, onProgress, onSuccess, onError } = options
|
|
const formData = new FormData()
|
|
formData.append(uploadConfig.filename || 'file', file)
|
|
|
|
uploading.value = true
|
|
|
|
uploadConfig.apiObj(formData, {
|
|
onUploadProgress: (progressEvent) => {
|
|
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
|
onProgress({ percent }, file)
|
|
}
|
|
})
|
|
.then((res) => {
|
|
const data = uploadConfig.parseData(res)
|
|
if (data.code === uploadConfig.successCode) {
|
|
onSuccess(data, file)
|
|
message.success('上传成功')
|
|
emit('uploadSuccess', data, file)
|
|
} else {
|
|
onError(new Error(data.msg || '上传失败'))
|
|
message.error(data.msg || '上传失败')
|
|
emit('uploadError', data.msg || '上传失败', file)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
onError(error)
|
|
message.error('上传失败:' + error.message)
|
|
emit('uploadError', error.message, file)
|
|
})
|
|
.finally(() => {
|
|
uploading.value = false
|
|
})
|
|
}
|
|
|
|
// 上传前校验
|
|
const beforeUpload = async (file) => {
|
|
// 文件大小校验
|
|
const maxSizeMB = uploadConfig.maxSize || 10
|
|
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
|
|
|
if (file.size > maxSizeBytes) {
|
|
message.error(`图片大小不能超过 ${maxSizeMB}MB`)
|
|
return false
|
|
}
|
|
|
|
// 图片尺寸校验
|
|
if (props.minWidth || props.maxWidth || props.minHeight || props.maxHeight) {
|
|
try {
|
|
const dimensions = await getImageDimensions(file)
|
|
const { width, height } = dimensions
|
|
|
|
if (props.minWidth && width < props.minWidth) {
|
|
message.error(`图片宽度不能小于 ${props.minWidth}px`)
|
|
return false
|
|
}
|
|
if (props.maxWidth && width > props.maxWidth) {
|
|
message.error(`图片宽度不能大于 ${props.maxWidth}px`)
|
|
return false
|
|
}
|
|
if (props.minHeight && height < props.minHeight) {
|
|
message.error(`图片高度不能小于 ${props.minHeight}px`)
|
|
return false
|
|
}
|
|
if (props.maxHeight && height > props.maxHeight) {
|
|
message.error(`图片高度不能大于 ${props.maxHeight}px`)
|
|
return false
|
|
}
|
|
} catch (error) {
|
|
message.error('图片尺寸校验失败')
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// 获取图片尺寸
|
|
const getImageDimensions = (file) => {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image()
|
|
const reader = new FileReader()
|
|
|
|
reader.onload = (e) => {
|
|
img.src = e.target.result
|
|
img.onload = () => {
|
|
resolve({ width: img.width, height: img.height })
|
|
}
|
|
img.onerror = reject
|
|
}
|
|
reader.onerror = reject
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
// 处理预览
|
|
const handlePreview = async (file) => {
|
|
if (!file.url && !file.preview) {
|
|
file.preview = await getBase64(file.originFileObj)
|
|
}
|
|
previewImage.value = file.url || file.preview
|
|
previewVisible.value = true
|
|
emit('preview', file)
|
|
}
|
|
|
|
// 获取Base64
|
|
const getBase64 = (file) => {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.readAsDataURL(file)
|
|
reader.onload = () => resolve(reader.result)
|
|
reader.onerror = (error) => reject(error)
|
|
})
|
|
}
|
|
|
|
// 处理文件列表变化
|
|
const handleChange = ({ fileList: newFileList }) => {
|
|
// 更新文件列表,确保上传成功的文件有正确的 url
|
|
const updatedFileList = newFileList.map((file) => {
|
|
// 如果文件上传成功且有响应数据但没有 url,则设置 url
|
|
if (file.status === 'done' && file.response?.src && !file.url) {
|
|
return {
|
|
...file,
|
|
url: file.response.src
|
|
}
|
|
}
|
|
return file
|
|
})
|
|
|
|
fileList.value = updatedFileList
|
|
|
|
// 过滤掉失败的文件
|
|
const validFileList = updatedFileList.filter((file) => file.status !== 'error')
|
|
|
|
// 提取成功的文件URL
|
|
const successFiles = validFileList
|
|
.filter((file) => file.status === 'done' && (file.url || file.response?.src))
|
|
.map((file) => file.url || file.response?.src)
|
|
|
|
// 触发更新事件
|
|
if (props.returnUrl) {
|
|
// 返回URL字符串或数组
|
|
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
|
|
emit('update:modelValue', value)
|
|
emit('change', value, validFileList)
|
|
} else {
|
|
// 返回完整文件列表
|
|
emit('update:modelValue', validFileList)
|
|
emit('change', validFileList)
|
|
}
|
|
}
|
|
|
|
// 拖拽相关
|
|
const handleDragEnter = (e) => {
|
|
e.preventDefault()
|
|
isDragOver.value = true
|
|
}
|
|
|
|
const handleDragLeave = (e) => {
|
|
e.preventDefault()
|
|
isDragOver.value = false
|
|
}
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault()
|
|
isDragOver.value = false
|
|
}
|
|
|
|
// 取消预览
|
|
const handleCancel = () => {
|
|
previewVisible.value = false
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.image-upload {
|
|
width: 100%;
|
|
}
|
|
|
|
.upload-area {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
}
|
|
|
|
.upload-icon {
|
|
font-size: 24px;
|
|
color: rgba(0, 0, 0, 0.45);
|
|
}
|
|
|
|
.ant-upload-text {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: rgba(0, 0, 0, 0.85);
|
|
}
|
|
|
|
.ant-upload-tip {
|
|
margin-top: 4px;
|
|
font-size: 10px;
|
|
color: rgba(0, 0, 0, 0.45);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.drag-over {
|
|
border: 2px dashed #1890ff;
|
|
background-color: rgba(24, 144, 255, 0.05);
|
|
}
|
|
|
|
.drag-over .upload-icon {
|
|
color: #1890ff;
|
|
}
|
|
</style>
|