feat(报告): 报告导出 pdf 轻量版

This commit is contained in:
baiqi 2024-09-06 15:47:25 +08:00 committed by Craftsman
parent 7c8c7c4555
commit c00303e863
3 changed files with 101 additions and 50 deletions

View File

@ -131,6 +131,7 @@ export interface MsTableProps<T> {
// 行选择器禁用配置
rowSelectionDisabledConfig?: MsTableRowSelectionDisabledConfig;
sorter?: Record<string, any>; // 排序
hoverable?: boolean; // 是否展示hover效果
[key: string]: any;
}

View File

@ -7,10 +7,12 @@ const A4_HEIGHT = 842;
const HEADER_HEIGHT = 16;
const FOOTER_HEIGHT = 16;
const PAGE_HEIGHT = A4_HEIGHT - FOOTER_HEIGHT - HEADER_HEIGHT;
const pdfWidth = A4_WIDTH - 32; // 左右分别 16px 间距
const totalWidth = 1370;
const realPageHeight = Math.ceil(PAGE_HEIGHT * (totalWidth / pdfWidth) * 1.2); // 实际每页高度
const MAX_CANVAS_HEIGHT = realPageHeight * 23; // 一次截图最大高度是 25 页整
const PDF_WIDTH = A4_WIDTH - 32; // 左右分别 16px 间距
const CONTAINER_WIDTH = 1190;
export const SCALE_RATIO = 1.2;
// 实际每页高度 = PDF页面高度/页面容器宽度与 pdf 宽度的比例(这里比例*SCALE_RATIO 是因为html2canvas截图时生成的是 SCALE_RATIO 倍的清晰度)
export const IMAGE_HEIGHT = Math.ceil(PAGE_HEIGHT * (CONTAINER_WIDTH / PDF_WIDTH) * SCALE_RATIO);
export const MAX_CANVAS_HEIGHT = IMAGE_HEIGHT * 25; // 一次截图最大高度是 20 页整(过长会无法截完整,出现空白)
/**
* svg为base64
@ -72,7 +74,6 @@ async function replaceSvgWithBase64(container: HTMLElement) {
* 使html2canvas截图时MAX_CANVAS_HEIGHT截图高度 pdf pdf
*/
export default async function exportPDF(name: string, contentId: string) {
console.log('start', new Date().getMinutes(), new Date().getSeconds());
const element = document.getElementById(contentId);
if (element) {
await replaceSvgWithBase64(element);
@ -86,52 +87,55 @@ export default async function exportPDF(name: string, contentId: string) {
pdf.setFontSize(10);
// 计算pdf总页数
let totalPages = 0;
let position = 0;
let position = 0; // 当前截图位置
let pageIndex = 1;
let loopTimes = 0;
const canvasList: HTMLCanvasElement[] = [];
const screenshotList: HTMLCanvasElement[] = [];
// 创建图片裁剪画布
const cropCanvas = document.createElement('canvas');
cropCanvas.width = CONTAINER_WIDTH * SCALE_RATIO; // 因为截图时放大了 SCALE_RATIO 倍,所以这里也要放大
cropCanvas.height = IMAGE_HEIGHT;
const tempContext = cropCanvas.getContext('2d', { willReadFrequently: true });
// 这里是大的分页,也就是截图画布的分页
while (position < totalHeight) {
// 这里是大的分页,也就是截图画布的分页
const offscreenHeight = Math.min(MAX_CANVAS_HEIGHT, totalHeight - position);
// 截图高度
const screenshotHeight = Math.min(MAX_CANVAS_HEIGHT, totalHeight - position);
// eslint-disable-next-line no-await-in-loop
const canvas = await html2canvas(element, {
x: 0,
y: position,
width: totalWidth,
height: offscreenHeight,
width: CONTAINER_WIDTH,
height: screenshotHeight,
backgroundColor: '#f9f9fe',
scale: window.devicePixelRatio * 1.2, // 增加清晰度
scale: window.devicePixelRatio * SCALE_RATIO, // 缩放增加清晰度
});
canvasList.push(canvas);
position += offscreenHeight;
totalPages += Math.ceil(canvas.height / realPageHeight);
screenshotList.push(canvas);
position += screenshotHeight;
totalPages += Math.ceil(canvas.height / IMAGE_HEIGHT);
loopTimes++;
}
totalPages -= loopTimes - 1; // 减去多余的页数
// 生成 PDF
canvasList.forEach((canvas) => {
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const pages = Math.ceil(canvasHeight / realPageHeight);
screenshotList.forEach((_canvas) => {
const canvasWidth = _canvas.width;
const canvasHeight = _canvas.height;
const pages = Math.ceil(canvasHeight / IMAGE_HEIGHT);
for (let i = 1; i <= pages; i++) {
// 这里是小的分页,是 pdf 的每一页
const pagePosition = (i - 1) * realPageHeight;
// 创建临时画布
const tempCanvas = document.createElement('canvas');
tempCanvas.width = canvasWidth;
tempCanvas.height = realPageHeight;
const tempContext = tempCanvas.getContext('2d', { willReadFrequently: true });
const pagePosition = (i - 1) * IMAGE_HEIGHT;
if (tempContext) {
// 填充背景颜色为白色
tempContext.fillStyle = '#ffffff';
tempContext.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
if (pageIndex === totalPages) {
// 填充背景颜色为白色
tempContext.fillStyle = '#ffffff';
tempContext.fillRect(0, 0, cropCanvas.width, cropCanvas.height);
}
// 将大分页的画布图片裁剪成pdf 页面内容大小,并渲染到临时画布上
tempContext.drawImage(canvas, 0, -pagePosition, canvasWidth, canvasHeight);
const tempCanvasData = tempCanvas.toDataURL('image/jpeg', 0.8);
tempContext.drawImage(_canvas, 0, -pagePosition, canvasWidth, canvasHeight);
const tempCanvasData = cropCanvas.toDataURL('image/jpeg', 0.8);
// 将临时画布图片渲染到 pdf 上
pdf.addImage(tempCanvasData, 'jpeg', 16, 16, pdfWidth, PAGE_HEIGHT);
pdf.addImage(tempCanvasData, 'jpeg', 16, 16, PDF_WIDTH, PAGE_HEIGHT);
}
tempCanvas.remove();
cropCanvas.remove();
pdf.text(
`${pageIndex} / ${totalPages}`,
pdf.internal.pageSize.width / 2 - 10,
@ -142,9 +146,8 @@ export default async function exportPDF(name: string, contentId: string) {
pageIndex++;
}
}
canvas.remove();
_canvas.remove();
});
pdf.save(`${name}.pdf`);
console.log('end', new Date().getMinutes(), new Date().getSeconds());
}
}

View File

@ -1,6 +1,6 @@
<template>
<a-spin :loading="loading" class="block">
<div id="report-detail" class="p-[16px]">
<a-spin :loading="loading" class="report-detail-container">
<div id="report-detail" class="report-detail">
<div class="report-header">
<div class="flex-1 break-all">{{ detail.name }}</div>
<div class="one-line-text">
@ -91,7 +91,13 @@
</div>
<div class="mt-[16px]">
<div v-for="item of innerCardList" v-show="showItem(item)" :key="item.id" class="card-item mt-[16px]">
<div
v-for="item of innerCardList"
v-show="showItem(item)"
:key="item.id"
class="card-item mt-[16px]"
:class="`${item.value}`"
>
<div class="wrapper-preview-card">
<div class="flex items-center justify-between">
<div v-if="item.value !== ReportCardTypeEnum.CUSTOM_CARD" class="mb-[8px] font-medium">
@ -107,14 +113,16 @@
/>
<div v-else-if="item.value === ReportCardTypeEnum.SUMMARY" v-html="getContent(item).content"></div>
<MsBaseTable v-else-if="item.value === ReportCardTypeEnum.BUG_DETAIL" v-bind="bugTableProps"> </MsBaseTable>
<MsBaseTable v-else-if="item.value === ReportCardTypeEnum.FUNCTIONAL_DETAIL" v-bind="caseTableProps">
<template #caseLevel="{ record }">
<CaseLevel :case-level="record.priority" />
</template>
<template #lastExecResult="{ record }">
<ExecuteResult :execute-result="record.executeResult" />
</template>
</MsBaseTable>
<div v-else-if="item.value === ReportCardTypeEnum.FUNCTIONAL_DETAIL" id="functionalCase">
<MsBaseTable v-bind="caseTableProps">
<template #caseLevel="{ record }">
<CaseLevel :case-level="record.priority" />
</template>
<template #lastExecResult="{ record }">
<ExecuteResult :execute-result="record.executeResult" />
</template>
</MsBaseTable>
</div>
<MsBaseTable
v-else-if="item.value === ReportCardTypeEnum.API_CASE_DETAIL"
v-bind="useApiTable.propsRes.value"
@ -184,6 +192,7 @@
} from '@/config/testPlan';
import { useI18n } from '@/hooks/useI18n';
import { addCommasToNumber } from '@/utils';
import exportPdf, { MAX_CANVAS_HEIGHT, SCALE_RATIO } from '@/utils/exportPdf';
import type {
configItem,
@ -198,7 +207,7 @@
import { defaultGroupConfig, defaultSingleConfig } from './component/reportConfig';
import { getSummaryDetail } from '@/views/test-plan/report/utils';
import exportPdf from '@/workers/exportPDF/exportPDFWorker';
import html2canvas from 'html2canvas-pro';
const { t } = useI18n();
const route = useRoute();
@ -496,6 +505,7 @@
scroll: { x: '100%', y: 'auto' },
columns: bugColumns,
showSelectorAll: false,
hoverable: false,
});
/** 用例明细 */
@ -565,11 +575,13 @@
propsRes: caseTableProps,
loadList: loadCaseList,
setLoadListParams: setLoadCaseListParams,
setPagination: setCasePagination,
} = useTable(reportFeatureCaseList(), {
scroll: { x: '100%', y: 'auto' },
columns: caseColumns.value,
heightUsed: 20,
showSelectorAll: false,
hoverable: false,
});
/** 接口/场景明细 */
@ -609,12 +621,14 @@
columns: apiColumns.value,
showSelectorAll: false,
showSetting: false,
hoverable: false,
});
const useScenarioTable = useTable(getScenarioPage, {
scroll: { x: '100%', y: 'auto' },
columns: apiColumns.value,
showSelectorAll: false,
showSetting: false,
hoverable: false,
});
async function getDetail() {
@ -632,7 +646,7 @@
innerCardList.value = isGroup.value ? cloneDeep(defaultGroupConfig) : cloneDeep(defaultSingleConfig);
}
setLoadBugListParams({ reportId: reportId.value, shareId: shareId.value ?? undefined, pageSize: 500 });
setLoadCaseListParams({ reportId: reportId.value, shareId: shareId.value ?? undefined, startPager: false });
setLoadCaseListParams({ reportId: reportId.value, shareId: shareId.value ?? undefined, pageSize: 500 });
useApiTable.setLoadListParams({
reportId: reportId.value,
shareId: shareId.value ?? undefined,
@ -645,9 +659,29 @@
});
await Promise.all([loadBugList(), loadCaseList(), useApiTable.loadList(), useScenarioTable.loadList()]);
setTimeout(() => {
nextTick(() => {
exportPdf(detail.value.name, 'report-detail');
});
exportPdf(detail.value.name, 'report-detail');
// nextTick(async () => {
// const element = document.getElementById('functionalCase');
// if (element) {
// while (caseTableProps.value.msPagination!.current * 500 < caseTableProps.value.msPagination!.total) {
// console.log('start html2canvas', new Date().getMinutes(), new Date().getSeconds());
// // eslint-disable-next-line no-await-in-loop
// const canvas = await html2canvas(element, {
// x: 0,
// y: 848,
// width: 1190,
// height: MAX_CANVAS_HEIGHT,
// backgroundColor: '#f9f9fe',
// scale: window.devicePixelRatio * SCALE_RATIO, //
// });
// console.log('end html2canvas', new Date().getMinutes(), new Date().getSeconds());
// exportPdf(detail.value.name, 'report-detail', canvas);
// setCasePagination({ current: caseTableProps.value.msPagination!.current + 1 });
// // eslint-disable-next-line no-await-in-loop
// await loadCaseList();
// }
// }
// });
}, 0);
} catch (error) {
// eslint-disable-next-line no-console
@ -663,6 +697,16 @@
</script>
<style lang="less" scoped>
.report-detail-container {
@apply flex justify-center;
.report-detail {
@apply overflow-x-auto;
padding: 16px;
width: 1190px;
.ms-scroll-bar();
}
}
.report-header {
@apply mb-4 flex items-center bg-white;
@ -751,4 +795,7 @@
:deep(.arco-table-body) {
max-height: 100% !important;
}
:deep(#ms-table-footer-wrapper) {
@apply hidden;
}
</style>