Compare commits
No commits in common. "1cc6b471d30173b991cbce055da787f7ee943475" and "f0a940eb106663ae13c5443e0c91006beade9cda" have entirely different histories.
1cc6b471d3
...
f0a940eb10
@ -12,7 +12,6 @@ export interface ConversationStreamEvent {
|
|||||||
export function fetchConversations(limit = 50, search?: string) {
|
export function fetchConversations(limit = 50, search?: string) {
|
||||||
return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", {
|
return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", {
|
||||||
params: { limit, search },
|
params: { limit, search },
|
||||||
timeout: 30_000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ export function createConversation(title?: string) {
|
|||||||
export function fetchConversation(id: number, offset = 0) {
|
export function fetchConversation(id: number, offset = 0) {
|
||||||
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
|
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
|
||||||
params: { limit: 20, offset },
|
params: { limit: 20, offset },
|
||||||
timeout: 60_000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,8 +45,6 @@ export async function streamConversationMessage(
|
|||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
) {
|
) {
|
||||||
const token = localStorage.getItem("risk_prediction_token");
|
const token = localStorage.getItem("risk_prediction_token");
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 180_000);
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
||||||
{
|
{
|
||||||
@ -59,7 +55,6 @@ export async function streamConversationMessage(
|
|||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ content, messageType }),
|
body: JSON.stringify({ content, messageType }),
|
||||||
signal: controller.signal,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@ -69,51 +64,32 @@ export async function streamConversationMessage(
|
|||||||
if (!response.ok || !response.body)
|
if (!response.ok || !response.body)
|
||||||
throw new Error(`流式请求失败(HTTP ${response.status})`);
|
throw new Error(`流式请求失败(HTTP ${response.status})`);
|
||||||
|
|
||||||
try {
|
const reader = response.body.getReader();
|
||||||
const reader = response.body.getReader();
|
const decoder = new TextDecoder();
|
||||||
const decoder = new TextDecoder();
|
let buffer = "";
|
||||||
let buffer = "";
|
let completed = false;
|
||||||
let completed = false;
|
while (true) {
|
||||||
while (true) {
|
const { value, done } = await reader.read();
|
||||||
const { value, done } = await reader.read();
|
if (done) break;
|
||||||
if (done) break;
|
buffer += decoder.decode(value, { stream: true });
|
||||||
buffer += decoder.decode(value, { stream: true });
|
const lines = buffer.split("\n");
|
||||||
const lines = buffer.split("\n");
|
buffer = lines.pop() ?? "";
|
||||||
buffer = lines.pop() ?? "";
|
for (const line of lines) {
|
||||||
for (const line of lines) {
|
if (!line.startsWith("data:")) continue;
|
||||||
if (!line.startsWith("data:")) continue;
|
const raw = line.slice(5).trim();
|
||||||
const raw = line.slice(5).trim();
|
if (!raw) continue;
|
||||||
if (!raw) continue;
|
const event = JSON.parse(raw) as ConversationStreamEvent;
|
||||||
let event: ConversationStreamEvent;
|
onEvent(event);
|
||||||
try {
|
if (event.type === "done") completed = true;
|
||||||
event = JSON.parse(raw) as ConversationStreamEvent;
|
|
||||||
} catch {
|
|
||||||
throw new Error("AI 流式响应格式错误,请重试");
|
|
||||||
}
|
|
||||||
onEvent(event);
|
|
||||||
if (event.type === "done") completed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (buffer.startsWith("data:")) {
|
|
||||||
const raw = buffer.slice(5).trim();
|
|
||||||
if (raw) {
|
|
||||||
let event: ConversationStreamEvent;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(raw) as ConversationStreamEvent;
|
|
||||||
} catch {
|
|
||||||
throw new Error("AI 流式响应格式错误,请重试");
|
|
||||||
}
|
|
||||||
onEvent(event);
|
|
||||||
if (event.type === "done") completed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!completed) throw new Error("AI 流式连接中断,请重试");
|
|
||||||
} catch (reason) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
throw new Error("AI 响应超时,请稍后重试", { cause: reason });
|
|
||||||
}
|
|
||||||
throw reason;
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
|
if (buffer.startsWith("data:")) {
|
||||||
|
const raw = buffer.slice(5).trim();
|
||||||
|
if (raw) {
|
||||||
|
const event = JSON.parse(raw) as ConversationStreamEvent;
|
||||||
|
onEvent(event);
|
||||||
|
if (event.type === "done") completed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!completed) throw new Error("AI 流式连接中断,请重试");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { http } from "@/api/http";
|
|
||||||
import type { RiskReportResult } from "@/types/risk";
|
|
||||||
|
|
||||||
export function downloadReportPdf(payload: RiskReportResult) {
|
|
||||||
return http.post<Blob>("/v1/reports/pdf", payload, {
|
|
||||||
responseType: "blob",
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function downloadReportWord(payload: RiskReportResult) {
|
|
||||||
return http.post<Blob>("/v1/reports/word", payload, {
|
|
||||||
responseType: "blob",
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -11,9 +11,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
|
|
||||||
import { featureFlags } from "@/config/features";
|
defineProps<{ modelValue: string; loading: boolean }>();
|
||||||
|
|
||||||
defineProps<{ modelValue: string; loading: boolean; canUpload: boolean }>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:modelValue": [value: string];
|
"update:modelValue": [value: string];
|
||||||
@ -95,7 +93,6 @@ 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="语音录入"
|
||||||
@ -105,7 +102,6 @@ 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="拍照识别"
|
||||||
|
|||||||
@ -24,8 +24,6 @@ interface CardData extends AssistantResponseCard {
|
|||||||
anomalyCount?: number;
|
anomalyCount?: number;
|
||||||
cooperationCount?: number | null;
|
cooperationCount?: number | null;
|
||||||
frequencyCoefficient?: number;
|
frequencyCoefficient?: number;
|
||||||
frequencyBasis?: string;
|
|
||||||
dataAvailable?: boolean;
|
|
||||||
tier?: string;
|
tier?: string;
|
||||||
topProblems?: Array<{ name: string; count: number }>;
|
topProblems?: Array<{ name: string; count: number }>;
|
||||||
frequentProblems?: Array<{ name: string; count: number }>;
|
frequentProblems?: Array<{ name: string; count: number }>;
|
||||||
@ -96,14 +94,14 @@ function formatCount(value: unknown) {
|
|||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div>
|
<div>
|
||||||
<b>{{ formatCount(data.anomalyCount) }}</b
|
<b>{{ formatCount(data.anomalyCount) }}</b
|
||||||
><span>近3年异常</span>
|
><span>历史异常</span>
|
||||||
</div>
|
|
||||||
<div v-if="data.cooperationCount != null">
|
|
||||||
<b>{{ formatCount(data.cooperationCount) }}</b
|
|
||||||
><span>合作频次(采购订单)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b
|
<b>{{ formatCount(data.cooperationCount) }}</b
|
||||||
|
><span>合作次数</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<b>×{{ data.frequencyCoefficient }}</b
|
||||||
><span>频次系数</span>
|
><span>频次系数</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,12 +9,8 @@ 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 = [
|
||||||
{
|
{
|
||||||
@ -68,28 +64,15 @@ const actions = [
|
|||||||
prompt: "请帮我识别检测报告并进行风险判定",
|
prompt: "请帮我识别检测报告并进行风险判定",
|
||||||
icon: Camera,
|
icon: Camera,
|
||||||
tone: "sky",
|
tone: "sky",
|
||||||
requiresUpload: true,
|
|
||||||
},
|
},
|
||||||
...(featureFlags.voiceEntry
|
{
|
||||||
? [
|
title: "语音录入异常",
|
||||||
{
|
description: "口述信息自动结构化",
|
||||||
title: "语音录入异常",
|
prompt: "记录一条新的质量异常",
|
||||||
description: "口述信息自动结构化",
|
icon: Mic,
|
||||||
prompt: "记录一条新的质量异常",
|
tone: "purple",
|
||||||
icon: Mic,
|
},
|
||||||
tone: "purple",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
const visibleActions = computed(() =>
|
|
||||||
actions.filter(
|
|
||||||
(action) =>
|
|
||||||
!("requiresUpload" in action) ||
|
|
||||||
!action.requiresUpload ||
|
|
||||||
props.canUpload,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -103,7 +86,7 @@ const visibleActions = computed(() =>
|
|||||||
|
|
||||||
<div class="welcome-grid">
|
<div class="welcome-grid">
|
||||||
<button
|
<button
|
||||||
v-for="action in visibleActions"
|
v-for="action in actions"
|
||||||
:key="action.title"
|
:key="action.title"
|
||||||
class="welcome-action"
|
class="welcome-action"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const props = defineProps<{
|
|||||||
result: RiskAssessmentResult;
|
result: RiskAssessmentResult;
|
||||||
canMonitor?: boolean;
|
canMonitor?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ generateReport: [] }>();
|
|
||||||
const expanded = ref(true);
|
const expanded = ref(true);
|
||||||
const feedback = ref("");
|
const feedback = ref("");
|
||||||
|
|
||||||
@ -142,51 +141,26 @@ const stars = computed(() => {
|
|||||||
|
|
||||||
<section class="supplier-analysis">
|
<section class="supplier-analysis">
|
||||||
<h3><Factory :size="14" /> 供应商合作频次分析</h3>
|
<h3><Factory :size="14" /> 供应商合作频次分析</h3>
|
||||||
<template
|
<div v-if="result.supplierFreq?.supplierName">
|
||||||
v-if="
|
<span>{{ result.supplierFreq.supplierName }}</span>
|
||||||
result.supplierFreq?.dataAvailable ||
|
<strong>异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong>
|
||||||
result.supplierFreq?.supplierName
|
</div>
|
||||||
"
|
<div v-if="result.supplierFreq">
|
||||||
>
|
<span>频次风险系数</span
|
||||||
<div v-if="result.supplierFreq.supplierName">
|
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
||||||
<span>{{ result.supplierFreq.supplierName }}</span>
|
</div>
|
||||||
<strong
|
<div v-if="result.supplierFreq?.tier">
|
||||||
>近3年异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
<span>供应商层级</span><strong>{{ result.supplierFreq.tier }}</strong>
|
||||||
>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div v-if="result.supplierFreq.alpha != null">
|
<span>合作次数</span
|
||||||
<span>频次风险系数</span
|
><strong>{{
|
||||||
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
result.supplierFreq?.cooperationCount ?? "暂无数据"
|
||||||
</div>
|
}}</strong>
|
||||||
<div v-if="result.supplierFreq.tier">
|
</div>
|
||||||
<span>供应商层级</span
|
<div v-if="result.supplierFreq?.suggestion">
|
||||||
><strong>{{ result.supplierFreq.tier }}</strong>
|
<span>建议</span>{{ result.supplierFreq.suggestion }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result.supplierFreq.cooperationCount != null">
|
|
||||||
<span>合作频次(采购订单)</span>
|
|
||||||
<strong>{{ result.supplierFreq.cooperationCount }} 次</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="result.supplierFreq.suggestion">
|
|
||||||
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p v-else class="supplier-analysis__empty">
|
|
||||||
{{
|
|
||||||
result.supplierFreq?.note || "未指定供应商,暂无供应商频次分析。"
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<small
|
|
||||||
v-if="
|
|
||||||
result.supplierFreq?.dataAvailable ||
|
|
||||||
result.supplierFreq?.supplierName
|
|
||||||
"
|
|
||||||
class="supplier-analysis__note"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
result.supplierFreq.note ||
|
|
||||||
"合作频次按近3年采购订单去重单据编号统计;异常数仍按异常案例库统计。"
|
|
||||||
}}
|
|
||||||
</small>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="standards-card">
|
<section class="standards-card">
|
||||||
@ -292,7 +266,7 @@ const stars = computed(() => {
|
|||||||
<button
|
<button
|
||||||
class="primary-action"
|
class="primary-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('generateReport')"
|
@click="notify('生成报告')"
|
||||||
>
|
>
|
||||||
<FileDown :size="14" /> 生成报告
|
<FileDown :size="14" /> 生成报告
|
||||||
</button>
|
</button>
|
||||||
@ -533,15 +507,6 @@ h3 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supplier-analysis__empty,
|
|
||||||
.supplier-analysis__note {
|
|
||||||
display: block;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #6d5a91;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.standards-card {
|
.standards-card {
|
||||||
border: 1px solid #bfdbfe;
|
border: 1px solid #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ 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();
|
||||||
@ -56,13 +55,6 @@ function startNewChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openConversation(id: number) {
|
async function openConversation(id: number) {
|
||||||
if (conversationStore.current?.id === id) {
|
|
||||||
await router.push({
|
|
||||||
name: "assessment",
|
|
||||||
query: { conversation: String(id) },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await conversationStore.open(id);
|
await conversationStore.open(id);
|
||||||
await router.push({
|
await router.push({
|
||||||
@ -156,7 +148,7 @@ function searchConversations() {
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="knowledge-progress"><i></i></div>
|
<div class="knowledge-progress"><i></i></div>
|
||||||
<p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
|
<p>{{ user?.role ?? "未登录" }} · 知识库 2,448 / 3,000 条</p>
|
||||||
<p>标准库 219 条</p>
|
<p>标准库 219 条</p>
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -1,989 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export const featureFlags = {
|
|
||||||
voiceEntry: false,
|
|
||||||
adminConfigurationPanels: false,
|
|
||||||
} as const;
|
|
||||||
@ -1,488 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ArrowLeft, Download, FileText } from "@lucide/vue";
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
|
|
||||||
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
|
|
||||||
import type { RiskReportResult } from "@/types/risk";
|
|
||||||
import { formatRiskLevel } from "@/utils/risk";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
let parsed: RiskReportResult | null = null;
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem("dieya-risk-report");
|
|
||||||
parsed = raw ? (JSON.parse(raw) as RiskReportResult) : null;
|
|
||||||
} catch {
|
|
||||||
parsed = null;
|
|
||||||
}
|
|
||||||
const report = computed(() => parsed);
|
|
||||||
const dimensionRows = computed(() => {
|
|
||||||
if (!report.value) return [];
|
|
||||||
if (report.value.dimensionScores?.length) {
|
|
||||||
return report.value.dimensionScores.filter(
|
|
||||||
(item) => item.dimension !== "节点阶段" && item.dimension !== "缺陷等级",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return report.value.factors
|
|
||||||
.filter(
|
|
||||||
(factor) => factor.name !== "节点阶段" && factor.name !== "缺陷等级",
|
|
||||||
)
|
|
||||||
.map((factor) => ({
|
|
||||||
dimension: factor.name,
|
|
||||||
score: 100 - factor.value,
|
|
||||||
weight: factor.weight,
|
|
||||||
status: "database",
|
|
||||||
reason: null,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const generatedAt = new Intl.DateTimeFormat("zh-CN", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
}).format(new Date());
|
|
||||||
|
|
||||||
async function downloadWord() {
|
|
||||||
if (!report.value) return;
|
|
||||||
const response = await downloadReportWord(report.value);
|
|
||||||
const blob = response.data;
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = `蝶雅新品风险预估报告-${report.value.subjectId}.docx`;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadPdf() {
|
|
||||||
if (!report.value) return;
|
|
||||||
const response = await downloadReportPdf(report.value);
|
|
||||||
const url = URL.createObjectURL(response.data);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = `蝶雅新品风险预估报告-${report.value.subjectId}.pdf`;
|
|
||||||
anchor.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string) {
|
|
||||||
window.alert(`${message}功能待接入服务端`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimensionConclusion(item: {
|
|
||||||
dimension: string;
|
|
||||||
score: number;
|
|
||||||
reason?: string | null;
|
|
||||||
}) {
|
|
||||||
const supplier = report.value?.supplierFreq;
|
|
||||||
if (item.dimension === "供应商表现" && supplier) {
|
|
||||||
const orderText =
|
|
||||||
supplier.cooperationCount != null
|
|
||||||
? `合作 ${supplier.cooperationCount} 次、`
|
|
||||||
: "";
|
|
||||||
return `${Math.round(item.score)} 分 · ${orderText}异常 ${supplier.anomalyCount ?? 0} 次`;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
item.dimension === "标准符合性" &&
|
|
||||||
report.value?.standardJudgment?.length
|
|
||||||
) {
|
|
||||||
return `${Math.round(item.score)} 分 · 命中 ${report.value.standardJudgment.length} 条判定标准,待检测项需人工确认`;
|
|
||||||
}
|
|
||||||
return `${Math.round(item.score)} 分${item.reason ? ` · ${item.reason}` : ""}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="report-page">
|
|
||||||
<header class="report-actions">
|
|
||||||
<button type="button" class="secondary-button" @click="downloadPdf">
|
|
||||||
<Download :size="15" />导出 PDF
|
|
||||||
</button>
|
|
||||||
<button type="button" class="secondary-button" @click="downloadWord">
|
|
||||||
<Download :size="15" />导出 Word
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
@click="notify('钉钉发送')"
|
|
||||||
>
|
|
||||||
<FileText :size="15" />钉钉发送
|
|
||||||
</button>
|
|
||||||
<button type="button" class="primary-button" @click="router.back()">
|
|
||||||
<ArrowLeft :size="15" />返回对话
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<p v-if="!report" class="report-empty">
|
|
||||||
暂无报告数据,请从风险评估结果卡片点击“生成报告”。
|
|
||||||
</p>
|
|
||||||
<article v-else class="report-sheet">
|
|
||||||
<h1>新品风险预估报告</h1>
|
|
||||||
<p class="report-subtitle">
|
|
||||||
蝶雅产品部 · 风险预估系统 · 自动生成 · {{ generatedAt }}
|
|
||||||
</p>
|
|
||||||
<div class="report-meta">
|
|
||||||
<span><b>产品/评估对象:</b>{{ report.subjectId }}</span>
|
|
||||||
><span
|
|
||||||
><b>供应商:</b
|
|
||||||
>{{ report.supplierFreq?.supplierName || "未指定" }}</span
|
|
||||||
><span
|
|
||||||
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
|
|
||||||
report.score.toFixed(0)
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>一、风险结论</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>评估项</th>
|
|
||||||
<th>结论</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>综合评分</td>
|
|
||||||
<td class="risk">
|
|
||||||
{{ Math.round(report.score) }} / 100 ·
|
|
||||||
{{ formatRiskLevel(report.level) }} ·
|
|
||||||
{{ report.riskBadge || "需关注" }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>风险警示</td>
|
|
||||||
<td>
|
|
||||||
{{
|
|
||||||
report.alertText ||
|
|
||||||
"当前暂无足够历史数据,建议按标准库完成基础验证。"
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="item in dimensionRows" :key="item.dimension">
|
|
||||||
<td>{{ item.dimension }}({{ item.weight }}%)</td>
|
|
||||||
<td>{{ dimensionConclusion(item) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<template v-if="report.categoryWarning">
|
|
||||||
<h2>二、品类风险预警</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>品类</th>
|
|
||||||
<th>历史异常</th>
|
|
||||||
<th>风险等级</th>
|
|
||||||
<th>建议</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>{{ report.categoryWarning.category }}</td>
|
|
||||||
<td>{{ report.categoryWarning.anomalyCount }} 条</td>
|
|
||||||
<td>{{ report.categoryWarning.riskLevel }}</td>
|
|
||||||
<td>
|
|
||||||
{{
|
|
||||||
report.categoryWarning.suggestion ||
|
|
||||||
"结合相似品类和标准库预判"
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="report.supplierFreq?.supplierName">
|
|
||||||
<h2>三、供应商合作频次分析</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>维度</th>
|
|
||||||
<th>数值</th>
|
|
||||||
<th>说明</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="report.supplierFreq.cooperationCount != null">
|
|
||||||
<td>合作次数</td>
|
|
||||||
<td>{{ report.supplierFreq.cooperationCount }} 次</td>
|
|
||||||
<td>近三年采购订单去重单据数</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>异常次数</td>
|
|
||||||
<td>{{ report.supplierFreq.anomalyCount ?? 0 }} 次</td>
|
|
||||||
<td>异常案例库统计</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="report.supplierFreq.alpha != null">
|
|
||||||
<td>频次风险系数</td>
|
|
||||||
<td>×{{ report.supplierFreq.alpha.toFixed(2) }}</td>
|
|
||||||
<td>α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="report.supplierFreq.tier">
|
|
||||||
<td>供应商层级</td>
|
|
||||||
<td>{{ report.supplierFreq.tier }}</td>
|
|
||||||
<td>{{ report.supplierFreq.suggestion }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<h2>四、相似案例匹配({{ report.similarCases?.length || 0 }} 条)</h2>
|
|
||||||
<template v-if="report.similarCases?.length">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>日期</th>
|
|
||||||
<th>产品</th>
|
|
||||||
<th>问题</th>
|
|
||||||
<th>处理方案</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="item in report.similarCases"
|
|
||||||
:key="`${item.date}-${item.product}-${item.problem}`"
|
|
||||||
>
|
|
||||||
<td>{{ item.date || "未知" }}</td>
|
|
||||||
<td>{{ item.product || "未命名" }}</td>
|
|
||||||
<td>{{ item.problem || "未填写" }}</td>
|
|
||||||
<td>{{ item.solution || "未填写" }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<p v-else class="report-paragraph">
|
|
||||||
暂无相关案例,RAG 相似案例检索未返回匹配结果。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>五、预防建议</h2>
|
|
||||||
<ol>
|
|
||||||
<li
|
|
||||||
v-for="suggestion in report.aiSuggestions?.length
|
|
||||||
? report.aiSuggestions
|
|
||||||
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
|
|
||||||
:key="suggestion"
|
|
||||||
>
|
|
||||||
{{ suggestion }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>六、AI 推理方案(无历史案例场景)</h2>
|
|
||||||
<p class="report-paragraph">
|
|
||||||
<template v-if="report.similarCases?.length">
|
|
||||||
本次评估已匹配
|
|
||||||
{{ report.similarCases.length }}
|
|
||||||
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>七、标准判定结果</h2>
|
|
||||||
<template v-if="report.standardJudgment?.length">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>判定维度</th>
|
|
||||||
<th>判定标准</th>
|
|
||||||
<th>标准来源</th>
|
|
||||||
<th>当前状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="standard in report.standardJudgment"
|
|
||||||
:key="`${standard.category}-${standard.riskPoint}`"
|
|
||||||
>
|
|
||||||
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
|
|
||||||
<td>{{ standard.rule }}</td>
|
|
||||||
<td>{{ standard.source }}</td>
|
|
||||||
<td>{{ standard.status }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
<p v-else class="report-paragraph">暂无相关标准。</p>
|
|
||||||
<p class="report-paragraph report-engine-note">
|
|
||||||
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<footer class="report-footer">
|
|
||||||
<span>编制:蝶雅风险预估系统</span
|
|
||||||
><span>生成时间:{{ generatedAt }}</span>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.report-page {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f5f7fb;
|
|
||||||
padding: 24px 28px 40px;
|
|
||||||
}
|
|
||||||
.report-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
max-width: 780px;
|
|
||||||
}
|
|
||||||
.action-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.primary-button,
|
|
||||||
.secondary-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 650;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.primary-button {
|
|
||||||
border: 1px solid #111827;
|
|
||||||
background: #111827;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.secondary-button {
|
|
||||||
border: 1px solid #d1d9e6;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
.report-empty {
|
|
||||||
max-width: 780px;
|
|
||||||
margin: 80px auto;
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
.report-sheet {
|
|
||||||
max-width: 780px;
|
|
||||||
margin: 0 auto;
|
|
||||||
border: 1px solid #dce3ed;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
padding: 48px;
|
|
||||||
box-shadow: 0 10px 26px rgb(15 23 42 / 10%);
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 28px;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
.report-subtitle {
|
|
||||||
margin: 0 0 28px;
|
|
||||||
border-bottom: 2px solid #111827;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.report-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 32px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
margin: 24px 0 12px;
|
|
||||||
border-left: 2px solid #111827;
|
|
||||||
padding-left: 8px;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border: 1px solid #dbe3ef;
|
|
||||||
padding: 9px 12px;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background: #111827;
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.risk {
|
|
||||||
color: #d97706;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
ol {
|
|
||||||
padding-left: 24px;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.9;
|
|
||||||
}
|
|
||||||
.report-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 36px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.report-paragraph {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.report-page {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.report-sheet {
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
.report-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.action-group {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.report-meta {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
min-width: 680px;
|
|
||||||
}
|
|
||||||
.report-sheet {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media print {
|
|
||||||
.report-page {
|
|
||||||
padding: 0;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
.report-actions {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.report-sheet {
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
max-width: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
break-after: avoid;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -13,49 +13,22 @@ 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>>({});
|
||||||
|
|
||||||
@ -70,40 +43,6 @@ 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({
|
||||||
@ -111,38 +50,6 @@ 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"
|
||||||
@ -164,9 +71,6 @@ 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;
|
||||||
@ -177,8 +81,6 @@ 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,
|
||||||
@ -187,32 +89,6 @@ 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] ?? "";
|
||||||
}
|
}
|
||||||
@ -239,8 +115,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -254,10 +128,7 @@ function formatDate(value: string | null) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => void load());
|
||||||
void load();
|
|
||||||
void loadModelWeights();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -280,7 +151,6 @@ onMounted(() => {
|
|||||||
: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' })"
|
||||||
@ -359,7 +229,6 @@ onMounted(() => {
|
|||||||
<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' })"
|
||||||
@ -429,27 +298,6 @@ onMounted(() => {
|
|||||||
($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')"
|
||||||
@ -547,14 +395,6 @@ onMounted(() => {
|
|||||||
<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>
|
||||||
@ -586,54 +426,10 @@ onMounted(() => {
|
|||||||
<strong>解决方案</strong>
|
<strong>解决方案</strong>
|
||||||
<p>{{ item.solution }}</p>
|
<p>{{ item.solution }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ocrFiles(item).length" class="attachment-list">
|
<div v-if="item.photos?.length" class="attachment-note">
|
||||||
<button
|
<ImagePlus :size="14" />已关联 {{ item.photos.length }} 个附件
|
||||||
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"
|
||||||
@ -655,7 +451,6 @@ onMounted(() => {
|
|||||||
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
|
||||||
"
|
"
|
||||||
@ -667,7 +462,6 @@ onMounted(() => {
|
|||||||
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
|
||||||
"
|
"
|
||||||
@ -683,10 +477,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="admin-tools">
|
<aside class="admin-tools">
|
||||||
<article
|
<article class="tool-card">
|
||||||
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"
|
||||||
@ -704,24 +495,23 @@ onMounted(() => {
|
|||||||
<Plus :size="14" />打开录入表单
|
<Plus :size="14" />打开录入表单
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
<article
|
<article class="tool-card">
|
||||||
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>
|
||||||
<button class="tool-link" type="button" @click="ocrOpen = true">
|
<label class="upload-zone"
|
||||||
<Upload :size="14" />开始 OCR 识别
|
><Upload :size="17" /><span>选择图片 / PDF</span
|
||||||
</button>
|
><small>识别结果将填入风险项表单</small
|
||||||
|
><input type="file" accept="image/*,.pdf"
|
||||||
|
/></label>
|
||||||
</article>
|
</article>
|
||||||
<article v-if="featureFlags.voiceEntry" class="tool-card">
|
<article 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>
|
||||||
@ -733,10 +523,7 @@ onMounted(() => {
|
|||||||
<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
|
<article class="tool-card">
|
||||||
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"
|
||||||
@ -769,135 +556,6 @@ onMounted(() => {
|
|||||||
</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>
|
||||||
|
|
||||||
@ -1192,86 +850,19 @@ onMounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.attachment-list {
|
.attachment-note {
|
||||||
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;
|
||||||
border: 1px solid var(--color-border-strong);
|
margin-top: 12px;
|
||||||
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;
|
||||||
@ -1493,111 +1084,6 @@ onMounted(() => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,9 @@ 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";
|
||||||
import type { RiskAssessmentResult } from "@/types/risk";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -23,7 +21,6 @@ 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"
|
||||||
@ -107,10 +104,6 @@ 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;
|
||||||
@ -218,11 +211,6 @@ async function openFromRoute() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReport(result: RiskAssessmentResult) {
|
|
||||||
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result));
|
|
||||||
void router.push({ name: "report" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(
|
async function submit(
|
||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
) {
|
) {
|
||||||
@ -270,7 +258,6 @@ 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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -321,7 +308,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
v-if="message.riskResult"
|
v-if="message.riskResult"
|
||||||
:result="message.riskResult"
|
:result="message.riskResult"
|
||||||
:can-monitor="canMonitor"
|
:can-monitor="canMonitor"
|
||||||
@generate-report="openReport(message.riskResult)"
|
|
||||||
/>
|
/>
|
||||||
<AssistantResponseCard
|
<AssistantResponseCard
|
||||||
v-if="message.responseCard"
|
v-if="message.responseCard"
|
||||||
@ -349,7 +335,6 @@ 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"
|
||||||
@ -425,7 +410,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<OcrRecognitionDialog :open="ocrOpen" @close="ocrOpen = false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,8 @@ 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: "第三阶段(产品开发)",
|
||||||
@ -76,10 +74,6 @@ 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>
|
||||||
|
|||||||
@ -41,16 +41,6 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
permission: "risk.assess",
|
permission: "risk.assess",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "report",
|
|
||||||
name: "report",
|
|
||||||
component: () => import("@/pages/ReportPage.vue"),
|
|
||||||
meta: {
|
|
||||||
title: "风险报告",
|
|
||||||
requiresAuth: true,
|
|
||||||
permission: "risk.assess",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "overview",
|
path: "overview",
|
||||||
name: "overview",
|
name: "overview",
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
@ -60,20 +59,10 @@ export const useReviewStore = defineStore("review", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function review(itemId: number, action: "通过" | "驳回", note: string) {
|
async function review(itemId: number, action: "通过" | "驳回", note: string) {
|
||||||
error.value = null;
|
const response = await reviewRiskItem(itemId, { action, note });
|
||||||
try {
|
if (!response.data.data) throw new Error("审核响应为空");
|
||||||
const response = await reviewRiskItem(itemId, { action, note });
|
items.value = items.value.filter((item) => item.id !== itemId);
|
||||||
if (!response.data.data) throw new Error("审核响应为空");
|
return response.data.data;
|
||||||
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 {
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -60,12 +60,9 @@ export interface RiskAssessmentResult {
|
|||||||
supplierName: string | null;
|
supplierName: string | null;
|
||||||
cooperationCount: number | null;
|
cooperationCount: number | null;
|
||||||
anomalyCount: number | null;
|
anomalyCount: number | null;
|
||||||
alpha: number | null;
|
alpha: number;
|
||||||
tier: string | null;
|
tier: string | null;
|
||||||
suggestion: string | null;
|
suggestion: string | null;
|
||||||
frequencyBasis?: string;
|
|
||||||
note?: string | null;
|
|
||||||
dataAvailable?: boolean;
|
|
||||||
} | null;
|
} | null;
|
||||||
standardJudgment?: Array<{
|
standardJudgment?: Array<{
|
||||||
category: string;
|
category: string;
|
||||||
@ -77,8 +74,6 @@ export interface RiskAssessmentResult {
|
|||||||
dimensionScores?: RiskDimensionScore[];
|
dimensionScores?: RiskDimensionScore[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RiskReportResult = RiskAssessmentResult;
|
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
scoreRange: string;
|
scoreRange: string;
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
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;
|
||||||
@ -12,8 +10,6 @@ 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 {
|
||||||
@ -24,22 +20,7 @@ export interface RiskItem extends RiskItemCreate {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RiskItemUpdate = Partial<
|
export type RiskItemUpdate = Partial<RiskItemCreate>;
|
||||||
Pick<
|
|
||||||
RiskItemCreate,
|
|
||||||
| "occurredOn"
|
|
||||||
| "productName"
|
|
||||||
| "productCategory"
|
|
||||||
| "materialType"
|
|
||||||
| "supplier"
|
|
||||||
| "factory"
|
|
||||||
| "stageNew"
|
|
||||||
| "description"
|
|
||||||
| "defectLevel"
|
|
||||||
| "solution"
|
|
||||||
| "photos"
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ReviewAction {
|
export interface ReviewAction {
|
||||||
action: "通过" | "驳回";
|
action: "通过" | "驳回";
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
export const roleLabels: Record<string, string> = {
|
|
||||||
admin: "管理员",
|
|
||||||
product_manager: "产品经理",
|
|
||||||
reviewer: "审核员",
|
|
||||||
普通用户: "普通用户",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function roleLabel(role: string | null | undefined) {
|
|
||||||
return role ? (roleLabels[role] ?? role) : "未登录";
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user