<script lang="ts" setup>
|
/**
|
* AI 导入弹窗:上传一份已有的检验报告 → 识别成模板草稿 → 交给设计器。
|
* <p>
|
* 弹窗只负责「拿草稿」这一段:上传、绑附件、请求识别、给用户看结果。
|
* 装到画布上是设计器的事,由父组件在用户确认后来做——
|
* 画布非空时要先弹替换确认,那份判断属于设计器而不是这里。
|
*/
|
import type { QcReportAiApi } from '#/api/mes/qc/report/ai';
|
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
|
import { computed, ref, watch } from 'vue';
|
|
import { IconifyIcon } from '@vben/icons';
|
|
import { Alert, Button, Input, message, Modal, Spin, Tag, Upload } from 'ant-design-vue';
|
|
import { generateAiDraft } from '#/api/mes/qc/report/ai';
|
import {
|
bindAttachments,
|
deleteAttachments,
|
listAttachments,
|
uploadFile,
|
} from '#/api/system/storage';
|
import {
|
buildQualityComponentCatalog,
|
getQualityComponent,
|
registerAllQualityComponents,
|
} from '#/components/quality';
|
|
import { SCHEMA_VERSION } from '../constants';
|
|
defineOptions({ name: 'QcReportAiImportModal' });
|
|
const props = defineProps<{
|
templateId?: number;
|
}>();
|
|
const open = defineModel<boolean>('open', { default: false });
|
|
const emit = defineEmits<{
|
/** 用户确认把草稿装到画布上 */
|
apply: [
|
payload: {
|
components?: QcReportAiApi.DraftComponent[];
|
page?: QcReportAiApi.DraftPage;
|
},
|
];
|
}>();
|
|
/** 与后端 yudao.qcreport.ai-import.max-files 一致;这里只做界面上的即时约束,权威校验在后端 */
|
const MAX_FILES = 3;
|
|
const ACCEPT =
|
'.pdf,.docx,.xlsx,.png,.jpg,.jpeg,.bmp,.gif,.webp,.txt,.csv';
|
|
const fileList = ref<UploadFile[]>([]);
|
const hint = ref('');
|
const recognizing = ref(false);
|
const draft = ref<QcReportAiApi.DraftResp>();
|
|
/** Upload 内部持有的文件对象类型(RcFile,继承自浏览器的 File) */
|
type UploadedFile = NonNullable<UploadFile['originFileObj']>;
|
|
/** 已选文件数;AntDV 的 fileList 里可能混着上传失败项,这里只认有实体文件的 */
|
const selectedFiles = computed(() =>
|
fileList.value
|
.map((item) => item.originFileObj)
|
.filter((file): file is UploadedFile => Boolean(file)),
|
);
|
|
const canRecognize = computed(
|
() => !recognizing.value && selectedFiles.value.length > 0,
|
);
|
|
/** 组件草稿的展示行:类型展示名 + 认得出来的属性 */
|
const draftRows = computed(() =>
|
(draft.value?.components ?? []).map((component, index) => {
|
const definition = getQualityComponent(component.type);
|
const propsText = Object.entries(component.props ?? {})
|
.filter(([, value]) => value !== undefined && value !== '')
|
.map(([key, value]) => `${fieldLabel(component.type, key)}=${String(value)}`)
|
.join(',');
|
return {
|
index: index + 1,
|
label: definition?.label ?? component.type ?? '(未知组件)',
|
type: component.type ?? '',
|
registered: Boolean(definition),
|
propsText,
|
};
|
}),
|
);
|
|
const unknownCount = computed(
|
() => draftRows.value.filter((row) => !row.registered).length,
|
);
|
|
/** 软提示:被跳过的页、无法表达的区块等 */
|
const warnings = computed(() => draft.value?.warnings ?? []);
|
|
const durationText = computed(() =>
|
draft.value?.durationMs ? `${(draft.value.durationMs / 1000).toFixed(1)} 秒` : '',
|
);
|
|
/** 属性 key → 字段显示名;认不出定义时退回 key 本身 */
|
function fieldLabel(type: string | undefined, key: string): string {
|
const definition = getQualityComponent(type);
|
return (
|
definition?.propertySchema.find((field) => field.key === key)?.label ?? key
|
);
|
}
|
|
/** 交给 Upload 自己收集文件,不走它内置的上传通道(上传时机由用户点按钮决定) */
|
const beforeUpload: UploadProps['beforeUpload'] = () => false;
|
|
function reset() {
|
fileList.value = [];
|
hint.value = '';
|
draft.value = undefined;
|
}
|
|
watch(open, (value) => {
|
if (value) {
|
reset();
|
}
|
});
|
|
/**
|
* 清掉模板上已挂着的源文件。
|
* <p>
|
* 语义是**替换**而不是累积:一次导入对应一份源文件,新的进来、旧的出去。这些文件只是识别用的输入,
|
* 模板真正的产物是版本 Schema,没有归档它们的必要。
|
* <p>
|
* 必须在这一步删,不能指望绑定时顺手清掉 —— 绑定的实现是「只删旧的附件关联记录、保留 blob 记录
|
* (文件本身不删除)」,旧文件删不了行也删不了盘,重复导入几次磁盘上就多几份再没人引用的文件。
|
*/
|
async function removeExistingSourceFiles(templateId: number) {
|
const existing = await listAttachments({
|
application: 'file',
|
recordType: 'qc_report_template',
|
recordId: templateId,
|
});
|
const attachmentIds = (existing ?? [])
|
.map((blob) => blob.storageAttachmentId)
|
.filter((id): id is number => typeof id === 'number');
|
if (attachmentIds.length > 0) {
|
await deleteAttachments(attachmentIds);
|
}
|
}
|
|
/** 上传 → 清旧源文件 → 绑附件 → 识别。四步都要走完,失败即中断并如实报错 */
|
async function handleRecognize() {
|
const templateId = Number(props.templateId);
|
if (!Number.isFinite(templateId) || templateId <= 0) {
|
message.error('缺少模板编号,无法关联上传文件');
|
return;
|
}
|
const files = selectedFiles.value;
|
if (files.length === 0) {
|
return;
|
}
|
|
recognizing.value = true;
|
try {
|
// 上传返回的顺序即 pages 顺序,直接按这个顺序把 blobId 交给后端
|
const blobs = await uploadFile(files);
|
const blobIds = blobs.map((blob) => blob.id);
|
if (blobIds.length === 0) {
|
message.error('文件上传失败,请重新选择文件');
|
return;
|
}
|
|
// 放在上传之后:上传失败就什么都不动,上一份源文件还在
|
await removeExistingSourceFiles(templateId);
|
|
// 先绑定再识别:识别失败文件也已归属到模板,不会留成孤儿附件
|
await bindAttachments({
|
application: 'file',
|
recordType: 'qc_report_template',
|
recordId: templateId,
|
blobItems: blobIds.map((blobId) => ({ blobId })),
|
});
|
|
// 注册表要显式注册才有内容(设计器 init 时做过一次,这里是幂等的兜底)
|
registerAllQualityComponents();
|
draft.value = await generateAiDraft({
|
blobIds,
|
templateId,
|
schemaVersion: SCHEMA_VERSION,
|
catalog: buildQualityComponentCatalog(),
|
hint: hint.value.trim() || undefined,
|
});
|
if ((draft.value?.components ?? []).length === 0) {
|
message.warning('没有识别出任何组件,请换一份更清晰的文件重试');
|
}
|
} finally {
|
// 失败时不在这一层吞掉错误:后端已按「维度 + 实际值 + 可执行动作」给了精确文案,
|
// 由请求层统一弹出即可,这里包一层反而会盖住它
|
recognizing.value = false;
|
}
|
}
|
|
function handleApply() {
|
emit('apply', {
|
components: draft.value?.components,
|
page: draft.value?.page,
|
});
|
open.value = false;
|
}
|
</script>
|
|
<template>
|
<Modal
|
v-model:open="open"
|
title="AI 导入模板"
|
:width="720"
|
:footer="null"
|
:mask-closable="!recognizing"
|
:closable="!recognizing"
|
>
|
<div class="flex flex-col gap-3">
|
<Alert
|
type="info"
|
show-icon
|
message="识别结果只是草稿"
|
description="AI 识别出的内容不会自动保存,需要你到设计器里确认、调整后点「保存」才会成为模板版本。"
|
/>
|
|
<template v-if="!draft">
|
<Upload.Dragger
|
v-model:file-list="fileList"
|
:multiple="true"
|
:max-count="MAX_FILES"
|
:accept="ACCEPT"
|
:before-upload="beforeUpload"
|
:disabled="recognizing"
|
>
|
<p class="ant-upload-drag-icon">
|
<IconifyIcon icon="lucide:upload-cloud" class="text-2xl" />
|
</p>
|
<p class="ant-upload-text">点击或拖拽文件到此处</p>
|
<p class="ant-upload-hint text-xs">
|
支持扫描件/照片(png、jpg、jpeg、bmp、gif、webp)、PDF、Word(.docx)、Excel(.xlsx)、文本(txt、csv),
|
一次最多 {{ MAX_FILES }} 个文件,多页文件按页序识别。
|
</p>
|
<p class="ant-upload-hint text-xs">
|
.doc / .xls 旧格式不支持,请先用 Office 另存为 .docx / .xlsx。
|
</p>
|
</Upload.Dragger>
|
|
<Input
|
v-model:value="hint"
|
placeholder="补充说明(选填),例如:这是 IQC 来料检验报告"
|
:disabled="recognizing"
|
/>
|
|
<Spin v-if="recognizing" size="small">
|
<div class="py-2 text-xs text-gray-500">
|
正在识别,预计 10–60 秒。扫描件按页逐页识别,每页单独一次调用,页数多时会明显更久;
|
最长等待约 5 分钟,超时后请减少文件数量或页数再试。请勿关闭页面。
|
</div>
|
</Spin>
|
|
<div class="flex justify-end gap-2">
|
<Button :disabled="recognizing" @click="open = false">取消</Button>
|
<Button
|
type="primary"
|
:loading="recognizing"
|
:disabled="!canRecognize"
|
@click="handleRecognize"
|
>
|
<IconifyIcon icon="lucide:sparkles" class="mr-1" />
|
开始识别
|
</Button>
|
</div>
|
</template>
|
|
<template v-else>
|
<div class="flex items-center gap-2 text-xs text-gray-500">
|
<span>识别出 {{ draftRows.length }} 个组件</span>
|
<span v-if="durationText">耗时 {{ durationText }}</span>
|
<Tag v-if="unknownCount > 0" color="orange">
|
{{ unknownCount }} 个组件设计器不认识
|
</Tag>
|
</div>
|
|
<Alert
|
v-if="draft.summary"
|
type="success"
|
show-icon
|
:message="draft.summary"
|
/>
|
|
<Alert v-if="warnings.length > 0" type="warning" show-icon>
|
<template #message>
|
<div class="text-xs">
|
<div v-for="(warning, index) in warnings" :key="index">
|
· {{ warning }}
|
</div>
|
</div>
|
</template>
|
</Alert>
|
|
<div class="max-h-72 overflow-auto rounded border border-border">
|
<div
|
v-for="row in draftRows"
|
:key="row.index"
|
class="flex gap-2 border-b border-border px-3 py-2 text-xs last:border-b-0"
|
>
|
<span class="w-6 shrink-0 text-gray-400">{{ row.index }}</span>
|
<div class="min-w-0 flex-1">
|
<div class="flex items-center gap-2">
|
<span class="font-medium">{{ row.label }}</span>
|
<span class="text-gray-400">{{ row.type }}</span>
|
<Tag v-if="!row.registered" color="orange">未注册,将被跳过</Tag>
|
</div>
|
<div v-if="row.propsText" class="mt-0.5 truncate text-gray-500">
|
{{ row.propsText }}
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="text-xs text-gray-500">
|
表格只识别结构,文件里的具体数据行不会写进模板;数据在出件时由业务单据提供。
|
</div>
|
|
<div class="flex justify-end gap-2">
|
<Button @click="reset">
|
<IconifyIcon icon="lucide:refresh-cw" class="mr-1" />
|
重新上传
|
</Button>
|
<Button type="primary" :disabled="draftRows.length === 0" @click="handleApply">
|
<IconifyIcon icon="lucide:check" class="mr-1" />
|
生成到画布
|
</Button>
|
</div>
|
</template>
|
</div>
|
</Modal>
|
</template>
|