990 lines
26 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.

<script setup lang="ts">
import axios from "axios";
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
LoaderCircle,
RefreshCw,
Send,
Upload,
X,
} from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { judgeOcrMeasurements, recognizeOcrFiles } from "@/api/ocr";
import { createRiskItem } from "@/api/riskItems";
import type { OcrBatchResult, OcrExtractedFields } from "@/types/ocr";
import type { RiskItemCreate } from "@/types/riskItem";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; submitted: [] }>();
const files = ref<File[]>([]);
const recognizing = ref(false);
const submitting = ref(false);
const rejudging = ref(false);
const error = ref("");
const result = ref<OcrBatchResult | null>(null);
const originalJudgment = ref<Record<string, unknown> | null>(null);
const submitted = ref(false);
const draft = ref<RiskItemCreate>(emptyDraft());
const confidence = computed(() => {
const values = (result.value?.files ?? [])
.map((file) => file.confidence)
.filter((value): value is number => value !== null);
return values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: null;
});
const allWarnings = computed(() => [
...new Set((result.value?.files ?? []).flatMap((file) => file.warnings)),
]);
const canSubmit = computed(
() =>
draft.value.productName.trim().length > 0 &&
draft.value.description.trim().length >= 5,
);
watch(
() => props.open,
(open) => {
if (open) reset();
},
);
function emptyDraft(): RiskItemCreate {
return {
sourceType: "OCR",
sourceFileId: null,
occurredOn: null,
productName: "",
productCategory: "",
materialType: "",
supplier: "",
factory: "",
stageNew: "第三阶段(产品开发)",
description: "",
defectLevel: null,
solution: null,
photos: [],
recognitionConfidence: null,
ocrPayload: {},
};
}
function reset() {
files.value = [];
result.value = null;
originalJudgment.value = null;
error.value = "";
submitted.value = false;
draft.value = emptyDraft();
}
function close() {
if (!recognizing.value && !submitting.value) emit("close");
}
function selectFiles(event: Event) {
const selected = Array.from((event.target as HTMLInputElement).files ?? []);
error.value = "";
result.value = null;
if (selected.length > 5) {
error.value = "一次最多上传 5 个文件";
files.value = [];
return;
}
const invalid = selected.find(
(file) =>
!["image/jpeg", "image/png", "application/pdf"].includes(file.type) ||
file.size > 10 * 1024 * 1024,
);
if (invalid) {
error.value = `${invalid.name} 格式不支持或超过 10MB`;
files.value = [];
return;
}
files.value = selected;
}
async function recognize() {
if (!files.value.length || recognizing.value) return;
recognizing.value = true;
error.value = "";
try {
const response = await recognizeOcrFiles(files.value);
if (!response.data.data) throw new Error("OCR 响应为空");
result.value = response.data.data;
fillDraft(response.data.data);
} catch (reason) {
error.value = apiError(reason, "OCR 识别失败,请重新上传或手动填写");
} finally {
recognizing.value = false;
}
}
function fillDraft(data: OcrBatchResult) {
const fields = data.fields;
draft.value = {
...emptyDraft(),
sourceFileId: data.files[0]?.fileId ?? null,
occurredOn: fields.occurredOn,
productName: fields.productName.value ?? "",
productCategory: fields.productCategory.value ?? "",
materialType: fields.materialType.value ?? "",
supplier: fields.supplier.value ?? "",
factory: fields.factory.value ?? "",
stageNew: normalizeStage(fields.stageNew.value),
description: fields.description.value ?? "",
defectLevel: normalizeLevel(fields.defectLevel.value),
photos: data.files.map((file) => file.fileId),
recognitionConfidence: confidence.value,
ocrPayload: {
files: data.files.map((file) => ({
fileId: file.fileId,
fileName: file.fileName,
engine: file.engine,
pageCount: file.pageCount,
confidence: file.confidence,
})),
requiresReview: data.requiresReview,
},
};
originalJudgment.value = structuredClone({
measurements: data.measurements,
judgments: data.judgments,
});
}
function updateMeasurement(index: number, rawValue: string) {
if (!result.value) return;
const measurement = result.value.measurements[index];
const numeric = rawValue.match(/[+-]?\d+(?:\.\d+)?/);
const unit = rawValue.match(
/(?:mg\/kg|μg\/g|ug\/g|g\/L|mg\/L|N[·.]?m|mPa[·.]?s|Pa[·.]?s|mm|cm|nm|%|级|℃|°C)$/i,
);
measurement.rawValue = rawValue;
measurement.value = numeric ? Number(numeric[0]) : null;
measurement.unit = unit?.[0] ?? null;
measurement.confidence = 1;
measurement.needsReview = false;
}
async function rejudge() {
if (!result.value?.measurements.length || rejudging.value) return;
rejudging.value = true;
error.value = "";
try {
const response = await judgeOcrMeasurements(result.value.measurements);
result.value.judgments = response.data.data ?? [];
} catch (reason) {
error.value = apiError(reason, "重新判定失败");
} finally {
rejudging.value = false;
}
}
async function submit() {
if (!canSubmit.value || submitting.value) return;
submitting.value = true;
error.value = "";
try {
draft.value.ocrPayload = {
...(draft.value.ocrPayload ?? {}),
originalRecognition: originalJudgment.value,
confirmedMeasurements: result.value?.measurements ?? [],
confirmedJudgments: result.value?.judgments ?? [],
confirmedAt: new Date().toISOString(),
};
await createRiskItem({
...draft.value,
productCategory: draft.value.productCategory || null,
materialType: draft.value.materialType || null,
supplier: draft.value.supplier || null,
factory: draft.value.factory || null,
});
submitted.value = true;
emit("submitted");
} catch (reason) {
error.value = apiError(reason, "提交审核失败");
} finally {
submitting.value = false;
}
}
function fieldNeedsReview(key: keyof OcrExtractedFields) {
const field = result.value?.fields[key];
return (
typeof field === "object" &&
field !== null &&
"needsReview" in field &&
field.needsReview
);
}
function normalizeStage(value: string | null) {
if (!value) return "第三阶段(产品开发)";
const stages = [
"第一阶段(需求发现)",
"第二阶段(产品立项)",
"第三阶段(产品开发)",
"第四阶段(量产上市)",
"第五阶段(产品生命管理)",
];
return (
stages.find(
(stage) => stage.includes(value) || value.includes(stage.slice(5, -1)),
) ?? value
);
}
function normalizeLevel(value: string | null) {
const level = value?.toUpperCase().replace("级", "");
return level && ["A", "B", "C"].includes(level) ? level : null;
}
function formatConfidence(value: number | null) {
return value === null ? "待人工确认" : `${Math.round(value * 100)}%`;
}
function judgmentClass(value: string) {
return value === "合格"
? "is-pass"
: value === "不合格"
? "is-fail"
: "is-review";
}
function apiError(reason: unknown, fallback: string) {
if (axios.isAxiosError(reason)) {
return String(reason.response?.data?.message || fallback);
}
return reason instanceof Error ? reason.message : fallback;
}
</script>
<template>
<div v-if="open" class="ocr-overlay" @mousedown.self="close">
<section
class="ocr-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="ocr-title"
>
<header class="ocr-header">
<div class="ocr-title">
<span class="ocr-title-icon"><FileSearch :size="19" /></span>
<div>
<h2 id="ocr-title">OCR 异常识别</h2>
<p>检测报告、巡检单及异常报告</p>
</div>
</div>
<button
class="icon-button"
type="button"
title="关闭"
:disabled="recognizing || submitting"
@click="close"
>
<X :size="18" />
</button>
</header>
<div v-if="submitted" class="submitted-state">
<CheckCircle2 :size="34" />
<h3>已提交审核</h3>
<p>识别结果和标准判定已进入审核队列,审核通过后才会写入案例库。</p>
<button class="primary-button" type="button" @click="close">
完成
</button>
</div>
<div v-else class="ocr-content">
<section class="upload-section">
<label class="upload-zone">
<Upload :size="22" />
<strong>{{
files.length ? "重新选择文件" : "选择图片或 PDF"
}}</strong>
<span>JPG / PNG / PDF,单个不超过 10MB,最多 5 个</span>
<input
type="file"
accept="image/jpeg,image/png,application/pdf"
multiple
@change="selectFiles"
/>
</label>
<div v-if="files.length" class="selected-files">
<span v-for="file in files" :key="`${file.name}-${file.size}`">
<FileText :size="13" />{{ file.name
}}<small>{{ (file.size / 1024 / 1024).toFixed(2) }}MB</small>
</span>
</div>
<button
class="primary-button recognize-button"
type="button"
:disabled="!files.length || recognizing"
@click="recognize"
>
<LoaderCircle v-if="recognizing" class="spin" :size="16" />
<FileSearch v-else :size="16" />
{{
recognizing
? "正在识别并匹配标准…"
: result
? "重新识别"
: "开始识别"
}}
</button>
</section>
<p v-if="error" class="error-message" role="alert">
<AlertTriangle :size="15" />{{ error }}
</p>
<template v-if="result">
<section class="result-summary">
<div>
<strong>识别完成</strong>
<span
>{{ result.files.length }} 个文件 ·
{{
result.files.reduce((sum, file) => sum + file.pageCount, 0)
}}
页</span
>
</div>
<span
class="confidence"
:class="{ low: confidence === null || confidence < 0.8 }"
>
置信度 {{ formatConfidence(confidence) }}
</span>
</section>
<div v-if="allWarnings.length" class="warning-list">
<p v-for="warning in allWarnings" :key="warning">
<AlertTriangle :size="14" />{{ warning }}
</p>
</div>
<form class="ocr-form" @submit.prevent="submit">
<header class="section-title">
<div>
<h3>识别字段</h3>
<p>标黄字段需要人工核对,可直接修改。</p>
</div>
</header>
<div class="field-grid">
<label :class="{ review: fieldNeedsReview('productName') }"
>产品名称 *<input
v-model.trim="draft.productName"
required
maxlength="200"
/></label>
<label :class="{ review: fieldNeedsReview('productCategory') }"
>产品品类<input
v-model.trim="draft.productCategory"
maxlength="100"
/></label>
<label :class="{ review: fieldNeedsReview('materialType') }"
>物料<input v-model.trim="draft.materialType" maxlength="200"
/></label>
<label :class="{ review: fieldNeedsReview('supplier') }"
>供应商<input v-model.trim="draft.supplier" maxlength="300"
/></label>
<label :class="{ review: fieldNeedsReview('factory') }"
>加工厂<input v-model.trim="draft.factory" maxlength="300"
/></label>
<label
>发生日期<input v-model="draft.occurredOn" type="date"
/></label>
<label :class="{ review: fieldNeedsReview('stageNew') }"
>发生阶段<select v-model="draft.stageNew">
<option>第一阶段(需求发现)</option>
<option>第二阶段(产品立项)</option>
<option>第三阶段(产品开发)</option>
<option>第四阶段(量产上市)</option>
<option>第五阶段(产品生命管理)</option>
</select></label
>
<label :class="{ review: fieldNeedsReview('defectLevel') }"
>缺陷等级<select v-model="draft.defectLevel">
<option :value="null">未确定</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select></label
>
</div>
<label
class="description-field"
:class="{ review: fieldNeedsReview('description') }"
>
异常描述 *
<textarea
v-model.trim="draft.description"
required
minlength="5"
maxlength="10000"
rows="4"
/>
</label>
<section v-if="result.measurements.length" class="judgment-section">
<header class="section-title">
<div>
<h3>标准数值判定</h3>
<p>可修正检测值并重新判定,原始识别值会保留。</p>
</div>
<button
class="secondary-button compact-button"
type="button"
:disabled="rejudging"
@click="rejudge"
>
<LoaderCircle
v-if="rejudging"
class="spin"
:size="14"
/><RefreshCw v-else :size="14" />重新判定
</button>
</header>
<div class="judgment-table-wrap">
<table>
<thead>
<tr>
<th>检测项</th>
<th>检测值</th>
<th>标准</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr
v-for="(measurement, index) in result.measurements"
:key="`${measurement.item}-${index}`"
>
<td>{{ measurement.item }}</td>
<td>
<input
class="measurement-input"
:value="measurement.rawValue"
@input="
updateMeasurement(
index,
($event.target as HTMLInputElement).value,
)
"
/>
</td>
<td>
<span>{{
result.judgments[index]?.standardValue || "未匹配"
}}</span
><small>{{ result.judgments[index]?.basis }}</small>
</td>
<td>
<select
v-if="result.judgments[index]"
v-model="result.judgments[index].result"
class="judgment-select"
:class="judgmentClass(result.judgments[index].result)"
@change="
result.judgments[index].manuallyConfirmed = true
"
>
<option>合格</option>
<option>不合格</option>
<option>待人工确认</option>
<option>未匹配标准</option></select
><small>{{ result.judgments[index]?.reason }}</small>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<details class="raw-result">
<summary>查看 OCR 原始文字</summary>
<article v-for="file in result.files" :key="file.fileId">
<strong>{{ file.fileName }}</strong>
<pre>{{ file.rawText }}</pre>
</article>
</details>
<footer class="dialog-actions">
<button
class="secondary-button"
type="button"
:disabled="submitting"
@click="reset"
>
<RefreshCw :size="15" />重新上传
</button>
<button
class="primary-button"
type="submit"
:disabled="!canSubmit || submitting"
>
<LoaderCircle v-if="submitting" class="spin" :size="16" /><Send
v-else
:size="16"
/>
{{ submitting ? "提交中…" : "确认并提交审核" }}
</button>
</footer>
</form>
</template>
</div>
</section>
</div>
</template>
<style scoped>
.ocr-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: rgb(15 23 42 / 48%);
}
.ocr-dialog {
display: flex;
width: min(920px, 100%);
max-height: min(880px, calc(100vh - 48px));
flex-direction: column;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 22px 54px rgb(15 23 42 / 22%);
}
.ocr-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-light);
padding: 17px 20px;
}
.ocr-title {
display: flex;
align-items: center;
gap: 11px;
}
.ocr-title-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 6px;
background: #e0f2fe;
color: #0369a1;
}
.ocr-title h2,
.ocr-title p,
.section-title h3,
.section-title p,
.submitted-state h3,
.submitted-state p {
margin: 0;
}
.ocr-title h2 {
font-size: 16px;
}
.ocr-title p,
.section-title p {
margin-top: 3px;
color: var(--color-text-muted);
font-size: 11px;
}
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.ocr-content {
overflow-y: auto;
padding: 20px;
}
.upload-section {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
}
.upload-zone {
display: grid;
min-height: 102px;
place-items: center;
align-content: center;
gap: 4px;
border: 1px dashed var(--color-border-strong);
border-radius: 6px;
background: var(--color-bg);
color: #0369a1;
cursor: pointer;
}
.upload-zone strong {
color: var(--color-text);
font-size: 13px;
}
.upload-zone span {
color: var(--color-text-muted);
font-size: 11px;
}
.upload-zone input {
display: none;
}
.selected-files {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.selected-files span {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
border: 1px solid var(--color-border-light);
border-radius: 4px;
padding: 5px 7px;
color: var(--color-text-secondary);
font-size: 11px;
}
.selected-files small {
color: var(--color-text-muted);
}
button {
font: inherit;
}
.primary-button,
.secondary-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 5px;
padding: 0 15px;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.primary-button {
border: 1px solid var(--color-accent);
background: var(--color-accent);
color: white;
}
.secondary-button {
border: 1px solid var(--color-border-strong);
background: white;
color: var(--color-text-secondary);
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.recognize-button {
min-width: 190px;
}
.error-message,
.warning-list p {
display: flex;
align-items: flex-start;
gap: 7px;
}
.error-message {
margin: 14px 0 0;
color: var(--color-danger);
font-size: 12px;
}
.result-summary {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 11px 13px;
background: #f0fdf4;
}
.result-summary div {
display: grid;
gap: 2px;
}
.result-summary strong {
color: #166534;
font-size: 13px;
}
.result-summary div span {
color: #4b5563;
font-size: 11px;
}
.confidence {
border-radius: 4px;
padding: 4px 7px;
background: #dcfce7;
color: #166534;
font-size: 11px;
font-weight: 700;
}
.confidence.low {
background: #fef3c7;
color: #92400e;
}
.warning-list {
margin-top: 10px;
border-left: 3px solid #f59e0b;
padding: 6px 10px;
background: #fffbeb;
}
.warning-list p {
margin: 3px 0;
color: #92400e;
font-size: 11px;
}
.ocr-form {
display: grid;
gap: 15px;
margin-top: 18px;
}
.section-title {
display: flex;
align-items: end;
justify-content: space-between;
}
.section-title h3 {
font-size: 14px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 11px;
}
.ocr-form label {
display: grid;
gap: 5px;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 650;
}
.ocr-form input,
.ocr-form select,
.ocr-form textarea {
min-width: 0;
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 8px 9px;
background: white;
color: var(--color-text);
font: inherit;
font-size: 12px;
box-sizing: border-box;
}
.ocr-form textarea {
resize: vertical;
}
.ocr-form label.review input,
.ocr-form label.review select,
.ocr-form label.review textarea {
border-color: #f59e0b;
background: #fffbeb;
}
.judgment-section {
display: grid;
gap: 10px;
border-top: 1px solid var(--color-border-light);
padding-top: 15px;
}
.judgment-table-wrap {
overflow-x: auto;
border: 1px solid var(--color-border-light);
border-radius: 5px;
}
.judgment-table-wrap table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
font-size: 11px;
}
.judgment-table-wrap th,
.judgment-table-wrap td {
border-bottom: 1px solid var(--color-border-light);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.judgment-table-wrap th {
background: var(--color-bg);
color: var(--color-text-muted);
}
.judgment-table-wrap td span,
.judgment-table-wrap td small {
display: block;
}
.judgment-table-wrap td small {
max-width: 280px;
margin-top: 3px;
color: var(--color-text-muted);
line-height: 1.45;
}
.judgment {
width: fit-content;
border-radius: 3px;
padding: 2px 6px;
font-weight: 700;
}
.judgment.is-pass {
background: #dcfce7;
color: #166534;
}
.judgment.is-fail {
background: #fee2e2;
color: #b91c1c;
}
.judgment.is-review {
background: #fef3c7;
color: #92400e;
}
.compact-button {
min-height: 32px;
padding: 0 10px;
}
.measurement-input {
width: 110px !important;
}
.judgment-select {
width: 112px !important;
border: 0 !important;
font-weight: 700 !important;
}
.judgment-select.is-pass {
background: #dcfce7;
color: #166534;
}
.judgment-select.is-fail {
background: #fee2e2;
color: #b91c1c;
}
.judgment-select.is-review {
background: #fef3c7;
color: #92400e;
}
.raw-result {
border: 1px solid var(--color-border-light);
border-radius: 5px;
padding: 9px 11px;
}
.raw-result summary {
color: var(--color-text-secondary);
font-size: 11px;
cursor: pointer;
}
.raw-result article {
margin-top: 10px;
}
.raw-result article strong {
font-size: 11px;
}
.raw-result pre {
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-muted);
font-family: inherit;
font-size: 11px;
line-height: 1.55;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
border-top: 1px solid var(--color-border-light);
padding-top: 15px;
}
.submitted-state {
display: grid;
min-height: 360px;
place-items: center;
align-content: center;
gap: 10px;
padding: 30px;
text-align: center;
color: var(--color-success);
}
.submitted-state h3 {
color: var(--color-text);
}
.submitted-state p {
max-width: 430px;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.6;
}
.submitted-state .primary-button {
margin-top: 8px;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 780px) {
.ocr-overlay {
align-items: end;
padding: 0;
}
.ocr-dialog {
width: 100%;
max-height: 94vh;
border-radius: 8px 8px 0 0;
}
.ocr-content {
padding: 15px;
}
.upload-section {
grid-template-columns: 1fr;
}
.recognize-button {
min-width: 0;
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.field-grid {
grid-template-columns: 1fr;
}
.result-summary {
align-items: flex-start;
gap: 8px;
}
.dialog-actions {
flex-direction: column-reverse;
}
.dialog-actions button {
width: 100%;
}
}
</style>