chenhj
2026-04-23 7b512f46f0aaea0e155301b4e28a83423877c725
feat(upload): 新增通用文件上传和图片预览上传组件

- 新增通用上传接口,支持 FormData 批量传文件
- 新增图片附件预览组件,支持缩略图显示和弹窗预览
- 新增图片附件上传组件,支持多图上传、限制数量及文件格式大小校验
- 新增文件附件上传组件,支持拖拽上传、文件格式和大小校验
- 上传组件支持列表项展示和删除操作
- 上传成功后自动合并新文件到现有列表并支持回调通知
- 实现上传过程的进度控制与状态提示
- 支持图片和文件的不同处理和展示方式
- 代码结构清晰,便于维护和扩展
已添加4个文件
733 ■■■■■ 文件已修改
src/api/basicData/common.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentPreview/image/index.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/file/index.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/image/index.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/common.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
import request from '@/utils/request'
// é€šç”¨ä¸Šä¼ æŽ¥å£ï¼Œæ”¯æŒ FormData æ‰¹é‡ä¼ æ–‡ä»¶
export function uploadFile(data) {
  return request({
    url: '/common/upload',
    method: 'post',
    data,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}
src/components/AttachmentPreview/image/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
  list: {
    type: Array,
    default: () => [],
  },
  thumbSize: {
    type: Number,
    default: 72,
  },
  gap: {
    type: Number,
    default: 10,
  },
})
const normalizedList = computed(() => {
  return (props.list || [])
    .filter((item) => item && item.previewURL)
    .map((item, index) => ({
      id: item.id ?? index,
      name: item.originalFilename || `image-${index + 1}`,
      url: item.previewURL,
    }))
})
const previewUrls = computed(() => normalizedList.value.map((item) => item.url))
</script>
<template>
  <div class="attachment-image-preview">
    <div v-if="!normalizedList.length" class="empty">暂无图片</div>
    <div v-else class="thumbs" :style="{ gap: `${gap}px` }">
      <el-image
        v-for="(item, index) in normalizedList"
        :key="item.id"
        class="thumb"
        :style="{ width: `${thumbSize}px`, height: `${thumbSize}px` }"
        :src="item.url"
        :preview-src-list="previewUrls"
        :initial-index="index"
        fit="cover"
        preview-teleported
      />
    </div>
  </div>
</template>
<style scoped lang="scss">
.attachment-image-preview {
  width: 100%;
}
.empty {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--el-text-color-secondary);
  border: 1px dashed var(--el-border-color);
  border-radius: 8px;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
}
.thumb {
  border: 1px solid var(--el-border-color);
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  background: #fff;
}
</style>
src/components/AttachmentUpload/file/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'files',
  },
  limit: {
    type: Number,
    default: 20,
  },
  fileSize: {
    type: Number,
    default: 50,
  },
  fileType: {
    type: Array,
    default: () => [],
  },
  buttonText: {
    type: String,
    default: '单击选择文件',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const { proxy } = getCurrentInstance()
const uploadRef = ref()
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const innerList = ref([])
function readListFromProps() {
  if (props.index > -1) {
    const row = props.fileList?.[props.index]
    return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
  }
  return Array.isArray(props.fileList) ? props.fileList : []
}
watch(
  () => props.fileList,
  () => {
    innerList.value = [...readListFromProps()]
  },
  { deep: true, immediate: true },
)
const currentList = computed({
  get() {
    return innerList.value
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    innerList.value = nextList
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  if (!props.fileType.length) return `单个文件不超过 ${props.fileSize}MB`
  return `支持 ${props.fileType.join('/')},单个文件不超过 ${props.fileSize}MB`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.downloadURL || item.previewURL || item.previewUrl || ''
}
function getItemName(item, index = 0) {
  if (!item) return `file-${index + 1}`
  if (typeof item === 'string') return `file-${index + 1}`
  return item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `file-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.downloadURL || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  const extension = rawFile.name.includes('.')
    ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
    : ''
  if (props.fileType.length) {
    const isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
    if (!isValidType) {
      proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„æ–‡ä»¶`)
      return false
    }
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`文件大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('文件上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    currentList.value = [...currentList.value, ...responseList]
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
}
function openFile(file) {
  const fileUrl = file.url || getItemUrl(file.rawData)
  if (!fileUrl) return
  window.open(fileUrl, '_blank')
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-file">
    <el-upload
      ref="uploadRef"
      drag
      :auto-upload="false"
      :multiple="true"
      :show-file-list="true"
      :file-list="displayFileList"
      :disabled="disabled || uploading"
      :limit="limit"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :on-exceed="handleExceed"
      :on-preview="openFile"
    >
      <el-icon class="upload-drag-icon"><UploadFilled /></el-icon>
      <div class="el-upload__text">
        å°†æ–‡ä»¶æ‹–到此处,或 <em>{{ buttonText }}</em>
      </div>
      <div class="upload-tip">{{ uploadTip }}</div>
    </el-upload>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-file {
  width: 100%;
}
.upload-drag-icon {
  font-size: 40px;
  color: var(--el-text-color-secondary);
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
</style>
src/components/AttachmentUpload/image/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,335 @@
<script setup>
import {Plus} from '@element-plus/icons-vue'
import {uploadFile} from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'images',
  },
  limit: {
    type: Number,
    default: 10,
  },
  fileSize: {
    type: Number,
    default: 10,
  },
  fileType: {
    type: Array,
    default: () => ['png', 'jpg', 'jpeg', 'webp'],
  },
  buttonText: {
    type: String,
    default: '上传图片',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const {proxy} = getCurrentInstance()
const uploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const currentList = computed({
  get() {
    if (props.index > -1) {
      const row = props.fileList?.[props.index]
      return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
    }
    return Array.isArray(props.fileList) ? props.fileList : []
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  return `支持 ${props.fileType.join('/')},单张不超过 ${props.fileSize}MB`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.previewURL || ''
}
function getItemName(item, index = 0) {
  if (!item) return `image-${index + 1}`
  if (typeof item === 'string') return `image-${index + 1}`
  return item.name || item.fileName || item.originalFilename || `image-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `image-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || `image-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  let isValidType = false
  const extension = rawFile.name.includes('.')
      ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
      : ''
  if (props.fileType.length) {
    isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
  } else {
    isValidType = rawFile.type.includes('image')
  }
  if (!isValidType) {
    proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„图片`)
    return false
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`图片大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} å¼ å›¾ç‰‡ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('图片上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    console.log('responseList', responseList)
    currentList.value = [...currentList.value, ...responseList]
    console.log('currentList.value', currentList.value)
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handlePreview(file) {
  previewUrl.value = file.url || getItemUrl(file.rawData)
  previewVisible.value = true
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-image">
    <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :multiple="true"
        :show-file-list="true"
        :file-list="displayFileList"
        list-type="picture-card"
        accept="image/*"
        :disabled="disabled || uploading"
        :limit="limit"
        :on-change="handleChange"
        :on-remove="handleRemove"
        :on-preview="handlePreview"
        :on-exceed="handleExceed"
    >
      <div class="upload-trigger">
        <el-icon>
          <Plus/>
        </el-icon>
        <span>{{ buttonText }}</span>
      </div>
    </el-upload>
    <div class="upload-tip">
      {{ uploadTip }}
    </div>
    <el-dialog v-model="previewVisible" title="图片预览" width="720px" append-to-body>
      <img class="preview-image" :src="previewUrl" alt="preview"/>
    </el-dialog>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-image {
  width: 100%;
}
.upload-trigger {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
  line-height: 1.2;
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
.preview-image {
  display: block;
  max-width: 100%;
  margin: 0 auto;
}
:deep(.el-upload-list--picture-card) {
  margin: 0;
}
:deep(.el-upload--picture-card) {
  width: 132px;
  height: 132px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
  width: 132px;
  height: 132px;
}
</style>