feat(报告): 报告导出 pdf 轻量版
This commit is contained in:
parent
7c8c7c4555
commit
c00303e863
|
@ -131,6 +131,7 @@ export interface MsTableProps<T> {
|
|||
// 行选择器禁用配置
|
||||
rowSelectionDisabledConfig?: MsTableRowSelectionDisabledConfig;
|
||||
sorter?: Record<string, any>; // 排序
|
||||
hoverable?: boolean; // 是否展示hover效果
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue