接入OCR录入并完善角色权限界面

This commit is contained in:
kx_guo 2026-09-07 17:49:20 +08:00
parent ab20424aa9
commit 5397d128be
15 changed files with 1745 additions and 30 deletions

17
src/api/modelConfig.ts Normal file
View File

@ -0,0 +1,17 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
ModelWeightConfig,
ModelWeightConfigUpdate,
} from "@/types/modelConfig";
export function fetchModelConfig() {
return http.get<ApiResponse<ModelWeightConfig>>("/v1/model-config");
}
export function updateModelConfig(payload: ModelWeightConfigUpdate) {
return http.patch<ApiResponse<ModelWeightConfig>>(
"/v1/model-config",
payload,
);
}

31
src/api/ocr.ts Normal file
View File

@ -0,0 +1,31 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
OcrBatchResult,
OcrMeasurement,
OcrStandardJudgment,
} from "@/types/ocr";
export function recognizeOcrFiles(files: File[]) {
const body = new FormData();
files.forEach((file) => body.append("files", file));
return http.post<ApiResponse<OcrBatchResult>>("/v1/ocr/recognize", body, {
timeout: 180_000,
});
}
export function judgeOcrMeasurements(measurements: OcrMeasurement[]) {
return http.post<ApiResponse<OcrStandardJudgment[]>>("/v1/ocr/judge", {
measurements,
});
}
export function ocrFileUrl(fileId: string) {
return `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/ocr/files/${encodeURIComponent(fileId)}`;
}
export function downloadOcrFile(fileId: string) {
return http.get(`/v1/ocr/files/${encodeURIComponent(fileId)}`, {
responseType: "blob",
});
}

View File

@ -11,7 +11,9 @@ import {
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
defineProps<{ modelValue: string; loading: boolean }>(); import { featureFlags } from "@/config/features";
defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>();
const emit = defineEmits<{ const emit = defineEmits<{
"update:modelValue": [value: string]; "update:modelValue": [value: string];
@ -93,6 +95,7 @@ const functions: Array<{
<div class="composer-wrap"> <div class="composer-wrap">
<form class="composer" @submit.prevent="emit('submit')"> <form class="composer" @submit.prevent="emit('submit')">
<button <button
v-if="featureFlags.voiceEntry"
class="composer-icon" class="composer-icon"
type="button" type="button"
title="语音录入" title="语音录入"
@ -102,6 +105,7 @@ const functions: Array<{
<Mic :size="16" /> <Mic :size="16" />
</button> </button>
<button <button
v-if="canUpload"
class="composer-icon" class="composer-icon"
type="button" type="button"
title="拍照识别" title="拍照识别"

View File

@ -9,8 +9,12 @@ import {
Scale, Scale,
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed } from "vue";
import { featureFlags } from "@/config/features";
const emit = defineEmits<{ select: [prompt: string] }>(); const emit = defineEmits<{ select: [prompt: string] }>();
const props = defineProps<{ canUpload: boolean }>();
const actions = [ const actions = [
{ {
@ -64,15 +68,28 @@ const actions = [
prompt: "请帮我识别检测报告并进行风险判定", prompt: "请帮我识别检测报告并进行风险判定",
icon: Camera, icon: Camera,
tone: "sky", tone: "sky",
requiresUpload: true,
}, },
{ ...(featureFlags.voiceEntry
title: "语音录入异常", ? [
description: "口述信息自动结构化", {
prompt: "记录一条新的质量异常", title: "语音录入异常",
icon: Mic, description: "口述信息自动结构化",
tone: "purple", prompt: "记录一条新的质量异常",
}, icon: Mic,
tone: "purple",
},
]
: []),
]; ];
const visibleActions = computed(() =>
actions.filter(
(action) =>
!("requiresUpload" in action) ||
!action.requiresUpload ||
props.canUpload,
),
);
</script> </script>
<template> <template>
@ -86,7 +103,7 @@ const actions = [
<div class="welcome-grid"> <div class="welcome-grid">
<button <button
v-for="action in actions" v-for="action in visibleActions"
:key="action.title" :key="action.title"
class="welcome-action" class="welcome-action"
type="button" type="button"

View File

@ -14,6 +14,7 @@ import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
import { roleLabel } from "@/utils/roles";
import { useConversationStore } from "@/stores/conversation.store"; import { useConversationStore } from "@/stores/conversation.store";
const auth = useAuthStore(); const auth = useAuthStore();
@ -155,7 +156,7 @@ function searchConversations() {
<footer> <footer>
<div class="knowledge-progress"><i></i></div> <div class="knowledge-progress"><i></i></div>
<p>{{ user?.role ?? "未登录" }} · 知识库 2,448 / 3,000 条</p> <p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
<p>标准库 219 条</p> <p>标准库 219 条</p>
</footer> </footer>
</aside> </aside>

View File

@ -0,0 +1,989 @@
<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>

4
src/config/features.ts Normal file
View File

@ -0,0 +1,4 @@
export const featureFlags = {
voiceEntry: false,
adminConfigurationPanels: false,
} as const;

View File

@ -13,22 +13,49 @@ import {
Mic, Mic,
Plus, Plus,
RefreshCw, RefreshCw,
Save,
SlidersHorizontal,
Upload, Upload,
Users,
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { downloadOcrFile } from "@/api/ocr";
import { fetchModelConfig, updateModelConfig } from "@/api/modelConfig";
import { useReviewStore } from "@/stores/review.store"; import { useReviewStore } from "@/stores/review.store";
import { useAuthStore } from "@/stores/auth.store";
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
import { featureFlags } from "@/config/features";
import type { OcrStandardJudgment } from "@/types/ocr";
import type { RiskItem, RiskItemUpdate } from "@/types/riskItem"; import type { RiskItem, RiskItemUpdate } from "@/types/riskItem";
import type { ModelWeightConfig, WeightDimension } from "@/types/modelConfig";
const router = useRouter(); const router = useRouter();
const store = useReviewStore(); const store = useReviewStore();
const auth = useAuthStore();
const sourceFilter = ref("all"); const sourceFilter = ref("all");
const statusFilter = ref("待审核"); const statusFilter = ref("待审核");
const expandedId = ref<number | null>(null); const expandedId = ref<number | null>(null);
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const reviewingId = ref<number | null>(null); const reviewingId = ref<number | null>(null);
const ocrOpen = ref(false);
const attachmentError = ref("");
const modelConfig = ref<ModelWeightConfig | null>(null);
const modelConfigError = ref("");
const savingWeights = ref(false);
const weightDraft = reactive<Record<WeightDimension, number>>({
供应商表现: 25,
物料品类: 20,
产品品类: 15,
加工厂质量: 15,
标准符合性: 10,
节点阶段: 10,
缺陷等级: 5,
});
const frequencyCoefficient = ref(0.3);
const frequencyCap = ref(1.5);
const notes = reactive<Record<number, string>>({}); const notes = reactive<Record<number, string>>({});
const editForm = reactive<Record<number, RiskItemUpdate>>({}); const editForm = reactive<Record<number, RiskItemUpdate>>({});
@ -43,6 +70,40 @@ const preReviewCount = computed(
const sourceCount = computed( const sourceCount = computed(
() => new Set(store.items.map((item) => item.sourceType)).size, () => new Set(store.items.map((item) => item.sourceType)).size,
); );
const weightTotal = computed(() =>
Object.values(weightDraft).reduce(
(sum, value) => sum + Number(value || 0),
0,
),
);
const permissionRows = [
{ role: "管理员", assess: true, submit: true, review: true, weights: true },
{
role: "产品经理",
assess: true,
submit: true,
review: true,
weights: false,
},
{ role: "审核员", assess: true, submit: false, review: true, weights: false },
{
role: "普通用户",
assess: true,
submit: false,
review: false,
weights: false,
},
];
const permissionKeys = ["assess", "submit", "review", "weights"] as const;
const weightRows: Array<{ dimension: WeightDimension; description: string }> = [
{ dimension: "供应商表现", description: "含频次风险系数 α 修正" },
{ dimension: "物料品类", description: "按物料历史异常率" },
{ dimension: "产品品类", description: "拆解到具体产品品类" },
{ dimension: "加工厂质量", description: "巡检 / 批次合格率" },
{ dimension: "节点阶段", description: "开发阶段越靠后风险越高" },
{ dimension: "缺陷等级", description: "A 级缺陷加权" },
{ dimension: "标准符合性", description: "按判定标准库量化达标率计分" },
];
function load() { function load() {
return store.loadPending({ return store.loadPending({
@ -50,6 +111,38 @@ function load() {
status: statusFilter.value, status: statusFilter.value,
}); });
} }
async function loadModelWeights() {
if (!auth.hasPermission("model_weight.manage")) return;
modelConfigError.value = "";
try {
const response = await fetchModelConfig();
if (!response.data.data) throw new Error("模型配置响应为空");
modelConfig.value = response.data.data;
Object.assign(weightDraft, response.data.data.weights);
frequencyCoefficient.value = response.data.data.frequencyCoefficient;
frequencyCap.value = response.data.data.frequencyCap;
} catch {
modelConfigError.value = "模型权重加载失败";
}
}
async function saveModelWeights() {
if (Math.abs(weightTotal.value - 100) > 0.01 || savingWeights.value) return;
savingWeights.value = true;
modelConfigError.value = "";
try {
const response = await updateModelConfig({
weights: { ...weightDraft },
frequencyCoefficient: frequencyCoefficient.value,
frequencyCap: frequencyCap.value,
});
if (!response.data.data) throw new Error("模型配置响应为空");
modelConfig.value = response.data.data;
} catch {
modelConfigError.value = "权重保存失败,请检查各维度合计是否为100%";
} finally {
savingWeights.value = false;
}
}
function sourceClass(source: string) { function sourceClass(source: string) {
return source === "OCR" return source === "OCR"
? "source-ocr" ? "source-ocr"
@ -71,6 +164,9 @@ function sourceIcon(source: string) {
function levelClass(level: string | null | undefined) { function levelClass(level: string | null | undefined) {
return level ? `level-${level.toLowerCase()}` : "level-empty"; return level ? `level-${level.toLowerCase()}` : "level-empty";
} }
function isOwnSubmission(item: RiskItem) {
return Boolean(auth.user?.empNo && item.submittedBy === auth.user.empNo);
}
function toggleItem(id: number) { function toggleItem(id: number) {
expandedId.value = expandedId.value === id ? null : id; expandedId.value = expandedId.value === id ? null : id;
if (editingId.value === id) editingId.value = null; if (editingId.value === id) editingId.value = null;
@ -81,6 +177,8 @@ function beginEdit(item: RiskItem) {
editForm[item.id] = { editForm[item.id] = {
productName: item.productName, productName: item.productName,
productCategory: item.productCategory, productCategory: item.productCategory,
materialType: item.materialType,
occurredOn: item.occurredOn,
supplier: item.supplier, supplier: item.supplier,
factory: item.factory, factory: item.factory,
stageNew: item.stageNew, stageNew: item.stageNew,
@ -89,6 +187,32 @@ function beginEdit(item: RiskItem) {
solution: item.solution, solution: item.solution,
}; };
} }
function ocrFiles(item: RiskItem) {
const files = item.ocrPayload?.files;
if (!Array.isArray(files)) return [];
return files.filter(
(file): file is { fileId: string; fileName: string } =>
typeof file === "object" &&
file !== null &&
typeof file.fileId === "string" &&
typeof file.fileName === "string",
);
}
function ocrJudgments(item: RiskItem): OcrStandardJudgment[] {
const judgments = item.ocrPayload?.confirmedJudgments;
return Array.isArray(judgments) ? (judgments as OcrStandardJudgment[]) : [];
}
async function openAttachment(fileId: string) {
attachmentError.value = "";
try {
const response = await downloadOcrFile(fileId);
const url = URL.createObjectURL(response.data);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch {
attachmentError.value = "附件打开失败或已被清理";
}
}
function editValue(id: number, key: keyof RiskItemUpdate) { function editValue(id: number, key: keyof RiskItemUpdate) {
return editForm[id]?.[key] ?? ""; return editForm[id]?.[key] ?? "";
} }
@ -115,6 +239,8 @@ async function review(item: RiskItem, action: "通过" | "驳回") {
try { try {
await store.review(item.id, action, note); await store.review(item.id, action, note);
expandedId.value = null; expandedId.value = null;
} catch {
// The exact API error is shown above the queue.
} finally { } finally {
reviewingId.value = null; reviewingId.value = null;
} }
@ -128,7 +254,10 @@ function formatDate(value: string | null) {
minute: "2-digit", minute: "2-digit",
}).format(new Date(value)); }).format(new Date(value));
} }
onMounted(() => void load()); onMounted(() => {
void load();
void loadModelWeights();
});
</script> </script>
<template> <template>
@ -151,6 +280,7 @@ onMounted(() => void load());
:class="{ spinning: store.loading }" :class="{ spinning: store.loading }"
/>刷新</button />刷新</button
><button ><button
v-if="auth.hasPermission('risk_item.submit')"
class="button button--primary" class="button button--primary"
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
@ -229,6 +359,7 @@ onMounted(() => void load());
<ClipboardCheck :size="28" /><strong>当前没有符合条件的风险项</strong <ClipboardCheck :size="28" /><strong>当前没有符合条件的风险项</strong
><span>新提交的风险项通过预审后会出现在这里。</span ><span>新提交的风险项通过预审后会出现在这里。</span
><button ><button
v-if="auth.hasPermission('risk_item.submit')"
class="button button--secondary" class="button button--secondary"
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
@ -298,6 +429,27 @@ onMounted(() => void load());
($event.target as HTMLInputElement).value, ($event.target as HTMLInputElement).value,
) )
" /></label " /></label
><label
>物料<input
:value="editValue(item.id, 'materialType')"
@input="
setEditValue(
item.id,
'materialType',
($event.target as HTMLInputElement).value,
)
" /></label
><label
>发生日期<input
type="date"
:value="editValue(item.id, 'occurredOn')"
@input="
setEditValue(
item.id,
'occurredOn',
($event.target as HTMLInputElement).value,
)
" /></label
><label ><label
>供应商<input >供应商<input
:value="editValue(item.id, 'supplier')" :value="editValue(item.id, 'supplier')"
@ -395,6 +547,14 @@ onMounted(() => void load());
<dt>产品品类</dt> <dt>产品品类</dt>
<dd>{{ item.productCategory || "未填写" }}</dd> <dd>{{ item.productCategory || "未填写" }}</dd>
</div> </div>
<div>
<dt>物料</dt>
<dd>{{ item.materialType || "未填写" }}</dd>
</div>
<div>
<dt>发生日期</dt>
<dd>{{ item.occurredOn || "未填写" }}</dd>
</div>
<div> <div>
<dt>供应商</dt> <dt>供应商</dt>
<dd>{{ item.supplier || "未填写" }}</dd> <dd>{{ item.supplier || "未填写" }}</dd>
@ -426,10 +586,54 @@ onMounted(() => void load());
<strong>解决方案</strong> <strong>解决方案</strong>
<p>{{ item.solution }}</p> <p>{{ item.solution }}</p>
</div> </div>
<div v-if="item.photos?.length" class="attachment-note"> <div v-if="ocrFiles(item).length" class="attachment-list">
<ImagePlus :size="14" />已关联 {{ item.photos.length }} 个附件 <button
v-for="file in ocrFiles(item)"
:key="file.fileId"
type="button"
title="通过鉴权打开原始附件"
@click="openAttachment(file.fileId)"
>
<FileText :size="14" />{{ file.fileName }}
</button>
</div>
<p v-if="attachmentError" class="form-error">
{{ attachmentError }}
</p>
<div v-if="ocrJudgments(item).length" class="ocr-judgments">
<strong>OCR 标准判定</strong>
<div
v-for="judgment in ocrJudgments(item)"
:key="`${judgment.measurementItem}-${judgment.standardRuleId}`"
>
<span
>{{ judgment.measurementItem }}:{{
judgment.measuredValue
}}</span
>
<span>{{ judgment.standardValue || "未匹配标准" }}</span>
<b
:class="
judgment.result === '不合格'
? 'is-fail'
: judgment.result === '合格'
? 'is-pass'
: 'is-review'
"
>{{ judgment.result }}</b
>
<small v-if="judgment.standardRuleId"
>标准 #{{ judgment.standardRuleId }}
{{ judgment.basis || "" }}</small
>
</div>
</div> </div>
<div class="review-controls"> <div class="review-controls">
<p v-if="isOwnSubmission(item)" class="self-review-warning">
<AlertTriangle
:size="14"
/>该风险项由当前账号提交。根据审核防自审规则,请由其他审核员或管理者处理。
</p>
<textarea <textarea
v-model="notes[item.id]" v-model="notes[item.id]"
rows="2" rows="2"
@ -451,6 +655,7 @@ onMounted(() => void load());
type="button" type="button"
:disabled=" :disabled="
item.status !== '待审核' || item.status !== '待审核' ||
isOwnSubmission(item) ||
reviewingId !== null || reviewingId !== null ||
(notes[item.id]?.trim().length ?? 0) < 2 (notes[item.id]?.trim().length ?? 0) < 2
" "
@ -462,6 +667,7 @@ onMounted(() => void load());
type="button" type="button"
:disabled=" :disabled="
item.status !== '待审核' || item.status !== '待审核' ||
isOwnSubmission(item) ||
reviewingId !== null || reviewingId !== null ||
(notes[item.id]?.trim().length ?? 0) < 2 (notes[item.id]?.trim().length ?? 0) < 2
" "
@ -477,7 +683,10 @@ onMounted(() => void load());
</div> </div>
</section> </section>
<aside class="admin-tools"> <aside class="admin-tools">
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--blue" <span class="tool-icon tool-icon--blue"
><ClipboardCheck :size="16" ><ClipboardCheck :size="16"
@ -495,23 +704,24 @@ onMounted(() => void load());
<Plus :size="14" />打开录入表单 <Plus :size="14" />打开录入表单
</button> </button>
</article> </article>
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--cyan" <span class="tool-icon tool-icon--cyan"
><ImagePlus :size="16" ><ImagePlus :size="16"
/></span> /></span>
<div> <div>
<h3>OCR 识别录入</h3> <h3>OCR 识别录入</h3>
<p>上传检测报告或缺陷照片</p> <p>上传检测报告、巡检单或异常报告</p>
</div> </div>
</header> </header>
<label class="upload-zone" <button class="tool-link" type="button" @click="ocrOpen = true">
><Upload :size="17" /><span>选择图片 / PDF</span <Upload :size="14" />开始 OCR 识别
><small>识别结果将填入风险项表单</small </button>
><input type="file" accept="image/*,.pdf"
/></label>
</article> </article>
<article class="tool-card"> <article v-if="featureFlags.voiceEntry" class="tool-card">
<header> <header>
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span> <span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
<div> <div>
@ -523,7 +733,10 @@ onMounted(() => void load());
<Mic :size="17" /><span>按住说话</span></button <Mic :size="17" /><span>按住说话</span></button
><small class="tool-hint">普通话 · 最长 60 秒</small> ><small class="tool-hint">普通话 · 最长 60 秒</small>
</article> </article>
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--green" <span class="tool-icon tool-icon--green"
><FileSpreadsheet :size="16" ><FileSpreadsheet :size="16"
@ -556,6 +769,135 @@ onMounted(() => void load());
</article> </article>
</aside> </aside>
</div> </div>
<div
v-if="
featureFlags.adminConfigurationPanels &&
auth.hasPermission('model_weight.manage')
"
class="administration-config"
>
<section class="admin-config-panel">
<header class="admin-config-heading">
<span><Users :size="17" /></span>
<div>
<h2>用户权限(含审核角色)</h2>
<p>四角色权限矩阵</p>
</div>
</header>
<div class="config-table-wrap">
<table class="config-table">
<thead>
<tr>
<th>角色</th>
<th>评估/查询</th>
<th>录入/上传</th>
<th>审核</th>
<th>权重配置</th>
</tr>
</thead>
<tbody>
<tr v-for="row in permissionRows" :key="row.role">
<td>{{ row.role }}</td>
<td v-for="key in permissionKeys" :key="key">
<Check v-if="row[key]" class="permission-yes" :size="15" />
<span v-else class="permission-no">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="admin-config-panel">
<header class="admin-config-heading">
<span><SlidersHorizontal :size="17" /></span>
<div>
<h2>模型权重配置</h2>
<p>七维模型,保存后对新评估实时生效</p>
</div>
<strong>七维 · 合计 {{ weightTotal }}%</strong>
</header>
<div class="config-table-wrap">
<table class="config-table weight-table">
<thead>
<tr>
<th>维度</th>
<th>权重</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in weightRows" :key="row.dimension">
<td>{{ row.dimension }}</td>
<td>
<label
><input
v-model.number="weightDraft[row.dimension]"
type="number"
min="1"
max="100"
step="0.5"
/><span>%</span></label
>
</td>
<td>{{ row.description }}</td>
</tr>
<tr class="frequency-row">
<td>频次风险系数</td>
<td colspan="2">
<label
>α = 1.0 + (N / 100) ×
<input
v-model.number="frequencyCoefficient"
type="number"
min="0"
max="1"
step="0.05"
/></label>
<label
>上限
<input
v-model.number="frequencyCap"
type="number"
min="1"
max="3"
step="0.1"
/></label>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="config-actions">
<p v-if="modelConfigError" class="form-error">
{{ modelConfigError }}
</p>
<span v-else-if="modelConfig?.updatedBy"
>最近更新:{{ modelConfig.updatedBy }}</span
>
<span v-else>权重合计必须为 100%</span>
<button
class="button button--primary"
type="button"
:disabled="savingWeights || Math.abs(weightTotal - 100) > 0.01"
@click="saveModelWeights"
>
<LoaderCircle
v-if="savingWeights"
class="spinning"
:size="14"
/><Save v-else :size="14" />{{
savingWeights ? "保存中…" : "保存配置"
}}
</button>
</footer>
</section>
</div>
<OcrRecognitionDialog
:open="ocrOpen"
@close="ocrOpen = false"
@submitted="load"
/>
</div> </div>
</template> </template>
@ -850,19 +1192,86 @@ onMounted(() => void load());
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
} }
.attachment-note { .attachment-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.attachment-list button {
display: inline-flex; display: inline-flex;
min-width: 0;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
margin-top: 12px; border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 5px 8px;
background: white;
color: var(--color-accent); color: var(--color-accent);
font-size: 10px; font-size: 10px;
cursor: pointer;
}
.ocr-judgments {
display: grid;
gap: 7px;
margin-top: 14px;
border: 1px solid var(--color-border-light);
border-radius: 5px;
padding: 10px;
}
.ocr-judgments > strong {
font-size: 11px;
}
.ocr-judgments > div {
display: grid;
grid-template-columns: minmax(100px, 0.8fr) minmax(180px, 1.5fr) auto;
gap: 6px 10px;
align-items: start;
border-top: 1px solid var(--color-border-light);
padding-top: 7px;
color: var(--color-text-secondary);
font-size: 10px;
}
.ocr-judgments b {
border-radius: 3px;
padding: 2px 5px;
font-size: 10px;
}
.ocr-judgments b.is-pass {
background: var(--color-success-soft);
color: var(--color-success);
}
.ocr-judgments b.is-fail {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.ocr-judgments b.is-review {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.ocr-judgments small {
grid-column: 1 / -1;
color: var(--color-text-faint);
} }
.review-controls { .review-controls {
align-items: flex-end; align-items: flex-end;
flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-top: 17px; margin-top: 17px;
} }
.self-review-warning {
display: flex;
width: 100%;
align-items: flex-start;
gap: 6px;
margin: 0;
border-left: 3px solid var(--color-warning);
padding: 7px 9px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 11px;
line-height: 1.5;
}
.review-controls textarea { .review-controls textarea {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
@ -1084,6 +1493,111 @@ onMounted(() => void load());
color: var(--color-success); color: var(--color-success);
font-size: 10px; font-size: 10px;
} }
.administration-config {
display: grid;
gap: 14px;
margin-top: 18px;
}
.admin-config-panel {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
padding: 18px 20px;
}
.admin-config-heading {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 13px;
}
.admin-config-heading > span {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 5px;
background: var(--color-bg);
color: var(--color-accent);
}
.admin-config-heading h2,
.admin-config-heading p {
margin: 0;
}
.admin-config-heading h2 {
font-size: 14px;
}
.admin-config-heading p {
margin-top: 2px;
color: var(--color-text-muted);
font-size: 10px;
}
.admin-config-heading > strong {
margin-left: auto;
color: var(--color-accent);
font-size: 10px;
}
.config-table-wrap {
overflow-x: auto;
}
.config-table {
width: 100%;
min-width: 600px;
border-collapse: collapse;
font-size: 11px;
}
.config-table th,
.config-table td {
border-bottom: 1px solid var(--color-border-light);
padding: 9px 10px;
text-align: left;
}
.config-table th {
background: var(--color-bg);
color: var(--color-text-muted);
font-weight: 650;
}
.permission-yes {
color: var(--color-success);
}
.permission-no {
color: var(--color-danger);
}
.weight-table input {
width: 58px;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 5px;
font: inherit;
font-size: 11px;
}
.weight-table label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.weight-table label + label {
margin-left: 14px;
}
.frequency-row td:first-child {
color: #7c3aed;
font-weight: 650;
}
.config-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding-top: 14px;
color: var(--color-text-muted);
font-size: 10px;
}
.config-actions .form-error {
margin: 0 auto 0 0;
}
.config-actions .button {
min-height: 34px;
}
.spinning { .spinning {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }

View File

@ -7,6 +7,7 @@ import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue"; import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue"; import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue"; import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
import { useConversationStore } from "@/stores/conversation.store"; import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
@ -21,6 +22,7 @@ const {
error, error,
} = storeToRefs(conversationStore); } = storeToRefs(conversationStore);
const prompt = ref(""); const prompt = ref("");
const ocrOpen = ref(false);
const scrollArea = ref<HTMLElement | null>(null); const scrollArea = ref<HTMLElement | null>(null);
const tool = ref< const tool = ref<
| "assessment" | "assessment"
@ -104,6 +106,10 @@ function openTool(
router.push({ name: "review" }); router.push({ name: "review" });
return; return;
} }
if (value === "ocr") {
ocrOpen.value = true;
return;
}
tool.value = value; tool.value = value;
toolInput.value = ""; toolInput.value = "";
toolFile.value = null; toolFile.value = null;
@ -258,6 +264,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</p> </p>
<AssistantWelcome <AssistantWelcome
v-if="!conversation?.messages.length" v-if="!conversation?.messages.length"
:can-upload="auth.hasPermission('risk_item.submit')"
@select="usePrompt" @select="usePrompt"
/> />
@ -335,6 +342,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<AssistantComposer <AssistantComposer
v-model="prompt" v-model="prompt"
:loading="loading" :loading="loading"
:can-upload="auth.hasPermission('risk_item.submit')"
@submit="submit" @submit="submit"
@select="usePrompt" @select="usePrompt"
@tool="openTool" @tool="openTool"
@ -410,6 +418,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</div> </div>
</form> </form>
</div> </div>
<OcrRecognitionDialog :open="ocrOpen" @close="ocrOpen = false" />
</div> </div>
</template> </template>

View File

@ -11,8 +11,10 @@ const reviewStore = useReviewStore();
const submitted = ref(false); const submitted = ref(false);
const form = reactive<RiskItemCreate>({ const form = reactive<RiskItemCreate>({
sourceType: "表单", sourceType: "表单",
occurredOn: null,
productName: "", productName: "",
productCategory: "", productCategory: "",
materialType: "",
supplier: "", supplier: "",
factory: "", factory: "",
stageNew: "第三阶段(产品开发)", stageNew: "第三阶段(产品开发)",
@ -74,6 +76,10 @@ async function submit() {
<label <label
>产品品类<input v-model.trim="form.productCategory" maxlength="100" >产品品类<input v-model.trim="form.productCategory" maxlength="100"
/></label> /></label>
<label
>物料<input v-model.trim="form.materialType" maxlength="200"
/></label>
<label>发生日期<input v-model="form.occurredOn" type="date" /></label>
<label <label
>供应商<input v-model.trim="form.supplier" maxlength="300" >供应商<input v-model.trim="form.supplier" maxlength="300"
/></label> /></label>

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
@ -59,10 +60,20 @@ export const useReviewStore = defineStore("review", () => {
} }
async function review(itemId: number, action: "通过" | "驳回", note: string) { async function review(itemId: number, action: "通过" | "驳回", note: string) {
const response = await reviewRiskItem(itemId, { action, note }); error.value = null;
if (!response.data.data) throw new Error("审核响应为空"); try {
items.value = items.value.filter((item) => item.id !== itemId); const response = await reviewRiskItem(itemId, { action, note });
return response.data.data; if (!response.data.data) throw new Error("审核响应为空");
items.value = items.value.filter((item) => item.id !== itemId);
return response.data.data;
} catch (reason) {
error.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "审核操作失败")
: reason instanceof Error
? reason.message
: "审核操作失败";
throw reason;
}
} }
return { return {

22
src/types/modelConfig.ts Normal file
View File

@ -0,0 +1,22 @@
export type WeightDimension =
| "供应商表现"
| "物料品类"
| "产品品类"
| "加工厂质量"
| "标准符合性"
| "节点阶段"
| "缺陷等级";
export interface ModelWeightConfig {
weights: Record<WeightDimension, number>;
frequencyCoefficient: number;
frequencyCap: number;
updatedBy: string | null;
updatedAt: string | null;
}
export interface ModelWeightConfigUpdate {
weights: Record<WeightDimension, number>;
frequencyCoefficient: number;
frequencyCap: number;
}

61
src/types/ocr.ts Normal file
View File

@ -0,0 +1,61 @@
export interface OcrField {
value: string | null;
confidence: number | null;
needsReview: boolean;
}
export interface OcrExtractedFields {
productName: OcrField;
productCategory: OcrField;
supplier: OcrField;
factory: OcrField;
materialType: OcrField;
stageNew: OcrField;
description: OcrField;
defectLevel: OcrField;
occurredOn: string | null;
}
export interface OcrMeasurement {
item: string;
rawValue: string;
value: number | null;
unit: string | null;
confidence: number | null;
needsReview: boolean;
}
export interface OcrStandardJudgment {
measurementItem: string;
measuredValue: string;
standardRuleId: number | null;
riskPoint: string | null;
standardValue: string | null;
result: "合格" | "不合格" | "待人工确认" | "未匹配标准";
basis: string | null;
reason: string | null;
manuallyConfirmed?: boolean;
}
export interface OcrFileResult {
fileId: string;
fileName: string;
contentType: string;
engine: "webimage" | "paddle-vl-parser";
rawText: string;
confidence: number | null;
pageCount: number;
fields: OcrExtractedFields;
measurements: OcrMeasurement[];
judgments: OcrStandardJudgment[];
warnings: string[];
requiresReview: boolean;
}
export interface OcrBatchResult {
files: OcrFileResult[];
fields: OcrExtractedFields;
measurements: OcrMeasurement[];
judgments: OcrStandardJudgment[];
requiresReview: boolean;
}

View File

@ -1,8 +1,10 @@
export interface RiskItemCreate { export interface RiskItemCreate {
sourceType: string; sourceType: string;
sourceFileId?: string | null; sourceFileId?: string | null;
occurredOn?: string | null;
productName: string; productName: string;
productCategory?: string | null; productCategory?: string | null;
materialType?: string | null;
supplier?: string | null; supplier?: string | null;
factory?: string | null; factory?: string | null;
stageNew?: string | null; stageNew?: string | null;
@ -10,6 +12,8 @@ export interface RiskItemCreate {
defectLevel?: string | null; defectLevel?: string | null;
solution?: string | null; solution?: string | null;
photos?: string[]; photos?: string[];
recognitionConfidence?: number | null;
ocrPayload?: Record<string, unknown>;
} }
export interface RiskItem extends RiskItemCreate { export interface RiskItem extends RiskItemCreate {
@ -20,7 +24,22 @@ export interface RiskItem extends RiskItemCreate {
createdAt: string; createdAt: string;
} }
export type RiskItemUpdate = Partial<RiskItemCreate>; export type RiskItemUpdate = Partial<
Pick<
RiskItemCreate,
| "occurredOn"
| "productName"
| "productCategory"
| "materialType"
| "supplier"
| "factory"
| "stageNew"
| "description"
| "defectLevel"
| "solution"
| "photos"
>
>;
export interface ReviewAction { export interface ReviewAction {
action: "通过" | "驳回"; action: "通过" | "驳回";

10
src/utils/roles.ts Normal file
View File

@ -0,0 +1,10 @@
export const roleLabels: Record<string, string> = {
admin: "管理员",
product_manager: "产品经理",
reviewer: "审核员",
普通用户: "普通用户",
};
export function roleLabel(role: string | null | undefined) {
return role ? (roleLabels[role] ?? role) : "未登录";
}