Compare commits
10 Commits
69a1433a86
...
c2a0756d2f
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a0756d2f | |||
| 763d8cc545 | |||
| f04aad9a59 | |||
| 71437dfa25 | |||
| c502bc17c4 | |||
| 77741c619f | |||
| 8ed60807f9 | |||
| 343c04530c | |||
| ca9fc9ee9d | |||
| d05be62e02 |
@ -18,7 +18,8 @@ export async function fetchSupplierProfile(
|
||||
if (running) return { data: { data: await running } };
|
||||
const request = (async () => {
|
||||
const response = await http.get<ApiResponse<SupplierProfile>>(
|
||||
`/v1/suppliers/${encodeURIComponent(key)}`,
|
||||
"/v1/suppliers/profile",
|
||||
{ params: { supplier_name: key } },
|
||||
);
|
||||
if (!response.data.data) throw new Error("供应商画像响应为空");
|
||||
cache.set(key, { data: response.data.data, at: Date.now() });
|
||||
|
||||
@ -15,11 +15,12 @@ import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import type { AssistantResponseCard } from "@/types/conversation";
|
||||
import type { ReportCard } from "@/types/risk";
|
||||
|
||||
const props = defineProps<{ card: AssistantResponseCard }>();
|
||||
const router = useRouter();
|
||||
const emit = defineEmits<{
|
||||
generateReport: [card: AssistantResponseCard];
|
||||
generateReport: [card: ReportCard];
|
||||
}>();
|
||||
interface CardData extends AssistantResponseCard {
|
||||
name?: string;
|
||||
@ -119,7 +120,7 @@ function formatCount(value: unknown) {
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
emit("generateReport", props.card);
|
||||
emit("generateReport", JSON.parse(JSON.stringify(props.card)) as ReportCard);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -158,7 +159,7 @@ function generateReport() {
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<b>{{ formatCount(data.anomalyCount) }}</b
|
||||
><span>近3年异常</span>
|
||||
><span>近3个自然年度异常</span>
|
||||
</div>
|
||||
<div v-if="data.cooperationCount != null">
|
||||
<b>{{ formatCount(data.cooperationCount) }}</b
|
||||
@ -389,8 +390,7 @@ function generateReport() {
|
||||
<button
|
||||
type="button"
|
||||
class="response-actions__primary"
|
||||
:disabled="card.type !== 'solution'"
|
||||
:title="card.type === 'solution' ? '追加到当前风险报告' : '功能开发中'"
|
||||
title="追加到当前风险报告"
|
||||
@click="generateReport"
|
||||
>
|
||||
<CheckCircle2 :size="14" />生成报告
|
||||
@ -403,14 +403,6 @@ function generateReport() {
|
||||
>
|
||||
查看标准详情
|
||||
</button>
|
||||
<button
|
||||
v-if="card.type === 'solution'"
|
||||
type="button"
|
||||
disabled
|
||||
title="功能开发中"
|
||||
>
|
||||
📌 收藏方案
|
||||
</button>
|
||||
<button
|
||||
v-if="card.type === 'solution'"
|
||||
type="button"
|
||||
|
||||
@ -211,7 +211,7 @@ function openSupplier() {
|
||||
<div v-if="result.supplierFreq.supplierName">
|
||||
<span>{{ result.supplierFreq.supplierName }}</span>
|
||||
<strong
|
||||
>近3年异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
||||
>近3个自然年度异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
||||
>
|
||||
</div>
|
||||
<div v-if="result.supplierFreq.alpha != null">
|
||||
@ -251,7 +251,7 @@ function openSupplier() {
|
||||
>
|
||||
{{
|
||||
result.supplierFreq.note ||
|
||||
"合作频次按近3年采购订单去重单据编号统计;异常数仍按异常案例库统计。"
|
||||
"合作频次按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例库统计。"
|
||||
}}
|
||||
</small>
|
||||
</section>
|
||||
|
||||
@ -15,6 +15,14 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"];
|
||||
const navigationRoutes: Record<string, string> = {
|
||||
对话: "assessment",
|
||||
看板: "overview",
|
||||
供应商: "supplier",
|
||||
标准库: "standards",
|
||||
报告: "report",
|
||||
管理: "review",
|
||||
};
|
||||
const visibleNavigation = computed(() =>
|
||||
navigation.filter(
|
||||
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
|
||||
@ -52,19 +60,14 @@ function prefetch(item: string) {
|
||||
v-for="item in visibleNavigation"
|
||||
:key="item"
|
||||
:to="{
|
||||
name:
|
||||
item === '看板'
|
||||
? 'overview'
|
||||
: item === '标准库'
|
||||
? 'standards'
|
||||
: item === '管理'
|
||||
? 'review'
|
||||
: 'assessment',
|
||||
name: navigationRoutes[item] || 'assessment',
|
||||
}"
|
||||
:class="{
|
||||
active:
|
||||
(item === '看板' && route.name === 'overview') ||
|
||||
(item === '供应商' && route.name === 'supplier') ||
|
||||
(item === '标准库' && route.name === 'standards') ||
|
||||
(item === '报告' && route.name === 'report') ||
|
||||
(item === '管理' && route.name === 'review') ||
|
||||
(item === '对话' && route.name === 'assessment'),
|
||||
}"
|
||||
|
||||
@ -38,7 +38,7 @@ function submit() {
|
||||
<form class="assessment-form" @submit.prevent="submit">
|
||||
<div class="form-intro">
|
||||
<strong>新品风险参数</strong>
|
||||
<span>系统将结合近 3 年异常案例计算七维安全评分</span>
|
||||
<span>系统将结合近3个自然年度异常案例计算七维安全评分</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="field-wide">
|
||||
|
||||
@ -7,24 +7,20 @@ import {
|
||||
Smartphone,
|
||||
} from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const mode = ref<"password" | "dingtalk">("password");
|
||||
const useDevelopmentLogin = ref(true);
|
||||
const username = ref("local-admin");
|
||||
const password = ref("risk-admin-2026");
|
||||
|
||||
const redirectPath = computed(() => {
|
||||
const redirect = route.query.redirect;
|
||||
return typeof redirect === "string" && redirect.startsWith("/")
|
||||
? redirect
|
||||
: "/assessment";
|
||||
});
|
||||
// 登录后的统一落点是智能对话页,避免从其他受保护页面跳转到登录后
|
||||
// 又回到原页面,保证用户进入系统后先看到对话工作台。
|
||||
const redirectPath = computed(() => "/assessment");
|
||||
|
||||
async function submitPassword() {
|
||||
if (!username.value.trim() || password.value.length < 8 || auth.loading)
|
||||
|
||||
@ -1,21 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Download, FileText } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed, onActivated, onMounted, shallowRef, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
|
||||
import type { RiskReportResult } from "@/types/risk";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import { useConversationStore } from "@/stores/conversation.store";
|
||||
import type { ReportCard, RiskReportResult } from "@/types/risk";
|
||||
import { formatRiskLevel } from "@/utils/risk";
|
||||
import { readConversationReport } from "@/utils/conversationReports";
|
||||
|
||||
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 route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const report = shallowRef<RiskReportResult | null>(null);
|
||||
|
||||
function loadReport() {
|
||||
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(() => {
|
||||
if (!report.value) return [];
|
||||
if (report.value.dimensionScores?.length) {
|
||||
@ -63,6 +143,17 @@ async function downloadPdf() {
|
||||
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) {
|
||||
window.alert(`${message}功能待接入服务端`);
|
||||
}
|
||||
@ -93,10 +184,20 @@ function dimensionConclusion(item: {
|
||||
<template>
|
||||
<div class="report-page">
|
||||
<header class="report-actions">
|
||||
<button type="button" class="secondary-button" @click="downloadPdf">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="!report"
|
||||
@click="downloadPdf"
|
||||
>
|
||||
<Download :size="15" />导出 PDF
|
||||
</button>
|
||||
<button type="button" class="secondary-button" @click="downloadWord">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="!report"
|
||||
@click="downloadWord"
|
||||
>
|
||||
<Download :size="15" />导出 Word
|
||||
</button>
|
||||
<button
|
||||
@ -106,12 +207,12 @@ function dimensionConclusion(item: {
|
||||
>
|
||||
<FileText :size="15" />钉钉发送
|
||||
</button>
|
||||
<button type="button" class="primary-button" @click="router.back()">
|
||||
<button type="button" class="primary-button" @click="returnToConversation">
|
||||
<ArrowLeft :size="15" />返回对话
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="!report" class="report-empty">
|
||||
暂无报告数据,请从风险评估结果卡片点击“生成报告”。
|
||||
当前会话暂无报告,请返回对话后从风险评估结果卡片点击“生成报告”。
|
||||
</p>
|
||||
<article v-else class="report-sheet">
|
||||
<h1>新品风险预估报告</h1>
|
||||
@ -190,7 +291,7 @@ function dimensionConclusion(item: {
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-if="report.supplierFreq?.supplierName">
|
||||
<template v-if="report.supplierFreq?.supplierName || reportData.supplierCards.length">
|
||||
<h2>三、供应商合作频次分析</h2>
|
||||
<table>
|
||||
<thead>
|
||||
@ -201,32 +302,55 @@ function dimensionConclusion(item: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="report.supplierFreq.cooperationCount != null">
|
||||
<tr v-if="report.supplierFreq?.cooperationCount != null">
|
||||
<td>合作次数</td>
|
||||
<td>{{ report.supplierFreq.cooperationCount }} 次</td>
|
||||
<td>近三年采购订单去重单据数</td>
|
||||
<td>{{ report.supplierFreq?.cooperationCount }} 次</td>
|
||||
<td>按自然年度边界采购订单去重单据数</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>异常次数</td>
|
||||
<td>{{ report.supplierFreq.anomalyCount ?? 0 }} 次</td>
|
||||
<td>{{ report.supplierFreq?.anomalyCount ?? 0 }} 次</td>
|
||||
<td>异常案例库统计</td>
|
||||
</tr>
|
||||
<tr v-if="report.supplierFreq.alpha != null">
|
||||
<tr v-if="report.supplierFreq?.alpha != null">
|
||||
<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>
|
||||
</tr>
|
||||
<tr v-if="report.supplierFreq.tier">
|
||||
<tr v-if="report.supplierFreq?.tier">
|
||||
<td>供应商层级</td>
|
||||
<td>{{ report.supplierFreq.tier }}</td>
|
||||
<td>{{ report.supplierFreq.suggestion }}</td>
|
||||
<td>{{ report.supplierFreq?.tier }}</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<h2>四、相似案例匹配({{ report.similarCases?.length || 0 }} 条)</h2>
|
||||
<template v-if="report.similarCases?.length">
|
||||
<h2>{{ reportData.materialCards.length ? "五" : "四" }}、相似案例匹配({{ reportData.similarCases.length }} 条)</h2>
|
||||
<template v-if="reportData.similarCases.length">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -238,7 +362,7 @@ function dimensionConclusion(item: {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in report.similarCases"
|
||||
v-for="item in reportData.similarCases"
|
||||
:key="`${item.date}-${item.product}-${item.problem}`"
|
||||
>
|
||||
<td>{{ item.date || "未知" }}</td>
|
||||
@ -253,11 +377,11 @@ function dimensionConclusion(item: {
|
||||
暂无相关案例,RAG 相似案例检索未返回匹配结果。
|
||||
</p>
|
||||
|
||||
<h2>五、预防建议</h2>
|
||||
<h2>{{ reportData.materialCards.length ? "六" : "五" }}、预防建议</h2>
|
||||
<ol>
|
||||
<li
|
||||
v-for="suggestion in report.aiSuggestions?.length
|
||||
? report.aiSuggestions
|
||||
v-for="suggestion in reportData.suggestions.length
|
||||
? reportData.suggestions
|
||||
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
|
||||
:key="suggestion"
|
||||
>
|
||||
@ -265,61 +389,30 @@ function dimensionConclusion(item: {
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>六、AI 推理方案(无历史案例场景)</h2>
|
||||
<h2>{{ reportData.materialCards.length ? "七" : "六" }}、AI 推理方案(无历史案例场景)</h2>
|
||||
<p class="report-paragraph">
|
||||
<template v-if="report.similarCases?.length">
|
||||
<template v-if="reportData.similarCases.length">
|
||||
本次评估已匹配
|
||||
{{ report.similarCases.length }}
|
||||
{{ reportData.similarCases.length }}
|
||||
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
|
||||
</template>
|
||||
<template v-else>
|
||||
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<template v-if="report.aiSolutionCards?.length">
|
||||
<h3 class="report-subsection-title">AI 案例推理方案明细(已追加)</h3>
|
||||
<section
|
||||
v-for="(solution, index) in report.aiSolutionCards"
|
||||
:key="`${solution.title || 'solution'}-${index}`"
|
||||
class="ai-solution-report"
|
||||
>
|
||||
<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>
|
||||
<template v-if="reportData.solutionCards.length">
|
||||
<section v-for="(card, index) in reportData.solutionCards" :key="`solution-${index}`" class="ai-solution-report">
|
||||
<header><strong>{{ card.title }}</strong><span v-if="card.confidence">置信度:{{ card.confidence }}</span></header>
|
||||
<p v-if="card.analysisBase"><b>分析基础:</b>{{ card.analysisBase }}</p>
|
||||
<p v-if="card.standardFallbackEnabled"><b>标准兜底校验:</b>已启用(无直接历史案例)</p>
|
||||
<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>
|
||||
<ol v-if="card.suggestions?.length"><li v-for="suggestion in card.suggestions" :key="suggestion">{{ suggestion }}</li></ol>
|
||||
<p v-if="card.warning" class="ai-solution-report__warning">{{ card.warning }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<h2>七、标准判定结果</h2>
|
||||
<template v-if="report.standardJudgment?.length">
|
||||
<h2>{{ reportData.materialCards.length ? "八" : "七" }}、标准判定结果</h2>
|
||||
<template v-if="reportData.standards.length">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -331,7 +424,7 @@ function dimensionConclusion(item: {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="standard in report.standardJudgment"
|
||||
v-for="standard in reportData.standards"
|
||||
:key="`${standard.category}-${standard.riskPoint}`"
|
||||
>
|
||||
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
|
||||
@ -342,7 +435,11 @@ function dimensionConclusion(item: {
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<p v-else class="report-paragraph">暂无相关标准。</p>
|
||||
<template v-for="(card, index) in reportData.standardCards" :key="`standard-card-${index}`">
|
||||
<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>
|
||||
@ -494,6 +591,45 @@ ol {
|
||||
color: #334155;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -11,7 +11,11 @@ import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
|
||||
import { useConversationStore } from "@/stores/conversation.store";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { AiSolutionReport, RiskAssessmentResult } from "@/types/risk";
|
||||
import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
|
||||
import {
|
||||
readConversationReport,
|
||||
writeConversationReport,
|
||||
} from "@/utils/conversationReports";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -69,6 +73,29 @@ const toolLabel = computed(() => {
|
||||
if (tool.value === "cases") 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(() =>
|
||||
loading.value ? "正在调用风险评分引擎…" : "",
|
||||
@ -155,6 +182,27 @@ function submitTool() {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.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();
|
||||
if (!finalValue && !toolFile.value) {
|
||||
@ -243,32 +291,73 @@ async function openFromRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReport(result: RiskAssessmentResult) {
|
||||
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result));
|
||||
void router.push({ name: "report" });
|
||||
/**
|
||||
* 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 openSolutionReport(card: Record<string, unknown>) {
|
||||
const solution = card as AiSolutionReport;
|
||||
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) {
|
||||
const conversationId = conversation.value?.id;
|
||||
if (!conversationId) return;
|
||||
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) {
|
||||
const conversationId = conversation.value?.id;
|
||||
if (!conversationId) return;
|
||||
const current = conversation.value?.messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((message) => message.riskResult)?.riskResult;
|
||||
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 stored = readConversationReport(conversationId, auth.user?.id);
|
||||
const sameReport = Boolean(
|
||||
current && stored && current.subjectId === stored.subjectId,
|
||||
);
|
||||
const base: RiskAssessmentResult =
|
||||
sameReport && stored
|
||||
? structuredClone(stored)
|
||||
? cloneReport(stored)
|
||||
: current
|
||||
? structuredClone(current)
|
||||
? cloneReport(current)
|
||||
: stored || {
|
||||
subjectId: "AI 案例推理方案",
|
||||
score: 50,
|
||||
@ -276,23 +365,23 @@ function openSolutionReport(card: Record<string, unknown>) {
|
||||
modelVersion: "ai-solution-card",
|
||||
factors: [],
|
||||
};
|
||||
const existing = base.aiSolutionCards ?? [];
|
||||
const existingCards = base.reportCards ?? [];
|
||||
const merged: RiskAssessmentResult = {
|
||||
...base,
|
||||
aiSolutionCards: [...existing, solution],
|
||||
aiSuggestions: [
|
||||
...(base.aiSuggestions ?? []),
|
||||
...(solution.suggestions ?? []),
|
||||
// All newly appended cards use one canonical collection. Migrate old
|
||||
// AI-only entries once so they are not lost when the first generic card
|
||||
// is appended to an existing session.
|
||||
aiSolutionCards: base.aiSolutionCards,
|
||||
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,
|
||||
};
|
||||
sessionStorage.setItem("dieya-risk-report", JSON.stringify(merged));
|
||||
void router.push({ name: "report" });
|
||||
writeConversationReport(conversationId, merged, auth.user?.id);
|
||||
void router.push({
|
||||
name: "report",
|
||||
query: { conversation: String(conversationId) },
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(
|
||||
@ -464,9 +553,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
||||
</button>
|
||||
<p class="eyebrow">CHAT TOOL</p>
|
||||
<h2>{{ toolTitle }}</h2>
|
||||
<p class="dialog-description">
|
||||
填写必要信息后,将生成一条带上下文的对话请求。
|
||||
</p>
|
||||
<p class="dialog-description">{{ toolDescription }}</p>
|
||||
<div v-if="tool === 'assessment'" class="assessment-fields">
|
||||
<label
|
||||
>产品品类<input v-model="assessmentFields.category" autofocus
|
||||
@ -499,6 +586,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
||||
v-if="tool !== 'ocr' && tool !== 'voice'"
|
||||
v-model="toolInput"
|
||||
:maxlength="tool === 'cases' ? 50 : 2000"
|
||||
:placeholder="toolPlaceholder"
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
@ -517,7 +605,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
||||
<button type="button" class="dialog-secondary" @click="closeTool">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="dialog-primary">带入对话</button>
|
||||
<button type="submit" class="dialog-primary">
|
||||
{{ toolSubmitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { fetchSupplierOptions } from "@/api/suppliers";
|
||||
@ -10,6 +10,11 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const supplierNames = ref<string[]>([]);
|
||||
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 loading = ref(false);
|
||||
const error = ref("");
|
||||
@ -89,11 +94,86 @@ async function loadOptions() {
|
||||
typeof route.query.supplier === "string"
|
||||
? route.query.supplier
|
||||
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
|
||||
supplierQuery.value = selected.value;
|
||||
supplierResults.value = supplierNames.value;
|
||||
if (selected.value) await loadProfile();
|
||||
} catch {
|
||||
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() {
|
||||
if (!selected.value) return;
|
||||
loading.value = true;
|
||||
@ -116,11 +196,15 @@ function chooseSupplier() {
|
||||
void loadProfile();
|
||||
}
|
||||
onMounted(() => void loadOptions());
|
||||
onBeforeUnmount(() => {
|
||||
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
|
||||
});
|
||||
watch(
|
||||
() => route.query.supplier,
|
||||
(value) => {
|
||||
if (typeof value === "string" && value !== selected.value) {
|
||||
selected.value = value;
|
||||
supplierQuery.value = value;
|
||||
void loadProfile();
|
||||
}
|
||||
},
|
||||
@ -132,16 +216,46 @@ watch(
|
||||
<div class="supplier-inner">
|
||||
<header class="supplier-head">
|
||||
<h1>供应商深度画像</h1>
|
||||
<select
|
||||
v-model="selected"
|
||||
<div class="supplier-combobox" @focusout="closeSupplierDropdown">
|
||||
<input
|
||||
:value="supplierQuery"
|
||||
class="supplier-select"
|
||||
aria-label="选择供应商"
|
||||
@change="chooseSupplier"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-label="搜索并选择供应商"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="supplierDropdownOpen"
|
||||
placeholder="输入供应商名称"
|
||||
@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)"
|
||||
>
|
||||
<option v-for="name in supplierNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</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">{{
|
||||
tierLabel
|
||||
}}</span>
|
||||
@ -155,7 +269,7 @@ watch(
|
||||
<section class="stat-row">
|
||||
<div class="stat-card clickable">
|
||||
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
|
||||
<div class="stat-label">近3年异常</div>
|
||||
<div class="stat-label">近3个自然年度异常</div>
|
||||
</div>
|
||||
<div class="stat-card clickable">
|
||||
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
|
||||
@ -240,7 +354,7 @@ watch(
|
||||
<table class="prototype-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>近 3 年异常数 N</th>
|
||||
<th>近3个自然年度异常数 N</th>
|
||||
<th>基础分</th>
|
||||
<th>档位含义</th>
|
||||
<th>在库示例</th>
|
||||
@ -418,6 +532,8 @@ watch(
|
||||
font-weight: 650;
|
||||
}
|
||||
.supplier-select {
|
||||
width: 180px;
|
||||
min-height: 34px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #e4e4e2;
|
||||
border-radius: 5px;
|
||||
@ -428,6 +544,47 @@ watch(
|
||||
font-size: 13px;
|
||||
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 {
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
@ -700,6 +857,13 @@ watch(
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.supplier-combobox {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.supplier-combobox .supplier-select {
|
||||
width: 100%;
|
||||
}
|
||||
.version-note {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@ export interface RiskAssessmentResult {
|
||||
}>;
|
||||
aiSuggestions?: string[];
|
||||
aiSolutionCards?: AiSolutionReport[];
|
||||
reportCards?: ReportCard[];
|
||||
riskBadge?: string | null;
|
||||
alertText?: string | null;
|
||||
categoryWarning?: {
|
||||
@ -103,6 +104,43 @@ export interface AiSolutionReport {
|
||||
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 interface ModelInfo {
|
||||
|
||||
42
src/utils/conversationReports.ts
Normal file
42
src/utils/conversationReports.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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