This commit is contained in:
2026-06-18 17:17:26 +08:00
parent 064bf14a5a
commit 4b4f1d1715
9 changed files with 577 additions and 92 deletions

View File

@@ -1,5 +1,17 @@
<template>
<div class="hy-report">
<button
v-if="showToolbar"
type="button"
class="export-fab no-print"
:disabled="exporting"
:aria-busy="exporting"
@click="handleExport"
>
<span class="export-fab__icon">{{ exporting ? '…' : '↓' }}</span>
<span class="export-fab__text">{{ exporting ? '生成中' : '下载 PDF' }}</span>
</button>
<div ref="reportRef" class="hy-container">
<ReportHeaderSection :report-time="reportTime" />
@@ -25,8 +37,8 @@
<script setup>
import { computed, ref } from 'vue';
import { parseRoot, extractReportUrl, REPORT_USAGE_NOTICE } from './reportHelper';
import { printReportAsPdf } from './reportExport';
import { parseRoot, REPORT_USAGE_NOTICE } from './reportHelper';
import { downloadReportAsPdf } from './reportExport';
import ReportHeaderSection from './components/ReportHeaderSection.vue';
import RiskRatingSection from './components/RiskRatingSection.vue';
import RiskOverviewSection from './components/RiskOverviewSection.vue';
@@ -44,10 +56,10 @@ const props = defineProps({
index: { type: Number, default: 0 },
notifyRiskStatus: { type: Function, default: () => {} },
reportDateTime: { type: String, default: '' },
showToolbar: { type: Boolean, default: false },
});
const root = computed(() => parseRoot(props.data));
const reportUrl = computed(() => extractReportUrl(props.data));
const reportRef = ref(null);
const exporting = ref(false);
@@ -58,12 +70,26 @@ const reportTime = computed(
async function handleExport() {
exporting.value = true;
try {
await printReportAsPdf(reportRef.value);
await downloadReportAsPdf(reportRef.value, {
filename: `${props.apiId}_海宇个人风险报告_${formatFileDate(new Date())}.pdf`,
});
} finally {
exporting.value = false;
}
}
function formatFileDate(date) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
defineExpose({ reportRef, handleExport });
</script>
@@ -85,12 +111,70 @@ defineExpose({ reportRef, handleExport });
margin-top: 4px;
}
.export-fab {
position: fixed;
right: 24px;
bottom: 32px;
z-index: 1000;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
border: none;
border-radius: 999px;
background: #0a6e8e;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 16px rgba(10, 110, 142, 0.35);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(10, 110, 142, 0.45);
}
&:disabled {
opacity: 0.72;
cursor: wait;
}
}
.export-fab__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
font-size: 16px;
line-height: 1;
}
.export-fab__text {
white-space: nowrap;
}
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.export-fab {
right: 16px;
bottom: 20px;
padding: 12px 14px;
}
.export-fab__text {
font-size: 13px;
}
}
@media print {
.no-print {
display: none !important;

View File

@@ -1,18 +1,109 @@
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const PAGE_MARGIN_MM = 8;
/**
* 通过浏览器打印对话框导出 PDF目标打印机选「另存为 PDF」
* 将报告 DOM 渲染为 PDF 文件并触发浏览器下载
*
* @param {HTMLElement} reportElement
* @param {{
* filename?: string,
* beforeCapture?: () => void | Promise<void>,
* afterCapture?: () => void,
* }} [options]
*/
export async function printReportAsPdf(reportElement) {
export async function downloadReportAsPdf(reportElement, options = {}) {
if (!reportElement) return;
document.body.classList.add('dwbg9fb2-printing');
const {
filename = `报告_${formatFileDate(new Date())}.pdf`,
beforeCapture,
afterCapture,
} = options;
await new Promise((resolve) => requestAnimationFrame(resolve));
if (beforeCapture) {
await beforeCapture();
}
const scrollLeft = window.scrollX;
const scrollTop = window.scrollY;
window.scrollTo(0, 0);
await waitForPaint();
try {
window.print();
const canvas = await html2canvas(reportElement, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: -window.scrollY,
windowWidth: document.documentElement.clientWidth,
});
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const contentWidth = A4_WIDTH_MM - PAGE_MARGIN_MM * 2;
const contentHeight = A4_HEIGHT_MM - PAGE_MARGIN_MM * 2;
const imgWidth = contentWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const imgData = canvas.toDataURL('image/jpeg', 0.92);
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(
imgData,
'JPEG',
PAGE_MARGIN_MM,
PAGE_MARGIN_MM + position,
imgWidth,
imgHeight,
);
heightLeft -= contentHeight;
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(
imgData,
'JPEG',
PAGE_MARGIN_MM,
PAGE_MARGIN_MM + position,
imgWidth,
imgHeight,
);
heightLeft -= contentHeight;
}
pdf.save(filename);
} finally {
window.setTimeout(() => {
document.body.classList.remove('dwbg9fb2-printing');
}, 500);
window.scrollTo(scrollLeft, scrollTop);
if (afterCapture) afterCapture();
}
}
function formatFileDate(date) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
function waitForPaint() {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
}

View File

@@ -129,7 +129,7 @@
<span class="case-text">{{ caseListText(c) }}</span>
<span class="case-arrow">{{ expandedIndex === i ? '' : '>' }}</span>
</div>
<div v-if="expandedIndex === i" class="case-block">
<div v-if="isCaseExpanded(i)" class="case-block">
<div class="case-info">
<div><span>案件类型:</span><span>{{ c.sectionLabel }}</span></div>
<div><span>诉讼地位:</span><span>{{ c.n_ssdw }}</span></div>
@@ -361,11 +361,23 @@ const allCases = computed(() => {
});
const expandedIndex = ref(null);
const expandAllForPrint = ref(false);
function isCaseExpanded(index) {
return expandAllForPrint.value || expandedIndex.value === index;
}
function toggleCase(index) {
if (expandAllForPrint.value) return;
expandedIndex.value = expandedIndex.value === index ? null : index;
}
function setExpandAllForPrint(expand) {
expandAllForPrint.value = expand;
}
defineExpose({ setExpandAllForPrint });
const dishonestList = computed(() =>
extractJudicialList(props.data, [
'dishonest',
@@ -985,4 +997,16 @@ function partiesText(dsrxx) {
overflow-x: auto;
}
}
@media print {
.case-arrow {
display: none;
}
.case-list-group,
.case-block,
.module-card {
break-inside: avoid-page;
}
}
</style>

View File

@@ -1,5 +1,17 @@
<template>
<div class="gamma-report">
<button
v-if="showToolbar"
type="button"
class="export-fab no-print"
:disabled="exporting"
:aria-busy="exporting"
@click="handleExport"
>
<span class="export-fab__icon">{{ exporting ? '…' : '↓' }}</span>
<span class="export-fab__text">{{ exporting ? '生成中' : '下载 PDF' }}</span>
</button>
<div ref="reportRef" class="gamma-container">
<ReportHeaderSection :report-time="reportTime" />
@@ -26,15 +38,15 @@
<OverdueSurveySection :data="root.loanRiskTagV10" />
<CreditPanoramaSection :data="root.loanRiskTagV21" />
<LoanIntentSection :data="root.loanRiskTagV11" />
<JudicialCaseSection :data="root.personalLawsuit" />
<JudicialCaseSection ref="judicialCaseRef" :data="root.personalLawsuit" />
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { parseRoot, extractReportUrl } from './reportHelper';
import { printReportAsPdf } from './reportExport';
import { parseRoot } from './reportHelper';
import { downloadReportAsPdf } from './reportExport';
import RiskAssessmentSection from './components/RiskAssessmentSection.vue';
import RiskSummarySection from './components/RiskSummarySection.vue';
import BasicInfoSection from './components/BasicInfoSection.vue';
@@ -59,13 +71,40 @@ const props = defineProps({
});
const root = computed(() => parseRoot(props.data));
const reportUrl = computed(() => extractReportUrl(props.data));
const reportRef = ref(null);
const judicialCaseRef = ref(null);
const exporting = ref(false);
const reportTime = computed(
() => props.reportDateTime || new Date().toLocaleString('zh-CN'),
);
async function handleExport() {
exporting.value = true;
try {
await downloadReportAsPdf(reportRef.value, {
filename: `${props.apiId}_海宇贷前风险档案_${formatFileDate(new Date())}.pdf`,
beforeCapture: () => judicialCaseRef.value?.setExpandAllForPrint(true),
afterCapture: () => judicialCaseRef.value?.setExpandAllForPrint(false),
});
} finally {
exporting.value = false;
}
}
function formatFileDate(date) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
defineExpose({ reportRef, handleExport });
</script>
<style lang="scss">
@@ -73,58 +112,61 @@ const reportTime = computed(
</style>
<style lang="scss" scoped>
.report-toolbar {
max-width: 1200px;
margin: 0 auto 16px;
padding: 12px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
.export-fab {
position: fixed;
right: 24px;
bottom: 32px;
z-index: 1000;
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: #8b4513;
}
.toolbar-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 8px 18px;
gap: 8px;
padding: 12px 18px;
border: none;
border-radius: 6px;
border-radius: 999px;
background: #8b4513;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 4px 16px rgba(139, 69, 19, 0.35);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(139, 69, 19, 0.45);
}
&--secondary {
background: #fff;
color: #8b4513;
border: 1px solid #d4af37;
&:disabled {
opacity: 0.72;
cursor: wait;
}
}
@media (max-width: 768px) {
.report-toolbar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
.export-fab__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
font-size: 16px;
line-height: 1;
}
.export-fab__text {
white-space: nowrap;
}
@media (max-width: 640px) {
.export-fab {
right: 16px;
bottom: 20px;
padding: 12px 14px;
}
.export-fab__text {
font-size: 13px;
}
}

View File

@@ -1,19 +1,109 @@
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const PAGE_MARGIN_MM = 8;
/**
* 通过浏览器打印对话框导出 PDF目标打印机选「另存为 PDF」
* 零依赖,适合当前纯前端报告查看器;复杂图表场景可改用服务端渲染 PDF。
* 将报告 DOM 渲染为 PDF 文件并触发浏览器下载
*
* @param {HTMLElement} reportElement
* @param {{
* filename?: string,
* beforeCapture?: () => void | Promise<void>,
* afterCapture?: () => void,
* }} [options]
*/
export async function printReportAsPdf(reportElement) {
export async function downloadReportAsPdf(reportElement, options = {}) {
if (!reportElement) return;
document.body.classList.add('dwbg9fb3-printing');
const {
filename = `报告_${formatFileDate(new Date())}.pdf`,
beforeCapture,
afterCapture,
} = options;
await new Promise((resolve) => requestAnimationFrame(resolve));
if (beforeCapture) {
await beforeCapture();
}
const scrollLeft = window.scrollX;
const scrollTop = window.scrollY;
window.scrollTo(0, 0);
await waitForPaint();
try {
window.print();
const canvas = await html2canvas(reportElement, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: -window.scrollY,
windowWidth: document.documentElement.clientWidth,
});
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const contentWidth = A4_WIDTH_MM - PAGE_MARGIN_MM * 2;
const contentHeight = A4_HEIGHT_MM - PAGE_MARGIN_MM * 2;
const imgWidth = contentWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
const imgData = canvas.toDataURL('image/jpeg', 0.92);
let heightLeft = imgHeight;
let position = 0;
pdf.addImage(
imgData,
'JPEG',
PAGE_MARGIN_MM,
PAGE_MARGIN_MM + position,
imgWidth,
imgHeight,
);
heightLeft -= contentHeight;
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(
imgData,
'JPEG',
PAGE_MARGIN_MM,
PAGE_MARGIN_MM + position,
imgWidth,
imgHeight,
);
heightLeft -= contentHeight;
}
pdf.save(filename);
} finally {
window.setTimeout(() => {
document.body.classList.remove('dwbg9fb3-printing');
}, 500);
window.scrollTo(scrollLeft, scrollTop);
if (afterCapture) afterCapture();
}
}
function formatFileDate(date) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
function waitForPaint() {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
}

View File

@@ -12,6 +12,8 @@ export const RISK_LEVEL_DESC = [
export const RISK_SUMMARY_CATEGORIES = [
{ key: 'mobile4Verify', title: '基本信息', icon: '📛' },
{ key: 'personalLawsuit', title: '司法案件', icon: '📑' },
{ key: 'courtRiskTagV31', title: '限高被执行人', icon: '⚖️' },
{ key: 'courtRiskTagV41', title: '失信被执行人', icon: '⚖️' },
{ key: 'loanRiskTagV11', title: '借贷意向', icon: '📈' },
{ key: 'loanRiskTagV10', title: '逾期勘测V3', icon: '📑' },
{ key: 'loanRiskTagV12', title: '借贷行为验证', icon: '📊' },
@@ -35,7 +37,7 @@ export function buildRiskSummaryCards(risks = {}) {
return {
key,
title: meta?.title ?? key,
icon: meta?.icon ?? '⚠️',
icon: meta?.icon ?? '📋',
items: Array.isArray(risks[key]) ? risks[key] : [],
};
});