单个及批量上传文件和文件夹思路——AntDesign
一、问题
antDesign的Upload组件设置了direcotry属性,允许上传文件夹后;点击上传时无法 选择单个文件。刚开始的做法是直接重新写了upload组件上传框的点击事件,手动调用 window.showOpenFilePicker允许点击上传单个文件;但是需求又要求点击的时候既支持 选择文件和文件夹。由于文件夹和文件选择框的弹出API不同,必须分解为两个交互。
一个框支持上传单个文件,一个支持批量上传。
二、解决方法
1.点击上传:弹出文件夹选择框和文件选择框因选择的 文件类型完全不同,交互也不相同,所以对应不同的API;选择文件和文件夹的交互必须分开
文件夹选择框:window.showDirectoryPicker()
文件选择框:window.showOpenFilePicker()
2.拖拽上传:拖拽文件夹和文件的本质是相同的,可以一次性实现拖拽文件夹和文件,交互无须分开
因为浏览器http协议限制不允许一次性访问本地的文件夹,所以无论是 点击还是拖拽的批量上传文件或文件夹,本质上都是一个一个的上传文件
axios直接传文件夹会报错: net::ERR_FILE_NOT_FOUND,所以只能一个一个的遍历上传
3.拖拽时文件夹上传框,不允许上传单个文件:
drop回调 判断如果是单个文件element.webkitGetAsEntry().isFile,则从文件列表中删除
4.拖拽时文件上传框,不允许上传文件夹:
不开启dircotry选项
5.完整代码
<template>
<!-- 批量上传电子发票 -->
<AModal visible title="批量上传电子发票" :maskClosable="false" centered :width="720" @cancel="handleClose">
<ASteps :current="current" :items="stepArray" size="small" class="steps-wrap">
<AStep
v-for="(item, itemKey) of stepArray"
:key="itemKey"
:class="[{ 'font-semibold text-cb06 ': itemKey === current }]"
:title="item.title"
></AStep
></ASteps>
<div v-loading="loading" class="modal-area mt-6">
<AForm>
<!-- 上传文件 -->
<template v-if="isUpload">
<div>
<div class="tip-area flex justify-between">
<div class="limit text-ctext02">
仅支持<span>pdf</span>格式,<span>单个文件最大1M,最多可传50个</span>,文件名称需为<span>订单编号</span>
</div>
<div class="text-center">
<ATooltip overlayClassName="white-tooltip file-tooltip" placement="bottom">
<AButton size="small" type="link" @click.stop>查看示例图 </AButton>
<template #title>
<div class="text-center">
<div class="text-[#000]">
<SvgIcon name="pdf"></SvgIcon> <span class="bg-cr06 bg-opacity-30">订单ID</span> .pdf
</div>
<div class="tip">
<div class="text-cr06">需为订单编号</div>
<div class="text-ctext01">方便上传时快速识别订单</div>
</div>
</div>
</template>
</ATooltip>
</div>
</div>
<div class="upload-area flex gap-6">
<div class="flex-1">
<AUploadDragger
v-model:fileList="fileList"
name="file"
accept=".pdf"
:maxCount="50"
:show-upload-list="false"
:before-upload="beforeUpload"
@change="handleChangeFile"
>
<div class="p-4">
<p>
<SvgIconfont
symbol-name="icon_a-download_linexiazai"
:class="[' text-[40px] text-cbd02']"
></SvgIconfont>
</p>
<p class="flex justify-center items-center font-semibold !mt-4 !mb-2 text-ctext02">
<SvgIconfont
symbol-name="icon_a-file_2_linewendang2"
color="#B3B3B3"
class="text-[16px]"
></SvgIconfont>
<span class="ml-1">单个文件上传</span>
</p>
<p class="text-ctext02 text-[12px]">点击或将【文件】拖拽到这里上传</p>
</div>
</AUploadDragger>
</div>
<div class="flex-1">
<AUploadDragger
v-model:fileList="fileList"
name="file"
multiple
directory
accept=".pdf"
:maxCount="50"
:show-upload-list="false"
:before-upload="beforeUpload"
@change="handleChangeFile"
@drop="handleDropFile"
>
<div class="p-4">
<p>
<SvgIconfont
symbol-name="icon_a-download_linexiazai"
:class="[' text-[40px] text-cbd02']"
></SvgIconfont>
</p>
<p class="flex justify-center items-center font-semibold !mt-4 !mb-2 text-ctext02">
<SvgIconfont
symbol-name="icon_a-folder_linewenjianjia"
color="#B3B3B3"
class="text-[16px]"
></SvgIconfont>
<span class="ml-1">文件夹上传</span>
</p>
<p class="text-ctext02 text-[12px]">点击或将【文件夹】拖拽到这里上传</p>
</div>
</AUploadDragger>
</div>
</div>
</div>
<!-- 文件列表 -->
<template v-if="fileList?.length">
<ul class="file-list-area">
<li v-for="(file, fileKey) of fileList" :key="fileKey">
<div class="flex justify-between">
<div>
<ASpin :indicator="indicator" :class="['!mr-1', { '!opacity-0': !file.loading }]" />
<SvgIcon name="pdf"></SvgIcon>
<span :class="['ml-1', { 'text-cr06': file.errMsg }]">{{ file.name }}</span>
<template v-if="file.errMsg">
<div class="text-sm text-cr06 ml-4">
{{ file.errMsg }}
</div>
</template>
</div>
<div :class="['delete', { '!block': file.errMsg }]" title="删除文件" @click="handleDelete(fileKey)">
<SvgIconfont
symbol-name="icon_a-delect_lineshanchu"
:class="['text-[16px] text-ctext02', { '!text-cr06': file.errMsg }]"
></SvgIconfont>
</div>
</div>
</li>
</ul>
</template>
</template>
<!-- 关联订单 -->
<template v-else-if="isRelatedOrder">
<ATable :class="['mt-[22px]']" :columns="columns" :dataSource="tableData" bordered :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'filename'">
<div class="h-8 leading-[32px]">
<SvgIcon name="pdf"></SvgIcon> <span>{{ record.filename }}</span>
</div>
</template>
<template v-if="column.key === 'orderId'">
<template v-if="record.isEdit.value">
<!-- <AInput v-model:value="record.orderId.value" class="!w-max !rounded-[4px]"> </AInput> -->
<DigitInput v-model:value="record.orderId.value" class="!w-max !rounded-[4px]"> </DigitInput>
</template>
<template v-else>
<div class="h-8 leading-[32px]">
<span v-if="record.orderId.value">{{ record.orderId.value }} </span>
<template v-else>
<SvgIconfont symbol-name="icon_a-error_warning_filljinggao" class="text-cr06 text-base">
</SvgIconfont>
<span> 未识别到订单ID </span>
</template>
<EditOutlined :style="{ color: '#1890FF' }" @click="record.isEdit.value = true" />
</div>
</template>
</template>
</template>
</ATable>
</template>
<!-- 上传完成 -->
<template v-else-if="isFinish">
<div class="finish-wrap">
<div class="finish-item mb-4">
<SvgIconfont
symbol-name="icon_a-checkbox_circle_fillxuanzegouxuankuang"
:class="['text-cg06 text-[19px]']"
></SvgIconfont>
上传成功 <span> {{ finishResult.successCount }}</span
>个
</div>
<div class="flex justify-between finish-item">
<div>
<SvgIconfont
symbol-name="icon_a-error_warning_filljinggao"
:class="['text-cr06 text-[19px]']"
></SvgIconfont>
上传失败
<span class="text-cr06"> {{ finishResult.failCount }}</span
>个
</div>
<AButton v-if="finishResult.failCount" type="link" class="!text-base" @click="handleDownloadFailData">
下载失败原因
</AButton>
</div>
</div>
</template>
</AForm>
</div>
<template #footer>
<AButton v-if="isUpload" @click="handleClick('cancel')">取消</AButton>
<AButton v-if="isRelatedOrder" @click="handleClick('cancel')">上一步</AButton>
<AButton v-if="isUpload" type="primary" @click="handleClick('relatedOrder')">去关联订单</AButton>
<AButton v-if="isRelatedOrder" type="primary" @click="handleClick('confirmUpload')">确认上传</AButton>
<AButton v-if="isFinish" type="primary" @click="handleSure">确认</AButton>
</template>
</AModal>
</template>
<script lang="tsx" setup>
import DigitInput from '@/components/DigitInput.vue'
import { requestUploadApplyInvoice, requestBatchInvoiceAssociatedOrder } from '@/api/order-management/apply-invoice'
import { Modal } from 'ant-design-vue'
import { EditOutlined, Loading3QuartersOutlined } from '@ant-design/icons-vue'
import { aoaToSheetXlsx } from '@/utils/excel'
import { h } from 'vue'
import type { BatchInvoiceResult } from '@/api/order-management/apply-invoice/model'
const emit = defineEmits(['close', 'sure'])
/**
* 步骤条
**/
const current = ref<number>(0)
const stepArray = [
{ title: '上传发票' },
{
title: '关联未开发票的订单'
},
{
title: '上传完成'
}
]
function next() {
current.value++
}
function previous() {
current.value--
}
const fileList = ref<FileList & { errMsg?: string; loading?: boolean; url?: string; uid?: string; name?: string }[]>([])
function handleClose() {
emit('close')
}
/**
* 1.上传文件
*/
const isUpload = computed(() => current.value === 0)
function beforeUpload() {
return false
}
const maxSize = 1
async function handleChangeFile({ file }) {
fileList.value.splice(fileList.value.length - singleFileArray.length)
singleFileArray = []
let targetIndex = fileList.value.findIndex((element) => element.uid === file.uid)
if (targetIndex === -1) {
return
}
let targetFile = fileList.value[targetIndex]
targetFile.loading = true
dealFile(file, targetIndex)
}
async function dealFile(file: File, targetIndex: number) {
let { errMsg, url } = (await requestUploadApplyInvoice({ fileList: file }))[0]
let targetFile = fileList.value[targetIndex]
targetFile.errMsg = errMsg
targetFile.url = url
targetFile.loading = false
if (!(file.size / 1024 / 1024 <= maxSize)) {
targetFile.errMsg = '单个文件最大1M'
}
}
let singleFileArray: any[] = []
async function handleDropFile(event: { dataTransfer: { items: any[] } }) {
event.dataTransfer.items.forEach((element) => {
/** 文件夹上传框不允许上传单个文件 */
if (element.webkitGetAsEntry().isFile || element.type) {
// console.log('单个文件', element, element.getAsFile())
singleFileArray.push(element.getAsFile())
} else {
// console.log('文件夹', element, element.getAsFile())
}
})
}
/**
* 删除
*/
function handleDelete(fileIndex: number) {
fileList.value = fileList.value.filter((element, elementIndex) => elementIndex !== fileIndex)
}
// <SvgIconfont symbol-name="icon_a-loading_linejiazai" class="text-base"></SvgIconfont>
const indicator = h(Loading3QuartersOutlined, {
style: {
fontSize: '12px',
fontWeight: 'bold',
color: '#B3B3B3'
},
spin: true
})
/**
* 操作
*/
const loading = ref(false)
function handleClick(type: string) {
switch (type) {
case 'cancel':
handleCancel()
break
case 'relatedOrder':
handleRelatedOrder()
break
case 'confirmUpload':
handleConfirmUpload()
break
default:
break
}
}
/**
* 取消
*/
function handleCancel() {
if (isUpload.value) {
handleClose()
} else if (isRelatedOrder.value) {
previous()
}
}
/**
* 2.去关联订单
*/
const columns = [
{
title: '电子发票pdf文件',
key: 'filename'
},
{
title: '关联订单',
key: 'orderId',
width: 400
}
]
const tableData = computed(() => {
return fileList.value.map((element) => {
return {
filename: element.name,
orderId: ref(getOrderId(element.name)),
isEdit: ref(false),
url: element.url
}
})
})
function getOrderId(filename: string) {
let temp = filename.replace('.pdf', '')
if (/^\d+$/.test(temp)) {
return temp
} else {
return ''
}
}
const isRelatedOrder = computed(() => current.value === 1)
function handleRelatedOrder() {
if (fileList.value.length) {
let hasError = fileList.value.find((element) => element.errMsg)
if (hasError) {
confirmModal('监测到您上传的部分文件不是PDF格式,请确认所有文件均为PDF格式且上传成功,再进行订单关联!')
return
}
next()
} else {
confirmModal('请上传电子发票pdf文件后,再进行订单关联!')
}
}
function confirmModal(text: string) {
Modal.confirm({
content: text,
icon: '',
cancelButtonProps: {
style: 'display:none'
}
})
}
/**
* 3.上传完成
*/
const isFinish = computed(() => current.value === 2)
const finishResult = reactive<BatchInvoiceResult>({})
let loadingTimeout: ReturnType<typeof setTimeout> | null = null
async function handleConfirmUpload() {
loading.value = true
try {
let result = await requestBatchInvoiceAssociatedOrder({
invoiceList: tableData.value.map((element) => {
return {
filename: element.filename,
orderId: element.orderId.value,
url: element.url
}
})
})
Object.assign(finishResult, result)
if (loadingTimeout) {
clearTimeout(loadingTimeout)
loadingTimeout = null
}
loadingTimeout = setTimeout(() => {
loading.value = false
next()
}, 1000)
} catch (error) {
loading.value = false
console.log('error', error)
}
}
function handleDownloadFailData() {
aoaToSheetXlsx({ header: finishResult.header, data: finishResult.errList, filename: finishResult.filename })
}
function handleSure() {
emit('sure', { type: 'batchUploadInvoice' })
handleClose()
}
</script>
<style lang="less" scoped>
@import url('@/styles/modal.less');
:deep(.anticon-check) {
font-size: 13px;
}
.steps-wrap {
padding: 0 24px;
.ant-steps-item {
flex: none;
&:nth-child(1) {
width: 216px;
}
&:nth-child(2) {
width: 296px;
}
.ant-steps-item-title,
.ant-steps-icon {
font-size: 16px;
}
}
&.ant-steps-small.ant-steps-horizontal:not(.ant-steps-label-vertical) .ant-steps-item {
padding-left: 8px;
&:first-child {
padding-left: 0;
}
}
}
.modal-area {
.tip-area {
margin-bottom: 16px;
padding: 10px 16px;
background: #e6f7ff;
border: 1px solid #1890ff;
border-radius: 4px;
.limit {
span {
color: #f53f3f;
}
}
}
.file-list-area {
li {
padding: 10px 4px 10px 12px;
border-bottom: 1px solid #f0f0f0;
.delete {
cursor: pointer;
display: none;
}
&:hover {
background: #f0f0f0;
.delete {
display: block;
}
}
}
}
.finish-wrap {
margin-top: 12px;
font-size: 16px;
.finish-item {
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
}
}
</style>
<style lang="less">
@import '@/styles/tooltip.less';
.file-tooltip {
.tip {
background: url('@/assets/fileTip.png');
width: 230px;
padding: 9px 0 6px;
margin-top: 2px;
}
}
</style>
三、总结
1.文件选择弹框和文件夹选择弹框api不一样,必须分开
2.拖拽上传可以同时支持文件和文件夹上传
3.批量上传的时候如果全部不符合想要的类型如何处理?
目前是发现一个不符合类型,就提示
没有判断是否全部不符合:需求上其实是希望如果全部不符合才提示(减少提示次数)—— 目前没有空处理(因为里面涉及到 文件夹中嵌套文件夹等递归问题,比较复杂,待完善)
4.欸,以前都没有做过,所以实现的时候没有思路,特此记录。