676 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ArrowLeft, Download, FileText } from "@lucide/vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
import type { ReportCard, RiskReportResult } from "@/types/risk";
import { formatRiskLevel } from "@/utils/risk";
const router = useRouter();
let parsed: RiskReportResult | null = null;
try {
const raw = sessionStorage.getItem("dieya-risk-report");
parsed = raw ? (JSON.parse(raw) as RiskReportResult) : null;
} catch {
parsed = null;
}
const report = computed(() => parsed);
const 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"),
standards,
similarCases,
suggestions,
};
});
const dimensionRows = computed(() => {
if (!report.value) return [];
if (report.value.dimensionScores?.length) {
return report.value.dimensionScores.filter(
(item) => item.dimension !== "节点阶段" && item.dimension !== "缺陷等级",
);
}
return report.value.factors
.filter(
(factor) => factor.name !== "节点阶段" && factor.name !== "缺陷等级",
)
.map((factor) => ({
dimension: factor.name,
score: 100 - factor.value,
weight: factor.weight,
status: "database",
reason: null,
}));
});
const generatedAt = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date());
async function downloadWord() {
if (!report.value) return;
const response = await downloadReportWord(report.value);
const blob = response.data;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `蝶雅新品风险预估报告-${report.value.subjectId}.docx`;
anchor.click();
URL.revokeObjectURL(url);
}
async function downloadPdf() {
if (!report.value) return;
const response = await downloadReportPdf(report.value);
const url = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `蝶雅新品风险预估报告-${report.value.subjectId}.pdf`;
anchor.click();
URL.revokeObjectURL(url);
}
function notify(message: string) {
window.alert(`${message}功能待接入服务端`);
}
function dimensionConclusion(item: {
dimension: string;
score: number;
reason?: string | null;
}) {
const supplier = report.value?.supplierFreq;
if (item.dimension === "供应商表现" && supplier) {
const orderText =
supplier.cooperationCount != null
? `合作 ${supplier.cooperationCount} 次、`
: "";
return `${Math.round(item.score)} 分 · ${orderText}异常 ${supplier.anomalyCount ?? 0} 次`;
}
if (
item.dimension === "标准符合性" &&
report.value?.standardJudgment?.length
) {
return `${Math.round(item.score)} 分 · 命中 ${report.value.standardJudgment.length} 条判定标准,待检测项需人工确认`;
}
return `${Math.round(item.score)} 分${item.reason ? ` · ${item.reason}` : ""}`;
}
</script>
<template>
<div class="report-page">
<header class="report-actions">
<button type="button" class="secondary-button" @click="downloadPdf">
<Download :size="15" />导出 PDF
</button>
<button type="button" class="secondary-button" @click="downloadWord">
<Download :size="15" />导出 Word
</button>
<button
type="button"
class="secondary-button"
@click="notify('钉钉发送')"
>
<FileText :size="15" />钉钉发送
</button>
<button type="button" class="primary-button" @click="router.back()">
<ArrowLeft :size="15" />返回对话
</button>
</header>
<p v-if="!report" class="report-empty">
暂无报告数据,请从风险评估结果卡片点击“生成报告”。
</p>
<article v-else class="report-sheet">
<h1>新品风险预估报告</h1>
<p class="report-subtitle">
蝶雅产品部 · 风险预估系统 · 自动生成 · {{ generatedAt }}
</p>
<div class="report-meta">
<span><b>产品/评估对象:</b>{{ report.subjectId }}</span>
><span
><b>供应商:</b
>{{ report.supplierFreq?.supplierName || "未指定" }}</span
><span
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
report.score.toFixed(0)
}}</span
>
</div>
<h2>一、风险结论</h2>
<table>
<thead>
<tr>
<th>评估项</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr>
<td>综合评分</td>
<td class="risk">
{{ Math.round(report.score) }} / 100 ·
{{ formatRiskLevel(report.level) }} ·
{{ report.riskBadge || "需关注" }}
</td>
</tr>
<tr>
<td>风险警示</td>
<td>
{{
report.alertText ||
"当前暂无足够历史数据,建议按标准库完成基础验证。"
}}
</td>
</tr>
<tr v-for="item in dimensionRows" :key="item.dimension">
<td>{{ item.dimension }}({{ item.weight }}%)</td>
<td>{{ dimensionConclusion(item) }}</td>
</tr>
</tbody>
</table>
<template v-if="report.categoryWarning">
<h2>二、品类风险预警</h2>
<table>
<thead>
<tr>
<th>品类</th>
<th>历史异常</th>
<th>风险等级</th>
<th>建议</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ report.categoryWarning.category }}</td>
<td>{{ report.categoryWarning.anomalyCount }} 条</td>
<td>{{ report.categoryWarning.riskLevel }}</td>
<td>
{{
report.categoryWarning.suggestion ||
"结合相似品类和标准库预判"
}}
</td>
</tr>
</tbody>
</table>
</template>
<template v-if="report.supplierFreq?.supplierName || reportData.supplierCards.length">
<h2>三、供应商合作频次分析</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>数值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-if="report.supplierFreq?.cooperationCount != null">
<td>合作次数</td>
<td>{{ report.supplierFreq?.cooperationCount }} 次</td>
<td>近三年采购订单去重单据数</td>
</tr>
<tr>
<td>异常次数</td>
<td>{{ report.supplierFreq?.anomalyCount ?? 0 }} 次</td>
<td>异常案例库统计</td>
</tr>
<tr v-if="report.supplierFreq?.alpha != null">
<td>频次风险系数</td>
<td>×{{ report.supplierFreq?.alpha?.toFixed(2) }}</td>
<td>α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5</td>
</tr>
<tr v-if="report.supplierFreq?.tier">
<td>供应商层级</td>
<td>{{ report.supplierFreq?.tier }}</td>
<td>{{ report.supplierFreq?.suggestion }}</td>
</tr>
<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>{{ reportData.materialCards.length ? "五" : "四" }}、相似案例匹配({{ reportData.similarCases.length }} 条)</h2>
<template v-if="reportData.similarCases.length">
<table>
<thead>
<tr>
<th>日期</th>
<th>产品</th>
<th>问题</th>
<th>处理方案</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in reportData.similarCases"
:key="`${item.date}-${item.product}-${item.problem}`"
>
<td>{{ item.date || "未知" }}</td>
<td>{{ item.product || "未命名" }}</td>
<td>{{ item.problem || "未填写" }}</td>
<td>{{ item.solution || "未填写" }}</td>
</tr>
</tbody>
</table>
</template>
<p v-else class="report-paragraph">
暂无相关案例,RAG 相似案例检索未返回匹配结果。
</p>
<h2>{{ reportData.materialCards.length ? "六" : "五" }}、预防建议</h2>
<ol>
<li
v-for="suggestion in reportData.suggestions.length
? reportData.suggestions
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
:key="suggestion"
>
{{ suggestion }}
</li>
</ol>
<h2>{{ reportData.materialCards.length ? "七" : "六" }}、AI 推理方案(无历史案例场景)</h2>
<p class="report-paragraph">
<template v-if="reportData.similarCases.length">
本次评估已匹配
{{ reportData.similarCases.length }}
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
</template>
<template v-else>
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
</template>
</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>{{ reportData.materialCards.length ? "八" : "七" }}、标准判定结果</h2>
<template v-if="reportData.standards.length">
<table>
<thead>
<tr>
<th>判定维度</th>
<th>判定标准</th>
<th>标准来源</th>
<th>当前状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="standard in reportData.standards"
:key="`${standard.category}-${standard.riskPoint}`"
>
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
<td>{{ standard.rule }}</td>
<td>{{ standard.source }}</td>
<td>{{ standard.status }}</td>
</tr>
</tbody>
</table>
</template>
<p v-else class="report-paragraph">暂无相关标准。</p>
<p class="report-paragraph report-engine-note">
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
</p>
<footer class="report-footer">
<span>编制:蝶雅风险预估系统</span
><span>生成时间:{{ generatedAt }}</span>
</footer>
</article>
</div>
</template>
<style scoped>
.report-page {
flex: 1;
overflow-y: auto;
background: #f5f7fb;
padding: 24px 28px 40px;
}
.report-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin: 0 auto 16px;
max-width: 780px;
}
.action-group {
display: flex;
gap: 6px;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 5px;
padding: 8px 12px;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.primary-button {
border: 1px solid #111827;
background: #111827;
color: white;
}
.secondary-button {
border: 1px solid #d1d9e6;
background: white;
color: #334155;
}
.report-empty {
max-width: 780px;
margin: 80px auto;
text-align: center;
color: #64748b;
}
.report-sheet {
max-width: 780px;
margin: 0 auto;
border: 1px solid #dce3ed;
border-radius: 8px;
background: white;
padding: 48px;
box-shadow: 0 10px 26px rgb(15 23 42 / 10%);
}
h1 {
margin: 0 0 4px;
text-align: center;
font-size: 28px;
color: #111827;
}
.report-subtitle {
margin: 0 0 28px;
border-bottom: 2px solid #111827;
padding-bottom: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.report-meta {
display: flex;
flex-wrap: wrap;
gap: 32px;
margin-bottom: 24px;
color: #334155;
font-size: 12px;
}
h2 {
margin: 24px 0 12px;
border-left: 2px solid #111827;
padding-left: 8px;
color: #111827;
font-size: 15px;
}
table {
width: 100%;
margin-bottom: 16px;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
border: 1px solid #dbe3ef;
padding: 9px 12px;
text-align: left;
vertical-align: top;
}
th {
background: #111827;
color: white;
font-size: 12px;
font-weight: 600;
}
.risk {
color: #d97706;
font-size: 15px;
font-weight: 700;
}
ol {
padding-left: 24px;
color: #334155;
font-size: 13px;
line-height: 1.9;
}
.report-footer {
display: flex;
justify-content: space-between;
margin-top: 36px;
color: #94a3b8;
font-size: 11px;
}
.report-paragraph {
margin: 0 0 12px;
color: #334155;
font-size: 13px;
line-height: 1.8;
}
.ai-solution-report {
margin: 0 0 16px;
border: 1px solid #bfdbfe;
border-left: 3px solid #7c3aed;
border-radius: 7px;
padding: 14px;
background: #fafbff;
}
.report-subsection-title {
margin: 14px 0 8px;
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;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: #1d4ed8;
font-size: 13px;
}
.ai-solution-report header span {
border: 1px solid #fde68a;
border-radius: 999px;
padding: 2px 8px;
background: #fffbeb;
color: #d97706;
font-size: 10px;
}
.ai-solution-report p,
.ai-solution-report li {
color: #334155;
font-size: 12px;
line-height: 1.7;
}
.ai-solution-report p {
margin: 6px 0;
}
.ai-solution-report h3 {
margin: 10px 0 4px;
font-size: 12px;
}
.ai-solution-report ul,
.ai-solution-report ol {
margin: 4px 0;
padding-left: 20px;
}
.ai-solution-report__warning {
border-radius: 4px;
padding: 7px 9px;
background: #fffbeb;
color: #92400e !important;
font-size: 11px !important;
}
@media (max-width: 700px) {
.report-page {
padding: 12px;
}
.report-sheet {
padding: 24px 16px;
}
.report-actions {
flex-direction: column;
}
.action-group {
justify-content: flex-end;
}
.report-meta {
flex-direction: column;
gap: 5px;
}
table {
min-width: 680px;
}
.report-sheet {
overflow-x: auto;
}
}
@media print {
.report-page {
padding: 0;
background: white;
}
.report-actions {
display: none;
}
.report-sheet {
border: 0;
box-shadow: none;
max-width: none;
padding: 0;
}
h2 {
break-after: avoid;
}
table {
break-inside: avoid;
}
}
</style>