feat(报告): 导出PDF报告雏形

This commit is contained in:
baiqi 2024-08-30 10:08:47 +08:00 committed by Craftsman
parent 5c4de546d9
commit 1c69fb293a
20 changed files with 1062 additions and 47 deletions

View File

@ -55,14 +55,17 @@
"ace-builds": "^1.35.2", "ace-builds": "^1.35.2",
"ahooks-vue": "^0.15.1", "ahooks-vue": "^0.15.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"canvg": "^4.0.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"fastq": "^1.17.1", "fastq": "^1.17.1",
"github-markdown-css": "^5.6.1", "github-markdown-css": "^5.6.1",
"html2canvas-pro": "^1.5.8",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
"json-schema-traverse": "^1.0.0", "json-schema-traverse": "^1.0.0",
"jsonpath-plus": "^8.1.0", "jsonpath-plus": "^8.1.0",
"jspdf": "^2.5.1",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lossless-json": "^4.0.1", "lossless-json": "^4.0.1",

View File

@ -1,11 +1,11 @@
<template> <template>
<div v-if="props.executeResult" class="flex items-center"> <div v-if="props.executeResult" class="flex items-center">
<MsIcon <!-- <MsIcon
:type="lastExecuteResultMap[props.executeResult]?.icon || ''" :type="lastExecuteResultMap[props.executeResult]?.icon || ''"
class="mr-1" class="mr-1"
:size="16" :size="16"
:style="{ color: lastExecuteResultMap[props.executeResult]?.color }" :style="{ color: lastExecuteResultMap[props.executeResult]?.color }"
></MsIcon> ></MsIcon> -->
<span class="text-[14px]">{{ lastExecuteResultMap[props.executeResult]?.statusText || '-' }}</span> <span class="text-[14px]">{{ lastExecuteResultMap[props.executeResult]?.statusText || '-' }}</span>
</div> </div>
</template> </template>

View File

@ -14,7 +14,7 @@ export default function useMenuTree() {
const menuTree = computed(() => { const menuTree = computed(() => {
const copyRouter = cloneDeep(appClientMenus) as RouteRecordNormalized[]; const copyRouter = cloneDeep(appClientMenus) as RouteRecordNormalized[];
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => { copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
return (a.meta.order || 0) - (b.meta.order || 0); return (a.meta?.order || 0) - (b.meta?.order || 0);
}); });
function travel(_routes: RouteRecordRaw[], layer: number) { function travel(_routes: RouteRecordRaw[], layer: number) {
if (!_routes) return null; if (!_routes) return null;

View File

@ -189,7 +189,9 @@ export const seriesConfig = {
type: 'pie', type: 'pie',
radius: ['75%', '93%'], radius: ['75%', '93%'],
center: ['50%', '50%'], center: ['50%', '50%'],
avoidLabelOverlap: false, minAngle: 5, // 设置扇区的最小角度
minShowLabelAngle: 10, // 设置标签显示的最小角度
avoidLabelOverlap: true, // 避免标签重叠
label: { label: {
show: false, show: false,
position: 'center', position: 'center',

View File

@ -112,6 +112,11 @@ export enum ShareEnum {
SHARE_REPORT_TEST_PLAN = 'shareReportTestPlan', SHARE_REPORT_TEST_PLAN = 'shareReportTestPlan',
} }
export enum FullPageEnum {
FULL_PAGE = 'fullPage',
FULL_PAGE_TEST_PLAN_EXPORT_PDF = 'fullPageTestPlanExportPDF',
}
export const RouteEnum = { export const RouteEnum = {
...ApiTestRouteEnum, ...ApiTestRouteEnum,
...SettingRouteEnum, ...SettingRouteEnum,
@ -123,4 +128,5 @@ export const RouteEnum = {
...UITestRouteEnum, ...UITestRouteEnum,
...WorkbenchRouteEnum, ...WorkbenchRouteEnum,
...ShareEnum, ...ShareEnum,
...FullPageEnum,
}; };

View File

@ -0,0 +1,27 @@
<template>
<a-layout class="layout">
<router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in" appear>
<!-- transition内必须有且只有一个根元素不然会导致二级路由的组件无法渲染 -->
<div class="page-content">
<component :is="Component" :key="route.fullPath" />
</div>
</transition>
</router-view>
</a-layout>
</template>
<script setup lang="ts"></script>
<style lang="less" scoped>
.layout {
@apply h-full w-full;
.page-content {
@apply overflow-y-auto;
height: 100vh;
background-color: var(--color-bg-3);
.ms-scroll-bar();
}
}
</style>

View File

@ -6,6 +6,7 @@ export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
export const PAGE_LAYOUT = () => import('@/layout/page-layout.vue'); export const PAGE_LAYOUT = () => import('@/layout/page-layout.vue');
export const NO_PERMISSION_LAYOUT = () => import('@/layout/no-permission-layout.vue'); export const NO_PERMISSION_LAYOUT = () => import('@/layout/no-permission-layout.vue');
export const SHARE_LAYOUT = () => import('@/layout/share-layout.vue'); export const SHARE_LAYOUT = () => import('@/layout/share-layout.vue');
export const FULL_PAGE_LAYOUT = () => import('@/layout/full-page-layout.vue');
export const INDEX_ROUTE: RouteRecordRaw = { export const INDEX_ROUTE: RouteRecordRaw = {
path: '/index', path: '/index',

View File

@ -0,0 +1,20 @@
import { FullPageEnum } from '@/enums/routeEnum';
import { FULL_PAGE_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const FullPage: AppRouteRecordRaw = {
path: '/fullPage',
name: FullPageEnum.FULL_PAGE,
redirect: '/fullPage/testPlanExportPDF',
component: FULL_PAGE_LAYOUT,
children: [
{
path: 'testPlanExportPDF',
name: FullPageEnum.FULL_PAGE_TEST_PLAN_EXPORT_PDF,
component: () => import('@/views/test-plan/report/detail/exportPDF.vue'),
},
],
};
export default FullPage;

View File

@ -367,14 +367,14 @@
} else { } else {
validArr = tempArr.filter((e) => e.key === props?.detailInfo?.status); validArr = tempArr.filter((e) => e.key === props?.detailInfo?.status);
} }
const pieBorderWidth = validArr.filter((e) => Number(detail.value[e.value]) > 0).length === 1 ? 0 : 2;
charOptions.value.series.data = validArr.map((item: any) => { charOptions.value.series.data = validArr.map((item: any) => {
return { return {
value: detail.value[item.value] || 0, value: detail.value[item.value] || 0,
name: t(item.label), name: t(item.label),
itemStyle: { itemStyle: {
color: item.color, color: item.color,
borderWidth: 2, borderWidth: pieBorderWidth,
borderColor: '#ffffff', borderColor: '#ffffff',
}, },
}; };

View File

@ -259,14 +259,14 @@
rateKey: 'requestPendingRate', rateKey: 'requestPendingRate',
}, },
]; ];
const requestChartBorderWidth = tempArr.filter((e) => Number(detail.value[e.value]) > 0).length === 1 ? 0 : 2;
requestCharOptions.value.series.data = tempArr.map((item: any) => { requestCharOptions.value.series.data = tempArr.map((item: any) => {
return { return {
value: detail.value[item.value] || 0, value: detail.value[item.value] || 0,
name: t(item.label), name: t(item.label),
itemStyle: { itemStyle: {
color: item.color, color: item.color,
borderWidth: 2, borderWidth: requestChartBorderWidth,
borderColor: '#ffffff', borderColor: '#ffffff',
}, },
}; };
@ -279,6 +279,11 @@
rote: `${detail.value[item.rateKey] || 0}%`, rote: `${detail.value[item.rateKey] || 0}%`,
}; };
}); });
const stepChartBorderWidth =
tempArr.filter((e) => Number(detail.value[`step${e.value.charAt(0).toUpperCase() + e.value.slice(1)}`]) > 0)
.length === 1
? 0
: 2;
stepCharOptions.value.series.data = tempArr.map((item: any) => { stepCharOptions.value.series.data = tempArr.map((item: any) => {
const valueName = `step${item.value.charAt(0).toUpperCase() + item.value.slice(1)}`; const valueName = `step${item.value.charAt(0).toUpperCase() + item.value.slice(1)}`;
return { return {
@ -286,7 +291,7 @@
name: t(item.label), name: t(item.label),
itemStyle: { itemStyle: {
color: item.color, color: item.color,
borderWidth: 2, borderWidth: stepChartBorderWidth,
borderColor: '#ffffff', borderColor: '#ffffff',
}, },
}; };

View File

@ -16,7 +16,6 @@
import useTable from '@/components/pure/ms-table/useTable'; import useTable from '@/components/pure/ms-table/useTable';
import { getReportBugList, getReportShareBugList } from '@/api/modules/test-plan/report'; import { getReportBugList, getReportShareBugList } from '@/api/modules/test-plan/report';
import { useI18n } from '@/hooks/useI18n';
import useOpenNewPage from '@/hooks/useOpenNewPage'; import useOpenNewPage from '@/hooks/useOpenNewPage';
import { ReportBugItem } from '@/models/testPlan/report'; import { ReportBugItem } from '@/models/testPlan/report';
@ -27,8 +26,6 @@
const { openNewPage } = useOpenNewPage(); const { openNewPage } = useOpenNewPage();
const { t } = useI18n();
const props = defineProps<{ const props = defineProps<{
reportId: string; reportId: string;
shareId?: string; shareId?: string;

View File

@ -25,6 +25,7 @@
const props = defineProps<{ const props = defineProps<{
detail: PlanReportDetail; detail: PlanReportDetail;
animation?: boolean; //
}>(); }>();
const legendData = ref<LegendData[]>([]); const legendData = ref<LegendData[]>([]);
@ -35,6 +36,7 @@
tooltip: { tooltip: {
...toolTipConfig, ...toolTipConfig,
}, },
animation: props.animation === undefined ? true : props.animation, //
series: { series: {
...seriesConfig, ...seriesConfig,
data: [ data: [
@ -78,13 +80,17 @@
}); });
function initExecuteOptions() { function initExecuteOptions() {
executeCharOptions.value.series.data = statusConfig.map((item: StatusListType) => { const pieBorderWidth =
statusConfig.filter((e) => Number(props.detail.executeCount[e.value]) > 0).length === 1 ? 0 : 2;
executeCharOptions.value.series.data = statusConfig
.filter((item) => props.detail.executeCount[item.value] > 0)
.map((item: StatusListType) => {
return { return {
value: props.detail.executeCount[item.value] || 0, value: props.detail.executeCount[item.value] || 0,
name: t(item.label), name: t(item.label),
itemStyle: { itemStyle: {
color: item.color, color: item.color,
borderWidth: 2, borderWidth: pieBorderWidth,
borderColor: '#ffffff', borderColor: '#ffffff',
}, },
}; };

View File

@ -27,6 +27,9 @@
<MsIcon type="icon-icon_share1" class="mr-2 font-[16px]" /> <MsIcon type="icon-icon_share1" class="mr-2 font-[16px]" />
{{ t('common.share') }} {{ t('common.share') }}
</MsButton> </MsButton>
<MsButton type="icon" status="secondary" class="ml-4 !rounded-[var(--border-radius-small)]" @click="exportPdf">
导出 PDF
</MsButton>
</div> </div>
</template> </template>
@ -39,11 +42,12 @@
import { planReportShare } from '@/api/modules/test-plan/report'; import { planReportShare } from '@/api/modules/test-plan/report';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';
import useOpenNewPage from '@/hooks/useOpenNewPage';
import useAppStore from '@/store/modules/app'; import useAppStore from '@/store/modules/app';
import { hasAnyPermission } from '@/utils/permission'; import { hasAnyPermission } from '@/utils/permission';
import type { PlanReportDetail } from '@/models/testPlan/testPlanReport'; import type { PlanReportDetail } from '@/models/testPlan/testPlanReport';
import { RouteEnum } from '@/enums/routeEnum'; import { FullPageEnum, RouteEnum } from '@/enums/routeEnum';
const props = defineProps<{ const props = defineProps<{
detail: PlanReportDetail; detail: PlanReportDetail;
@ -54,6 +58,7 @@
const appStore = useAppStore(); const appStore = useAppStore();
const { t } = useI18n(); const { t } = useI18n();
const { copy, isSupported } = useClipboard({ legacy: true }); const { copy, isSupported } = useClipboard({ legacy: true });
const { openNewPage } = useOpenNewPage();
const shareLink = ref<string>(''); const shareLink = ref<string>('');
const shareLoading = ref<boolean>(false); const shareLoading = ref<boolean>(false);
@ -78,4 +83,11 @@
console.log(error); console.log(error);
} }
} }
function exportPdf() {
openNewPage(FullPageEnum.FULL_PAGE_TEST_PLAN_EXPORT_PDF, {
id: props.detail.id,
type: props.isGroup ? 'GROUP' : 'TEST_PLAN',
});
}
</script> </script>

View File

@ -14,8 +14,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import MsCard from '@/components/pure/ms-card/index.vue'; import MsCard from '@/components/pure/ms-card/index.vue';
import PlanDetailHeaderRight from '@/views/test-plan/report/detail/component/system-card/planDetailHeaderRight.vue'; import PlanDetailHeaderRight from '@/views/test-plan/report/detail/component/system-card/planDetailHeaderRight.vue';

View File

@ -13,7 +13,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { TriggerPopupTranslate } from '@arco-design/web-vue'; import { TriggerPopupTranslate } from '@arco-design/web-vue';
import { useI18n } from '@/hooks/useI18n'; import { useI18n } from '@/hooks/useI18n';

View File

@ -235,7 +235,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
@ -387,13 +386,16 @@
const passRateData = statusConfig.filter((item) => ['success'].includes(item.value)); const passRateData = statusConfig.filter((item) => ['success'].includes(item.value));
const { success } = caseCountDetail; const { success } = caseCountDetail;
const valueList = success ? statusConfig : passRateData; const valueList = success ? statusConfig : passRateData;
return valueList.map((item: StatusListType) => { const chartBorderWidth = valueList.filter((e) => Number(caseCountDetail[e.value]) > 0).length === 1 ? 0 : 2;
return valueList
.filter((item) => caseCountDetail[item.value] > 0)
.map((item: StatusListType) => {
return { return {
value: caseCountDetail[item.value] || 0, value: caseCountDetail[item.value] || 0,
name: t(item.label), name: t(item.label),
itemStyle: { itemStyle: {
color: success ? item.color : '#D4D4D8', color: success ? item.color : '#D4D4D8',
borderWidth: 2, borderWidth: chartBorderWidth,
borderColor: '#ffffff', borderColor: '#ffffff',
}, },
}; };

View File

@ -1,4 +1,5 @@
<template> <template>
<a-spin :loading="loading" class="h-full w-full">
<ViewReport <ViewReport
v-model:card-list="cardItemList" v-model:card-list="cardItemList"
:detail-info="detail" :detail-info="detail"
@ -6,10 +7,10 @@
is-preview is-preview
@update-success="getDetail()" @update-success="getDetail()"
/> />
</a-spin>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
@ -28,12 +29,17 @@
const isGroup = computed(() => route.query.type === 'GROUP'); const isGroup = computed(() => route.query.type === 'GROUP');
const cardItemList = ref<configItem[]>([]); const cardItemList = ref<configItem[]>([]);
const loading = ref<boolean>(false);
async function getDetail() { async function getDetail() {
try { try {
loading.value = true;
detail.value = await getReportDetail(reportId.value); detail.value = await getReportDetail(reportId.value);
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console
console.log(error); console.log(error);
} finally {
loading.value = false;
} }
} }

View File

@ -0,0 +1,754 @@
<template>
<a-spin :loading="loading" class="block">
<div id="report-detail" class="p-[16px]">
<div class="report-header">
<div class="flex-1 break-all">{{ detail.name }}</div>
<div class="one-line-text">
<span class="text-[var(--color-text-4)]">{{ t('report.detail.api.executionTime') }}</span>
{{ detail.startTime ? dayjs(detail.startTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
<span class="text-[var(--color-text-4)]">{{ t('report.detail.api.executionTimeTo') }}</span>
{{ detail.endTime ? dayjs(detail.endTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
</div>
</div>
<div class="analysis-wrapper" :data-cards="cardCount">
<div class="analysis min-w-[330px]">
<div class="block-title">{{ t('report.detail.api.reportAnalysis') }}</div>
<ReportMetricsItem
v-for="analysisItem in reportAnalysisList"
:key="analysisItem.name"
:item-info="analysisItem"
/>
</div>
<div class="analysis min-w-[410px]">
<ExecuteAnalysis :detail="detail" :animation="false" />
</div>
<div v-if="functionalCaseTotal" class="analysis min-w-[330px]">
<div class="block-title">{{ t('report.detail.useCaseAnalysis') }}</div>
<div class="flex">
<div class="mr-[24px] flex-1">
<SingleStatusProgress :detail="detail" type="FUNCTIONAL" status="pending" />
<SingleStatusProgress :detail="detail" type="FUNCTIONAL" status="success" />
<SingleStatusProgress :detail="detail" type="FUNCTIONAL" status="block" />
<SingleStatusProgress :detail="detail" type="FUNCTIONAL" status="error" class="!mb-0" />
</div>
<div class="relative">
<div class="charts">
<div class="text-[12px] !text-[var(--color-text-4)]">{{ t('report.passRate') }}</div>
<div class="flex justify-center text-[16px] font-medium">
<div class="one-line-text max-w-[100px] text-[var(--color-text-1)]">{{ functionCasePassRate }} </div>
</div>
</div>
<div class="flex h-full w-full items-center justify-center">
<MsChart width="100px" height="100px" :options="functionCaseOptions" />
</div>
</div>
</div>
</div>
<div v-if="apiCaseTotal" class="analysis min-w-[330px]">
<div class="block-title">{{ t('report.detail.apiUseCaseAnalysis') }}</div>
<div class="flex">
<div class="mr-[24px] flex-1">
<SingleStatusProgress type="API" :detail="detail" status="pending" />
<SingleStatusProgress type="API" :detail="detail" status="success" />
<SingleStatusProgress type="API" :detail="detail" status="fakeError" />
<SingleStatusProgress type="API" :detail="detail" status="error" class="!mb-0" />
</div>
<div class="relative">
<div class="charts">
<div class="text-[12px] !text-[var(--color-text-4)]">{{ t('report.passRate') }}</div>
<div class="flex justify-center text-[16px] font-medium">
<div class="one-line-text max-w-[100px] text-[var(--color-text-1)]">{{ apiCasePassRate }} </div>
</div>
</div>
<div class="flex h-full w-full items-center justify-center">
<MsChart width="100px" height="100px" :options="apiCaseOptions" />
</div>
</div>
</div>
</div>
<div v-if="scenarioCaseTotal" class="analysis min-w-[330px]">
<div class="block-title">{{ t('report.detail.scenarioUseCaseAnalysis') }}</div>
<div class="flex">
<div class="mr-[24px] flex-1">
<SingleStatusProgress type="SCENARIO" :detail="detail" status="pending" />
<SingleStatusProgress type="SCENARIO" :detail="detail" status="success" />
<SingleStatusProgress type="SCENARIO" :detail="detail" status="fakeError" />
<SingleStatusProgress type="SCENARIO" :detail="detail" status="error" class="!mb-0" />
</div>
<div class="relative">
<div class="charts">
<div class="text-[12px] !text-[var(--color-text-4)]">{{ t('report.passRate') }}</div>
<div class="flex justify-center text-[16px] font-medium">
<div class="one-line-text max-w-[100px] text-[var(--color-text-1)]">{{ scenarioCasePassRate }} </div>
</div>
</div>
<div class="flex h-full w-full items-center justify-center">
<MsChart width="100px" height="100px" :options="scenarioCaseOptions" />
</div>
</div>
</div>
</div>
</div>
<div class="mt-[16px]">
<div v-for="item of innerCardList" v-show="showItem(item)" :key="item.id" class="card-item mt-[16px]">
<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">
{{ t(item.label) }}
</div>
</div>
<ReportDetailTable
v-if="item.value === ReportCardTypeEnum.SUB_PLAN_DETAIL"
v-model:current-mode="currentMode"
:report-id="detail.id"
:share-id="shareId"
is-preview
/>
<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>
<MsBaseTable
v-else-if="item.value === ReportCardTypeEnum.API_CASE_DETAIL"
v-bind="useApiTable.propsRes.value"
>
<template #priority="{ record }">
<caseLevel :case-level="record.priority" />
</template>
<template #lastExecResult="{ record }">
<ExecutionStatus :module-type="ReportEnum.API_REPORT" :status="record.executeResult" />
</template>
</MsBaseTable>
<MsBaseTable
v-else-if="item.value === ReportCardTypeEnum.SCENARIO_CASE_DETAIL"
v-bind="useScenarioTable.propsRes.value"
>
<template #priority="{ record }">
<caseLevel :case-level="record.priority" />
</template>
<template #lastExecResult="{ record }">
<ExecutionStatus :module-type="ReportEnum.API_REPORT" :status="record.executeResult" />
</template>
</MsBaseTable>
<div v-else-if="item.value === ReportCardTypeEnum.CUSTOM_CARD" v-html="item.content"></div>
</div>
</div>
</div>
</div>
</a-spin>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { cloneDeep } from 'lodash-es';
import dayjs from 'dayjs';
import MsChart from '@/components/pure/chart/index.vue';
import MsBaseTable from '@/components/pure/ms-table/base-table.vue';
import type { MsTableColumn } from '@/components/pure/ms-table/type';
import useTable from '@/components/pure/ms-table/useTable';
import CaseLevel from '@/components/business/ms-case-associate/caseLevel.vue';
import ExecuteResult from '@/components/business/ms-case-associate/executeResult.vue';
import ExecutionStatus from '@/views/api-test/report/component/reportStatus.vue';
import SingleStatusProgress from '@/views/test-plan/report/component/singleStatusProgress.vue';
import ExecuteAnalysis from '@/views/test-plan/report/detail/component/system-card/executeAnalysis.vue';
import ReportDetailTable from '@/views/test-plan/report/detail/component/system-card/reportDetailTable.vue';
import ReportMetricsItem from '@/views/test-plan/report/detail/component/system-card/ReportMetricsItem.vue';
import {
getApiPage,
getReportBugList,
getReportDetail,
getReportFeatureCaseList,
getReportLayout,
getReportShareBugList,
getReportShareFeatureCaseList,
getScenarioPage,
} from '@/api/modules/test-plan/report';
import {
commonConfig,
defaultCount,
defaultReportDetail,
seriesConfig,
statusConfig,
toolTipConfig,
} from '@/config/testPlan';
import { useI18n } from '@/hooks/useI18n';
import { addCommasToNumber } from '@/utils';
import type {
configItem,
countDetail,
PlanReportDetail,
ReportMetricsItemModel,
StatusListType,
} from '@/models/testPlan/testPlanReport';
import { customValueForm } from '@/models/testPlan/testPlanReport';
import { ReportEnum } from '@/enums/reportEnum';
import { ReportCardTypeEnum } from '@/enums/testPlanReportEnum';
import { defaultGroupConfig, defaultSingleConfig } from './component/reportConfig';
import { getSummaryDetail } from '@/views/test-plan/report/utils';
import exportPdf from '@/workers/exportPDF/exportPDFWorker';
const { t } = useI18n();
const route = useRoute();
const innerCardList = defineModel<configItem[]>('cardList', {
default: [],
});
const detail = ref<PlanReportDetail>({ ...cloneDeep(defaultReportDetail) });
const reportId = ref<string>(route.query.id as string);
const isGroup = computed(() => route.query.type === 'GROUP');
const loading = ref<boolean>(false);
const richText = ref<{ summary: string; richTextTmpFileIds?: string[] }>({
summary: '',
});
const reportForm = ref({
reportName: '',
});
/**
* 分享share
*/
const shareId = ref<string>(route.query.shareId as string);
//
const functionCaseOptions = ref({
...commonConfig,
tooltip: {
...toolTipConfig,
},
animation: false, //
series: {
...seriesConfig,
data: [
{
value: 0,
name: t('common.success'),
itemStyle: {
color: '#00C261',
},
},
],
},
});
//
const apiCaseOptions = ref({
...commonConfig,
tooltip: {
...toolTipConfig,
},
animation: false, //
series: {
...seriesConfig,
data: [
{
value: 0,
name: t('common.success'),
itemStyle: {
color: '#00C261',
},
},
],
},
});
//
const scenarioCaseOptions = ref({
...commonConfig,
tooltip: {
...toolTipConfig,
},
animation: false, //
series: {
...seriesConfig,
data: [
{
value: 0,
name: t('common.success'),
itemStyle: {
color: '#00C261',
},
},
],
},
});
//
function getPassRateData(caseDetailCount: countDetail) {
const caseCountDetail = caseDetailCount || defaultCount;
const passRateData = statusConfig.filter((item) => ['success'].includes(item.value));
const { success } = caseCountDetail;
const valueList = success ? statusConfig : passRateData;
const chartBorderWidth = valueList.filter((e) => Number(caseCountDetail[e.value]) > 0).length === 1 ? 0 : 2;
return valueList
.filter((item) => caseCountDetail[item.value] > 0)
.map((item: StatusListType) => {
return {
value: caseCountDetail[item.value] || 0,
name: t(item.label),
itemStyle: {
color: success ? item.color : '#D4D4D8',
borderWidth: chartBorderWidth,
borderColor: '#ffffff',
},
};
});
}
//
function initOptionsData() {
const { functionalCount, apiCaseCount, apiScenarioCount } = detail.value;
functionCaseOptions.value.series.data = getPassRateData(functionalCount);
apiCaseOptions.value.series.data = getPassRateData(apiCaseCount);
scenarioCaseOptions.value.series.data = getPassRateData(apiScenarioCount);
}
const functionCasePassRate = computed(() => {
const apiCaseDetail = getSummaryDetail(detail.value.functionalCount || defaultCount);
return apiCaseDetail.successRate;
});
const apiCasePassRate = computed(() => {
const apiCaseDetail = getSummaryDetail(detail.value.apiCaseCount || defaultCount);
return apiCaseDetail.successRate;
});
const scenarioCasePassRate = computed(() => {
const apiScenarioDetail = getSummaryDetail(detail.value.apiScenarioCount || defaultCount);
return apiScenarioDetail.successRate;
});
const functionalCaseTotal = computed(() => getSummaryDetail(detail.value.functionalCount).caseTotal);
const apiCaseTotal = computed(() => getSummaryDetail(detail.value.apiCaseCount).caseTotal);
const scenarioCaseTotal = computed(() => getSummaryDetail(detail.value.apiScenarioCount).caseTotal);
const reportAnalysisList = computed<ReportMetricsItemModel[]>(() => {
if (isGroup.value) {
return [
{
name: t('report.detail.testPlanTotal'),
value: detail.value.planCount,
unit: t('report.detail.number'),
icon: 'plan_total',
},
{
name: t('report.detail.testPlanCaseTotal'),
value: detail.value.caseTotal,
unit: t('report.detail.number'),
icon: 'case_total',
},
{
name: t('report.passRate'),
value: detail.value.passRate,
unit: '%',
icon: 'passRate',
},
{
name: t('report.detail.totalDefects'),
value: addCommasToNumber(detail.value.bugCount),
unit: t('report.detail.number'),
icon: 'bugTotal',
},
];
}
return [
{
name: t('report.detail.threshold'),
value: detail.value.passThreshold,
unit: '%',
icon: 'threshold',
},
{
name: t('report.passRate'),
value: detail.value.passRate,
unit: '%',
icon: 'passRate',
},
{
name: t('report.detail.performCompletion'),
value: detail.value.executeRate,
unit: '%',
icon: 'passRate',
},
{
name: t('report.detail.totalDefects'),
value: addCommasToNumber(detail.value.bugCount),
unit: t('report.detail.number'),
icon: 'bugTotal',
},
];
});
function showItem(item: configItem) {
switch (item.value) {
case ReportCardTypeEnum.FUNCTIONAL_DETAIL:
return functionalCaseTotal.value > 0;
case ReportCardTypeEnum.API_CASE_DETAIL:
return apiCaseTotal.value > 0;
case ReportCardTypeEnum.SCENARIO_CASE_DETAIL:
return scenarioCaseTotal.value > 0;
default:
return true;
}
}
const cardCount = computed(() => {
const totalList = [functionalCaseTotal.value, apiCaseTotal.value, scenarioCaseTotal.value];
let count = 2;
totalList.forEach((item: number) => {
if (item > 0) {
count++;
}
});
return count;
});
const originLayoutInfo = ref([]);
async function getDefaultLayout() {
try {
const res = await getReportLayout(detail.value.id, shareId.value);
const result = res.map((item: any) => {
return {
id: item.id,
value: item.name,
label: item.label,
content: item.value || '',
type: item.type,
enableEdit: false,
richTextTmpFileIds: item.richTextTmpFileIds,
};
});
innerCardList.value = result;
originLayoutInfo.value = cloneDeep(result);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
}
}
const isDefaultLayout = ref<boolean>(false);
//
function getContent(item: configItem): customValueForm {
if (isDefaultLayout.value) {
return {
content: richText.value.summary || '',
label: t(item.label),
richTextTmpFileIds: [],
};
}
return {
content: item.content || '',
label: t(item.label),
richTextTmpFileIds: item.richTextTmpFileIds,
};
}
const currentMode = ref<string>('drawer');
/** 缺陷明细 */
const bugColumns: MsTableColumn = [
{
title: 'ID',
dataIndex: 'num',
width: 80,
},
{
title: 'bugManagement.bugName',
dataIndex: 'title',
},
{
title: 'bugManagement.status',
dataIndex: 'status',
width: 80,
},
{
title: 'bugManagement.handleMan',
dataIndex: 'handleUserName',
width: 150,
},
{
title: 'bugManagement.numberOfCase',
dataIndex: 'relationCaseCount',
width: 70,
},
];
const reportBugList = () => {
return !shareId.value ? getReportBugList : getReportShareBugList;
};
const {
propsRes: bugTableProps,
loadList: loadBugList,
setLoadListParams: setLoadBugListParams,
} = useTable(reportBugList(), {
scroll: { x: '100%', y: 'auto' },
columns: bugColumns,
showSelectorAll: false,
});
/** 用例明细 */
const staticColumns: MsTableColumn = [
{
title: 'ID',
dataIndex: 'num',
width: 80,
ellipsis: true,
},
{
title: 'case.caseName',
dataIndex: 'name',
},
{
title: 'common.executionResult',
dataIndex: 'executeResult',
slotName: 'lastExecResult',
width: 80,
},
];
const lastStaticColumns: MsTableColumn = [
{
title: 'common.belongModule',
dataIndex: 'moduleName',
ellipsis: true,
width: 200,
},
{
title: 'case.caseLevel',
dataIndex: 'priority',
slotName: 'caseLevel',
width: 80,
},
{
title: 'testPlan.featureCase.executor',
dataIndex: 'executeUser',
width: 150,
},
{
title: 'testPlan.featureCase.bugCount',
dataIndex: 'bugCount',
width: 70,
},
];
const testPlanNameColumns: MsTableColumn = [
{
title: 'report.plan.name',
dataIndex: 'planName',
width: 200,
},
];
const caseColumns = computed(() => {
if (isGroup.value) {
return [...staticColumns, ...testPlanNameColumns, ...lastStaticColumns];
}
return [...staticColumns, ...lastStaticColumns];
});
const reportFeatureCaseList = () => {
return !shareId.value ? getReportFeatureCaseList : getReportShareFeatureCaseList;
};
const {
propsRes: caseTableProps,
loadList: loadCaseList,
setLoadListParams: setLoadCaseListParams,
} = useTable(reportFeatureCaseList(), {
scroll: { x: '100%', y: 'auto' },
columns: caseColumns.value,
heightUsed: 20,
showSelectorAll: false,
});
/** 接口/场景明细 */
const apiStaticColumns: MsTableColumn = [
{
title: 'ID',
dataIndex: 'num',
width: 110,
},
{
title: 'common.name',
dataIndex: 'name',
},
{
title: 'report.detail.level',
dataIndex: 'priority',
slotName: 'priority',
width: 80,
},
{
title: 'common.executionResult',
dataIndex: 'executeResult',
slotName: 'lastExecResult',
width: 80,
},
];
const apiColumns = computed(() => {
if (isGroup.value) {
return [...apiStaticColumns, ...testPlanNameColumns, ...lastStaticColumns];
}
return [...apiStaticColumns, ...lastStaticColumns];
});
const useApiTable = useTable(getApiPage, {
scroll: { x: '100%', y: 'auto' },
columns: apiColumns.value,
showSelectorAll: false,
showSetting: false,
});
const useScenarioTable = useTable(getScenarioPage, {
scroll: { x: '100%', y: 'auto' },
columns: apiColumns.value,
showSelectorAll: false,
showSetting: false,
});
async function getDetail() {
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) {
getDefaultLayout();
} else {
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 });
useApiTable.setLoadListParams({
reportId: reportId.value,
shareId: shareId.value ?? undefined,
pageSize: 500,
});
useScenarioTable.setLoadListParams({
reportId: reportId.value,
shareId: shareId.value ?? undefined,
pageSize: 500,
});
await Promise.all([loadBugList(), loadCaseList(), useApiTable.loadList(), useScenarioTable.loadList()]);
setTimeout(() => {
nextTick(() => {
exportPdf(detail.value.name, 'report-detail');
});
}, 0);
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
} finally {
loading.value = false;
}
}
onBeforeMount(() => {
getDetail();
});
</script>
<style lang="less" scoped>
.report-header {
@apply mb-4 flex items-center bg-white;
padding: 16px;
border-radius: var(--border-radius-large);
box-shadow: 0 0 10px rgb(120 56 135 / 5%);
}
.block-title {
@apply mb-4 font-medium;
}
.analysis-wrapper {
@apply mb-4 grid items-center gap-4;
.analysis {
padding: 24px;
height: 250px;
border: 1px solid transparent;
box-shadow: 0 0 10px rgba(120 56 135/ 5%);
@apply rounded-xl bg-white;
.charts {
@apply absolute text-center;
top: 50%;
right: 0;
bottom: 0;
left: 50%;
z-index: 99;
width: 70px;
height: 42px;
transform: translateY(-50%) translateX(-50%);
}
}
&[data-cards='2'],
&[data-cards='4'] {
grid-template-columns: repeat(2, 1fr);
}
&[data-cards='3'] {
grid-template-columns: repeat(3, 1fr);
}
// 523
&[data-cards='5'] {
grid-template-columns: repeat(6, 1fr);
& > .analysis:nth-child(1),
& > .analysis:nth-child(2) {
grid-column: span 3;
}
& > .analysis:nth-child(n + 3) {
grid-column: span 2;
}
}
}
.card-item {
position: relative;
width: 100%;
border: 1px solid transparent;
border-radius: 12px;
.action {
position: absolute;
top: -14px;
left: 50%;
transform: translateX(-50%);
z-index: 9 !important;
background: white;
opacity: 0;
@apply flex items-center justify-end;
.actionList {
padding: 4px;
border-radius: 4px;
@apply flex items-center justify-center;
}
}
&:hover > .action {
opacity: 1;
}
&:hover > .action > .actionList {
color: rgb(var(--primary-5));
box-shadow: 0 4px 10px -1px rgba(100 100 102/ 15%);
}
}
.wrapper-preview-card {
display: flex;
padding: 16px;
border-radius: 12px;
box-shadow: 0 0 10px rgb(120 56 135 / 5%);
@apply flex-col bg-white;
}
:deep(.arco-table-body) {
max-height: 100% !important;
}
</style>

View File

@ -0,0 +1,150 @@
import { Canvg } from 'canvg';
import html2canvas from 'html2canvas-pro';
import JSPDF from 'jspdf';
const A4_WIDTH = 595;
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 页整
/**
* 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);
});
}
/**
* PDF
* @param name
* @param contentId DOM id
* @description html2canvas生成图片jsPDF生成pdf
* 使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);
const totalHeight = element.scrollHeight;
// jsPDFs实例
const pdf = new JSPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});
pdf.setFontSize(10);
// 计算pdf总页数
let totalPages = 0;
let position = 0;
let pageIndex = 1;
let loopTimes = 0;
const canvasList: HTMLCanvasElement[] = [];
while (position < totalHeight) {
// 这里是大的分页,也就是截图画布的分页
const offscreenHeight = 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,
backgroundColor: '#f9f9fe',
scale: window.devicePixelRatio * 1.2, // 增加清晰度
});
canvasList.push(canvas);
position += offscreenHeight;
totalPages += Math.ceil(canvas.height / realPageHeight);
loopTimes++;
}
totalPages -= loopTimes - 1; // 减去多余的页数
// 生成 PDF
canvasList.forEach((canvas) => {
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const pages = Math.ceil(canvasHeight / realPageHeight);
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 });
if (tempContext) {
// 填充背景颜色为白色
tempContext.fillStyle = '#ffffff';
tempContext.fillRect(0, 0, tempCanvas.width, tempCanvas.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.text(
`${pageIndex} / ${totalPages}`,
pdf.internal.pageSize.width / 2 - 10,
pdf.internal.pageSize.height - 4
);
if (i < pages) {
pdf.addPage();
pageIndex++;
}
}
canvas.remove();
});
pdf.save(`${name}.pdf`);
console.log('end', new Date().getMinutes(), new Date().getSeconds());
}
}

View File

@ -0,0 +1,27 @@
// eslint-disable-next-line import/default
import exportPDFWorker from './exportPDFWorker?worker';
import html2canvas from 'html2canvas-pro';
// eslint-disable-next-line new-cap
const worker = new exportPDFWorker();
worker.onmessage = (event: MessageEvent) => {
const { name, pdfBlob } = event.data;
const url = URL.createObjectURL(pdfBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const exportPdf = async (name: string, contentId: string) => {
const element = document.getElementById(contentId);
if (element) {
// await replaceSvgWithBase64(element);
}
};
export default exportPdf;