404 lines
13 KiB
Vue
404 lines
13 KiB
Vue
<template>
|
||
<div v-loading="loading" :element-loading-text="loadingText">
|
||
<el-steps :active="1" align-center simple>
|
||
<el-step style="cursor: pointer;" @click.native="active = 0" title="上传质保单" icon="el-icon-upload" />
|
||
<el-step style="cursor: pointer;" @click.native="active = 1" title="质保单处理" icon="el-icon-edit" />
|
||
<el-step style="cursor: pointer;" @click.native="active = 2" title="质保单审核" icon="el-icon-check" />
|
||
</el-steps>
|
||
<div v-if="active === 0" style="padding: 30px;">
|
||
<file-upload v-model="uploadQualityCertificateForm.qualityCertificate" />
|
||
</div>
|
||
|
||
<div v-if="active === 1">
|
||
<el-row>
|
||
<el-alert title="质保单处理" type="info" />
|
||
</el-row>
|
||
|
||
<!-- 提取质保单信息,选择使用ocr还是使用大模型 -->
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<div
|
||
style="border: 1px solid #ccc; border-radius: 10px; padding: 20px; cursor: pointer; text-align: center; position: relative;">
|
||
<!-- 齿轮图标,绝对定位在左上角 -->
|
||
<i class="el-icon-setting"
|
||
style="position: absolute; left: 10px; top: 10px; font-size: 20px; color: #606266; cursor: pointer; z-index: 2;"
|
||
@click.stop="openKeyListDialog"></i>
|
||
<div style="margin-bottom: 10px; display: flex; align-items: center; justify-content: center;">
|
||
<span style="font-weight: bold; font-size: 16px;">OCR识别</span>
|
||
<el-tooltip content="通过OCR自动识别PDF质保单内容" placement="top">
|
||
<i class="el-icon-question" style="margin-left: 6px; color: #409EFF; cursor: pointer;"></i>
|
||
</el-tooltip>
|
||
</div>
|
||
<img @click="handleOcr" :src="ocrImage" alt="ocr" style="width: 120px; height: 120px; object-fit: contain;"
|
||
append-to-body />
|
||
</div>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<div style="border: 1px solid #ccc; border-radius: 10px; padding: 20px; cursor: pointer; text-align: center;">
|
||
<div style="margin-bottom: 10px; display: flex; align-items: center; justify-content: center;">
|
||
<span style="font-weight: bold; font-size: 16px;">大模型识别</span>
|
||
<el-tooltip content="通过AI大模型识别图片质保单内容" placement="top">
|
||
<i class="el-icon-question" style="margin-left: 6px; color: #409EFF; cursor: pointer;"></i>
|
||
</el-tooltip>
|
||
</div>
|
||
<img @click="handleModel" :src="modelImage" alt="model"
|
||
style="width: 120px; height: 120px; object-fit: contain;" />
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<div v-if="active === 2">
|
||
<div v-if="resultDiff">
|
||
<merger :info="info" :old-result="oldResult" :new-result="newResult" @confirm="handleMergerConfirm" />
|
||
</div>
|
||
<div v-else>
|
||
<el-row>
|
||
<el-alert title="请核对识别结果是否正确" type="success" />
|
||
</el-row>
|
||
<el-row :gutter="20">
|
||
<el-col :span="12">
|
||
<el-table :data="newResult" style="width: 100%">
|
||
<el-table-column prop="attrKey" label="属性名称" />
|
||
<el-table-column prop="attrValue" label="属性值" />
|
||
</el-table>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<div>
|
||
<img style="width: 100%; height: 100%;" :src="file.url" alt="">
|
||
</div>
|
||
</el-col>
|
||
</el-row>
|
||
<el-button type="primary" @click="handleConfirm">确认</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- keyList配置弹窗 -->
|
||
<el-dialog title="配置OCR识别字段" :visible.sync="showKeyListDialog" width="400px" append-to-body>
|
||
<div>
|
||
<el-tag type="info" style="margin-bottom: 8px;">每行一个字段名,顺序影响识别</el-tag>
|
||
<el-input type="textarea" :rows="8" v-model="keyListInput" placeholder="如:订货单位\n合同号\n产品名称..." />
|
||
</div>
|
||
<span slot="footer" class="dialog-footer">
|
||
<el-button @click="showKeyListDialog = false">取消</el-button>
|
||
<el-button type="primary" @click="saveKeyList">保存</el-button>
|
||
</span>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import FileUpload from '@/components/FileUpload'
|
||
import { listByIds } from '@/api/system/oss'
|
||
import { updatePurchasePlanDetail } from '@/api/wms/purchasePlanDetail'
|
||
import { recognizeText, recognizeBomByModel, recognizePdfText } from '@/api/system/ocr'
|
||
import { listBomItem } from '@/api/wms/bomItem'
|
||
import { getRawMaterial } from '@/api/wms/rawMaterial'
|
||
import Merger from './merger.vue'
|
||
|
||
import modelImage from '@/assets/images/model.png'
|
||
import ocrImage from '@/assets/images/ocr.png'
|
||
|
||
const so = {
|
||
annex: {
|
||
loading: '正在保存质保单',
|
||
handler: async (vm, newVal) => {
|
||
return await updatePurchasePlanDetail({
|
||
...vm.info,
|
||
annex: newVal
|
||
})
|
||
}
|
||
},
|
||
ocr: {
|
||
loading: '等待ocr识别结果',
|
||
handler: async (vm) => {
|
||
function extractData(text) {
|
||
// 优先读取localStorage配置的keyList
|
||
let keyList = [];
|
||
try {
|
||
const local = localStorage.getItem('ocrKeyList');
|
||
if (local) {
|
||
keyList = local.split('\n').map(k => k.trim()).filter(k => k);
|
||
}
|
||
} catch { }
|
||
// 默认keyList
|
||
if (!keyList.length) {
|
||
keyList = [
|
||
"订货单位", "合同号", "产品名称", "执行标准",
|
||
"卷号", "原料坯号", "规格", "材质",
|
||
"表面状态", "调制度", "切边要求", "包装种类",
|
||
"毛重", "净重", "参考长度"
|
||
];
|
||
}
|
||
// 预处理:将复合键合并(如"合同号"、"卷号"等)
|
||
let normalizedText = text
|
||
.replace(/卷\s*号/g, "卷号")
|
||
.replace(/原料\s*坯号/g, "原料坯号")
|
||
.replace(/切边\s*要求/g, "切边要求")
|
||
.replace(/包装\s*种类/g, "包装种类");
|
||
const lines = normalizedText.split(/\r?\n/).filter(line => line.trim());
|
||
const result = [];
|
||
// 核心数据行处理(跳过首行公司名)
|
||
for (let i = 1; i < lines.length; i++) {
|
||
let currentLine = lines[i];
|
||
let remaining = currentLine;
|
||
// 处理单行中的多组键值对
|
||
while (remaining) {
|
||
let found = false;
|
||
// 检查剩余文本是否以已知键开头
|
||
for (const key of keyList) {
|
||
if (remaining.startsWith(key)) {
|
||
found = true;
|
||
// 移动到键之后的位置
|
||
let afterKey = remaining.substring(key.length).trim();
|
||
// 查找下一个键的起始位置
|
||
let nextKeyIndex = afterKey.length;
|
||
for (const k of keyList) {
|
||
const idx = afterKey.indexOf(k);
|
||
if (idx > -1 && idx < nextKeyIndex) {
|
||
nextKeyIndex = idx;
|
||
}
|
||
}
|
||
// 提取当前键的值
|
||
const value = afterKey.substring(0, nextKeyIndex).trim();
|
||
result.push({
|
||
attrKey: key,
|
||
attrValue: key === "毛重" || key === "净重"
|
||
? value.replace(/\D/g, "") // 提取数字部分
|
||
: value
|
||
});
|
||
// 更新剩余文本
|
||
remaining = afterKey.substring(nextKeyIndex).trim();
|
||
break;
|
||
}
|
||
}
|
||
// 如果没有找到更多键,结束循环
|
||
if (!found) break;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
const { text } = (await recognizePdfText({ pdfUrl: vm.file.url })).data
|
||
console.log(text)
|
||
const result = extractData(text)
|
||
console.log(result)
|
||
vm.newResult = result;
|
||
return result;
|
||
}
|
||
},
|
||
model: {
|
||
loading: '等待大模型识别结果',
|
||
handler: async (vm) => {
|
||
const res = await recognizeBomByModel({ imageUrl: vm.file.url })
|
||
vm.newResult = res.data.attributes;
|
||
vm.$modal.msgSuccess("识别成功");
|
||
return res;
|
||
}
|
||
},
|
||
bom: {
|
||
loading: '正在处理BOM'
|
||
},
|
||
oss: {
|
||
loading: '正在获取质保单',
|
||
handler: async (vm, newVal) => {
|
||
const res = await listByIds(newVal)
|
||
vm.file = res.data[0];
|
||
vm.active = 1;
|
||
return res.data[0];
|
||
}
|
||
},
|
||
old: {
|
||
loading: '正在获取历史质保单',
|
||
handler: async (vm, newVal) => {
|
||
// 查询对应的bomId
|
||
const res = await getRawMaterial(vm.info.rawMaterialId)
|
||
const bomId = res.data.bomId;
|
||
const bomItemRes = await listBomItem({
|
||
bomId,
|
||
})
|
||
vm.oldResult = bomItemRes.rows;
|
||
return bomItemRes.rows;
|
||
}
|
||
},
|
||
compare: {
|
||
loading: '正在比较新旧质保单',
|
||
handler: async (vm) => {
|
||
// 先检查新旧result是否一致
|
||
if (vm.oldResult.length !== vm.newResult.length) {
|
||
vm.resultDiff = true;
|
||
return;
|
||
}
|
||
// 比较新旧result是否一致
|
||
for (let i = 0; i < vm.oldResult.length; i++) {
|
||
if (vm.oldResult[i].attrKey !== vm.newResult[i].attrKey || vm.oldResult[i].attrValue !== vm.newResult[i].attrValue) {
|
||
vm.resultDiff = true;
|
||
return;
|
||
}
|
||
}
|
||
vm.resultDiff = false;
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 原子操作, 用于细化的进度展示, 無論其同步還是異步一律視作異步函數執行, 並返回一個Promise
|
||
const atoms = {
|
||
|
||
}
|
||
|
||
export default {
|
||
name: 'QualityCerticate',
|
||
components: {
|
||
FileUpload,
|
||
Merger
|
||
},
|
||
props: {
|
||
info: {
|
||
type: Object,
|
||
default: () => ({})
|
||
}
|
||
},
|
||
watch: {
|
||
info: {
|
||
handler(newVal) {
|
||
this.active = 0;
|
||
if (newVal.annex) {
|
||
this.uploadQualityCertificateForm.qualityCertificate = newVal.annex;
|
||
} else {
|
||
this.uploadQualityCertificateForm.qualityCertificate = undefined;
|
||
}
|
||
},
|
||
deep: true,
|
||
immediate: true
|
||
},
|
||
'uploadQualityCertificateForm.qualityCertificate': {
|
||
handler(newVal) {
|
||
if (newVal) {
|
||
this.loadingMethod('oss')
|
||
}
|
||
this.loadingMethod('annex')
|
||
},
|
||
immediate: true
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
uploadQualityCertificateForm: {
|
||
qualityCertificate: undefined,
|
||
qualityCertificateType: undefined,
|
||
},
|
||
active: 0,
|
||
file: undefined,
|
||
loading: false,
|
||
loadingText: '加载中...',
|
||
resultDiff: true,
|
||
oldResult: [],
|
||
newResult: [],
|
||
modelImage,
|
||
ocrImage,
|
||
showKeyListDialog: false,
|
||
keyListInput: '',
|
||
}
|
||
},
|
||
methods: {
|
||
handleOcr() {
|
||
// 识别file是不是pdf, 只有pdf可以使用ocr
|
||
if (!this.file.url.endsWith('.pdf')) {
|
||
this.$modal.msgError('质保单不是pdf格式');
|
||
return;
|
||
}
|
||
this.loadingMethod('ocr', async (res) => {
|
||
await this.loadingMethod('old')
|
||
await this.loadingMethod('compare')
|
||
this.active = 2;
|
||
})
|
||
},
|
||
handleModel() {
|
||
this.loadingMethod('model', async (res) => {
|
||
await this.loadingMethod('old')
|
||
await this.loadingMethod('compare')
|
||
this.active = 2;
|
||
})
|
||
|
||
},
|
||
async handleConfirm() {
|
||
// 变更状态
|
||
this.active = 3;
|
||
this.$emit('confirm')
|
||
},
|
||
handleMergerConfirm(res) {
|
||
if (res.status === 'start') {
|
||
this.loading = true;
|
||
this.loadingText = '正在处理产品BOM';
|
||
} else if (res.status === 'success') {
|
||
this.loading = false;
|
||
this.active = 3;
|
||
this.$emit('confirm')
|
||
} else if (res.status === 'error') {
|
||
this.loading = false;
|
||
this.$modal.msgError('质保单处理失败');
|
||
}
|
||
},
|
||
async loadingMethod(key, fn) {
|
||
this.loading = true;
|
||
this.loadingText = so[key].loading;
|
||
try {
|
||
const res = await so[key].handler(this, this.uploadQualityCertificateForm.qualityCertificate)
|
||
fn && await fn(res)
|
||
return res;
|
||
} catch {
|
||
this.$modal.msgError('操作失败');
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
// 比较新旧result是否一致
|
||
handleCompareResult() {
|
||
// 先检查新旧result是否一致
|
||
if (this.oldResult.length !== this.newResult.length) {
|
||
this.resultDiff = true;
|
||
return;
|
||
}
|
||
// 比较新旧result是否一致
|
||
for (let i = 0; i < this.oldResult.length; i++) {
|
||
if (this.oldResult[i].attrKey !== this.newResult[i].attrKey || this.oldResult[i].attrValue !== this.newResult[i].attrValue) {
|
||
this.resultDiff = true;
|
||
return;
|
||
}
|
||
}
|
||
this.resultDiff = false;
|
||
},
|
||
// 保存keyList到localStorage
|
||
saveKeyList() {
|
||
localStorage.setItem('ocrKeyList', this.keyListInput)
|
||
this.showKeyListDialog = false
|
||
this.$message.success('已保存OCR字段配置')
|
||
},
|
||
// 打开弹窗时初始化内容
|
||
openKeyListDialog() {
|
||
let local = ''
|
||
try {
|
||
local = localStorage.getItem('ocrKeyList') || ''
|
||
} catch { }
|
||
if (local) {
|
||
this.keyListInput = local
|
||
} else {
|
||
this.keyListInput = [
|
||
"订货单位", "合同号", "产品名称", "执行标准",
|
||
"卷号", "原料坯号", "规格", "材质",
|
||
"表面状态", "调制度", "切边要求", "包装种类",
|
||
"毛重", "净重", "参考长度"
|
||
].join('\n')
|
||
}
|
||
this.showKeyListDialog = true
|
||
},
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.el-row {
|
||
margin-bottom: 20px;
|
||
margin-top: 20px;
|
||
}
|
||
</style> |