Files
klp-oa/klp-ui/src/views/wms/purchasePlan/panels/qualityCerticate.vue
2025-08-04 11:15:42 +08:00

404 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>