feat(报告): 导出PDF报告雏形
This commit is contained in:
parent
5c4de546d9
commit
1c69fb293a
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 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',
|
||||||
|
|
|
@ -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 {
|
} 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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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