Compare commits
No commits in common. "c2a0756d2f04b0e73556cf6e3bd108314ffcceb0" and "69a1433a86c8022c9a1beab72ae4987bacf2153b" have entirely different histories.
c2a0756d2f
...
69a1433a86
@ -18,8 +18,7 @@ export async function fetchSupplierProfile(
|
|||||||
if (running) return { data: { data: await running } };
|
if (running) return { data: { data: await running } };
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
const response = await http.get<ApiResponse<SupplierProfile>>(
|
const response = await http.get<ApiResponse<SupplierProfile>>(
|
||||||
"/v1/suppliers/profile",
|
`/v1/suppliers/${encodeURIComponent(key)}`,
|
||||||
{ params: { supplier_name: key } },
|
|
||||||
);
|
);
|
||||||
if (!response.data.data) throw new Error("供应商画像响应为空");
|
if (!response.data.data) throw new Error("供应商画像响应为空");
|
||||||
cache.set(key, { data: response.data.data, at: Date.now() });
|
cache.set(key, { data: response.data.data, at: Date.now() });
|
||||||
|
|||||||
@ -15,12 +15,11 @@ import { computed, ref } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import type { AssistantResponseCard } from "@/types/conversation";
|
import type { AssistantResponseCard } from "@/types/conversation";
|
||||||
import type { ReportCard } from "@/types/risk";
|
|
||||||
|
|
||||||
const props = defineProps<{ card: AssistantResponseCard }>();
|
const props = defineProps<{ card: AssistantResponseCard }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
generateReport: [card: ReportCard];
|
generateReport: [card: AssistantResponseCard];
|
||||||
}>();
|
}>();
|
||||||
interface CardData extends AssistantResponseCard {
|
interface CardData extends AssistantResponseCard {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -120,7 +119,7 @@ function formatCount(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateReport() {
|
function generateReport() {
|
||||||
emit("generateReport", JSON.parse(JSON.stringify(props.card)) as ReportCard);
|
emit("generateReport", props.card);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -159,7 +158,7 @@ function generateReport() {
|
|||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div>
|
<div>
|
||||||
<b>{{ formatCount(data.anomalyCount) }}</b
|
<b>{{ formatCount(data.anomalyCount) }}</b
|
||||||
><span>近3个自然年度异常</span>
|
><span>近3年异常</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.cooperationCount != null">
|
<div v-if="data.cooperationCount != null">
|
||||||
<b>{{ formatCount(data.cooperationCount) }}</b
|
<b>{{ formatCount(data.cooperationCount) }}</b
|
||||||
@ -390,7 +389,8 @@ function generateReport() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="response-actions__primary"
|
class="response-actions__primary"
|
||||||
title="追加到当前风险报告"
|
:disabled="card.type !== 'solution'"
|
||||||
|
:title="card.type === 'solution' ? '追加到当前风险报告' : '功能开发中'"
|
||||||
@click="generateReport"
|
@click="generateReport"
|
||||||
>
|
>
|
||||||
<CheckCircle2 :size="14" />生成报告
|
<CheckCircle2 :size="14" />生成报告
|
||||||
@ -403,6 +403,14 @@ function generateReport() {
|
|||||||
>
|
>
|
||||||
查看标准详情
|
查看标准详情
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="card.type === 'solution'"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="功能开发中"
|
||||||
|
>
|
||||||
|
📌 收藏方案
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="card.type === 'solution'"
|
v-if="card.type === 'solution'"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -211,7 +211,7 @@ function openSupplier() {
|
|||||||
<div v-if="result.supplierFreq.supplierName">
|
<div v-if="result.supplierFreq.supplierName">
|
||||||
<span>{{ result.supplierFreq.supplierName }}</span>
|
<span>{{ result.supplierFreq.supplierName }}</span>
|
||||||
<strong
|
<strong
|
||||||
>近3个自然年度异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
>近3年异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result.supplierFreq.alpha != null">
|
<div v-if="result.supplierFreq.alpha != null">
|
||||||
@ -251,7 +251,7 @@ function openSupplier() {
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
result.supplierFreq.note ||
|
result.supplierFreq.note ||
|
||||||
"合作频次按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例库统计。"
|
"合作频次按近3年采购订单去重单据编号统计;异常数仍按异常案例库统计。"
|
||||||
}}
|
}}
|
||||||
</small>
|
</small>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -15,14 +15,6 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"];
|
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"];
|
||||||
const navigationRoutes: Record<string, string> = {
|
|
||||||
对话: "assessment",
|
|
||||||
看板: "overview",
|
|
||||||
供应商: "supplier",
|
|
||||||
标准库: "standards",
|
|
||||||
报告: "report",
|
|
||||||
管理: "review",
|
|
||||||
};
|
|
||||||
const visibleNavigation = computed(() =>
|
const visibleNavigation = computed(() =>
|
||||||
navigation.filter(
|
navigation.filter(
|
||||||
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
||||||
@ -60,14 +52,19 @@ function prefetch(item: string) {
|
|||||||
v-for="item in visibleNavigation"
|
v-for="item in visibleNavigation"
|
||||||
:key="item"
|
:key="item"
|
||||||
:to="{
|
:to="{
|
||||||
name: navigationRoutes[item] || 'assessment',
|
name:
|
||||||
|
item === '看板'
|
||||||
|
? 'overview'
|
||||||
|
: item === '标准库'
|
||||||
|
? 'standards'
|
||||||
|
: item === '管理'
|
||||||
|
? 'review'
|
||||||
|
: 'assessment',
|
||||||
}"
|
}"
|
||||||
:class="{
|
:class="{
|
||||||
active:
|
active:
|
||||||
(item === '看板' && route.name === 'overview') ||
|
(item === '看板' && route.name === 'overview') ||
|
||||||
(item === '供应商' && route.name === 'supplier') ||
|
|
||||||
(item === '标准库' && route.name === 'standards') ||
|
(item === '标准库' && route.name === 'standards') ||
|
||||||
(item === '报告' && route.name === 'report') ||
|
|
||||||
(item === '管理' && route.name === 'review') ||
|
(item === '管理' && route.name === 'review') ||
|
||||||
(item === '对话' && route.name === 'assessment'),
|
(item === '对话' && route.name === 'assessment'),
|
||||||
}"
|
}"
|
||||||
|
|||||||
@ -38,7 +38,7 @@ function submit() {
|
|||||||
<form class="assessment-form" @submit.prevent="submit">
|
<form class="assessment-form" @submit.prevent="submit">
|
||||||
<div class="form-intro">
|
<div class="form-intro">
|
||||||
<strong>新品风险参数</strong>
|
<strong>新品风险参数</strong>
|
||||||
<span>系统将结合近3个自然年度异常案例计算七维安全评分</span>
|
<span>系统将结合近 3 年异常案例计算七维安全评分</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="field-wide">
|
<label class="field-wide">
|
||||||
|
|||||||
@ -7,20 +7,24 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import { useAuthStore } from "@/stores/auth.store";
|
import { useAuthStore } from "@/stores/auth.store";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mode = ref<"password" | "dingtalk">("password");
|
const mode = ref<"password" | "dingtalk">("password");
|
||||||
const useDevelopmentLogin = ref(true);
|
const useDevelopmentLogin = ref(true);
|
||||||
const username = ref("local-admin");
|
const username = ref("local-admin");
|
||||||
const password = ref("risk-admin-2026");
|
const password = ref("risk-admin-2026");
|
||||||
|
|
||||||
// 登录后的统一落点是智能对话页,避免从其他受保护页面跳转到登录后
|
const redirectPath = computed(() => {
|
||||||
// 又回到原页面,保证用户进入系统后先看到对话工作台。
|
const redirect = route.query.redirect;
|
||||||
const redirectPath = computed(() => "/assessment");
|
return typeof redirect === "string" && redirect.startsWith("/")
|
||||||
|
? redirect
|
||||||
|
: "/assessment";
|
||||||
|
});
|
||||||
|
|
||||||
async function submitPassword() {
|
async function submitPassword() {
|
||||||
if (!username.value.trim() || password.value.length < 8 || auth.loading)
|
if (!username.value.trim() || password.value.length < 8 || auth.loading)
|
||||||
|
|||||||
@ -1,101 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeft, Download, FileText } from "@lucide/vue";
|
import { ArrowLeft, Download, FileText } from "@lucide/vue";
|
||||||
import { computed, onActivated, onMounted, shallowRef, watch } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
|
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
|
||||||
import { useAuthStore } from "@/stores/auth.store";
|
import type { RiskReportResult } from "@/types/risk";
|
||||||
import { useConversationStore } from "@/stores/conversation.store";
|
|
||||||
import type { ReportCard, RiskReportResult } from "@/types/risk";
|
|
||||||
import { formatRiskLevel } from "@/utils/risk";
|
import { formatRiskLevel } from "@/utils/risk";
|
||||||
import { readConversationReport } from "@/utils/conversationReports";
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
let parsed: RiskReportResult | null = null;
|
||||||
const auth = useAuthStore();
|
try {
|
||||||
const conversationStore = useConversationStore();
|
const raw = sessionStorage.getItem("dieya-risk-report");
|
||||||
const report = shallowRef<RiskReportResult | null>(null);
|
parsed = raw ? (JSON.parse(raw) as RiskReportResult) : null;
|
||||||
|
} catch {
|
||||||
function loadReport() {
|
parsed = null;
|
||||||
const conversationId =
|
|
||||||
route.query.conversation ?? conversationStore.current?.id;
|
|
||||||
report.value = readConversationReport(
|
|
||||||
typeof conversationId === "string" || typeof conversationId === "number"
|
|
||||||
? conversationId
|
|
||||||
: null,
|
|
||||||
auth.user?.id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const report = computed(() => parsed);
|
||||||
// ReportPage is kept alive by MainLayout. Reload the session payload whenever
|
|
||||||
// the page becomes active so a newly generated report is immediately visible.
|
|
||||||
onMounted(loadReport);
|
|
||||||
onActivated(loadReport);
|
|
||||||
watch(
|
|
||||||
[() => route.query.conversation, () => conversationStore.current?.id],
|
|
||||||
loadReport,
|
|
||||||
);
|
|
||||||
const reportCards = computed<ReportCard[]>(
|
|
||||||
() => {
|
|
||||||
if (!report.value) return [];
|
|
||||||
if (report.value.reportCards?.length) return report.value.reportCards;
|
|
||||||
return (report.value.aiSolutionCards ?? []).map((solution) => ({
|
|
||||||
type: "solution" as const,
|
|
||||||
title: solution.title || "AI 案例推理生成",
|
|
||||||
confidence: solution.confidence,
|
|
||||||
analysisBase: solution.analysisBase,
|
|
||||||
standardFallbackEnabled: solution.standardFallbackEnabled,
|
|
||||||
standards: solution.standards,
|
|
||||||
similarCases: solution.similarCases,
|
|
||||||
risks: solution.risks,
|
|
||||||
suggestions: solution.suggestions,
|
|
||||||
warning: solution.warning,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const reportData = computed(() => {
|
|
||||||
const cards = reportCards.value;
|
|
||||||
const unique = <T>(items: T[], key: (item: T) => string) => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return items.filter((item) => {
|
|
||||||
const value = key(item);
|
|
||||||
if (seen.has(value)) return false;
|
|
||||||
seen.add(value);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const standards = unique(
|
|
||||||
[
|
|
||||||
...(report.value?.standardJudgment ?? []),
|
|
||||||
...cards.flatMap((card) => card.standards ?? []),
|
|
||||||
],
|
|
||||||
(item) => `${item.category}|${item.riskPoint}|${item.rule}|${item.status}`,
|
|
||||||
);
|
|
||||||
const similarCases = unique(
|
|
||||||
[
|
|
||||||
...(report.value?.similarCases ?? []),
|
|
||||||
...cards.flatMap((card) => card.similarCases ?? []),
|
|
||||||
],
|
|
||||||
(item) => `${item.caseId}|${item.date}|${item.product}|${item.problem ?? item.description}|${item.solution}`,
|
|
||||||
);
|
|
||||||
const suggestions = unique(
|
|
||||||
[
|
|
||||||
...(report.value?.aiSuggestions ?? []),
|
|
||||||
...cards.flatMap((card) => card.suggestions ?? []),
|
|
||||||
],
|
|
||||||
(item) => item,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
cards,
|
|
||||||
supplierCards: cards.filter((card) => card.type === "supplier"),
|
|
||||||
materialCards: cards.filter((card) => card.type === "material"),
|
|
||||||
solutionCards: cards.filter((card) => card.type === "solution"),
|
|
||||||
standardCards: cards.filter((card) => ["standard", "compliance"].includes(card.type)),
|
|
||||||
standards,
|
|
||||||
similarCases,
|
|
||||||
suggestions,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const dimensionRows = computed(() => {
|
const dimensionRows = computed(() => {
|
||||||
if (!report.value) return [];
|
if (!report.value) return [];
|
||||||
if (report.value.dimensionScores?.length) {
|
if (report.value.dimensionScores?.length) {
|
||||||
@ -143,17 +63,6 @@ async function downloadPdf() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function returnToConversation() {
|
|
||||||
const conversationId =
|
|
||||||
route.query.conversation ?? conversationStore.current?.id;
|
|
||||||
void router.push({
|
|
||||||
name: "assessment",
|
|
||||||
...(typeof conversationId === "string" || typeof conversationId === "number"
|
|
||||||
? { query: { conversation: conversationId } }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string) {
|
function notify(message: string) {
|
||||||
window.alert(`${message}功能待接入服务端`);
|
window.alert(`${message}功能待接入服务端`);
|
||||||
}
|
}
|
||||||
@ -184,20 +93,10 @@ function dimensionConclusion(item: {
|
|||||||
<template>
|
<template>
|
||||||
<div class="report-page">
|
<div class="report-page">
|
||||||
<header class="report-actions">
|
<header class="report-actions">
|
||||||
<button
|
<button type="button" class="secondary-button" @click="downloadPdf">
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
:disabled="!report"
|
|
||||||
@click="downloadPdf"
|
|
||||||
>
|
|
||||||
<Download :size="15" />导出 PDF
|
<Download :size="15" />导出 PDF
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="secondary-button" @click="downloadWord">
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
:disabled="!report"
|
|
||||||
@click="downloadWord"
|
|
||||||
>
|
|
||||||
<Download :size="15" />导出 Word
|
<Download :size="15" />导出 Word
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -207,12 +106,12 @@ function dimensionConclusion(item: {
|
|||||||
>
|
>
|
||||||
<FileText :size="15" />钉钉发送
|
<FileText :size="15" />钉钉发送
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="primary-button" @click="returnToConversation">
|
<button type="button" class="primary-button" @click="router.back()">
|
||||||
<ArrowLeft :size="15" />返回对话
|
<ArrowLeft :size="15" />返回对话
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<p v-if="!report" class="report-empty">
|
<p v-if="!report" class="report-empty">
|
||||||
当前会话暂无报告,请返回对话后从风险评估结果卡片点击“生成报告”。
|
暂无报告数据,请从风险评估结果卡片点击“生成报告”。
|
||||||
</p>
|
</p>
|
||||||
<article v-else class="report-sheet">
|
<article v-else class="report-sheet">
|
||||||
<h1>新品风险预估报告</h1>
|
<h1>新品风险预估报告</h1>
|
||||||
@ -291,7 +190,7 @@ function dimensionConclusion(item: {
|
|||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="report.supplierFreq?.supplierName || reportData.supplierCards.length">
|
<template v-if="report.supplierFreq?.supplierName">
|
||||||
<h2>三、供应商合作频次分析</h2>
|
<h2>三、供应商合作频次分析</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -302,55 +201,32 @@ function dimensionConclusion(item: {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="report.supplierFreq?.cooperationCount != null">
|
<tr v-if="report.supplierFreq.cooperationCount != null">
|
||||||
<td>合作次数</td>
|
<td>合作次数</td>
|
||||||
<td>{{ report.supplierFreq?.cooperationCount }} 次</td>
|
<td>{{ report.supplierFreq.cooperationCount }} 次</td>
|
||||||
<td>按自然年度边界采购订单去重单据数</td>
|
<td>近三年采购订单去重单据数</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>异常次数</td>
|
<td>异常次数</td>
|
||||||
<td>{{ report.supplierFreq?.anomalyCount ?? 0 }} 次</td>
|
<td>{{ report.supplierFreq.anomalyCount ?? 0 }} 次</td>
|
||||||
<td>异常案例库统计</td>
|
<td>异常案例库统计</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="report.supplierFreq?.alpha != null">
|
<tr v-if="report.supplierFreq.alpha != null">
|
||||||
<td>频次风险系数</td>
|
<td>频次风险系数</td>
|
||||||
<td>×{{ report.supplierFreq?.alpha?.toFixed(2) }}</td>
|
<td>×{{ report.supplierFreq.alpha.toFixed(2) }}</td>
|
||||||
<td>α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5</td>
|
<td>α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="report.supplierFreq?.tier">
|
<tr v-if="report.supplierFreq.tier">
|
||||||
<td>供应商层级</td>
|
<td>供应商层级</td>
|
||||||
<td>{{ report.supplierFreq?.tier }}</td>
|
<td>{{ report.supplierFreq.tier }}</td>
|
||||||
<td>{{ report.supplierFreq?.suggestion }}</td>
|
<td>{{ report.supplierFreq.suggestion }}</td>
|
||||||
</tr>
|
|
||||||
<template v-for="(card, cardIndex) in reportData.supplierCards" :key="`supplier-card-${cardIndex}`">
|
|
||||||
<tr v-if="card.supplierScore != null"><td>供应商得分</td><td>{{ card.supplierScore.toFixed(2) }} 分</td><td>供应商画像统计</td></tr>
|
|
||||||
<tr v-if="card.supplierBaseScore != null"><td>供应商基础分</td><td>{{ card.supplierBaseScore.toFixed(2) }} 分</td><td>供应商画像统计</td></tr>
|
|
||||||
<tr v-if="card.topProblems?.length"><td>高频问题</td><td>{{ card.topProblems.map((item) => `${item.name}(${item.count}次)`).join("、") }}</td><td>异常案例库统计</td></tr>
|
|
||||||
<tr v-if="card.note"><td>备注</td><td>{{ card.note }}</td><td>供应商画像统计</td></tr>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="reportData.materialCards.length">
|
|
||||||
<h2>四、物料风险分析</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>物料/字段</th><th>分析结果</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(card, index) in reportData.materialCards" :key="`material-${index}`">
|
|
||||||
<td>{{ card.material || card.title }}</td>
|
|
||||||
<td>
|
|
||||||
<span v-if="card.anomalyCount != null">历史异常:{{ card.anomalyCount }} 条;</span>
|
|
||||||
<span v-if="card.frequentProblems?.length">常见问题:{{ card.frequentProblems.map((item) => `${item.name}(${item.count}次)`).join("、") }};</span>
|
|
||||||
<span v-if="card.suggestions?.length">建议:{{ card.suggestions.join(";") }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<h2>{{ reportData.materialCards.length ? "五" : "四" }}、相似案例匹配({{ reportData.similarCases.length }} 条)</h2>
|
<h2>四、相似案例匹配({{ report.similarCases?.length || 0 }} 条)</h2>
|
||||||
<template v-if="reportData.similarCases.length">
|
<template v-if="report.similarCases?.length">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -362,7 +238,7 @@ function dimensionConclusion(item: {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="item in reportData.similarCases"
|
v-for="item in report.similarCases"
|
||||||
:key="`${item.date}-${item.product}-${item.problem}`"
|
:key="`${item.date}-${item.product}-${item.problem}`"
|
||||||
>
|
>
|
||||||
<td>{{ item.date || "未知" }}</td>
|
<td>{{ item.date || "未知" }}</td>
|
||||||
@ -377,11 +253,11 @@ function dimensionConclusion(item: {
|
|||||||
暂无相关案例,RAG 相似案例检索未返回匹配结果。
|
暂无相关案例,RAG 相似案例检索未返回匹配结果。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>{{ reportData.materialCards.length ? "六" : "五" }}、预防建议</h2>
|
<h2>五、预防建议</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li
|
<li
|
||||||
v-for="suggestion in reportData.suggestions.length
|
v-for="suggestion in report.aiSuggestions?.length
|
||||||
? reportData.suggestions
|
? report.aiSuggestions
|
||||||
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
|
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
|
||||||
:key="suggestion"
|
:key="suggestion"
|
||||||
>
|
>
|
||||||
@ -389,30 +265,61 @@ function dimensionConclusion(item: {
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h2>{{ reportData.materialCards.length ? "七" : "六" }}、AI 推理方案(无历史案例场景)</h2>
|
<h2>六、AI 推理方案(无历史案例场景)</h2>
|
||||||
<p class="report-paragraph">
|
<p class="report-paragraph">
|
||||||
<template v-if="reportData.similarCases.length">
|
<template v-if="report.similarCases?.length">
|
||||||
本次评估已匹配
|
本次评估已匹配
|
||||||
{{ reportData.similarCases.length }}
|
{{ report.similarCases.length }}
|
||||||
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
|
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
|
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<template v-if="reportData.solutionCards.length">
|
|
||||||
<section v-for="(card, index) in reportData.solutionCards" :key="`solution-${index}`" class="ai-solution-report">
|
<template v-if="report.aiSolutionCards?.length">
|
||||||
<header><strong>{{ card.title }}</strong><span v-if="card.confidence">置信度:{{ card.confidence }}</span></header>
|
<h3 class="report-subsection-title">AI 案例推理方案明细(已追加)</h3>
|
||||||
<p v-if="card.analysisBase"><b>分析基础:</b>{{ card.analysisBase }}</p>
|
<section
|
||||||
<p v-if="card.standardFallbackEnabled"><b>标准兜底校验:</b>已启用(无直接历史案例)</p>
|
v-for="(solution, index) in report.aiSolutionCards"
|
||||||
<ul v-if="card.risks?.length"><li v-for="risk in card.risks" :key="`${risk.title}-${risk.detail}`"><b>{{ risk.level }} · {{ risk.title }}:</b>{{ risk.detail }}</li></ul>
|
:key="`${solution.title || 'solution'}-${index}`"
|
||||||
<ol v-if="card.suggestions?.length"><li v-for="suggestion in card.suggestions" :key="suggestion">{{ suggestion }}</li></ol>
|
class="ai-solution-report"
|
||||||
<p v-if="card.warning" class="ai-solution-report__warning">{{ card.warning }}</p>
|
>
|
||||||
|
<header>
|
||||||
|
<strong>{{ solution.title || "AI 案例推理生成" }}</strong>
|
||||||
|
<span v-if="solution.confidence"
|
||||||
|
>置信度:{{ solution.confidence }}</span
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<p v-if="solution.analysisBase">
|
||||||
|
<b>分析基础:</b>{{ solution.analysisBase }}
|
||||||
|
</p>
|
||||||
|
<template v-if="solution.risks?.length">
|
||||||
|
<h3>风险项</h3>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="risk in solution.risks"
|
||||||
|
:key="`${risk.title}-${risk.detail}`"
|
||||||
|
>
|
||||||
|
<b>{{ risk.level }} · {{ risk.title }}:</b>{{ risk.detail }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<template v-if="solution.suggestions?.length">
|
||||||
|
<h3>建议方案</h3>
|
||||||
|
<ol>
|
||||||
|
<li v-for="suggestion in solution.suggestions" :key="suggestion">
|
||||||
|
{{ suggestion }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
<p v-if="solution.warning" class="ai-solution-report__warning">
|
||||||
|
{{ solution.warning }}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<h2>{{ reportData.materialCards.length ? "八" : "七" }}、标准判定结果</h2>
|
<h2>七、标准判定结果</h2>
|
||||||
<template v-if="reportData.standards.length">
|
<template v-if="report.standardJudgment?.length">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -424,7 +331,7 @@ function dimensionConclusion(item: {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="standard in reportData.standards"
|
v-for="standard in report.standardJudgment"
|
||||||
:key="`${standard.category}-${standard.riskPoint}`"
|
:key="`${standard.category}-${standard.riskPoint}`"
|
||||||
>
|
>
|
||||||
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
|
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
|
||||||
@ -435,11 +342,7 @@ function dimensionConclusion(item: {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="(card, index) in reportData.standardCards" :key="`standard-card-${index}`">
|
<p v-else class="report-paragraph">暂无相关标准。</p>
|
||||||
<p v-if="card.detectedValues?.length" class="report-paragraph"><b>检测值:</b>{{ card.detectedValues.map((item) => `${item.name}:${item.value}`).join("、") }}</p>
|
|
||||||
<p v-if="card.note" class="report-paragraph"><b>补充说明:</b>{{ card.note }}</p>
|
|
||||||
</template>
|
|
||||||
<p v-if="!reportData.standards.length" class="report-paragraph">暂无相关标准。</p>
|
|
||||||
<p class="report-paragraph report-engine-note">
|
<p class="report-paragraph report-engine-note">
|
||||||
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
|
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
|
||||||
</p>
|
</p>
|
||||||
@ -591,45 +494,6 @@ ol {
|
|||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.report-card-detail {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
border: 1px solid #dbe3ef;
|
|
||||||
border-left: 3px solid #2563eb;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: #fafcff;
|
|
||||||
}
|
|
||||||
.report-card-detail header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 7px;
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.report-card-detail header span {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.report-card-detail p,
|
|
||||||
.report-card-detail li {
|
|
||||||
margin: 4px 0;
|
|
||||||
color: #334155;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.report-card-detail ul {
|
|
||||||
margin: 4px 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
.report-card-detail__standards {
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
.report-card-detail__warning {
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #92400e !important;
|
|
||||||
}
|
|
||||||
.ai-solution-report header {
|
.ai-solution-report header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -11,11 +11,7 @@ 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 { ReportCard, RiskAssessmentResult } from "@/types/risk";
|
import type { AiSolutionReport, RiskAssessmentResult } from "@/types/risk";
|
||||||
import {
|
|
||||||
readConversationReport,
|
|
||||||
writeConversationReport,
|
|
||||||
} from "@/utils/conversationReports";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -73,29 +69,6 @@ const toolLabel = computed(() => {
|
|||||||
if (tool.value === "cases") return "案例关键词";
|
if (tool.value === "cases") return "案例关键词";
|
||||||
return "方案主题";
|
return "方案主题";
|
||||||
});
|
});
|
||||||
const toolDescription = computed(() => {
|
|
||||||
if (tool.value === "assessment")
|
|
||||||
return "填写品类、包装材质(必填),可补充规格、供应商、加工厂和开发阶段。";
|
|
||||||
if (tool.value === "material")
|
|
||||||
return "输入物料名称或组合,例如:软管、PET瓶、铝瓶+喷头、泵头。";
|
|
||||||
if (tool.value === "cases")
|
|
||||||
return "描述要检索的异常现象,例如:软管印刷遇水脱落、泵头漏液。";
|
|
||||||
if (tool.value === "solution")
|
|
||||||
return "描述需要生成方案的新品、品类、材质或风险问题,例如:铝瓶+喷头的密封验证方案。";
|
|
||||||
return "填写必要信息后,将生成一条带上下文的对话请求。";
|
|
||||||
});
|
|
||||||
const toolPlaceholder = computed(() => {
|
|
||||||
if (tool.value === "material") return "如:软管 / PET瓶 / 铝瓶+喷头";
|
|
||||||
if (tool.value === "cases") return "如:软管印刷遇水脱落,如何处理";
|
|
||||||
if (tool.value === "solution")
|
|
||||||
return "如:次氯酸消毒喷雾,铝瓶+喷头,生成验证方案";
|
|
||||||
return "请输入内容";
|
|
||||||
});
|
|
||||||
const toolSubmitLabel = computed(() =>
|
|
||||||
["assessment", "material", "cases", "solution"].includes(tool.value ?? "")
|
|
||||||
? "开始分析"
|
|
||||||
: "带入对话",
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadingText = computed(() =>
|
const loadingText = computed(() =>
|
||||||
loading.value ? "正在调用风险评分引擎…" : "",
|
loading.value ? "正在调用风险评分引擎…" : "",
|
||||||
@ -182,27 +155,6 @@ function submitTool() {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(",");
|
.join(",");
|
||||||
} else if (selectedTool === "material") {
|
|
||||||
const value = toolInput.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
toolError.value = "请输入要查询的物料名称";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toolInput.value = `查物料风险:${value}`;
|
|
||||||
} else if (selectedTool === "cases") {
|
|
||||||
const value = toolInput.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
toolError.value = "请输入要检索的异常现象或关键词";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toolInput.value = `搜索相似案例:${value}`;
|
|
||||||
} else if (selectedTool === "solution") {
|
|
||||||
const value = toolInput.value.trim();
|
|
||||||
if (!value) {
|
|
||||||
toolError.value = "请输入需要生成方案的产品或风险问题";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toolInput.value = `生成 AI 案例推理方案:${value}`;
|
|
||||||
}
|
}
|
||||||
const finalValue = toolInput.value.trim();
|
const finalValue = toolInput.value.trim();
|
||||||
if (!finalValue && !toolFile.value) {
|
if (!finalValue && !toolFile.value) {
|
||||||
@ -291,73 +243,32 @@ async function openFromRoute() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Vue/Pinia exposes conversation results as reactive proxies. Those proxies
|
|
||||||
* cannot be passed to structuredClone (the browser reports DataCloneError),
|
|
||||||
* while the report payload only contains JSON data. Serialising here also
|
|
||||||
* makes the value safe to put into sessionStorage.
|
|
||||||
*/
|
|
||||||
function cloneReport<T>(value: T): T {
|
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacySolutionCards(result: RiskAssessmentResult): ReportCard[] {
|
|
||||||
return (result.aiSolutionCards ?? []).map((item) => ({
|
|
||||||
type: "solution" as const,
|
|
||||||
title: item.title || "AI 案例推理生成",
|
|
||||||
confidence: item.confidence,
|
|
||||||
analysisBase: item.analysisBase,
|
|
||||||
standardFallbackEnabled: item.standardFallbackEnabled,
|
|
||||||
standards: item.standards,
|
|
||||||
similarCases: item.similarCases,
|
|
||||||
risks: item.risks,
|
|
||||||
suggestions: item.suggestions,
|
|
||||||
warning: item.warning,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function openReport(result: RiskAssessmentResult) {
|
function openReport(result: RiskAssessmentResult) {
|
||||||
const conversationId = conversation.value?.id;
|
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result));
|
||||||
if (!conversationId) return;
|
void router.push({ name: "report" });
|
||||||
const stored = readConversationReport(conversationId, auth.user?.id);
|
|
||||||
const storedCards = stored
|
|
||||||
? stored.reportCards?.length
|
|
||||||
? stored.reportCards
|
|
||||||
: legacySolutionCards(stored)
|
|
||||||
: [];
|
|
||||||
const merged: RiskAssessmentResult =
|
|
||||||
stored?.subjectId === result.subjectId
|
|
||||||
? {
|
|
||||||
...cloneReport(result),
|
|
||||||
reportCards: storedCards.length ? storedCards : result.reportCards ?? [],
|
|
||||||
aiSolutionCards: stored.aiSolutionCards?.length
|
|
||||||
? stored.aiSolutionCards
|
|
||||||
: result.aiSolutionCards ?? [],
|
|
||||||
}
|
|
||||||
: cloneReport(result);
|
|
||||||
writeConversationReport(conversationId, merged, auth.user?.id);
|
|
||||||
void router.push({
|
|
||||||
name: "report",
|
|
||||||
query: { conversation: String(conversationId) },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSolutionReport(card: ReportCard) {
|
function openSolutionReport(card: Record<string, unknown>) {
|
||||||
const conversationId = conversation.value?.id;
|
const solution = card as AiSolutionReport;
|
||||||
if (!conversationId) return;
|
|
||||||
const current = conversation.value?.messages
|
const current = conversation.value?.messages
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.riskResult)?.riskResult;
|
.find((message) => message.riskResult)?.riskResult;
|
||||||
const stored = readConversationReport(conversationId, auth.user?.id);
|
let stored: RiskAssessmentResult | null = null;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("dieya-risk-report");
|
||||||
|
stored = raw ? (JSON.parse(raw) as RiskAssessmentResult) : null;
|
||||||
|
} catch {
|
||||||
|
stored = null;
|
||||||
|
}
|
||||||
const sameReport = Boolean(
|
const sameReport = Boolean(
|
||||||
current && stored && current.subjectId === stored.subjectId,
|
current && stored && current.subjectId === stored.subjectId,
|
||||||
);
|
);
|
||||||
const base: RiskAssessmentResult =
|
const base: RiskAssessmentResult =
|
||||||
sameReport && stored
|
sameReport && stored
|
||||||
? cloneReport(stored)
|
? structuredClone(stored)
|
||||||
: current
|
: current
|
||||||
? cloneReport(current)
|
? structuredClone(current)
|
||||||
: stored || {
|
: stored || {
|
||||||
subjectId: "AI 案例推理方案",
|
subjectId: "AI 案例推理方案",
|
||||||
score: 50,
|
score: 50,
|
||||||
@ -365,23 +276,23 @@ function openSolutionReport(card: ReportCard) {
|
|||||||
modelVersion: "ai-solution-card",
|
modelVersion: "ai-solution-card",
|
||||||
factors: [],
|
factors: [],
|
||||||
};
|
};
|
||||||
const existingCards = base.reportCards ?? [];
|
const existing = base.aiSolutionCards ?? [];
|
||||||
const merged: RiskAssessmentResult = {
|
const merged: RiskAssessmentResult = {
|
||||||
...base,
|
...base,
|
||||||
// All newly appended cards use one canonical collection. Migrate old
|
aiSolutionCards: [...existing, solution],
|
||||||
// AI-only entries once so they are not lost when the first generic card
|
aiSuggestions: [
|
||||||
// is appended to an existing session.
|
...(base.aiSuggestions ?? []),
|
||||||
aiSolutionCards: base.aiSolutionCards,
|
...(solution.suggestions ?? []),
|
||||||
reportCards: [
|
|
||||||
...(existingCards.length ? existingCards : legacySolutionCards(base)),
|
|
||||||
card,
|
|
||||||
],
|
],
|
||||||
|
similarCases: solution.similarCases?.length
|
||||||
|
? [...(base.similarCases ?? []), ...solution.similarCases]
|
||||||
|
: base.similarCases,
|
||||||
|
standardJudgment: solution.standards?.length
|
||||||
|
? [...(base.standardJudgment ?? []), ...solution.standards]
|
||||||
|
: base.standardJudgment,
|
||||||
};
|
};
|
||||||
writeConversationReport(conversationId, merged, auth.user?.id);
|
sessionStorage.setItem("dieya-risk-report", JSON.stringify(merged));
|
||||||
void router.push({
|
void router.push({ name: "report" });
|
||||||
name: "report",
|
|
||||||
query: { conversation: String(conversationId) },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(
|
async function submit(
|
||||||
@ -553,7 +464,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
</button>
|
</button>
|
||||||
<p class="eyebrow">CHAT TOOL</p>
|
<p class="eyebrow">CHAT TOOL</p>
|
||||||
<h2>{{ toolTitle }}</h2>
|
<h2>{{ toolTitle }}</h2>
|
||||||
<p class="dialog-description">{{ toolDescription }}</p>
|
<p class="dialog-description">
|
||||||
|
填写必要信息后,将生成一条带上下文的对话请求。
|
||||||
|
</p>
|
||||||
<div v-if="tool === 'assessment'" class="assessment-fields">
|
<div v-if="tool === 'assessment'" class="assessment-fields">
|
||||||
<label
|
<label
|
||||||
>产品品类<input v-model="assessmentFields.category" autofocus
|
>产品品类<input v-model="assessmentFields.category" autofocus
|
||||||
@ -586,7 +499,6 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
v-if="tool !== 'ocr' && tool !== 'voice'"
|
v-if="tool !== 'ocr' && tool !== 'voice'"
|
||||||
v-model="toolInput"
|
v-model="toolInput"
|
||||||
:maxlength="tool === 'cases' ? 50 : 2000"
|
:maxlength="tool === 'cases' ? 50 : 2000"
|
||||||
:placeholder="toolPlaceholder"
|
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@ -605,9 +517,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
<button type="button" class="dialog-secondary" @click="closeTool">
|
<button type="button" class="dialog-secondary" @click="closeTool">
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="dialog-primary">
|
<button type="submit" class="dialog-primary">带入对话</button>
|
||||||
{{ toolSubmitLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import { fetchSupplierOptions } from "@/api/suppliers";
|
import { fetchSupplierOptions } from "@/api/suppliers";
|
||||||
@ -10,11 +10,6 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const supplierNames = ref<string[]>([]);
|
const supplierNames = ref<string[]>([]);
|
||||||
const selected = ref("");
|
const selected = ref("");
|
||||||
const supplierQuery = ref("");
|
|
||||||
const supplierResults = ref<string[]>([]);
|
|
||||||
const supplierDropdownOpen = ref(false);
|
|
||||||
const supplierSearching = ref(false);
|
|
||||||
let supplierSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const profile = ref<SupplierProfile | null>(null);
|
const profile = ref<SupplierProfile | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
@ -94,86 +89,11 @@ async function loadOptions() {
|
|||||||
typeof route.query.supplier === "string"
|
typeof route.query.supplier === "string"
|
||||||
? route.query.supplier
|
? route.query.supplier
|
||||||
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
|
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
|
||||||
supplierQuery.value = selected.value;
|
|
||||||
supplierResults.value = supplierNames.value;
|
|
||||||
if (selected.value) await loadProfile();
|
if (selected.value) await loadProfile();
|
||||||
} catch {
|
} catch {
|
||||||
error.value = "供应商列表加载失败,请重试。";
|
error.value = "供应商列表加载失败,请重试。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSupplierDropdown() {
|
|
||||||
supplierDropdownOpen.value = true;
|
|
||||||
if (!supplierQuery.value || supplierQuery.value === selected.value) {
|
|
||||||
supplierResults.value = supplierNames.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchSuppliers(value: string) {
|
|
||||||
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
|
|
||||||
const keyword = value.trim();
|
|
||||||
if (!keyword) {
|
|
||||||
supplierResults.value = supplierNames.value;
|
|
||||||
supplierSearching.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const local = supplierNames.value.filter((name) => matchesSupplier(name, keyword));
|
|
||||||
supplierResults.value = local;
|
|
||||||
supplierSearching.value = true;
|
|
||||||
supplierSearchTimer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetchSupplierOptions(keyword);
|
|
||||||
supplierResults.value =
|
|
||||||
response.data.data?.items.map((item) => item.name) ?? [];
|
|
||||||
} catch {
|
|
||||||
supplierResults.value = local;
|
|
||||||
} finally {
|
|
||||||
supplierSearching.value = false;
|
|
||||||
}
|
|
||||||
}, 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactSupplierText(value: string) {
|
|
||||||
return value.toLocaleLowerCase().replace(/[\s()()【】\[\]{}]+/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSupplier(name: string, keyword: string) {
|
|
||||||
const terms = [compactSupplierText(keyword)];
|
|
||||||
const compactKeyword = terms[0];
|
|
||||||
if (compactKeyword.endsWith("市") && compactKeyword.length > 2) {
|
|
||||||
terms.push(compactKeyword.slice(0, -1));
|
|
||||||
}
|
|
||||||
if (compactKeyword.endsWith("有限公司") && compactKeyword.length > 5) {
|
|
||||||
terms.push(compactKeyword.slice(0, -5));
|
|
||||||
}
|
|
||||||
const segments = name
|
|
||||||
.split(/[\//、,,;;|\\]+/)
|
|
||||||
.map(compactSupplierText)
|
|
||||||
.filter(Boolean);
|
|
||||||
return terms.some((term) =>
|
|
||||||
segments.some((segment) => segment.includes(term) || term.includes(segment)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSupplierInput(event: Event) {
|
|
||||||
supplierQuery.value = (event.target as HTMLInputElement).value;
|
|
||||||
supplierDropdownOpen.value = true;
|
|
||||||
searchSuppliers(supplierQuery.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectSupplier(name: string) {
|
|
||||||
selected.value = name;
|
|
||||||
supplierQuery.value = name;
|
|
||||||
supplierDropdownOpen.value = false;
|
|
||||||
chooseSupplier();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSupplierDropdown() {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
supplierDropdownOpen.value = false;
|
|
||||||
if (selected.value) supplierQuery.value = selected.value;
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
if (!selected.value) return;
|
if (!selected.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -196,15 +116,11 @@ function chooseSupplier() {
|
|||||||
void loadProfile();
|
void loadProfile();
|
||||||
}
|
}
|
||||||
onMounted(() => void loadOptions());
|
onMounted(() => void loadOptions());
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
|
|
||||||
});
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.supplier,
|
() => route.query.supplier,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (typeof value === "string" && value !== selected.value) {
|
if (typeof value === "string" && value !== selected.value) {
|
||||||
selected.value = value;
|
selected.value = value;
|
||||||
supplierQuery.value = value;
|
|
||||||
void loadProfile();
|
void loadProfile();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -216,46 +132,16 @@ watch(
|
|||||||
<div class="supplier-inner">
|
<div class="supplier-inner">
|
||||||
<header class="supplier-head">
|
<header class="supplier-head">
|
||||||
<h1>供应商深度画像</h1>
|
<h1>供应商深度画像</h1>
|
||||||
<div class="supplier-combobox" @focusout="closeSupplierDropdown">
|
<select
|
||||||
<input
|
v-model="selected"
|
||||||
:value="supplierQuery"
|
class="supplier-select"
|
||||||
class="supplier-select"
|
aria-label="选择供应商"
|
||||||
type="text"
|
@change="chooseSupplier"
|
||||||
role="combobox"
|
>
|
||||||
aria-label="搜索并选择供应商"
|
<option v-for="name in supplierNames" :key="name" :value="name">
|
||||||
aria-autocomplete="list"
|
{{ name }}
|
||||||
:aria-expanded="supplierDropdownOpen"
|
</option>
|
||||||
placeholder="输入供应商名称"
|
</select>
|
||||||
@focus="openSupplierDropdown"
|
|
||||||
@input="onSupplierInput"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="supplierDropdownOpen"
|
|
||||||
class="supplier-options"
|
|
||||||
role="listbox"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="name in supplierResults"
|
|
||||||
:key="name"
|
|
||||||
type="button"
|
|
||||||
class="supplier-option"
|
|
||||||
:class="{ active: name === selected }"
|
|
||||||
role="option"
|
|
||||||
:aria-selected="name === selected"
|
|
||||||
@mousedown.prevent="selectSupplier(name)"
|
|
||||||
>
|
|
||||||
{{ name }}
|
|
||||||
</button>
|
|
||||||
<span v-if="supplierSearching" class="supplier-option-state"
|
|
||||||
>搜索中…</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-else-if="!supplierResults.length"
|
|
||||||
class="supplier-option-state"
|
|
||||||
>未找到匹配供应商,可尝试输入公司简称、城市名或完整名称</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-if="profile" class="tier-badge" :class="tierClass">{{
|
<span v-if="profile" class="tier-badge" :class="tierClass">{{
|
||||||
tierLabel
|
tierLabel
|
||||||
}}</span>
|
}}</span>
|
||||||
@ -269,7 +155,7 @@ watch(
|
|||||||
<section class="stat-row">
|
<section class="stat-row">
|
||||||
<div class="stat-card clickable">
|
<div class="stat-card clickable">
|
||||||
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
|
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
|
||||||
<div class="stat-label">近3个自然年度异常</div>
|
<div class="stat-label">近3年异常</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card clickable">
|
<div class="stat-card clickable">
|
||||||
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
|
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
|
||||||
@ -354,7 +240,7 @@ watch(
|
|||||||
<table class="prototype-table">
|
<table class="prototype-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>近3个自然年度异常数 N</th>
|
<th>近 3 年异常数 N</th>
|
||||||
<th>基础分</th>
|
<th>基础分</th>
|
||||||
<th>档位含义</th>
|
<th>档位含义</th>
|
||||||
<th>在库示例</th>
|
<th>在库示例</th>
|
||||||
@ -532,8 +418,6 @@ watch(
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
.supplier-select {
|
.supplier-select {
|
||||||
width: 180px;
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
border: 1px solid #e4e4e2;
|
border: 1px solid #e4e4e2;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@ -544,47 +428,6 @@ watch(
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.supplier-combobox {
|
|
||||||
position: relative;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
.supplier-combobox .supplier-select {
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
.supplier-options {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
max-height: 260px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--color-border-strong);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 4px 0;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
.supplier-option {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.supplier-option:hover,
|
|
||||||
.supplier-option.active {
|
|
||||||
background: var(--color-accent-subtle);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
.supplier-option-state {
|
|
||||||
display: block;
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--color-text-faint);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.version-note {
|
.version-note {
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -857,13 +700,6 @@ watch(
|
|||||||
order: 3;
|
order: 3;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.supplier-combobox {
|
|
||||||
order: 3;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.supplier-combobox .supplier-select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.version-note {
|
.version-note {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,6 @@ export interface RiskAssessmentResult {
|
|||||||
}>;
|
}>;
|
||||||
aiSuggestions?: string[];
|
aiSuggestions?: string[];
|
||||||
aiSolutionCards?: AiSolutionReport[];
|
aiSolutionCards?: AiSolutionReport[];
|
||||||
reportCards?: ReportCard[];
|
|
||||||
riskBadge?: string | null;
|
riskBadge?: string | null;
|
||||||
alertText?: string | null;
|
alertText?: string | null;
|
||||||
categoryWarning?: {
|
categoryWarning?: {
|
||||||
@ -104,43 +103,6 @@ export interface AiSolutionReport {
|
|||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportCard {
|
|
||||||
type: "supplier" | "material" | "standard" | "compliance" | "solution";
|
|
||||||
title: string;
|
|
||||||
name?: string;
|
|
||||||
partyType?: string;
|
|
||||||
material?: string;
|
|
||||||
category?: string;
|
|
||||||
anomalyCount?: number | null;
|
|
||||||
cooperationCount?: number | null;
|
|
||||||
frequencyCoefficient?: number | null;
|
|
||||||
supplierBaseScore?: number | null;
|
|
||||||
supplierScore?: number | null;
|
|
||||||
tier?: string | null;
|
|
||||||
matchedCount?: number | null;
|
|
||||||
topProblems?: Array<{ name: string; count: number }>;
|
|
||||||
frequentProblems?: Array<{ name: string; count: number }>;
|
|
||||||
standards?: Array<{
|
|
||||||
category: string;
|
|
||||||
riskPoint: string;
|
|
||||||
rule: string;
|
|
||||||
source: string | null;
|
|
||||||
status: string;
|
|
||||||
}>;
|
|
||||||
similarCases?: NonNullable<RiskAssessmentResult["similarCases"]>;
|
|
||||||
confidence?: string | null;
|
|
||||||
analysisBase?: string | null;
|
|
||||||
risks?: Array<{ level: string; title: string; detail: string }>;
|
|
||||||
suggestions?: string[];
|
|
||||||
note?: string | null;
|
|
||||||
warning?: string | null;
|
|
||||||
question?: string | null;
|
|
||||||
detectedValues?: Array<{ name: string; value: string }>;
|
|
||||||
standardFallbackEnabled?: boolean;
|
|
||||||
frequencyBasis?: string | null;
|
|
||||||
dataAvailable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RiskReportResult = RiskAssessmentResult;
|
export type RiskReportResult = RiskAssessmentResult;
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
import type { RiskReportResult } from "@/types/risk";
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = "dieya-risk-reports";
|
|
||||||
type ReportMap = Record<string, RiskReportResult>;
|
|
||||||
|
|
||||||
function storageKey(userId: number | string | null | undefined) {
|
|
||||||
return `${STORAGE_PREFIX}:${userId ?? "current-user"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMap(userId: number | string | null | undefined): ReportMap {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(storageKey(userId));
|
|
||||||
if (!raw) return {};
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
return parsed && typeof parsed === "object" ? (parsed as ReportMap) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readConversationReport(
|
|
||||||
conversationId: number | string | null | undefined,
|
|
||||||
userId?: number | string | null,
|
|
||||||
): RiskReportResult | null {
|
|
||||||
if (conversationId == null || String(conversationId).trim() === "") return null;
|
|
||||||
return readMap(userId)[String(conversationId)] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeConversationReport(
|
|
||||||
conversationId: number | string,
|
|
||||||
report: RiskReportResult,
|
|
||||||
userId?: number | string | null,
|
|
||||||
) {
|
|
||||||
if (conversationId == null || String(conversationId).trim() === "") return;
|
|
||||||
const reports = readMap(userId);
|
|
||||||
reports[String(conversationId)] = report;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(storageKey(userId), JSON.stringify(reports));
|
|
||||||
} catch {
|
|
||||||
// Storage failure should not prevent opening the report page.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user