feat(报告): 测试计划批量导出 PDF 报告

This commit is contained in:
baiqi 2024-09-13 17:17:08 +08:00 committed by Craftsman
parent 69024bbe4c
commit 3f8c1da5e1
11 changed files with 330 additions and 198 deletions

View File

@ -182,3 +182,8 @@ export function logTestPlanReportExport(reportId: string) {
export function logTestPlanReportBatchExport(data: BatchApiParams) {
return MSR.post({ url: reportUrl.TestPlanBatchReportExportUrl, data });
}
// 批量导出报告日志
export function testPlanBatchReportExportGetIds(data: BatchApiParams) {
return MSR.post<string[]>({ url: reportUrl.TestPlanBatchReportExportGetIdsUrl, data });
}

View File

@ -72,3 +72,5 @@ export const getReportShareLayoutUrl = '/test-plan/report/share/get-layout';
export const TestPlanReportExportUrl = '/test-plan/report/export';
// 测试计划-报告-批量导出日志
export const TestPlanBatchReportExportUrl = '/test-plan/report/batch-export';
// 测试计划-报告-批量导出获取报告 ID 集合
export const TestPlanBatchReportExportGetIdsUrl = '/test-plan/report/batch-param';

View File

@ -18,19 +18,7 @@ export const PAGE_PDF_WIDTH_RATIO = CONTAINER_WIDTH / PDF_WIDTH; // 页面容器
// 实际每页高度 = PDF页面高度/页面容器宽度与 pdf 宽度的比例(这里比例*SCALE_RATIO 是因为html2canvas截图时生成的是 SCALE_RATIO 倍的清晰度)
export const IMAGE_HEIGHT = Math.ceil(PAGE_HEIGHT * PAGE_PDF_WIDTH_RATIO * SCALE_RATIO);
const commonOdfTableConfig: Partial<UserOptions> = {
headStyles: {
fillColor: '#793787',
},
styles: {
font: 'AlibabaPuHuiTi-3-55-Regular',
},
rowPageBreak: 'avoid',
margin: { top: 16, left: 16, right: 16, bottom: 16 },
tableWidth: PDF_WIDTH,
};
export type PdfTableConfig = Pick<UserOptions, 'columnStyles' | 'columns' | 'body'>;
export type PdfTableConfig = Pick<UserOptions, 'tableId' | 'columnStyles' | 'columns' | 'body'>;
/**
* PDF
@ -43,6 +31,7 @@ export default async function exportPDF(
name: string,
contentId: string,
autoTableConfig: PdfTableConfig[],
tableTitleMap?: Record<string, string>,
doneCallback?: () => void
) {
const element = document.getElementById(contentId);
@ -95,6 +84,26 @@ export default async function exportPDF(
}
const lastImagePageUseHeight =
(canvasHeight > IMAGE_HEIGHT ? canvasHeight - IMAGE_HEIGHT : canvasHeight) / PAGE_PDF_WIDTH_RATIO / SCALE_RATIO; // 最后一页带图片的pdf页面被图片占用的高度
const commonOdfTableConfig: Partial<UserOptions> = {
headStyles: {
fillColor: '#793787',
},
styles: {
font: 'AlibabaPuHuiTi-3-55-Regular',
},
rowPageBreak: 'avoid',
margin: { top: 16, left: 16, right: 16, bottom: 16 },
tableWidth: PDF_WIDTH,
willDrawPage: (data) => {
const title = tableTitleMap?.[data.table.id as string];
if (title && data.cursor) {
pdf.text(title, 16, data.cursor.y + 4);
// 在文字后加入 8px 高的空白
data.cursor.y += 12;
}
},
};
autoTableConfig.forEach((config, index) => {
autoTable(pdf, {
...config,

View File

@ -48,8 +48,40 @@ export default function useOpenNewPage() {
);
}
function openNewPageWithParams(
name: RouteRecordName | undefined,
query: Record<string, any> = {},
params: Record<string, any> = {}
) {
const pId = query.pId || appStore.currentProjectId;
if (pId) {
// 如果传入参数指定了项目 id则使用传入的项目 id
delete query.pId;
}
const orgId = query.orgId || appStore.currentOrgId;
if (orgId) {
// 如果传入参数指定了组织 id则使用传入的组织 id
delete query.orgId;
}
const queryParams = new URLSearchParams(query).toString();
const newTab = window.open(
`${window.location.origin}#${router.resolve({ name }).fullPath}?orgId=${orgId}&pId=${pId}&${queryParams}`,
'_blank'
);
// 等待新标签页加载完成后发送消息
if (newTab) {
newTab.onload = () => {
setTimeout(() => {
newTab.postMessage(JSON.stringify(params), window.location.origin);
}, 300);
};
}
}
return {
openNewPage,
openNewPageWidthSingleParam,
openNewPageWithParams,
};
}

View File

@ -1,5 +1,7 @@
import { TreeNodeData } from '@arco-design/web-vue';
import type { FilterResult } from '@/components/pure/ms-advance-filter/type';
import { RequestMethods } from '@/enums/apiEnum';
// 请求返回结构
@ -51,6 +53,7 @@ export interface BatchApiParams {
versionId?: string; // 版本 ID
refId?: string; // 版本来源
protocols?: string[]; // 协议集合
combineSearch?: FilterResult;
}
// 移动模块树

View File

@ -153,12 +153,12 @@ export function desensitize(str: string): string {
* @param str
* @returns
*/
export function characterLimit(str?: string): string {
export function characterLimit(str?: string, length?: number): string {
if (!str) return '';
if (str.length <= 20) {
if (str.length <= (length || 20)) {
return str;
}
return `${str.slice(0, 20 - 3)}...`;
return `${str.slice(0, length || 20 - 3)}...`;
}
/**

View File

@ -271,7 +271,7 @@
dataIndex: 'operation',
fixed: 'right',
title: hasAnyPermission(['PROJECT_API_REPORT:READ+DELETE']) ? 'common.operation' : '',
width: hasAnyPermission(['PROJECT_API_REPORT:READ+DELETE']) ? 130 : 50,
width: hasAnyPermission(['PROJECT_API_REPORT:READ+DELETE']) ? 100 : 50,
},
];

View File

@ -135,7 +135,7 @@
const keyword = ref<string>('');
const router = useRouter();
const route = useRoute();
const { openNewPage } = useOpenNewPage();
const { openNewPage, openNewPageWithParams } = useOpenNewPage();
type ReportShowType = 'All' | 'INDEPENDENT' | 'INTEGRATED';
const showType = ref<ReportShowType>('All');
@ -340,6 +340,11 @@
eventTag: 'batchStop',
permission: ['PROJECT_TEST_PLAN_REPORT:READ+DELETE'],
},
{
label: 'common.export',
eventTag: 'batchExport',
permission: ['PROJECT_TEST_PLAN_REPORT:READ+EXPORT'],
},
],
};
@ -362,30 +367,40 @@
projectId: appStore.currentProjectId,
};
openModal({
type: 'error',
title: t('report.delete.tip', {
count: params?.currentSelectCount || params?.selectedIds?.length,
}),
content: '',
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
onBeforeOk: async () => {
try {
await reportBathDelete(batchParams.value);
Message.success(t('apiTestDebug.deleteSuccess'));
resetSelector();
initData();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
if (event.eventTag === 'batchExport') {
openNewPageWithParams(
FullPageEnum.FULL_PAGE_TEST_PLAN_EXPORT_PDF,
{
type: showType.value,
},
batchParams.value
);
} else if (event.eventTag === 'batchStop') {
openModal({
type: 'error',
title: t('report.delete.tip', {
count: params?.currentSelectCount || params?.selectedIds?.length,
}),
content: '',
okText: t('common.confirmDelete'),
cancelText: t('common.cancel'),
okButtonProps: {
status: 'danger',
},
onBeforeOk: async () => {
try {
await reportBathDelete(batchParams.value);
Message.success(t('apiTestDebug.deleteSuccess'));
resetSelector();
initData();
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
},
hideCancel: false,
});
}
};
function searchList() {

View File

@ -1,5 +1,9 @@
<template>
<a-spin :loading="loading" :tip="t('report.detail.exportingPdf')" class="report-detail-container">
<a-spin
:loading="batchLoading || loading"
:tip="batchLoading ? batchExportTip : t('report.detail.exportingPdf')"
class="report-detail-container"
>
<div id="report-detail" class="report-detail">
<div class="report-header">
<div class="flex-1 break-all">{{ detail.name }}</div>
@ -139,7 +143,9 @@
getReportShareBugList,
getReportShareFeatureCaseList,
getScenarioPage,
logTestPlanReportBatchExport,
logTestPlanReportExport,
testPlanBatchReportExportGetIds,
} from '@/api/modules/test-plan/report';
import {
commonConfig,
@ -151,8 +157,9 @@
} from '@/config/testPlan';
import exportPDF, { PAGE_PDF_WIDTH_RATIO, PdfTableConfig } from '@/hooks/useExportPDF';
import { useI18n } from '@/hooks/useI18n';
import { addCommasToNumber } from '@/utils';
import { addCommasToNumber, characterLimit } from '@/utils';
import { BatchApiParams } from '@/models/common';
import type {
configItem,
countDetail,
@ -175,13 +182,6 @@
const reportId = ref<string>(route.query.id as string);
const isGroup = computed(() => route.query.type === 'GROUP');
const loading = ref<boolean>(true);
const richText = ref<{ summary: string; richTextTmpFileIds?: string[] }>({
summary: '',
});
const reportForm = ref({
reportName: '',
});
/**
* 分享share
@ -620,149 +620,165 @@
return status && iconTypeStatus[status] ? iconTypeStatus[status] : iconTypeStatus.DEFAULT;
}
async function getDetail() {
async function realExportPdf(name: string) {
const tableArr: PdfTableConfig[] = [];
if (!isDefaultLayout.value) {
await getDefaultLayout();
} else {
innerCardList.value = (isGroup.value ? cloneDeep(defaultGroupConfig) : cloneDeep(defaultSingleConfig)).filter(
(e: any) => [ReportCardTypeEnum.CUSTOM_CARD, ReportCardTypeEnum.SUMMARY].includes(e.value)
);
}
if (isGroup.value) {
await initGroupList();
tableArr.push({
tableId: 'group',
columnStyles: {
testPlanName: { cellWidth: 710 / PAGE_PDF_WIDTH_RATIO },
resultStatus: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
passThreshold: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
passRate: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
caseTotal: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: groupColumns.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullGroupList.value.map((e) => ({
...e,
resultStatus: t(getExecutionResult(e.resultStatus).label),
passRate: `${e.passRate}%`,
passThreshold: `${e.passThreshold}%`,
})) as RowInput[],
});
}
await Promise.all([initBugList(), initCaseList(), initApiList(), initScenarioList()]);
if (fullBugList.value.length > 0) {
tableArr.push({
tableId: 'bug',
columnStyles: {
num: { cellWidth: 120 / PAGE_PDF_WIDTH_RATIO },
title: { cellWidth: 600 / PAGE_PDF_WIDTH_RATIO },
status: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
handleUserName: { cellWidth: 270 / PAGE_PDF_WIDTH_RATIO },
relationCaseCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: bugColumns.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullBugList.value,
});
}
if (fullCaseList.value.length > 0) {
const columnStyles: Record<string, any> = {
num: { cellWidth: 100 / PAGE_PDF_WIDTH_RATIO },
name: { cellWidth: 240 / PAGE_PDF_WIDTH_RATIO },
executeResult: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
testPlanName: { cellWidth: 240 / PAGE_PDF_WIDTH_RATIO },
priority: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
moduleName: { cellWidth: 200 / PAGE_PDF_WIDTH_RATIO },
executeUser: { cellWidth: 100 / PAGE_PDF_WIDTH_RATIO },
relationCaseCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
};
if (!isGroup.value) {
delete columnStyles.testPlanName;
columnStyles.name.cellWidth = 480 / PAGE_PDF_WIDTH_RATIO;
}
tableArr.push({
tableId: 'case',
columnStyles,
columns: caseColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullCaseList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
const apiColumnStyles: Record<string, any> = {
num: { cellWidth: 150 / PAGE_PDF_WIDTH_RATIO },
name: { cellWidth: 240 / PAGE_PDF_WIDTH_RATIO },
executeResult: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
testPlanName: { cellWidth: 230 / PAGE_PDF_WIDTH_RATIO },
priority: { cellWidth: 80 / PAGE_PDF_WIDTH_RATIO },
moduleName: { cellWidth: 170 / PAGE_PDF_WIDTH_RATIO },
executeUser: { cellWidth: 120 / PAGE_PDF_WIDTH_RATIO },
bugCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
};
if (!isGroup.value) {
delete apiColumnStyles.testPlanName;
apiColumnStyles.name.cellWidth = 480 / PAGE_PDF_WIDTH_RATIO;
}
if (fullApiList.value.length > 0) {
tableArr.push({
tableId: 'apiCase',
columnStyles: apiColumnStyles,
columns: apiColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullApiList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
if (fullScenarioList.value.length > 0) {
tableArr.push({
tableId: 'scenario',
columnStyles: apiColumnStyles,
columns: apiColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullScenarioList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
await nextTick(async () => {
exportPDF(
name,
'report-detail',
tableArr,
{
group: t('report.detail.subPlanDetails'),
bug: t('report.detail.bugDetails'),
case: t('report.detail.featureCaseDetails'),
apiCase: t('report.detail.apiCaseDetails'),
scenario: t('report.detail.scenarioCaseDetails'),
},
() => {
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess', { name: characterLimit(name, 50) }));
}
);
});
}
async function getDetail(_reportId?: string) {
try {
loading.value = true;
detail.value = await getReportDetail(reportId.value);
const { defaultLayout, id, name, summary } = detail.value;
isDefaultLayout.value = defaultLayout;
richText.value.summary = summary;
reportForm.value.reportName = name;
initOptionsData();
if (!defaultLayout && id) {
await getDefaultLayout();
await initGroupList();
nextTick(async () => {
exportPDF(
name,
'report-detail',
[
{
columnStyles: {
testPlanName: { cellWidth: 710 / PAGE_PDF_WIDTH_RATIO },
resultStatus: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
passThreshold: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
passRate: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
caseTotal: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: groupColumns.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullGroupList.value.map((e) => ({
...e,
resultStatus: t(getExecutionResult(e.resultStatus).label),
passRate: `${e.passRate}%`,
passThreshold: `${e.passThreshold}%`,
})) as RowInput[],
},
],
() => {
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess'));
}
);
});
} else {
innerCardList.value = (isGroup.value ? cloneDeep(defaultGroupConfig) : cloneDeep(defaultSingleConfig)).filter(
(e: any) => [ReportCardTypeEnum.CUSTOM_CARD, ReportCardTypeEnum.SUMMARY].includes(e.value)
);
await Promise.all([initBugList(), initCaseList(), initApiList(), initScenarioList()]);
const tableArr: PdfTableConfig[] = [];
if (fullBugList.value.length > 0) {
tableArr.push({
columnStyles: {
num: { cellWidth: 120 / PAGE_PDF_WIDTH_RATIO },
title: { cellWidth: 600 / PAGE_PDF_WIDTH_RATIO },
status: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
handleUserName: { cellWidth: 270 / PAGE_PDF_WIDTH_RATIO },
relationCaseCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: bugColumns.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullBugList.value,
});
}
if (fullCaseList.value.length > 0) {
tableArr.push({
columnStyles: {
num: { cellWidth: 100 / PAGE_PDF_WIDTH_RATIO },
name: { cellWidth: 480 / PAGE_PDF_WIDTH_RATIO },
executeResult: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
priority: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
moduleName: { cellWidth: 200 / PAGE_PDF_WIDTH_RATIO },
executeUser: { cellWidth: 100 / PAGE_PDF_WIDTH_RATIO },
relationCaseCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: caseColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullCaseList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
if (fullApiList.value.length > 0) {
tableArr.push({
columnStyles: {
num: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
name: { cellWidth: 450 / PAGE_PDF_WIDTH_RATIO },
executeResult: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
priority: { cellWidth: 80 / PAGE_PDF_WIDTH_RATIO },
moduleName: { cellWidth: 200 / PAGE_PDF_WIDTH_RATIO },
executeUser: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
bugCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: apiColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullApiList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
if (apiColumns.value.length > 0) {
tableArr.push({
columnStyles: {
num: { cellWidth: 100 / PAGE_PDF_WIDTH_RATIO },
name: { cellWidth: 480 / PAGE_PDF_WIDTH_RATIO },
executeResult: { cellWidth: 110 / PAGE_PDF_WIDTH_RATIO },
priority: { cellWidth: 80 / PAGE_PDF_WIDTH_RATIO },
moduleName: { cellWidth: 200 / PAGE_PDF_WIDTH_RATIO },
executeUser: { cellWidth: 130 / PAGE_PDF_WIDTH_RATIO },
bugCount: { cellWidth: 90 / PAGE_PDF_WIDTH_RATIO },
},
columns: apiColumns.value.map((item) => ({
...item,
title: t(item.title as string),
dataKey: item.dataIndex,
})) as ColumnInput[],
body: fullScenarioList.value.map((e: any) => ({
...e,
executeResult: t(lastExecuteResultMap[e.executeResult]?.statusText || '-'),
executeUser: e.executeUser?.name || '-',
})) as RowInput[],
});
}
nextTick(async () => {
exportPDF(name, 'report-detail', tableArr, () => {
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess'));
});
});
if (_reportId) {
reportId.value = _reportId;
}
detail.value = await getReportDetail(reportId.value);
const { defaultLayout, name } = detail.value;
isDefaultLayout.value = defaultLayout;
initOptionsData();
await realExportPdf(name);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
@ -770,13 +786,59 @@
}
}
async function logExport() {
await logTestPlanReportExport(route.query.id as string);
async function logExport(params?: BatchApiParams) {
if (params) {
await logTestPlanReportBatchExport(params);
} else {
await logTestPlanReportExport(route.query.id as string);
}
}
const batchLoading = ref<boolean>(false);
const batchIds = ref<string[]>([]);
const exportCurrent = ref<number>(0);
const exportTotal = ref<number>(0);
const batchExportTip = computed(() =>
t('report.detail.batchExportingPdf', { current: exportCurrent.value, total: exportTotal.value })
);
async function initBatchIds(params: BatchApiParams) {
try {
batchLoading.value = true;
batchIds.value = await testPlanBatchReportExportGetIds(params);
exportTotal.value = batchIds.value.length;
while (batchIds.value.length > 0) {
exportCurrent.value += 1;
// eslint-disable-next-line no-await-in-loop
await getDetail(batchIds.value.shift());
}
nextTick(() => {
//
Message.clear();
Message.success(t('report.detail.batchExportPdfSuccess'));
});
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
batchLoading.value = false;
}
}
onBeforeMount(() => {
getDetail();
logExport();
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) {
return;
}
// id
const batchParams = event.data;
initBatchIds(batchParams);
logExport(batchParams);
});
if (reportId.value) {
getDetail();
logExport();
}
});
</script>

View File

@ -59,5 +59,7 @@ export default {
'report.detail.manualGenReportSuccess': 'The custom generated report was successful',
'report.detail.exportPdf': 'Export PDF',
'report.detail.exportingPdf': 'Exporting PDF report...',
'report.detail.exportPdfSuccess': 'PDF report exported successfully',
'report.detail.exportPdfSuccess': '{name} report exported successfully',
'report.detail.batchExportPdfSuccess': 'Batch export of PDF reports completed',
'report.detail.batchExportingPdf': 'Export progress: {current}/{total}',
};

View File

@ -58,6 +58,8 @@ export default {
'report.detail.reportNameNotEmpty': '报告名称不能为空',
'report.detail.manualGenReportSuccess': '自定义生成报告成功',
'report.detail.exportPdf': '导出 PDF',
'report.detail.exportingPdf': 'PDF报告导出中...',
'report.detail.exportPdfSuccess': 'PDF报告导出成功',
'report.detail.exportingPdf': 'PDF 报告导出中...',
'report.detail.exportPdfSuccess': '{name} 报告导出成功',
'report.detail.batchExportPdfSuccess': '批量导出 PDF 报告已完成',
'report.detail.batchExportingPdf': '导出进度:{current}/{total}',
};