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
<script lang="ts" setup>
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
 
import { ref } from 'vue';
 
import { IconifyIcon } from '@vben/icons';
 
import { Modal, Button, Upload, Descriptions, Table, Tag, message, Alert } from 'ant-design-vue';
 
import { ocrSaleQuotation } from '#/api/crm/saleQuotation/ai';
import { uploadFile } from '#/api/system/storage';
 
defineOptions({ name: 'CrmSaleQuotationOcrUpload' });
 
const emit = defineEmits<{
  success: [data: CrmSaleQuotationAiApi.OcrResultVO];
}>();
 
const open = ref(false);
const step = ref(0); // 0=upload, 1=processing, 2=preview
const fileList = ref<any[]>([]);
const ocrResult = ref<CrmSaleQuotationAiApi.OcrResultVO>();
const processingTip = ref('');
 
const itemColumns = [
  { title: '物料名称', dataIndex: 'itemName', key: 'itemName' },
  { title: '规格型号', dataIndex: 'itemSpec', key: 'itemSpec' },
  { title: '数量', dataIndex: 'count', key: 'count' },
  { title: '单价', dataIndex: 'quotationPrice', key: 'quotationPrice' },
];
 
function handleOpen() {
  open.value = true;
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
}
 
/** 自定义上传 */
async function handleUpload(options: any) {
  const { file, onSuccess, onError } = options;
  try {
    const result = await uploadFile([file as File]);
    const blobId = result?.[0]?.id;
    if (!blobId) {
      onError(new Error('上传失败'));
      return;
    }
    onSuccess({ blobId }, file);
    // 开始 OCR 识别
    step.value = 1;
    processingTip.value = '正在进行 AI OCR 识别,请稍候...';
    const ocrData = await ocrSaleQuotation(blobId);
    // 检查错误
    if (ocrData.rawText && !ocrData.name && !ocrData.customerName && ocrData.items?.length === 0) {
      // 所有字段为 null,rawText 包含错误提示
      processingTip.value = '';
      step.value = 0;
      message.error(ocrData.rawText);
      return;
    }
    // 检查是否全部为空
    const hasData = ocrData.name || ocrData.customerName || ocrData.quotationTime
      || ocrData.validUntil || ocrData.taxRate || ocrData.discountPercent
      || ocrData.remark || (ocrData.items && ocrData.items.length > 0);
    if (!hasData) {
      processingTip.value = '';
      step.value = 0;
      message.warning('未识别到报价数据,请手动录入或重新上传');
      return;
    }
    ocrResult.value = ocrData;
    step.value = 2;
  } catch {
    processingTip.value = '';
    step.value = 0;
    onError(new Error('上传失败'));
  }
}
 
function handleConfirm() {
  if (ocrResult.value) {
    emit('success', ocrResult.value);
  }
  handleClose();
}
 
function handleReset() {
  step.value = 0;
  fileList.value = [];
  ocrResult.value = undefined;
  processingTip.value = '';
}
 
function handleClose() {
  open.value = false;
  handleReset();
}
 
defineExpose({ open: handleOpen });
</script>
 
<template>
  <Modal v-model:open="open" title="AI 识别录入报价单" width="680px" :footer="null" @cancel="handleClose">
    <!-- Step 0: 上传 -->
    <template v-if="step === 0">
      <Upload.Dragger
        v-model:file-list="fileList"
        :max-count="1"
        :custom-request="handleUpload"
        accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.bmp,.txt,.csv"
        @remove="handleReset"
      >
        <p class="text-4xl text-gray-400">
          <IconifyIcon icon="ant-design:inbox-outlined" />
        </p>
        <p class="text-base text-gray-500">点击或拖拽报价文件到此区域上传</p>
        <p class="text-sm text-gray-400">
          支持 PDF、Word、Excel、图片、纯文本文件
        </p>
      </Upload.Dragger>
    </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">{{ processingTip }}</span>
      </div>
    </template>
 
    <!-- Step 2: 预览结果 -->
    <template v-else-if="step === 2 && ocrResult">
      <div class="mb-4">
        <Descriptions :column="2" bordered size="small">
          <Descriptions.Item v-if="ocrResult.name" label="报价单名称">
            {{ ocrResult.name }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.customerName" label="客户名称(需手动匹配)">
            <Tag color="orange">{{ ocrResult.customerName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.quotationTime" label="报价日期">
            {{ ocrResult.quotationTime }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.validUntil" label="有效期至">
            {{ ocrResult.validUntil }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.taxRate != null" label="税率(%)">
            {{ ocrResult.taxRate }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.discountPercent != null" label="折扣率(%)">
            {{ ocrResult.discountPercent }}
          </Descriptions.Item>
          <Descriptions.Item v-if="ocrResult.remark" label="备注" :span="2">
            {{ ocrResult.remark }}
          </Descriptions.Item>
        </Descriptions>
      </div>
 
      <!-- 物料明细 -->
      <div v-if="ocrResult.items?.length" class="mb-4">
        <div class="mb-2 text-sm font-medium">物料明细(需手动匹配实际物料)</div>
        <Table
          :columns="itemColumns"
          :data-source="ocrResult.items"
          :pagination="false"
          bordered
          size="small"
        />
      </div>
 
      <Alert
        message="客户名称和物料名称仅为文本识别结果,需在表单中手动选择匹配的 CRM 客户和实际物料。"
        type="info"
        show-icon
        class="mb-4"
      />
 
      <div class="flex justify-end gap-2">
        <Button @click="handleReset">重新上传</Button>
        <Button type="primary" @click="handleConfirm">确认并填入表单</Button>
      </div>
    </template>
  </Modal>
</template>