Compare commits

...

10 Commits

Author SHA1 Message Date
c2a0756d2f 修复报告会话关联与供应商搜索兼容性 2026-09-10 16:15:46 +08:00
763d8cc545 统一自然年度统计口径展示文案 2026-09-10 11:43:39 +08:00
f04aad9a59 修复顶部供应商与报告导航路由 2026-09-10 11:43:38 +08:00
71437dfa25 统一登录成功后进入智能对话页面 2026-09-10 11:43:38 +08:00
c502bc17c4 统一报告页面按类型追加内容 2026-09-09 17:36:57 +08:00
77741c619f 统一报告页面章节归并展示 2026-09-09 17:30:23 +08:00
8ed60807f9 按类型归并报告页面追加内容 2026-09-09 17:23:56 +08:00
343c04530c 兼容旧报告卡片并保留追加内容 2026-09-09 17:05:33 +08:00
ca9fc9ee9d 统一前端报告卡片展示与追加逻辑 2026-09-09 17:04:16 +08:00
d05be62e02 优化供应商搜索下拉与卡片操作
供应商画像选择器改为支持输入筛选的动态下拉框,输入关键词实时匹配并刷新画像;删除暂未实现的收藏方案按钮,保留已接入操作。
2026-09-09 15:51:43 +08:00
11 changed files with 616 additions and 154 deletions

View File

@ -18,7 +18,8 @@ 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/${encodeURIComponent(key)}`, "/v1/suppliers/profile",
{ 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() });

View File

@ -15,11 +15,12 @@ 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: AssistantResponseCard]; generateReport: [card: ReportCard];
}>(); }>();
interface CardData extends AssistantResponseCard { interface CardData extends AssistantResponseCard {
name?: string; name?: string;
@ -119,7 +120,7 @@ function formatCount(value: unknown) {
} }
function generateReport() { function generateReport() {
emit("generateReport", props.card); emit("generateReport", JSON.parse(JSON.stringify(props.card)) as ReportCard);
} }
</script> </script>
@ -158,7 +159,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
@ -389,8 +390,7 @@ function generateReport() {
<button <button
type="button" type="button"
class="response-actions__primary" class="response-actions__primary"
:disabled="card.type !== 'solution'" title="追加到当前风险报告"
:title="card.type === 'solution' ? '追加到当前风险报告' : '功能开发中'"
@click="generateReport" @click="generateReport"
> >
<CheckCircle2 :size="14" />生成报告 <CheckCircle2 :size="14" />生成报告
@ -403,14 +403,6 @@ 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"

View File

@ -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>

View File

@ -15,6 +15,14 @@ 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"),
@ -52,19 +60,14 @@ function prefetch(item: string) {
v-for="item in visibleNavigation" v-for="item in visibleNavigation"
:key="item" :key="item"
:to="{ :to="{
name: name: navigationRoutes[item] || 'assessment',
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'),
}" }"

View File

@ -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">

View File

@ -7,24 +7,20 @@ import {
Smartphone, Smartphone,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { 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; // 又回到原页面,保证用户进入系统后先看到对话工作台。
return typeof redirect === "string" && redirect.startsWith("/") const redirectPath = computed(() => "/assessment");
? 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)

View File

@ -1,21 +1,101 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, Download, FileText } from "@lucide/vue"; import { ArrowLeft, Download, FileText } from "@lucide/vue";
import { computed } from "vue"; import { computed, onActivated, onMounted, shallowRef, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { downloadReportPdf, downloadReportWord } from "@/api/reports"; 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 { formatRiskLevel } from "@/utils/risk";
import { readConversationReport } from "@/utils/conversationReports";
const router = useRouter(); const router = useRouter();
let parsed: RiskReportResult | null = null; const route = useRoute();
try { const auth = useAuthStore();
const raw = sessionStorage.getItem("dieya-risk-report"); const conversationStore = useConversationStore();
parsed = raw ? (JSON.parse(raw) as RiskReportResult) : null; const report = shallowRef<RiskReportResult | null>(null);
} catch {
parsed = 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(() => { const dimensionRows = computed(() => {
if (!report.value) return []; if (!report.value) return [];
if (report.value.dimensionScores?.length) { if (report.value.dimensionScores?.length) {
@ -63,6 +143,17 @@ 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}功能待接入服务端`);
} }
@ -93,10 +184,20 @@ function dimensionConclusion(item: {
<template> <template>
<div class="report-page"> <div class="report-page">
<header class="report-actions"> <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 <Download :size="15" />导出 PDF
</button> </button>
<button type="button" class="secondary-button" @click="downloadWord"> <button
type="button"
class="secondary-button"
:disabled="!report"
@click="downloadWord"
>
<Download :size="15" />导出 Word <Download :size="15" />导出 Word
</button> </button>
<button <button
@ -106,12 +207,12 @@ function dimensionConclusion(item: {
> >
<FileText :size="15" />钉钉发送 <FileText :size="15" />钉钉发送
</button> </button>
<button type="button" class="primary-button" @click="router.back()"> <button type="button" class="primary-button" @click="returnToConversation">
<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>
@ -190,7 +291,7 @@ function dimensionConclusion(item: {
</table> </table>
</template> </template>
<template v-if="report.supplierFreq?.supplierName"> <template v-if="report.supplierFreq?.supplierName || reportData.supplierCards.length">
<h2>三、供应商合作频次分析</h2> <h2>三、供应商合作频次分析</h2>
<table> <table>
<thead> <thead>
@ -201,32 +302,55 @@ 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>四、相似案例匹配({{ report.similarCases?.length || 0 }} 条)</h2> <h2>{{ reportData.materialCards.length ? "五" : "四" }}、相似案例匹配({{ reportData.similarCases.length }} 条)</h2>
<template v-if="report.similarCases?.length"> <template v-if="reportData.similarCases.length">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -238,7 +362,7 @@ function dimensionConclusion(item: {
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="item in report.similarCases" v-for="item in reportData.similarCases"
:key="`${item.date}-${item.product}-${item.problem}`" :key="`${item.date}-${item.product}-${item.problem}`"
> >
<td>{{ item.date || "未知" }}</td> <td>{{ item.date || "未知" }}</td>
@ -253,11 +377,11 @@ function dimensionConclusion(item: {
暂无相关案例,RAG 相似案例检索未返回匹配结果。 暂无相关案例,RAG 相似案例检索未返回匹配结果。
</p> </p>
<h2>五、预防建议</h2> <h2>{{ reportData.materialCards.length ? "六" : "五" }}、预防建议</h2>
<ol> <ol>
<li <li
v-for="suggestion in report.aiSuggestions?.length v-for="suggestion in reportData.suggestions.length
? report.aiSuggestions ? reportData.suggestions
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']" : ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
:key="suggestion" :key="suggestion"
> >
@ -265,61 +389,30 @@ function dimensionConclusion(item: {
</li> </li>
</ol> </ol>
<h2>六、AI 推理方案(无历史案例场景)</h2> <h2>{{ reportData.materialCards.length ? "七" : "六" }}、AI 推理方案(无历史案例场景)</h2>
<p class="report-paragraph"> <p class="report-paragraph">
<template v-if="report.similarCases?.length"> <template v-if="reportData.similarCases.length">
本次评估已匹配 本次评估已匹配
{{ report.similarCases.length }} {{ reportData.similarCases.length }}
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。 条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
</template> </template>
<template v-else> <template v-else>
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。 当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
</template> </template>
</p> </p>
<template v-if="reportData.solutionCards.length">
<template v-if="report.aiSolutionCards?.length"> <section v-for="(card, index) in reportData.solutionCards" :key="`solution-${index}`" class="ai-solution-report">
<h3 class="report-subsection-title">AI 案例推理方案明细(已追加)</h3> <header><strong>{{ card.title }}</strong><span v-if="card.confidence">置信度:{{ card.confidence }}</span></header>
<section <p v-if="card.analysisBase"><b>分析基础:</b>{{ card.analysisBase }}</p>
v-for="(solution, index) in report.aiSolutionCards" <p v-if="card.standardFallbackEnabled"><b>标准兜底校验:</b>已启用(无直接历史案例)</p>
:key="`${solution.title || 'solution'}-${index}`" <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>
class="ai-solution-report" <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>
<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>七、标准判定结果</h2> <h2>{{ reportData.materialCards.length ? "八" : "七" }}、标准判定结果</h2>
<template v-if="report.standardJudgment?.length"> <template v-if="reportData.standards.length">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -331,7 +424,7 @@ function dimensionConclusion(item: {
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="standard in report.standardJudgment" v-for="standard in reportData.standards"
:key="`${standard.category}-${standard.riskPoint}`" :key="`${standard.category}-${standard.riskPoint}`"
> >
<td>{{ standard.category }} · {{ standard.riskPoint }}</td> <td>{{ standard.category }} · {{ standard.riskPoint }}</td>
@ -342,7 +435,11 @@ function dimensionConclusion(item: {
</tbody> </tbody>
</table> </table>
</template> </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 class="report-paragraph report-engine-note">
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。 双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
</p> </p>
@ -494,6 +591,45 @@ 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;

View File

@ -11,7 +11,11 @@ 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 { AiSolutionReport, RiskAssessmentResult } from "@/types/risk"; import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
import {
readConversationReport,
writeConversationReport,
} from "@/utils/conversationReports";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -69,6 +73,29 @@ 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 ? "正在调用风险评分引擎…" : "",
@ -155,6 +182,27 @@ 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) {
@ -243,32 +291,73 @@ async function openFromRoute() {
} }
} }
function openReport(result: RiskAssessmentResult) { /**
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result)); * Vue/Pinia exposes conversation results as reactive proxies. Those proxies
void router.push({ name: "report" }); * 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>) { function legacySolutionCards(result: RiskAssessmentResult): ReportCard[] {
const solution = card as AiSolutionReport; 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 const current = conversation.value?.messages
.slice() .slice()
.reverse() .reverse()
.find((message) => message.riskResult)?.riskResult; .find((message) => message.riskResult)?.riskResult;
let stored: RiskAssessmentResult | null = null; const stored = readConversationReport(conversationId, auth.user?.id);
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
? structuredClone(stored) ? cloneReport(stored)
: current : current
? structuredClone(current) ? cloneReport(current)
: stored || { : stored || {
subjectId: "AI 案例推理方案", subjectId: "AI 案例推理方案",
score: 50, score: 50,
@ -276,23 +365,23 @@ function openSolutionReport(card: Record<string, unknown>) {
modelVersion: "ai-solution-card", modelVersion: "ai-solution-card",
factors: [], factors: [],
}; };
const existing = base.aiSolutionCards ?? []; const existingCards = base.reportCards ?? [];
const merged: RiskAssessmentResult = { const merged: RiskAssessmentResult = {
...base, ...base,
aiSolutionCards: [...existing, solution], // All newly appended cards use one canonical collection. Migrate old
aiSuggestions: [ // AI-only entries once so they are not lost when the first generic card
...(base.aiSuggestions ?? []), // is appended to an existing session.
...(solution.suggestions ?? []), 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)); writeConversationReport(conversationId, merged, auth.user?.id);
void router.push({ name: "report" }); void router.push({
name: "report",
query: { conversation: String(conversationId) },
});
} }
async function submit( async function submit(
@ -464,9 +553,7 @@ 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"> <p class="dialog-description">{{ toolDescription }}</p>
填写必要信息后,将生成一条带上下文的对话请求。
</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
@ -499,6 +586,7 @@ 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
@ -517,7 +605,9 @@ 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> <button type="submit" class="dialog-primary">
{{ toolSubmitLabel }}
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from "vue-router";
import { fetchSupplierOptions } from "@/api/suppliers"; import { fetchSupplierOptions } from "@/api/suppliers";
@ -10,6 +10,11 @@ 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("");
@ -89,11 +94,86 @@ 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;
@ -116,11 +196,15 @@ 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();
} }
}, },
@ -132,16 +216,46 @@ watch(
<div class="supplier-inner"> <div class="supplier-inner">
<header class="supplier-head"> <header class="supplier-head">
<h1>供应商深度画像</h1> <h1>供应商深度画像</h1>
<select <div class="supplier-combobox" @focusout="closeSupplierDropdown">
v-model="selected" <input
class="supplier-select" :value="supplierQuery"
aria-label="选择供应商" class="supplier-select"
@change="chooseSupplier" type="text"
> role="combobox"
<option v-for="name in supplierNames" :key="name" :value="name"> aria-label="搜索并选择供应商"
{{ name }} aria-autocomplete="list"
</option> :aria-expanded="supplierDropdownOpen"
</select> 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)"
>
{{ 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>
@ -155,7 +269,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>
@ -240,7 +354,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>
@ -418,6 +532,8 @@ 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;
@ -428,6 +544,47 @@ 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;
@ -700,6 +857,13 @@ 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%;
} }

View File

@ -54,6 +54,7 @@ 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?: {
@ -103,6 +104,43 @@ 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 {

View 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.
}
}