云
4 天以前 a241b66efcc41e973290a6722fb7a4d79b833ea1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
<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>