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; rowSelectionDisabledConfig?: MsTableRowSelectionDisabledConfig;
sorter?: Record<string, any>; // 排序 sorter?: Record<string, any>; // 排序
hoverable?: boolean; // 是否展示hover效果
[key: string]: any; [key: string]: any;
} }

View File

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

View File

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