<script lang="ts" setup>
|
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
import type { FinanceReimbursementAiApi } from '#/api/finance/reimbursement/ai';
|
import type { SystemStorageApi } from '#/api/system/storage';
|
|
import { computed, ref } from 'vue';
|
|
import { IconifyIcon } from '@vben/icons';
|
|
import {
|
Alert,
|
Button,
|
Modal,
|
Table,
|
Tag,
|
Upload,
|
message,
|
} from 'ant-design-vue';
|
|
import { ocrReimbursementInvoices } from '#/api/finance/reimbursement/ai';
|
import { uploadFile } from '#/api/system/storage';
|
|
import { INVOICE_TYPE_OPTIONS } from '../data';
|
|
defineOptions({ name: 'FinanceReimbursementOcrUpload' });
|
|
const emit = defineEmits<{
|
success: [payload: FinanceReimbursementAiApi.OcrImportPayload];
|
}>();
|
|
/** 已上传文件:uid -> blob,用于删除文件和确认回填 */
|
const blobMap = new Map<string, SystemStorageApi.StorageBlob>();
|
/** blobId -> 文件名,用于展示识别失败原因 */
|
const blobNameMap = new Map<number, string>();
|
|
const open = ref(false);
|
const step = ref(0); // 0=上传, 1=识别中, 2=预览结果
|
const fileList = ref<UploadFile[]>([]);
|
const blobCount = ref(0);
|
const uploading = ref(0);
|
const results = ref<FinanceReimbursementAiApi.OcrResultVO[]>([]);
|
|
const columns = [
|
{ title: '发票号码', dataIndex: 'invoiceNo', key: 'invoiceNo', width: 170 },
|
{ title: '发票类型', dataIndex: 'invoiceType', key: 'invoiceType', width: 160 },
|
{ title: '开票日期', dataIndex: 'invoiceDate', key: 'invoiceDate', width: 110 },
|
{ title: '金额(元)', dataIndex: 'invoiceAmount', key: 'invoiceAmount', width: 110 },
|
{ title: '销售方', dataIndex: 'sellerName', key: 'sellerName' },
|
{ title: '状态', key: 'status', width: 80 },
|
];
|
|
/** 识别成功的发票 */
|
const successResults = computed(() =>
|
results.value.filter((item) => !item.rawText),
|
);
|
|
/** 识别失败的文件及原因 */
|
const failedMessages = computed(() =>
|
results.value
|
.filter((item) => item.rawText)
|
.map(
|
(item) =>
|
`${blobNameMap.get(item.blobId ?? -1) ?? '文件'}:${item.rawText}`,
|
),
|
);
|
|
function handleOpen() {
|
handleReset();
|
open.value = true;
|
}
|
|
/** 自定义上传:只负责把文件传到存储,识别由「开始识别」统一触发 */
|
const handleUpload: UploadProps['customRequest'] = async (options) => {
|
const { file, onError, onSuccess } = options;
|
uploading.value += 1;
|
try {
|
const result = await uploadFile([file as File]);
|
const blob = result?.[0];
|
if (!blob?.id) {
|
throw new Error('文件上传失败');
|
}
|
const uid = String((file as File & { uid?: string }).uid ?? blob.id);
|
blobMap.set(uid, blob);
|
blobNameMap.set(blob.id, blob.originalFilename || blob.name || '');
|
blobCount.value = blobMap.size;
|
onSuccess?.(blob);
|
} catch (error) {
|
onError?.(error as Error);
|
} finally {
|
uploading.value -= 1;
|
}
|
};
|
|
function handleRemove(file: UploadFile) {
|
blobMap.delete(String(file.uid));
|
blobCount.value = blobMap.size;
|
if (step.value === 2) {
|
handleReset();
|
}
|
}
|
|
/** 开始批量识别 */
|
async function handleStart() {
|
const blobs = [...blobMap.values()];
|
if (blobs.length === 0) {
|
message.warning('请先上传发票文件');
|
return;
|
}
|
step.value = 1;
|
try {
|
results.value =
|
(await ocrReimbursementInvoices(blobs.map((blob) => blob.id))) ?? [];
|
if (successResults.value.length === 0) {
|
step.value = 0;
|
message.error(
|
failedMessages.value[0] ?? '未识别到发票数据,请手动录入或重新上传',
|
);
|
return;
|
}
|
step.value = 2;
|
} catch {
|
step.value = 0;
|
}
|
}
|
|
/** 把 OCR 返回的发票类型归一化为下拉枚举值 */
|
function normalizeInvoiceType(raw?: string) {
|
if (!raw) {
|
return undefined;
|
}
|
if (raw.includes('收据')) {
|
return '收据';
|
}
|
if (raw.includes('专用')) {
|
return '增值税专用发票';
|
}
|
// 「增值税电子普通发票」「电子发票(普通发票)」统一归为电子发票
|
if (raw.includes('电子')) {
|
return '电子发票';
|
}
|
if (raw.includes('普通')) {
|
return '增值税普通发票';
|
}
|
return INVOICE_TYPE_OPTIONS[INVOICE_TYPE_OPTIONS.length - 1]?.value;
|
}
|
|
/** 确认识别结果,回填明细与附件 */
|
function handleConfirm() {
|
const items = successResults.value.map((item) => ({
|
invoiceType: normalizeInvoiceType(item.invoiceType),
|
invoiceNo: item.invoiceNo,
|
invoiceAmount: item.invoiceAmount,
|
invoiceDate: item.invoiceDate,
|
// 销售方即报销明细中的开票方
|
drawer: item.sellerName,
|
})) as unknown as FinanceReimbursementAiApi.OcrImportPayload['items'];
|
emit('success', { items, blobs: [...blobMap.values()] });
|
handleClose();
|
}
|
|
function handleReset() {
|
step.value = 0;
|
fileList.value = [];
|
blobMap.clear();
|
blobNameMap.clear();
|
blobCount.value = 0;
|
results.value = [];
|
}
|
|
function handleClose() {
|
open.value = false;
|
handleReset();
|
}
|
|
defineExpose({ open: handleOpen });
|
</script>
|
|
<template>
|
<Modal
|
v-model:open="open"
|
title="OCR 识别发票"
|
width="760px"
|
:footer="null"
|
@cancel="handleClose"
|
>
|
<!-- Step 0: 上传 -->
|
<template v-if="step === 0">
|
<Upload.Dragger
|
v-model:file-list="fileList"
|
multiple
|
:max-count="20"
|
:custom-request="handleUpload"
|
accept=".pdf,.png,.jpg,.jpeg,.gif,.bmp"
|
@remove="handleRemove"
|
>
|
<p class="text-4xl text-gray-400">
|
<IconifyIcon icon="ant-design:inbox-outlined" />
|
</p>
|
<p class="text-base text-gray-500">
|
点击或拖拽发票图片 / PDF 到此区域上传
|
</p>
|
<p class="text-sm text-gray-400">
|
支持 JPG、PNG、BMP、GIF、PDF,最多 20 个文件;手机端可直接拍照上传
|
</p>
|
</Upload.Dragger>
|
<Alert
|
class="mt-3"
|
type="info"
|
show-icon
|
message="识别结果仅用于回填,发票号码、金额、日期、销售方请在明细中核对后再提交。"
|
/>
|
<div class="mt-4 flex justify-end gap-2">
|
<Button @click="handleClose">取消</Button>
|
<Button
|
type="primary"
|
:disabled="uploading > 0 || blobCount === 0"
|
:loading="uploading > 0"
|
@click="handleStart"
|
>
|
{{ uploading > 0 ? '文件上传中...' : `开始识别(${blobCount})` }}
|
</Button>
|
</div>
|
</template>
|
|
<!-- Step 1: 识别中 -->
|
<template v-else-if="step === 1">
|
<div class="flex flex-col items-center gap-4 py-12">
|
<a-spin size="large" />
|
<span class="text-base text-gray-500">
|
正在批量识别 {{ blobCount }} 个发票文件,请稍候...
|
</span>
|
</div>
|
</template>
|
|
<!-- Step 2: 预览结果 -->
|
<template v-else-if="step === 2">
|
<Alert
|
v-if="failedMessages.length > 0"
|
class="mb-3"
|
type="warning"
|
show-icon
|
:message="`${failedMessages.length} 个文件未识别成功,失败文件仍会作为发票附件保存`"
|
:description="failedMessages.join(';')"
|
/>
|
<Table
|
:columns="columns"
|
:data-source="results"
|
:pagination="false"
|
bordered
|
size="small"
|
:scroll="{ y: 320 }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'status'">
|
<Tag v-if="record.rawText" color="red">失败</Tag>
|
<Tag v-else color="green">成功</Tag>
|
</template>
|
<template v-else-if="column.key === 'invoiceAmount'">
|
{{ record.invoiceAmount ?? '-' }}
|
</template>
|
</template>
|
</Table>
|
|
<Alert
|
class="mt-3"
|
type="info"
|
show-icon
|
message="确认后,识别成功的发票将追加为报销明细行;费用类型需手动选择,金额等信息可继续修改。"
|
/>
|
|
<div class="mt-4 flex justify-end gap-2">
|
<Button @click="handleReset">重新上传</Button>
|
<Button
|
type="primary"
|
:disabled="successResults.length === 0"
|
@click="handleConfirm"
|
>
|
确认并填入({{ successResults.length }})
|
</Button>
|
</div>
|
</template>
|
</Modal>
|
</template>
|