按原型统一风险报告页面与下载内容
完善报告页栏目和原型样式,统一风险卡片、网页报告、PDF与Word的字段口径;接入真实PDF和DOCX下载,修复合作频次、七维结论及标准判定展示差异。
This commit is contained in:
parent
5397d128be
commit
1cc6b471d3
16
src/api/reports.ts
Normal file
16
src/api/reports.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
488
src/pages/ReportPage.vue
Normal 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>
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -77,6 +77,8 @@ export interface RiskAssessmentResult {
|
||||
dimensionScores?: RiskDimensionScore[];
|
||||
}
|
||||
|
||||
export type RiskReportResult = RiskAssessmentResult;
|
||||
|
||||
export interface ModelInfo {
|
||||
modelVersion: string;
|
||||
scoreRange: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user