This commit is contained in:
404
src/components/business/VoucherUpload.vue
Normal file
404
src/components/business/VoucherUpload.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="voucher-upload"
|
||||
tabindex="0"
|
||||
@click="focusUploadArea"
|
||||
@paste="handlePaste"
|
||||
>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
class="voucher-upload__control"
|
||||
drag
|
||||
multiple
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleRemoveFile"
|
||||
list-type="picture"
|
||||
>
|
||||
<ElIcon class="voucher-upload__icon"><UploadFilled /></ElIcon>
|
||||
<div class="voucher-upload__text">将{{ voucherName }}拖到此处,或<em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持多张图片或附件,最多上传 {{ maxCount }} 个文件;点击上传区域后可粘贴文件
|
||||
</div>
|
||||
</template>
|
||||
<template #file="{ file }">
|
||||
<div class="voucher-upload__file-item">
|
||||
<img
|
||||
v-if="isUploadImage(file)"
|
||||
class="voucher-upload__file-cover"
|
||||
:src="getUploadFileUrl(file)"
|
||||
alt=""
|
||||
/>
|
||||
<div v-else class="voucher-upload__file-cover voucher-upload__file-cover--default">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
</div>
|
||||
<span class="voucher-upload__file-name" :title="file.name">{{ file.name }}</span>
|
||||
<button
|
||||
class="voucher-upload__file-remove"
|
||||
type="button"
|
||||
aria-label="移除文件"
|
||||
@click.stop="removeUploadFile(file)"
|
||||
>
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { genFileId } from 'element-plus'
|
||||
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
|
||||
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
|
||||
import { StorageService } from '@/api/modules'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
voucherName?: string
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
voucherName: '凭证',
|
||||
maxCount: 5
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | undefined]
|
||||
'uploading-change': [value: boolean]
|
||||
change: [value: string | undefined]
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement>()
|
||||
const uploadRef = ref<UploadInstance>()
|
||||
const uploadingCount = ref(0)
|
||||
const voucherFileKeyMap = new Map<number, string>()
|
||||
const removedUploadUids = new Set<number>()
|
||||
const fileObjectUrlMap = new Map<number, string>()
|
||||
const selectedUploadUids = new Set<number>()
|
||||
let activeUploadBatch = 0
|
||||
|
||||
const getMaxCount = () => Math.max(1, props.maxCount)
|
||||
|
||||
const showMaxCountWarning = () => {
|
||||
ElMessage.warning(`最多只能上传 ${getMaxCount()} 个文件`)
|
||||
}
|
||||
|
||||
const emitVoucherKeys = () => {
|
||||
const keys = Array.from(voucherFileKeyMap.values())
|
||||
const value = keys.length ? keys.join(',') : undefined
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
const setUploadingCount = (count: number) => {
|
||||
uploadingCount.value = Math.max(0, count)
|
||||
emit('uploading-change', uploadingCount.value > 0)
|
||||
}
|
||||
|
||||
const clearFiles = (emitValue = true) => {
|
||||
activeUploadBatch += 1
|
||||
setUploadingCount(0)
|
||||
voucherFileKeyMap.clear()
|
||||
removedUploadUids.clear()
|
||||
selectedUploadUids.clear()
|
||||
revokeAllFileObjectUrls()
|
||||
uploadRef.value?.clearFiles()
|
||||
if (emitValue) {
|
||||
emitVoucherKeys()
|
||||
}
|
||||
}
|
||||
|
||||
const revokeFileObjectUrl = (uid: number) => {
|
||||
const url = fileObjectUrlMap.get(uid)
|
||||
if (!url) return
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
fileObjectUrlMap.delete(uid)
|
||||
}
|
||||
|
||||
const revokeAllFileObjectUrls = () => {
|
||||
fileObjectUrlMap.forEach((url) => URL.revokeObjectURL(url))
|
||||
fileObjectUrlMap.clear()
|
||||
}
|
||||
|
||||
const isUploadImage = (file: UploadFile) => {
|
||||
if (file.raw?.type?.startsWith('image/')) return true
|
||||
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name)
|
||||
}
|
||||
|
||||
const getUploadFileUrl = (file: UploadFile) => {
|
||||
if (file.url) return file.url
|
||||
if (!file.raw) return ''
|
||||
|
||||
const cachedUrl = fileObjectUrlMap.get(file.uid)
|
||||
if (cachedUrl) return cachedUrl
|
||||
|
||||
const url = URL.createObjectURL(file.raw)
|
||||
fileObjectUrlMap.set(file.uid, url)
|
||||
return url
|
||||
}
|
||||
|
||||
const removeUploadFile = (uploadFile: UploadFile) => {
|
||||
uploadRef.value?.handleRemove(uploadFile)
|
||||
}
|
||||
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
|
||||
if (!selectedUploadUids.has(uploadFile.uid)) {
|
||||
if (selectedUploadUids.size >= getMaxCount()) {
|
||||
showMaxCountWarning()
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
|
||||
selectedUploadUids.add(uploadFile.uid)
|
||||
}
|
||||
|
||||
const uploadBatch = activeUploadBatch
|
||||
removedUploadUids.delete(uploadFile.uid)
|
||||
setUploadingCount(uploadingCount.value + 1)
|
||||
|
||||
try {
|
||||
ElMessage.info(`正在上传${props.voucherName}...`)
|
||||
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
const uploadUrlRes = await StorageService.getUploadUrl({
|
||||
file_name: file.name,
|
||||
content_type: contentType,
|
||||
purpose: 'attachment'
|
||||
})
|
||||
|
||||
if (uploadUrlRes.code !== 0) {
|
||||
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
|
||||
removeUploadFile(uploadFile)
|
||||
return
|
||||
}
|
||||
|
||||
const { upload_url, file_key } = uploadUrlRes.data
|
||||
await StorageService.uploadFile(upload_url, file, contentType)
|
||||
|
||||
if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) {
|
||||
return
|
||||
}
|
||||
|
||||
voucherFileKeyMap.set(uploadFile.uid, file_key)
|
||||
emitVoucherKeys()
|
||||
ElMessage.success('上传成功')
|
||||
} catch (error: any) {
|
||||
if (uploadBatch !== activeUploadBatch) return
|
||||
|
||||
console.error(`上传${props.voucherName}失败:`, error)
|
||||
ElMessage.error(error?.message || '上传失败,请重试')
|
||||
voucherFileKeyMap.delete(uploadFile.uid)
|
||||
emitVoucherKeys()
|
||||
removeUploadFile(uploadFile)
|
||||
} finally {
|
||||
if (uploadBatch === activeUploadBatch) {
|
||||
setUploadingCount(uploadingCount.value - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = (uploadFile: UploadFile) => {
|
||||
removedUploadUids.add(uploadFile.uid)
|
||||
selectedUploadUids.delete(uploadFile.uid)
|
||||
revokeFileObjectUrl(uploadFile.uid)
|
||||
voucherFileKeyMap.delete(uploadFile.uid)
|
||||
emitVoucherKeys()
|
||||
}
|
||||
|
||||
const focusUploadArea = () => {
|
||||
nextTick(() => rootRef.value?.focus())
|
||||
}
|
||||
|
||||
const getExtensionByMimeType = (mimeType: string) => {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'application/pdf': 'pdf',
|
||||
'text/plain': 'txt'
|
||||
}
|
||||
return extensionMap[mimeType] || 'bin'
|
||||
}
|
||||
|
||||
const normalizeClipboardFile = (file: File, index: number) => {
|
||||
if (file.name) return file
|
||||
|
||||
const extension = getExtensionByMimeType(file.type)
|
||||
return new File([file], `clipboard-${Date.now()}-${index + 1}.${extension}`, {
|
||||
type: file.type || 'application/octet-stream'
|
||||
})
|
||||
}
|
||||
|
||||
const startUploadFile = (file: File) => {
|
||||
const uploadRawFile = file as UploadRawFile
|
||||
uploadRawFile.uid = genFileId()
|
||||
uploadRef.value?.handleStart(uploadRawFile)
|
||||
}
|
||||
|
||||
const getClipboardFiles = (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return []
|
||||
|
||||
const files = Array.from(clipboardData.files || [])
|
||||
if (files.length) return files
|
||||
|
||||
return Array.from(clipboardData.items || [])
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => !!file)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (!isUploadVisible()) return
|
||||
|
||||
const files = getClipboardFiles(event)
|
||||
if (!files.length) return
|
||||
|
||||
event.preventDefault()
|
||||
const remainingCount = getMaxCount() - selectedUploadUids.size
|
||||
if (remainingCount <= 0) {
|
||||
showMaxCountWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length > remainingCount) {
|
||||
showMaxCountWarning()
|
||||
}
|
||||
|
||||
files.slice(0, remainingCount).map(normalizeClipboardFile).forEach(startUploadFile)
|
||||
}
|
||||
|
||||
const isUploadVisible = () => {
|
||||
const element = rootRef.value
|
||||
return (
|
||||
!!element &&
|
||||
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handlePaste)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('paste', handlePaste)
|
||||
revokeAllFileObjectUrls()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
clearFiles
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.voucher-upload {
|
||||
width: 100%;
|
||||
outline: none;
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
:deep(.el-upload-dragger) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
padding: 24px 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-upload-list__item) {
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-bottom: 12px;
|
||||
font-size: 28px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__file-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__file-cover {
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__file-cover--default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__file-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-regular);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__file-remove {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user