From 7b512f46f0aaea0e155301b4e28a83423877c725 Mon Sep 17 00:00:00 2001
From: chenhj <1263187585@qq.com>
Date: 星期四, 23 四月 2026 17:22:08 +0800
Subject: [PATCH] feat(upload): 新增通用文件上传和图片预览上传组件
---
src/api/basicData/common.js | 13 +
src/components/AttachmentUpload/image/index.vue | 335 +++++++++++++++++++++++++
src/components/AttachmentPreview/image/index.vue | 76 +++++
src/components/AttachmentUpload/file/index.vue | 309 +++++++++++++++++++++++
4 files changed, 733 insertions(+), 0 deletions(-)
diff --git a/src/api/basicData/common.js b/src/api/basicData/common.js
new file mode 100644
index 0000000..01c6f35
--- /dev/null
+++ b/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',
+ },
+ })
+}
diff --git a/src/components/AttachmentPreview/image/index.vue b/src/components/AttachmentPreview/image/index.vue
new file mode 100644
index 0000000..5211fac
--- /dev/null
+++ b/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>
diff --git a/src/components/AttachmentUpload/file/index.vue b/src/components/AttachmentUpload/file/index.vue
new file mode 100644
index 0000000..b68bd4f
--- /dev/null
+++ b/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>
diff --git a/src/components/AttachmentUpload/image/index.vue b/src/components/AttachmentUpload/image/index.vue
new file mode 100644
index 0000000..25bd13c
--- /dev/null
+++ b/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>
--
Gitblit v1.9.3