Files
one-pipe-system/src/components/business/VoucherUpload.vue
luo f0a2b84e53
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m51s
feat: 产品迭代7月份
2026-07-27 16:30:29 +08:00

492 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>