feat(报告): 导出PDF报告雏形
This commit is contained in:
parent
5c4de546d9
commit
1c69fb293a
|
@ -55,14 +55,17 @@
|
|||
"ace-builds": "^1.35.2",
|
||||
"ahooks-vue": "^0.15.1",
|
||||
"axios": "^1.7.2",
|
||||
"canvg": "^4.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dotenv": "^16.4.5",
|
||||
"echarts": "^5.5.1",
|
||||
"fastq": "^1.17.1",
|
||||
"github-markdown-css": "^5.6.1",
|
||||
"html2canvas-pro": "^1.5.8",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"jsonpath-plus": "^8.1.0",
|
||||
"jspdf": "^2.5.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lossless-json": "^4.0.1",
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<div v-if="props.executeResult" class="flex items-center">
|
||||
<MsIcon
|
||||
<!-- <MsIcon
|
||||
:type="lastExecuteResultMap[props.executeResult]?.icon || ''"
|
||||
class="mr-1"
|
||||
:size="16"
|
||||
:style="{ color: lastExecuteResultMap[props.executeResult]?.color }"
|
||||
></MsIcon>
|
||||
></MsIcon> -->
|
||||
<span class="text-[14px]">{{ lastExecuteResultMap[props.executeResult]?.statusText || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -14,7 +14,7 @@ export default function useMenuTree() {
|
|||
const menuTree = computed(() => {
|
||||
const copyRouter = cloneDeep(appClientMenus) as 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) {
|
||||
if (!_routes) return null;
|
||||
|
|
|
@ -189,7 +189,9 @@ export const seriesConfig = {
|
|||
type: 'pie',
|
||||
radius: ['75%', '93%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
minAngle: 5, // 设置扇区的最小角度
|
||||
minShowLabelAngle: 10, // 设置标签显示的最小角度
|
||||
avoidLabelOverlap: true, // 避免标签重叠
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center',
|
||||
|
|
|
@ -112,6 +112,11 @@ export enum ShareEnum {
|
|||
SHARE_REPORT_TEST_PLAN = 'shareReportTestPlan',
|
||||
}
|
||||
|
||||
export enum FullPageEnum {
|
||||
FULL_PAGE = 'fullPage',
|
||||
FULL_PAGE_TEST_PLAN_EXPORT_PDF = 'fullPageTestPlanExportPDF',
|
||||
}
|
||||
|
||||
export const RouteEnum = {
|
||||
...ApiTestRouteEnum,
|
||||
...SettingRouteEnum,
|
||||
|
@ -123,4 +128,5 @@ export const RouteEnum = {
|
|||
...UITestRouteEnum,
|
||||
...WorkbenchRouteEnum,
|
||||
...ShareEnum,
|
||||
...FullPageEnum,
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -6,6 +6,7 @@ export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
|
|||
export const PAGE_LAYOUT = () => import('@/layout/page-layout.vue');
|
||||
export const NO_PERMISSION_LAYOUT = () => import('@/layout/no-permission-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 = {
|
||||
path: '/index',
|
||||
|
|
|
@ -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;
|
|
@ -367,14 +367,14 @@
|
|||
} else {
|
||||
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) => {
|
||||
return {
|
||||
value: detail.value[item.value] || 0,
|
||||
name: t(item.label),
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
borderWidth: 2,
|
||||
borderWidth: pieBorderWidth,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -259,14 +259,14 @@
|
|||
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) => {
|
||||
return {
|
||||
value: detail.value[item.value] || 0,
|
||||
name: t(item.label),
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
borderWidth: 2,
|
||||
borderWidth: requestChartBorderWidth,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
@ -279,6 +279,11 @@
|
|||
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) => {
|
||||
const valueName = `step${item.value.charAt(0).toUpperCase() + item.value.slice(1)}`;
|
||||
return {
|
||||
|
@ -286,7 +291,7 @@
|
|||
name: t(item.label),
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
borderWidth: 2,
|
||||
borderWidth: stepChartBorderWidth,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
import useTable from '@/components/pure/ms-table/useTable';
|
||||
|
||||
import { getReportBugList, getReportShareBugList } from '@/api/modules/test-plan/report';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import useOpenNewPage from '@/hooks/useOpenNewPage';
|
||||
|
||||
import { ReportBugItem } from '@/models/testPlan/report';
|
||||
|
@ -27,8 +26,6 @@
|
|||
|
||||
const { openNewPage } = useOpenNewPage();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
reportId: string;
|
||||
shareId?: string;
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
|
||||
const props = defineProps<{
|
||||
detail: PlanReportDetail;
|
||||
animation?: boolean; // 是否开启动画
|
||||
}>();
|
||||
|
||||
const legendData = ref<LegendData[]>([]);
|
||||
|
@ -35,6 +36,7 @@
|
|||
tooltip: {
|
||||
...toolTipConfig,
|
||||
},
|
||||
animation: props.animation === undefined ? true : props.animation, // 关闭渲染动画
|
||||
series: {
|
||||
...seriesConfig,
|
||||
data: [
|
||||
|
@ -78,13 +80,17 @@
|
|||
});
|
||||
|
||||
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 {
|
||||
value: props.detail.executeCount[item.value] || 0,
|
||||
name: t(item.label),
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
borderWidth: 2,
|
||||
borderWidth: pieBorderWidth,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -27,6 +27,9 @@
|
|||
<MsIcon type="icon-icon_share1" class="mr-2 font-[16px]" />
|
||||
{{ t('common.share') }}
|
||||
</MsButton>
|
||||
<MsButton type="icon" status="secondary" class="ml-4 !rounded-[var(--border-radius-small)]" @click="exportPdf">
|
||||
导出 PDF
|
||||
</MsButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -39,11 +42,12 @@
|
|||
|
||||
import { planReportShare } from '@/api/modules/test-plan/report';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import useOpenNewPage from '@/hooks/useOpenNewPage';
|
||||
import useAppStore from '@/store/modules/app';
|
||||
import { hasAnyPermission } from '@/utils/permission';
|
||||
|
||||
import type { PlanReportDetail } from '@/models/testPlan/testPlanReport';
|
||||
import { RouteEnum } from '@/enums/routeEnum';
|
||||
import { FullPageEnum, RouteEnum } from '@/enums/routeEnum';
|
||||
|
||||
const props = defineProps<{
|
||||
detail: PlanReportDetail;
|
||||
|
@ -54,6 +58,7 @@
|
|||
const appStore = useAppStore();
|
||||
const { t } = useI18n();
|
||||
const { copy, isSupported } = useClipboard({ legacy: true });
|
||||
const { openNewPage } = useOpenNewPage();
|
||||
|
||||
const shareLink = ref<string>('');
|
||||
const shareLoading = ref<boolean>(false);
|
||||
|
@ -78,4 +83,11 @@
|
|||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
function exportPdf() {
|
||||
openNewPage(FullPageEnum.FULL_PAGE_TEST_PLAN_EXPORT_PDF, {
|
||||
id: props.detail.id,
|
||||
type: props.isGroup ? 'GROUP' : 'TEST_PLAN',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -14,8 +14,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import MsCard from '@/components/pure/ms-card/index.vue';
|
||||
import PlanDetailHeaderRight from '@/views/test-plan/report/detail/component/system-card/planDetailHeaderRight.vue';
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { TriggerPopupTranslate } from '@arco-design/web-vue';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
|
|
@ -235,7 +235,6 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
@ -387,13 +386,16 @@
|
|||
const passRateData = statusConfig.filter((item) => ['success'].includes(item.value));
|
||||
const { success } = caseCountDetail;
|
||||
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 {
|
||||
value: caseCountDetail[item.value] || 0,
|
||||
name: t(item.label),
|
||||
itemStyle: {
|
||||
color: success ? item.color : '#D4D4D8',
|
||||
borderWidth: 2,
|
||||
borderWidth: chartBorderWidth,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<template>
|
||||
<a-spin :loading="loading" class="h-full w-full">
|
||||
<ViewReport
|
||||
v-model:card-list="cardItemList"
|
||||
:detail-info="detail"
|
||||
|
@ -6,10 +7,10 @@
|
|||
is-preview
|
||||
@update-success="getDetail()"
|
||||
/>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
|
@ -28,12 +29,17 @@
|
|||
const isGroup = computed(() => route.query.type === 'GROUP');
|
||||
|
||||
const cardItemList = ref<configItem[]>([]);
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
async function getDetail() {
|
||||
try {
|
||||
loading.value = true;
|
||||
detail.value = await getReportDetail(reportId.value);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
// 有5个的时候,上面2个,下面3个
|
||||
&[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>
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
Loading…
Reference in New Issue