按原型统一风险报告页面与下载内容

完善报告页栏目和原型样式,统一风险卡片、网页报告、PDF与Word的字段口径;接入真实PDF和DOCX下载,修复合作频次、七维结论及标准判定展示差异。
This commit is contained in:
kx_guo 2026-09-08 11:48:24 +08:00
parent 5397d128be
commit 1cc6b471d3
7 changed files with 531 additions and 14 deletions

16
src/api/reports.ts Normal file
View File

@ -0,0 +1,16 @@
import { http } from "@/api/http";
import type { RiskReportResult } from "@/types/risk";
export function downloadReportPdf(payload: RiskReportResult) {
return http.post<Blob>("/v1/reports/pdf", payload, {
responseType: "blob",
timeout: 60_000,
});
}
export function downloadReportWord(payload: RiskReportResult) {
return http.post<Blob>("/v1/reports/word", payload, {
responseType: "blob",
timeout: 60_000,
});
}

View File

@ -98,9 +98,9 @@ function formatCount(value: unknown) {
<b>{{ formatCount(data.anomalyCount) }}</b
><span>近3年异常</span>
</div>
<div>
<div v-if="data.cooperationCount != null">
<b>{{ formatCount(data.cooperationCount) }}</b
><span>合作频次(代理)</span>
><span>合作频次(采购订单)</span>
</div>
<div>
<b>×{{ data.frequencyCoefficient?.toFixed(2) ?? "1.00" }}</b

View File

@ -19,6 +19,7 @@ const props = defineProps<{
result: RiskAssessmentResult;
canMonitor?: boolean;
}>();
const emit = defineEmits<{ generateReport: [] }>();
const expanded = ref(true);
const feedback = ref("");
@ -161,16 +162,9 @@ const stars = computed(() => {
<span>供应商层级</span
><strong>{{ result.supplierFreq.tier }}</strong>
</div>
<div>
<span>合作频次(代理)</span>
<strong
>{{
result.supplierFreq.cooperationCount ??
result.supplierFreq.anomalyCount ??
0
}}
次</strong
>
<div v-if="result.supplierFreq.cooperationCount != null">
<span>合作频次(采购订单)</span>
<strong>{{ result.supplierFreq.cooperationCount }} 次</strong>
</div>
<div v-if="result.supplierFreq.suggestion">
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
@ -190,7 +184,7 @@ const stars = computed(() => {
>
{{
result.supplierFreq.note ||
"当前以近3年异常记录数作为合作频次代理,不代表真实采购订单合作次数。"
"合作频次按近3年采购订单去重单据编号统计;异常数仍按异常案例库统计。"
}}
</small>
</section>
@ -298,7 +292,7 @@ const stars = computed(() => {
<button
class="primary-action"
type="button"
@click="notify('生成报告')"
@click="emit('generateReport')"
>
<FileDown :size="14" /> 生成报告
</button>

488
src/pages/ReportPage.vue Normal file
View File

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

View File

@ -11,6 +11,7 @@ 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 { RiskAssessmentResult } from "@/types/risk";
const route = useRoute();
const router = useRouter();
@ -217,6 +218,11 @@ async function openFromRoute() {
}
}
function openReport(result: RiskAssessmentResult) {
sessionStorage.setItem("dieya-risk-report", JSON.stringify(result));
void router.push({ name: "report" });
}
async function submit(
messageType: "text" | "image" | "file" | "audio" = "text",
) {
@ -315,6 +321,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
v-if="message.riskResult"
:result="message.riskResult"
:can-monitor="canMonitor"
@generate-report="openReport(message.riskResult)"
/>
<AssistantResponseCard
v-if="message.responseCard"

View File

@ -41,6 +41,16 @@ export const routes: RouteRecordRaw[] = [
permission: "risk.assess",
},
},
{
path: "report",
name: "report",
component: () => import("@/pages/ReportPage.vue"),
meta: {
title: "风险报告",
requiresAuth: true,
permission: "risk.assess",
},
},
{
path: "overview",
name: "overview",

View File

@ -77,6 +77,8 @@ export interface RiskAssessmentResult {
dimensionScores?: RiskDimensionScore[];
}
export type RiskReportResult = RiskAssessmentResult;
export interface ModelInfo {
modelVersion: string;
scoreRange: string;