990 lines
26 KiB
Vue
990 lines
26 KiB
Vue
<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>
|