feat(报告): 场景报告导出 pdf

This commit is contained in:
baiqi 2024-09-11 15:09:43 +08:00 committed by Craftsman
parent e2429d0c94
commit 76c33c015e
15 changed files with 621 additions and 190 deletions

View File

@ -33,7 +33,7 @@
<slot name="tbutton"></slot>
</div>
</slot>
<div class="right-operation-button-icon">
<div class="ms-drawer-right-operation-button">
<MsButton
v-if="props.showFullScreen"
type="icon"
@ -286,17 +286,21 @@
@apply w-full;
line-height: 24px;
.right-operation-button-icon .ms-button-icon {
border-radius: var(--border-radius-small);
color: var(--color-text-1);
.arco-icon {
margin-right: 8px;
.ms-drawer-right-operation-button {
.ms-button-icon,
.ms-drawer-fullscreen-btn {
border-radius: var(--border-radius-small);
color: var(--color-text-1);
}
&:hover {
color: rgb(var(--primary-5));
.arco-icon {
.arco-icon,
.ms-drawer-fullscreen-btn-icon {
margin-right: 8px;
color: var(--color-text-1);
}
&:hover {
color: rgb(var(--primary-5));
.arco-icon {
color: rgb(var(--primary-5));
}
}
}
}

View File

@ -115,6 +115,8 @@ export enum ShareEnum {
export enum FullPageEnum {
FULL_PAGE = 'fullPage',
FULL_PAGE_TEST_PLAN_EXPORT_PDF = 'fullPageTestPlanExportPDF',
FULL_PAGE_SCENARIO_EXPORT_PDF = 'fullPageScenarioExportPDF',
FULL_PAGE_CASE_EXPORT_PDF = 'fullPageCaseExportPDF',
}
export const RouteEnum = {

View File

@ -1,62 +1,11 @@
import '@/assets/fonts/AlibabaPuHuiTi-3-55-Regular-normal';
import { Canvg } from 'canvg';
import { replaceSvgWithBase64 } from '@/utils/exportPdf';
import html2canvas from 'html2canvas-pro';
import JSPDF from 'jspdf';
import autoTable, { UserOptions } from 'jspdf-autotable';
/**
* svg为base64
*/
async function inlineSvgUseElements(container: HTMLElement) {
const useElements = container.querySelectorAll('use');
useElements.forEach((useElement) => {
const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href');
if (href) {
const symbolId = href.substring(1);
const symbol = document.getElementById(symbolId);
if (symbol) {
const svgElement = useElement.closest('svg');
if (svgElement) {
svgElement.innerHTML = symbol.innerHTML;
}
}
}
});
}
/**
* svg转换为base64
*/
async function convertSvgToBase64(svgElement: SVGSVGElement) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const svgString = new XMLSerializer().serializeToString(svgElement);
if (ctx) {
const v = Canvg.fromString(ctx, svgString);
canvas.width = svgElement.clientWidth;
canvas.height = svgElement.clientHeight;
await v.render();
}
return canvas.toDataURL('image/png');
}
/**
* svg为base64
*/
async function replaceSvgWithBase64(container: HTMLElement) {
await inlineSvgUseElements(container);
const svgElements = container.querySelectorAll('.c-icon');
svgElements.forEach(async (svgElement) => {
const img = new Image();
img.src = await convertSvgToBase64(svgElement as SVGSVGElement);
img.width = svgElement.clientWidth;
img.height = svgElement.clientHeight;
img.style.marginRight = '8px';
svgElement.parentNode?.replaceChild(img, svgElement);
});
}
const A4_WIDTH = 595;
const A4_HEIGHT = 842;
const HEADER_HEIGHT = 16;
@ -64,7 +13,7 @@ const FOOTER_HEIGHT = 16;
export const PAGE_HEIGHT = A4_HEIGHT - FOOTER_HEIGHT - HEADER_HEIGHT;
export const PDF_WIDTH = A4_WIDTH - 32; // 左右分别 16px 间距
export const CONTAINER_WIDTH = 1190;
export const SCALE_RATIO = 1.5;
export const SCALE_RATIO = window.devicePixelRatio * 1.5;
export const PAGE_PDF_WIDTH_RATIO = CONTAINER_WIDTH / PDF_WIDTH; // 页面容器宽度与 pdf 宽度的比例
// 实际每页高度 = PDF页面高度/页面容器宽度与 pdf 宽度的比例(这里比例*SCALE_RATIO 是因为html2canvas截图时生成的是 SCALE_RATIO 倍的清晰度)
export const IMAGE_HEIGHT = Math.ceil(PAGE_HEIGHT * PAGE_PDF_WIDTH_RATIO * SCALE_RATIO);
@ -110,7 +59,7 @@ export default async function exportPDF(
width: CONTAINER_WIDTH,
height: element.clientHeight,
backgroundColor: '#f9f9fe',
scale: window.devicePixelRatio * SCALE_RATIO, // 缩放增加清晰度
scale: SCALE_RATIO, // 缩放增加清晰度
});
pdf.setFont('AlibabaPuHuiTi-3-55-Regular');
pdf.setFontSize(10);

View File

@ -14,6 +14,11 @@ const FullPage: AppRouteRecordRaw = {
name: FullPageEnum.FULL_PAGE_TEST_PLAN_EXPORT_PDF,
component: () => import('@/views/test-plan/report/detail/exportPDF.vue'),
},
{
path: 'scenarioExportPDF',
name: FullPageEnum.FULL_PAGE_SCENARIO_EXPORT_PDF,
component: () => import('@/views/api-test/report/exportPDF.vue'),
},
],
};

View File

@ -9,10 +9,10 @@ const FOOTER_HEIGHT = 16;
const PAGE_HEIGHT = A4_HEIGHT - FOOTER_HEIGHT - HEADER_HEIGHT;
const PDF_WIDTH = A4_WIDTH - 32; // 左右分别 16px 间距
const CONTAINER_WIDTH = 1190;
export const SCALE_RATIO = 1.2;
export const SCALE_RATIO = window.devicePixelRatio * 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 页整(过长会无法截完整,出现空白)
export const MAX_CANVAS_HEIGHT = IMAGE_HEIGHT * 20; // 一次截图最大高度是 20 页整(过长会无法截完整,出现空白)
/**
* svg为base64
@ -53,7 +53,7 @@ async function convertSvgToBase64(svgElement: SVGSVGElement) {
/**
* svg为base64
*/
async function replaceSvgWithBase64(container: HTMLElement) {
export async function replaceSvgWithBase64(container: HTMLElement) {
await inlineSvgUseElements(container);
const svgElements = container.querySelectorAll('.c-icon');
svgElements.forEach(async (svgElement) => {
@ -107,7 +107,7 @@ export default async function exportPDF(name: string, contentId: string) {
width: CONTAINER_WIDTH,
height: screenshotHeight,
backgroundColor: '#f9f9fe',
scale: window.devicePixelRatio * SCALE_RATIO, // 缩放增加清晰度
scale: SCALE_RATIO, // 缩放增加清晰度
});
screenshotList.push(canvas);
position += screenshotHeight;

View File

@ -9,24 +9,36 @@
show-full-screen
>
<template #tbutton>
<a-dropdown v-if="!props.doNotShowShare" position="br" @select="shareHandler">
<div class="ms-drawer-right-operation-button flex items-center">
<a-dropdown v-if="!props.doNotShowShare" position="br" @select="shareHandler">
<MsButton
v-permission="['PROJECT_API_REPORT:READ+SHARE']"
type="icon"
status="secondary"
class="mr-4 !rounded-[var(--border-radius-small)] text-[var(--color-text-1)]"
@click="shareHandler"
>
<MsIcon type="icon-icon_share1" class="mr-2 font-[16px] text-[var(--color-text-1)]" />
{{ t('common.share') }}
</MsButton>
<template #content>
<a-doption>
<span>{{ t('report.detail.api.copyLink') }}</span
><span>{{ t('report.detail.api.copyLinkTimeEnd', { time: shareTime }) }}</span>
</a-doption>
</template>
</a-dropdown>
<MsButton
v-permission="['PROJECT_API_REPORT:READ+SHARE']"
type="icon"
status="secondary"
class="mr-4 !rounded-[var(--border-radius-small)]"
@click="shareHandler"
class="mr-4 !rounded-[var(--border-radius-small)] text-[var(--color-text-1)]"
@click="exportHandler"
>
<MsIcon type="icon-icon_share1" class="mr-2 font-[16px]" />
{{ t('common.share') }}
<MsIcon type="icon-icon_bottom-align_outlined" class="mr-2 font-[16px] text-[var(--color-text-1)]" />
{{ t('common.export') }}
</MsButton>
<template #content>
<a-doption>
<span>{{ t('report.detail.api.copyLink') }}</span
><span>{{ t('report.detail.api.copyLinkTimeEnd', { time: shareTime }) }}</span>
</a-doption>
</template>
</a-dropdown>
</div>
</template>
<CaseReportCom
v-if="!props.isScenario"
@ -50,10 +62,11 @@
import { getShareInfo, getShareTime, reportCaseDetail, reportScenarioDetail } from '@/api/modules/api-test/report';
import { useI18n } from '@/hooks/useI18n';
import useOpenNewPage from '@/hooks/useOpenNewPage';
import { useAppStore } from '@/store';
import type { ReportDetail } from '@/models/apiTest/report';
import { RouteEnum } from '@/enums/routeEnum';
import { FullPageEnum, RouteEnum } from '@/enums/routeEnum';
const props = defineProps<{
reportId: string;
@ -67,6 +80,7 @@
const { t } = useI18n();
const { copy, isSupported } = useClipboard({ legacy: true });
const route = useRoute();
const { openNewPage } = useOpenNewPage();
const innerVisible = defineModel<boolean>('visible', {
required: true,
@ -188,6 +202,16 @@
console.log(error);
}
}
function exportHandler() {
openNewPage(
props.isScenario ? FullPageEnum.FULL_PAGE_SCENARIO_EXPORT_PDF : FullPageEnum.FULL_PAGE_CASE_EXPORT_PDF,
{
id: props.reportId,
}
);
}
onMounted(() => {
if (!props.doNotShowShare) {
getTime();

View File

@ -22,7 +22,7 @@
@change="loadControlLoop"
/>
</div>
<div class="flex w-full items-center justify-between rounded bg-[var(--color-text-n9)] p-4">
<div class="flex w-full items-center justify-between rounded bg-[var(--color-text-n9)] px-[16px] py-[8px]">
<div class="font-medium">
<span
:class="{ 'text-[rgb(var(--primary-5))]': activeType === 'ResContent' }"

View File

@ -257,7 +257,6 @@
.ms-scroll-bar();
}
.history-table-before {
display: flex;
justify-content: space-between;

View File

@ -16,12 +16,12 @@
@loaded="loadedReport"
>
<template #titleRight="{ loading }">
<div class="rightButtons flex items-center">
<div class="ms-drawer-right-operation-button flex items-center">
<MsButton
v-permission="['PROJECT_API_REPORT:READ+SHARE']"
type="icon"
status="secondary"
class="mr-4 !rounded-[var(--border-radius-small)]"
class="!rounded-[var(--border-radius-small)]"
:disabled="loading"
:loading="shareLoading"
@click="shareHandler"
@ -29,6 +29,16 @@
<MsIcon type="icon-icon_share1" class="mr-2 font-[16px]" />
{{ t('common.share') }}
</MsButton>
<MsButton
v-permission="['PROJECT_API_REPORT:READ+SHARE']"
type="icon"
status="secondary"
class="mr-4 !rounded-[var(--border-radius-small)]"
@click="exportHandler"
>
<MsIcon type="icon-icon_bottom-align_outlined" class="mr-2 font-[16px]" />
{{ t('common.export') }}
</MsButton>
</div>
</template>
<template #default="{ loading }">
@ -52,14 +62,16 @@
import { getShareInfo, reportScenarioDetail } from '@/api/modules/api-test/report';
import { useI18n } from '@/hooks/useI18n';
import useOpenNewPage from '@/hooks/useOpenNewPage';
import { useAppStore } from '@/store';
import type { ReportDetail } from '@/models/apiTest/report';
import { RouteEnum } from '@/enums/routeEnum';
import { FullPageEnum, RouteEnum } from '@/enums/routeEnum';
const appStore = useAppStore();
const { t } = useI18n();
const { copy, isSupported } = useClipboard({ legacy: true });
const { openNewPage } = useOpenNewPage();
const props = defineProps<{
visible: boolean;
@ -162,14 +174,16 @@
Message.error(t('common.copyNotSupport'));
}
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
/**
* 导出
*/
const exportLoading = ref<boolean>(false);
function exportHandler() {}
function exportHandler() {
openNewPage(FullPageEnum.FULL_PAGE_SCENARIO_EXPORT_PDF, {
id: props.reportId,
});
}
const detailDrawerRef = ref<InstanceType<typeof MsDetailDrawer>>();
@ -186,7 +200,6 @@
<style scoped lang="less">
.report-container {
padding: 16px;
height: calc(100vh - 56px);
background: var(--color-text-n9);
.report-header {
padding: 0 16px;

View File

@ -1,5 +1,5 @@
<template>
<div class="report-container h-full">
<div class="report-container">
<!-- 报告参数开始 -->
<ReportDetailHeader :detail="detail" show-type="API" />
<!-- 报告参数结束 -->
@ -35,13 +35,19 @@
<!-- 报告分析报告步骤分析和请求分析结束 -->
<!-- 报告明细开始 -->
<div class="report-info">
<reportInfoHeader v-model:keyword="cascaderKeywords" v-model:active-tab="activeTab" show-type="API" />
<reportInfoHeader
v-model:keyword="cascaderKeywords"
v-model:active-tab="activeTab"
show-type="API"
:is-export="props.isExport"
/>
<TiledList
:key-words="cascaderKeywords"
show-type="API"
:get-report-step-detail="props.getReportStepDetail"
:active-type="activeTab"
:report-detail="detail || []"
:is-export="props.isExport"
/>
</div>
<!-- 报告明细结束 -->
@ -71,6 +77,7 @@
const props = defineProps<{
detailInfo?: ReportDetail;
getReportStepDetail?: (...args: any) => Promise<any>; //
isExport?: boolean; // pdf
}>();
const detail = ref<ReportDetail>({
@ -170,6 +177,7 @@
legend: {
show: false,
},
animation: !props.isExport, // pdf
series: {
name: '',
type: 'pie',
@ -317,7 +325,6 @@
<style scoped lang="less">
.report-container {
height: calc(100vh - 56px);
background: var(--color-text-n9);
.report-header {
padding: 0 16px;

View File

@ -0,0 +1,338 @@
<template>
<div class="flex h-full flex-col gap-[16px]">
<MsTree
ref="treeRef"
v-model:selected-keys="selectedKeys"
v-model:expanded-keys="innerExpandedKeys"
v-model:data="steps"
:field-names="{ title: 'name', key: 'stepId', children: 'children' }"
title-class="step-tree-node-title"
node-highlight-class="step-tree-node-focus"
:animation="false"
action-on-node-click="expand"
disabled-title-tooltip
block-node
hide-switcher
>
<template #title="step">
<div class="flex flex-col">
<div class="flex w-full items-center gap-[8px]">
<div
class="flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[var(--color-text-brand)] px-[2px] !text-white"
>
{{ step.sort }}
</div>
<div class="step-node-content">
<div class="flex flex-1 items-center">
<!-- 步骤展开折叠按钮 -->
<!-- <div
v-if="step.children && step.children.length"
class="flex cursor-pointer items-center gap-[2px] text-[var(--color-text-4)]"
>
<MsIcon :type="'icon-icon_split_turn-down_arrow'" :size="14" />
<span class="mx-1"> {{ step.children?.length || 0 }}</span>
</div> -->
<!-- 展开折叠控制器 -->
<div v-show="getShowExpand(step)" class="mx-1">
<span v-if="step.fold" class="collapsebtn flex items-center justify-center">
<icon-right class="text-[var(--color-text-4)]" :style="{ 'font-size': '12px' }" />
</span>
<span v-else class="expand flex items-center justify-center">
<icon-down class="text-[rgb(var(--primary-6))]" :style="{ 'font-size': '12px' }" />
</span>
</div>
<div v-if="props.showType === 'API' && showCondition.includes(step.stepType)" class="flex-shrink-0">
<ConditionStatus class="mx-1" :status="step.stepType || ''" />
</div>
<div
class="step-name-container"
:class="{
'w-full flex-grow': showApiType.includes(step.stepType),
}"
>
<div class="mx-[4px] max-w-[400px] break-all text-[var(--color-text-1)]">
{{ step.name }}
</div>
</div>
</div>
<div class="flex items-center">
<stepStatus :status="step.status || 'PENDING'" />
<!-- 脚本报错 -->
<MsTag
v-if="step.scriptIdentifier"
type="primary"
theme="light"
:self-style="{
color: 'rgb(var(--primary-3))',
background: 'rgb(var(--primary-1))',
}"
>
<template #icon>
<MsIcon type="icon-icon_info_outlined" class="mx-1 !text-[rgb(var(--primary-3))]" size="16" />
<span class="!text-[rgb(var(--primary-3))]">{{ t('report.detail.api.scriptErrorTip') }}</span>
</template>
</MsTag>
<div v-show="showStatus(step)" class="flex">
<div class="mx-2 flex items-center">
<div v-show="step.code" class="mr-2 text-[var(--color-text-4)]">
{{ t('report.detail.api.statusCode') }}</div
>
<div v-show="step.code" class="max-w-[200px]" :style="{ color: statusCodeColor(step.code) }">
{{ step.code || '-' }}
</div>
</div>
<div v-show="step.requestTime !== null" class="mr-2 flex items-center text-[var(--color-text-4)]">
{{ t('report.detail.api.responseTime') }}
<span class="ml-2 text-[rgb(var(--success-6))]">
{{ step.requestTime !== null ? formatDuration(step.requestTime).split('-')[0] : '-' }}
{{ step.requestTime !== null ? formatDuration(step.requestTime).split('-')[1] : 'ms' }}
</span>
</div>
<div v-show="step.responseSize !== null" class="flex items-center text-[var(--color-text-4)]">
{{ t('report.detail.api.responseSize') }}
<span class="ml-2 text-[rgb(var(--success-6))]"> {{ step.responseSize || 0 }} bytes </span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-if="steps.length === 0" #empty>
<div
class="rounded-[var(--border-radius-small)] bg-[var(--color-fill-1)] p-[8px] text-center text-[12px] leading-[16px] text-[var(--color-text-4)]"
>
{{ t('common.noData') }}
</div>
</template>
</MsTree>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '@/hooks/useI18n';
import { formatDuration } from '@/utils';
import type { ScenarioItemType } from '@/models/apiTest/report';
import { ScenarioStepType } from '@/enums/apiEnum';
const stepStatus = defineAsyncComponent(() => import('./stepStatus.vue'));
const ConditionStatus = defineAsyncComponent(() => import('@/views/api-test/report/component/conditionStatus.vue'));
const MsTag = defineAsyncComponent(() => import('@/components/pure/ms-tag/ms-tag.vue'));
const MsTree = defineAsyncComponent(() => import('@/components/business/ms-tree/index.vue'));
const { t } = useI18n();
const props = defineProps<{
stepKeyword?: string;
expandAll?: boolean;
showType: 'API' | 'CASE';
activeType: 'tiled' | 'tab';
console?: string;
reportId?: string;
expandedKeys: (string | number)[];
}>();
const treeRef = ref<InstanceType<typeof MsTree>>();
const steps = defineModel<ScenarioItemType[]>('steps', {
required: true,
});
const innerExpandedKeys = defineModel<(string | number)[]>('expandedKeys', {
required: false,
});
const showApiType = ref<string[]>([
ScenarioStepType.API,
ScenarioStepType.API_CASE,
ScenarioStepType.CUSTOM_REQUEST,
ScenarioStepType.SCRIPT,
ScenarioStepType.TEST_PLAN_API_CASE,
]);
const selectedKeys = ref<(string | number)[]>([]);
const showCondition = ref<string[]>([
ScenarioStepType.API,
ScenarioStepType.API_CASE,
ScenarioStepType.CUSTOM_REQUEST,
ScenarioStepType.LOOP_CONTROLLER,
ScenarioStepType.IF_CONTROLLER,
ScenarioStepType.ONCE_ONLY_CONTROLLER,
ScenarioStepType.TEST_PLAN_API_CASE,
]);
function getShowExpand(item: ScenarioItemType) {
if (props.showType === 'API') {
return showApiType.value.includes(item.stepType) && props.activeType === 'tab';
}
return props.activeType === 'tab';
}
//
function statusCodeColor(code: string) {
if (code) {
const resCode = Number(code);
if (resCode >= 200 && resCode < 300) {
return 'rgb(var(--success-7)';
}
if (resCode >= 300 && resCode < 400) {
return 'rgb(var(--warning-7)';
}
return 'rgb(var(--danger-7)';
}
return '';
}
function showStatus(item: ScenarioItemType) {
if (showApiType.value.includes(item.stepType) && item.status && item.status !== 'PENDING') {
return true;
}
return item.children && item.children.length > 0 && item.status && item.status !== 'PENDING';
}
</script>
<style scoped lang="less">
// TODO:transform
.loop-levels(@index, @max) when (@index <= @max) {
:deep(.arco-tree-node[data-level='@{index}']) {
margin-left: @index * 32px;
}
.loop-levels(@index + 1, @max); //
}
.loop-levels(0, 99); //
:deep(.arco-tree-node) {
padding: 0 8px;
min-width: 1000px;
border: 1px solid var(--color-text-n8);
border-radius: var(--border-radius-medium) !important;
&:not(:first-child) {
margin-top: 4px;
}
&:hover {
background-color: white !important;
.arco-tree-node-title {
background-color: white !important;
}
}
.arco-tree-node-title {
@apply !cursor-pointer bg-white;
padding: 8px 4px;
&:hover {
background-color: white !important;
}
.step-node-content {
@apply flex w-full flex-1 items-center;
gap: 8px;
margin-right: 6px;
}
.step-name-container {
@apply flex items-center;
margin-right: 16px;
&:hover {
.edit-script-name-icon {
@apply visible;
}
}
.edit-script-name-icon {
@apply invisible cursor-pointer;
color: rgb(var(--primary-5));
}
}
.arco-tree-node-title-text {
@apply flex-1;
}
.resTime,
.resSize,
.statusCode {
margin-right: 8px;
color: var(--color-text-4);
@apply flex;
.resTimeCount {
color: rgb(var(--success-6));
}
.code {
display: inline-block;
max-width: 60px;
text-overflow: ellipsis;
word-break: keep-all;
white-space: nowrap;
}
}
}
.arco-tree-node-indent {
@apply hidden;
}
.arco-tree-node-switcher {
@apply hidden;
}
.arco-tree-node-drag-icon {
@apply hidden;
}
.ms-tree-node-extra {
gap: 4px;
background-color: white !important;
}
}
:deep(.arco-tree-node-selected) {
.arco-tree-node-title {
.step-tree-node-title {
font-weight: 400;
color: var(--color-text-1);
}
}
}
:deep(.step-tree-node-focus) {
background-color: white !important;
.arco-tree-node-title {
background-color: white;
}
}
:deep(.expand) {
width: 16px;
height: 16px;
border-radius: 50%;
background: rgb(var(--primary-1));
}
:deep(.collapsebtn) {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-text-n8) !important;
@apply bg-white;
}
:deep(.arco-table-expand-btn) {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: var(--color-text-n8) !important;
}
.resContentWrapper {
border-radius: 0 0 6px 6px;
@apply mb-4 bg-white p-4;
.resContent {
height: 38px;
border-radius: 6px;
}
}
:deep(.ms-tree-container .ms-tree .arco-tree-node .arco-tree-node-title) {
background: white;
}
:deep(.ms-tree-container .ms-tree .arco-tree-node-selected) {
background: white;
}
.line {
position: absolute;
top: 40px;
width: 100%;
height: 1px;
background: var(--color-text-n8);
}
:deep(.step-tree-node-title) {
width: 100%;
}
</style>

View File

@ -1,14 +1,14 @@
<template>
<div class="flex h-[36px] items-center justify-between">
<div class="mb-[16px] flex items-center justify-between">
<div class="flex items-center">
<div class="mr-2 font-medium leading-[36px]">{{ t('report.detail.api.reportDetail') }}</div>
<a-radio-group v-model:model-value="innerActiveTab" type="button" size="small">
<div class="mr-2 font-medium">{{ t('report.detail.api.reportDetail') }}</div>
<a-radio-group v-if="!props.isExport" v-model:model-value="innerActiveTab" type="button" size="small">
<a-radio v-for="item of methods" :key="item.value" :value="item.value">
{{ t(item.label) }}
</a-radio>
</a-radio-group>
</div>
<div class="w-[240px]">
<div v-if="!props.isExport" class="w-[240px]">
<MsCascader
v-model:model-value="innerKeyword"
mode="native"
@ -59,6 +59,7 @@
activeTab: 'tiled' | 'tab';
keyword: string;
showType: 'API' | 'CASE';
isExport?: boolean; // pdf
}>();
const emit = defineEmits(['update:activeTab', 'update:keyword']);

View File

@ -3,6 +3,7 @@
class="tiled-wrap p-4"
:class="{
'border border-solid border-[var(--color-text-n8)]': props.showType === 'API',
'!max-h-max': props.isExport,
}"
>
<div v-if="isFailedRetry" class="mb-[8px]">
@ -14,7 +15,19 @@
/>
</div>
<!-- 步骤树 -->
<ReadOnlyStepTree
v-if="props.isExport"
v-model:steps="currentTiledList"
v-model:expandedKeys="expandedKeys"
:show-type="props.showType"
:active-type="props.activeType"
:expand-all="isExpandAll"
:console="props.reportDetail.console"
:report-id="props.reportDetail.id"
:get-report-step-detail="props.getReportStepDetail"
/>
<StepTree
v-else
ref="stepTreeRef"
v-model:steps="currentTiledList"
v-model:expandedKeys="expandedKeys"
@ -45,6 +58,7 @@
import { cloneDeep, debounce } from 'lodash-es';
import MsTab from '@/components/pure/ms-tab/index.vue';
import ReadOnlyStepTree from './step/readOnlyTree.vue';
import StepDrawer from './step/stepDrawer.vue';
import StepTree from './step/stepTree.vue';
@ -61,6 +75,7 @@
showType: 'API' | 'CASE'; // |
keyWords: string;
getReportStepDetail?: (...args: any) => Promise<any>; //
isExport?: boolean; // pdf
}>();
const { t } = useI18n();
@ -209,7 +224,9 @@
<style scoped lang="less">
.tiled-wrap {
min-height: calc(100vh - 424px);
overflow: auto;
max-height: calc(100vh - 162px);
border-radius: 4px;
.ms-scroll-bar();
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<a-spin :loading="loading" :tip="t('report.detail.exportingPdf')" class="report-detail-container">
<div id="report-detail" class="report-detail">
<div class="mb-[16px] rounded-[var(--border-radius-small)] bg-white p-[16px]">{{ reportStepDetail?.name }}</div>
<ScenarioCom :detail-info="reportStepDetail" is-export />
</div>
</a-spin>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import ScenarioCom from './component/scenarioCom.vue';
import { reportScenarioDetail } from '@/api/modules/api-test/report';
import { useI18n } from '@/hooks/useI18n';
import exportPDF from '@/utils/exportPdf';
import { ReportDetail } from '@/models/apiTest/report';
const route = useRoute();
const { t } = useI18n();
const loading = ref(false);
const reportStepDetail = ref<ReportDetail>();
async function initReportDetail() {
try {
loading.value = true;
reportStepDetail.value = await reportScenarioDetail(route.query.id as string);
setTimeout(() => {
nextTick(async () => {
await exportPDF(reportStepDetail.value?.name || '', 'report-detail');
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess'));
});
}, 500);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
onBeforeMount(() => {
if (route.query.id) {
initReportDetail();
}
});
</script>
<style lang="less">
.arco-spin-mask-icon {
@apply !fixed;
}
</style>
<style lang="less" scoped>
.report-detail-container {
@apply flex justify-center;
.report-detail {
@apply overflow-x-auto;
padding: 16px;
width: 1190px;
.ms-scroll-bar();
}
}
</style>

View File

@ -148,7 +148,7 @@
statusConfig,
toolTipConfig,
} from '@/config/testPlan';
import exportPDF, { PAGE_PDF_WIDTH_RATIO } from '@/hooks/useExportPDF';
import exportPDF, { PAGE_PDF_WIDTH_RATIO, PdfTableConfig } from '@/hooks/useExportPDF';
import { useI18n } from '@/hooks/useI18n';
import { addCommasToNumber } from '@/utils';
@ -668,95 +668,98 @@
(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',
[
{
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,
},
{
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[],
},
{
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[],
},
{
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[],
},
],
() => {
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess'));
}
);
exportPDF(name, 'report-detail', tableArr, () => {
loading.value = false;
Message.success(t('report.detail.exportPdfSuccess'));
});
});
}
} catch (error) {