492 lines
13 KiB
Vue
492 lines
13 KiB
Vue
<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"
|
||
:accept="accept"
|
||
: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">
|
||
{{ 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, watch } 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'
|
||
import type { RefundAttachment } from '@/types/api/refund'
|
||
import type { FilePurpose } from '@/api/modules/storage'
|
||
|
||
interface Props {
|
||
modelValue?: string[] | string
|
||
voucherName?: string
|
||
maxCount?: number
|
||
accept?: string
|
||
tip?: string
|
||
purpose?: FilePurpose
|
||
maxSizeMb?: number
|
||
singleColumnCsv?: boolean
|
||
maxCsvRows?: number
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
voucherName: '凭证',
|
||
maxCount: 5,
|
||
accept: '',
|
||
tip: '',
|
||
purpose: 'attachment',
|
||
maxSizeMb: 0,
|
||
singleColumnCsv: false,
|
||
maxCsvRows: 0
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string[]]
|
||
'uploading-change': [value: boolean]
|
||
change: [value: string[]]
|
||
'files-change': [value: RefundAttachment[]]
|
||
}>()
|
||
|
||
const rootRef = ref<HTMLElement>()
|
||
const uploadRef = ref<UploadInstance>()
|
||
const uploadingCount = ref(0)
|
||
const voucherFileKeyMap = new Map<number, string>()
|
||
const voucherFileMetadataMap = new Map<number, RefundAttachment>()
|
||
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())
|
||
emit('update:modelValue', keys)
|
||
emit('change', keys)
|
||
}
|
||
|
||
const emitFileMetadata = () => {
|
||
emit('files-change', Array.from(voucherFileMetadataMap.values()))
|
||
}
|
||
|
||
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()
|
||
voucherFileMetadataMap.clear()
|
||
removedUploadUids.clear()
|
||
selectedUploadUids.clear()
|
||
revokeAllFileObjectUrls()
|
||
uploadRef.value?.clearFiles()
|
||
if (emitValue) {
|
||
emitVoucherKeys()
|
||
emitFileMetadata()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(value) => {
|
||
const keys = Array.isArray(value)
|
||
? value
|
||
: value
|
||
? value
|
||
.split(',')
|
||
.map((key) => key.trim())
|
||
.filter(Boolean)
|
||
: []
|
||
|
||
voucherFileKeyMap.clear()
|
||
keys.slice(0, getMaxCount()).forEach((key, index) => {
|
||
voucherFileKeyMap.set(index + 1, key)
|
||
})
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
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 (props.maxSizeMb > 0 && file.size > props.maxSizeMb * 1024 * 1024) {
|
||
ElMessage.warning(`文件不能超过 ${props.maxSizeMb}MB`)
|
||
removeUploadFile(uploadFile)
|
||
return
|
||
}
|
||
|
||
if (props.singleColumnCsv) {
|
||
const content = await file.text()
|
||
const rows = content.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
|
||
if (props.maxCsvRows > 0 && Math.max(rows.length - 1, 0) > props.maxCsvRows) {
|
||
ElMessage.warning(`CSV 数据行不能超过 ${props.maxCsvRows} 行`)
|
||
removeUploadFile(uploadFile)
|
||
return
|
||
}
|
||
if (rows.some((row) => row.includes(','))) {
|
||
ElMessage.warning('CSV 只能包含一列资产标识')
|
||
removeUploadFile(uploadFile)
|
||
return
|
||
}
|
||
}
|
||
|
||
if (props.accept) {
|
||
const accepted = props.accept.split(',').some((type) => {
|
||
const value = type.trim().toLowerCase()
|
||
return value.startsWith('.')
|
||
? file.name.toLowerCase().endsWith(value)
|
||
: file.type.toLowerCase() === value
|
||
})
|
||
if (!accepted) {
|
||
ElMessage.warning(`只能上传 ${props.accept} 格式的文件`)
|
||
removeUploadFile(uploadFile)
|
||
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: props.purpose
|
||
})
|
||
|
||
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)
|
||
voucherFileMetadataMap.set(uploadFile.uid, {
|
||
file_key,
|
||
file_name: file.name,
|
||
file_size: file.size
|
||
})
|
||
emitVoucherKeys()
|
||
emitFileMetadata()
|
||
ElMessage.success('上传成功')
|
||
} catch (error: any) {
|
||
if (uploadBatch !== activeUploadBatch) return
|
||
|
||
console.error(`上传${props.voucherName}失败:`, error)
|
||
ElMessage.error(error?.message || '上传失败,请重试')
|
||
voucherFileKeyMap.delete(uploadFile.uid)
|
||
voucherFileMetadataMap.delete(uploadFile.uid)
|
||
emitVoucherKeys()
|
||
emitFileMetadata()
|
||
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)
|
||
voucherFileMetadataMap.delete(uploadFile.uid)
|
||
emitVoucherKeys()
|
||
emitFileMetadata()
|
||
}
|
||
|
||
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>
|