feat(测试计划): 测试计划联调执行历史&补充权限&用例详情缺陷新建和关联&调整bug细节问题

This commit is contained in:
xinxin.wu 2024-05-17 17:37:09 +08:00 committed by Craftsman
parent d96d4fc8ac
commit a66ba037f5
24 changed files with 522 additions and 272 deletions

View File

@ -8,6 +8,7 @@ import {
batchCopyPlanUrl,
batchDeletePlanUrl,
BatchDisassociateCaseUrl,
BatchEditTestPlanUrl,
batchMovePlanUrl,
BatchRunCaseUrl,
BatchUpdateCaseExecutorUrl,
@ -16,6 +17,7 @@ import {
DeleteTestPlanModuleUrl,
DisassociateCaseUrl,
EditCaseLastExecResultUrl,
ExecuteHistoryUrl,
followPlanUrl,
GenerateReportUrl,
GetAssociatedBugUrl,
@ -52,6 +54,8 @@ import type {
BatchUpdateCaseExecutorParams,
DisassociateCaseParams,
EditLastExecResultParams,
ExecuteHistoryItem,
ExecuteHistoryType,
FollowPlanParams,
PassRateCountDetail,
PlanDetailBugItem,
@ -85,6 +89,11 @@ export function moveTestPlanModuleTree(data: MoveModules) {
return MSR.post({ url: MoveTestPlanModuleUrl, data });
}
// 批量编辑测试计划
export function batchEditTestPlan(data: TableQueryParams) {
return MSR.post({ url: BatchEditTestPlanUrl, data });
}
// 删除模块
export function deletePlanModuleTree(id: string) {
return MSR.get({ url: `${DeleteTestPlanModuleUrl}/${id}` });
@ -226,3 +235,7 @@ export function associateBugToPlan(data: TableQueryParams) {
export function testPlanCancelBug(id: string) {
return MSR.get({ url: `${TestPlanCancelBugUrl}/${id}` });
}
// 测试计划-用例详情-执行历史
export function executeHistory(data: ExecuteHistoryType) {
return MSR.post<ExecuteHistoryItem[]>({ url: ExecuteHistoryUrl, data });
}

View File

@ -22,6 +22,8 @@ export const UpdateTestPlanUrl = '/test-plan/update';
export const batchDeletePlanUrl = '/test-plan/batch-delete';
// 删除测试计划
export const deletePlanUrl = '/test-plan/delete';
// 测试计划批量编辑
export const BatchEditTestPlanUrl = '/test-plan/batch-edit';
// 获取统计数量
export const getStatisticalCountUrl = '/test-plan/getCount';
// 归档
@ -29,7 +31,7 @@ export const archivedPlanUrl = '/test-plan/archived';
// 批量复制
export const batchCopyPlanUrl = '/test-plan/batch-copy';
// 批量移动
export const batchMovePlanUrl = '/test-plan/batch/move';
export const batchMovePlanUrl = '/test-plan/batch-move';
// 批量归档
export const batchArchivedPlanUrl = '/test-plan/batch-archived';
// 计划详情缺陷管理列表
@ -74,3 +76,5 @@ export const BatchRunCaseUrl = '/test-plan/functional/case/batch/run';
export const GetTestPlanUsersUrl = '/test-plan/functional/case/user-option';
// 计划详情-功能用例-批量更新执行人
export const BatchUpdateCaseExecutorUrl = '/test-plan/functional/case/batch/update/executor';
// 计划详情-功能用例-执行历史
export const ExecuteHistoryUrl = '/test-plan/functional/case/exec/history';

View File

@ -86,7 +86,7 @@
}"
:expand-all="isExpandAll"
block-node
title-tooltip-position="left"
title-tooltip-position="top"
@select="folderNodeSelect"
>
<template #title="nodeData">
@ -212,9 +212,11 @@
moduleCountParams?: TableQueryParams; //
hideProjectSelect?: boolean; //
isHiddenCaseLevel?: boolean;
selectorAll: boolean;
}>(),
{
isHiddenCaseLevel: false,
selectorAll: false,
}
);
@ -421,6 +423,7 @@
selectable: true,
showSelectAll: true,
heightUsed: 310,
showSelectorAll: !props.selectorAll,
},
(record) => {
return {

View File

@ -185,6 +185,8 @@ export interface RunFeatureCaseParams extends ExecuteFeatureCaseFormParams {
notifier?: string;
}
export type ExecuteHistoryType = Pick<RunFeatureCaseParams, 'id' | 'testPlanId' | 'caseId'>;
export interface BatchExecuteFeatureCaseParams extends BatchFeatureCaseParams, ExecuteFeatureCaseFormParams {
notifier?: string;
}
@ -213,4 +215,19 @@ export interface PassRateCountDetail {
apiScenarioCount: number;
}
// 执行历史
export interface ExecuteHistoryItem {
status: string;
content: string;
contentText: string;
stepsExecResult: string;
createUser: string;
userName: string;
userLogo: string;
email: string;
steps: string;
createTime: string;
deleted: boolean;
}
export default {};

View File

@ -65,7 +65,7 @@
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import type { BatchEditCaseType, CustomAttributes } from '@/models/caseManagement/featureCase';
import type { CustomAttributes } from '@/models/caseManagement/featureCase';
import { TableQueryParams } from '@/models/common';
import Message from '@arco-design/web-vue/es/message';

View File

@ -25,7 +25,7 @@
children: 'children',
count: 'count',
}"
title-tooltip-position="left"
title-tooltip-position="top"
@select="caseNodeSelect"
@more-action-select="handleCaseMoreSelect"
@more-actions-close="moreActionsClose"

View File

@ -97,10 +97,10 @@
});
const innerKeyword = useVModel(props, 'keyword', emit);
function searchData() {
function searchData(keyword?: string) {
setLinkListParams({
...props.loadParams,
keyword: innerKeyword.value,
keyword,
projectId: appStore.currentProjectId,
condition: {
keyword: innerKeyword.value,
@ -123,7 +123,7 @@
}
onBeforeMount(() => {
searchData();
searchData(innerKeyword.value);
});
watch(
@ -139,7 +139,7 @@
() => props.caseId,
(val) => {
if (val) {
searchData();
searchData(innerKeyword.value);
}
}
);

View File

@ -57,7 +57,6 @@
import { TableKeyEnum } from '@/enums/tableEnum';
import { getCaseLevels } from '@/views/case-management/caseManagementFeature/components/utils';
import debounce from 'lodash-es/debounce';
const { t } = useI18n();

View File

@ -73,19 +73,19 @@
/>
<ms-base-table v-else v-bind="testPlanPropsRes" ref="planTableRef" v-on="testPlanTableEvent">
<template #name="{ record }">
<div class="one-line-text max-w-[300px]"> {{ record.name }}</div>
<a-popover title="" position="right">
<span class="ml-1 text-[rgb(var(--primary-5))]">{{ t('caseManagement.featureCase.preview') }}</span>
<template #content>
<div class="max-w-[600px] text-[14px] text-[var(--color-text-1)]">
{{ record.content }}
</div>
</template>
</a-popover>
<div class="flex flex-nowrap items-center">
<div class="one-line-text">{{ characterLimit(record.name) }}</div>
<a-popover title="" position="right" style="width: 480px">
<div class="ml-1 text-[rgb(var(--primary-5))]">{{ t('caseManagement.featureCase.preview') }}</div>
<template #content>
<div v-dompurify-html="record.content" class="markdown-body" style="margin-left: 48px"> </div>
</template>
</a-popover>
</div>
</template>
<template #handleUserName="{ record }">
<a-tooltip :content="record.handleUserName">
<div class="one-line-text max-w-[200px]">{{ characterLimit(record.handleUserName) }}</div>
<div class="one-line-text max-w-[200px]">{{ characterLimit(record.handleUserName) || '-' }}</div>
</a-tooltip>
</template>
<template #testPlanName="{ record }">
@ -259,7 +259,7 @@
{
title: 'caseManagement.featureCase.tableColumnID',
dataIndex: 'num',
width: 200,
width: 100,
showInTable: true,
showTooltip: true,
ellipsis: true,
@ -271,7 +271,7 @@
dataIndex: 'name',
showInTable: true,
showTooltip: true,
width: 300,
width: 250,
ellipsis: true,
showDrag: false,
},
@ -281,7 +281,7 @@
dataIndex: 'testPlanName',
showInTable: true,
showTooltip: true,
width: 300,
width: 200,
ellipsis: true,
showDrag: false,
},
@ -291,7 +291,7 @@
dataIndex: 'defectState',
showInTable: true,
showTooltip: true,
width: 300,
width: 150,
ellipsis: true,
showDrag: false,
},
@ -305,7 +305,7 @@
},
showInTable: true,
showTooltip: true,
width: 300,
width: 200,
ellipsis: true,
},
];
@ -364,7 +364,7 @@
return;
}
if (showType.value === 'link') {
bugTableListRef.value?.searchData();
bugTableListRef.value?.searchData(keyword.value);
} else {
setTestPlanListParams(initTableParams());
await testPlanLinkList();
@ -373,7 +373,7 @@
}
async function resetFetch() {
if (showType.value === 'link') {
bugTableListRef.value?.searchData();
bugTableListRef.value?.searchData(keyword.value);
} else {
setTestPlanListParams({ keyword: '', projectId: appStore.currentProjectId, testPlanCaseId: props.caseId });
await testPlanLinkList();

View File

@ -45,7 +45,7 @@
{{ t('caseManagement.caseReview.fail') }}
</div>
<div v-else-if="item.status === 'UNDER_REVIEWED'" class="flex items-center">
<MsIcon type="icon-icon_warning_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
<MsIcon type="icon-icon_warning_filled" class="mr-[4px] text-[rgb(var(--link-6))]" />
{{ t('caseManagement.caseReview.suggestion') }}
</div>
<div v-else-if="item.status === 'RE_REVIEWED'" class="flex items-center">
@ -57,11 +57,11 @@
{{ t('caseManagement.featureCase.execute.success') }}
</div>
<div v-if="item.status === 'BLOCKED'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('caseManagement.featureCase.execute.blocked') }}
</div>
<div v-if="item.status === 'FAILED'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--danger-6))]" />
{{ t('caseManagement.featureCase.execute.failed') }}
</div>
</div>

View File

@ -10,23 +10,30 @@
:type="RequestModuleEnum.CASE_MANAGEMENT"
hide-project-select
is-hidden-case-level
:selector-all="true"
@save="saveHandler"
>
</MsCaseAssociate>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import MsCaseAssociate from '@/components/business/ms-case-associate/index.vue';
import { RequestModuleEnum } from '@/components/business/ms-case-associate/utils';
import { getCaseList, getCaseModuleTree } from '@/api/modules/case-management/featureCase';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import type { AssociateCaseRequest } from '@/models/testPlan/testPlan';
import type { AssociateCaseRequest, AssociateCaseRequestType } from '@/models/testPlan/testPlan';
import { CaseLinkEnum } from '@/enums/caseEnum';
const { t } = useI18n();
const props = defineProps<{
hasNotAssociatedIds?: string[];
saveApi?: (params: AssociateCaseRequestType) => Promise<any>;
}>();
const innerVisible = defineModel<boolean>('visible', {
required: true,
@ -36,16 +43,31 @@
}>();
const appStore = useAppStore();
const route = useRoute();
const currentSelectCase = ref<keyof typeof CaseLinkEnum>('FUNCTIONAL');
const currentProjectId = ref(appStore.currentProjectId);
const confirmLoading = ref<boolean>(false);
const planId = ref(route.query.id as string);
function saveHandler(params: AssociateCaseRequest) {
async function saveHandler(params: AssociateCaseRequest) {
try {
confirmLoading.value = true;
emit('success', { ...params, functionalSelectIds: params.selectIds });
if (typeof props.saveApi !== 'function') {
emit('success', { ...params, functionalSelectIds: params.selectIds });
} else {
try {
await props.saveApi({
functionalSelectIds: params.selectIds,
testPlanId: planId.value,
});
emit('success', { ...params, functionalSelectIds: params.selectIds });
Message.success(t('ms.case.associate.associateSuccess'));
confirmLoading.value = false;
} catch (error) {
console.log(error);
}
}
innerVisible.value = false;
} catch (error) {
// eslint-disable-next-line no-console

View File

@ -1,100 +1,116 @@
<template>
<MsDialog
v-model:visible="isVisible"
dialog-size="small"
:title="t('testPlan.testPlanIndex.batchEdit', { number: props.batchParams.currentSelectCount })"
ok-text="common.update"
:confirm="confirmHandler"
:close="closeHandler"
unmount-on-close
:switch-props="{
switchName: t('caseManagement.featureCase.appendTag'),
switchTooltip: t('caseManagement.featureCase.enableTags'),
showSwitch: form.selectedAttrsId === 'tags' ? true : false,
enable: form.append,
}"
>
<div class="form">
<a-form ref="formRef" class="rounded-[4px]" :model="form" layout="vertical">
<a-form-item
field="selectedAttrsId"
:label="t('apiTestManagement.chooseAttr')"
:rules="[{ required: true, message: t('apiTestManagement.attrRequired') }]"
asterisk-position="end"
>
<a-select v-model="form.selectedAttrsId" :placeholder="t('common.pleaseSelect')">
<a-option v-for="item of attrOptions" :key="item.value" :value="item.value">
{{ t(item.name) }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
<a-modal v-model:visible="isVisible" title-align="start" class="ms-modal-upload ms-modal-medium" :width="480">
<template #title>
{{ t('common.edit') }}
<div class="text-[var(--color-text-4)]">
{{
t('case.batchModalSubTitle', {
count: props.batchParams.currentSelectCount,
})
}}
</div>
</template>
<a-form ref="formRef" class="rounded-[4px]" :model="form" layout="vertical">
<a-form-item
field="selectedAttrsId"
:label="t('apiTestManagement.chooseAttr')"
:rules="[{ required: true, message: t('apiTestManagement.attrRequired') }]"
asterisk-position="end"
>
<a-select v-model="form.selectedAttrsId" :placeholder="t('common.pleaseSelect')">
<a-option v-for="item of attrOptions" :key="item.value" :value="item.value">
{{ t(item.name) }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
v-if="form.selectedAttrsId === 'tags'"
field="tags"
:label="t('apiTestManagement.batchUpdate')"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
required
>
<MsTagsInput v-model:modelValue="form.tags" allow-clear></MsTagsInput>
</a-form-item>
<a-form-item
v-else
field="value"
:label="t('apiTestManagement.batchUpdate')"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
>
<a-select v-model="form.value" :placeholder="t('common.pleaseSelect')" :disabled="form.selectedAttrsId === ''">
<a-option v-for="item of valueOptions" :key="item.value" :value="item.value">
{{ t(item.label) }}
</a-option>
</a-select>
</a-form-item>
</a-form>
<template #footer>
<div class="flex" :class="[form.selectedAttrsId === 'tags' ? 'justify-between' : 'justify-end']">
<div
v-if="form.selectedAttrsId === 'tags'"
field="values"
:label="t('apiTestManagement.batchUpdate')"
:validate-trigger="['blur', 'input']"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
required
class="flex flex-row items-center justify-center"
style="padding-top: 10px"
>
<MsTagsInput
v-model:model-value="form.tags"
placeholder="common.tagsInputPlaceholder"
allow-clear
unique-value
retain-input-value
/>
</a-form-item>
<a-form-item
v-else
field="value"
:label="t('apiTestManagement.batchUpdate')"
:rules="[{ required: true, message: t('apiTestManagement.valueRequired') }]"
asterisk-position="end"
class="mb-0"
>
<a-select
v-model="form.value"
:placeholder="t('common.pleaseSelect')"
:disabled="form.selectedAttrsId === ''"
>
<a-option v-for="item of valueOptions" :key="item.value" :value="item.value">
{{ t(item.label) }}
</a-option>
</a-select>
</a-form-item>
</a-form>
</div>
</MsDialog>
<a-switch v-model="form.append" class="mr-1" size="small" type="line" />
<span class="flex items-center">
<span class="mr-1">{{ t('caseManagement.featureCase.appendTag') }}</span>
<span class="mt-[2px]">
<a-tooltip>
<IconQuestionCircle class="h-[16px] w-[16px] text-[rgb(var(--primary-5))]" />
<template #content>
<div>{{ t('caseManagement.featureCase.enableTags') }}</div>
<div>{{ t('caseManagement.featureCase.closeTags') }}</div>
</template>
</a-tooltip>
</span>
</span>
</div>
<div class="flex justify-end">
<a-button type="secondary" :disabled="batchEditLoading" @click="closeHandler">
{{ t('common.cancel') }}
</a-button>
<a-button class="ml-3" type="primary" :loading="batchEditLoading" @click="confirmHandler">
{{ t('common.update') }}
</a-button>
</div>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { FormInstance, ValidatedError } from '@arco-design/web-vue';
import { FormInstance } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
import MsDialog from '@/components/pure/ms-dialog/index.vue';
import type { BatchActionQueryParams } from '@/components/pure/ms-table/type';
import MsTagsInput from '@/components/pure/ms-tags-input/index.vue';
import { batchEditTestPlan } from '@/api/modules/test-plan/testPlan';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { TableQueryParams } from '@/models/common';
import { testPlanTypeEnum } from '@/enums/testPlanEnum';
import Message from '@arco-design/web-vue/es/message';
const isVisible = ref<boolean>(false);
const appStore = useAppStore();
const { t } = useI18n();
const appStore = useAppStore();
const props = defineProps<{
visible: boolean;
batchParams: BatchActionQueryParams;
activeFolder: string;
offspringIds: string[];
condition?: TableQueryParams;
showType: keyof typeof testPlanTypeEnum;
}>();
const emits = defineEmits<{
@ -102,7 +118,6 @@
(e: 'success'): void;
}>();
const currentProjectId = computed(() => appStore.currentProjectId);
const initForm = {
selectedAttrsId: '',
append: false,
@ -126,33 +141,33 @@
form.value = { ...initForm };
}
async function confirmHandler(enable: boolean | undefined) {
formRef.value?.validate(async (errors: undefined | Record<string, ValidatedError>) => {
const batchEditLoading = ref(false);
function confirmHandler() {
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
const customField = {
fieldId: '',
value: '',
};
batchEditLoading.value = true;
const { selectedIds, selectAll, excludeIds } = props.batchParams;
const params: TableQueryParams = {
selectIds: selectedIds || [],
selectAll: !!selectAll,
excludeIds: excludeIds || [],
projectId: currentProjectId.value,
append: enable as boolean,
tags: form.value.tags,
projectId: appStore.currentProjectId,
moduleIds: props.activeFolder === 'all' ? [] : [props.activeFolder, ...props.offspringIds],
customField: form.value.selectedAttrsId === 'systemTags' ? {} : customField,
condition: {
...props.condition,
},
...form.value,
type: props.showType,
};
await batchEditTestPlan(params);
Message.success(t('caseManagement.featureCase.editSuccess'));
closeHandler();
emits('success');
} catch (e) {
console.log(e);
} finally {
batchEditLoading.value = false;
}
} else {
return false;

View File

@ -44,6 +44,7 @@
class="mt-4"
:action-config="testPlanBatchActions"
filter-icon-align-left
:selectable="hasOperationPermission"
v-on="propsEvent"
@batch-action="handleTableBatch"
>
@ -115,7 +116,7 @@
<StatusProgress :status-detail="defaultCountDetailMap[record.id]" height="5px" />
</div>
<div class="text-[var(--color-text-1)]">
{{ `${record.passRate || 0}%` }}
{{ `${defaultCountDetailMap[record.id] ? defaultCountDetailMap[record.id].passRate : '-'}%` }}
</div>
</template>
<template #passRateTitleSlot="{ columnConfig }">
@ -173,10 +174,17 @@
<template #operation="{ record }">
<div class="flex items-center">
<MsButton v-if="record.functionalCaseCount > 0" class="!mx-0">{{
t('testPlan.testPlanIndex.execution')
}}</MsButton>
<a-divider v-if="record.functionalCaseCount > 0" direction="vertical" :margin="8"></a-divider>
<MsButton
v-if="record.functionalCaseCount > 0 && hasAnyPermission(['PROJECT_TEST_PLAN:READ+EXECUTE'])"
class="!mx-0"
@click="openDetail(record.id)"
>{{ t('testPlan.testPlanIndex.execution') }}</MsButton
>
<a-divider
v-if="record.functionalCaseCount > 0 && hasAnyPermission(['PROJECT_TEST_PLAN:READ+EXECUTE'])"
direction="vertical"
:margin="8"
></a-divider>
<MsButton
v-permission="['PROJECT_TEST_PLAN:READ+UPDATE']"
@ -245,6 +253,7 @@
:active-folder="props.activeFolder"
:offspring-ids="props.offspringIds"
:condition="conditionParams"
:show-type="showType"
@success="successHandler"
/>
</template>
@ -289,6 +298,7 @@
import { characterLimit } from '@/utils';
import { hasAnyPermission } from '@/utils/permission';
import type { TableQueryParams } from '@/models/common';
import { ModuleTreeNode } from '@/models/common';
import type { PassRateCountDetail, planStatusType, TestPlanItem } from '@/models/testPlan/testPlan';
import { TestPlanRouteEnum } from '@/enums/routeEnum';
@ -319,6 +329,10 @@
(e: 'editOrCopy', id: string, isCopy: boolean): void;
}>();
const hasOperationPermission = computed(() =>
hasAnyPermission(['PROJECT_TEST_PLAN:READ+UPDATE', 'PROJECT_TEST_PLAN:READ+EXECUTE', 'PROJECT_TEST_PLAN:READ+ADD'])
);
const columns: MsTableColumn = [
{
title: 'testPlan.testPlanIndex.ID',
@ -443,11 +457,11 @@
showDrag: true,
},
{
title: 'testPlan.testPlanIndex.operation',
title: hasOperationPermission.value ? 'testPlan.testPlanIndex.operation' : '',
slotName: 'operation',
dataIndex: 'operation',
fixed: 'right',
width: 200,
width: hasOperationPermission.value ? 200 : 50,
showInTable: true,
showDrag: false,
},
@ -537,37 +551,43 @@
{
label: 'common.archive',
eventTag: 'archive',
permission: ['PROJECT_TEST_PLAN:READ+UPDATE'],
},
];
const copyActions: ActionsItem[] = [
{
label: 'common.copy',
eventTag: 'copy',
permission: ['PROJECT_TEST_PLAN:READ+ADD'],
},
];
function getMoreActions(status: planStatusType, useCount: number) {
//
const copyAction = useCount > 0 ? copyActions : [];
if (status === 'COMPLETED' || status === 'ARCHIVED') {
//
if (status === 'ARCHIVED' || status === 'PREPARED' || status === 'UNDERWAY') {
return [
...copyAction,
{
isDivider: true,
},
{
label: 'common.delete',
danger: true,
eventTag: 'delete',
permission: ['PROJECT_TEST_PLAN:READ+DELETE'],
},
];
}
return [
...copyAction,
...archiveActions,
{
isDivider: true,
},
{
label: 'common.delete',
danger: true,
eventTag: 'delete',
permission: ['PROJECT_TEST_PLAN:READ+DELETE'],
},
];
}
@ -610,7 +630,6 @@
filter: propsRes.value.filter,
combine: batchParams.value.condition,
};
return {
type: showType.value,
moduleIds: props.activeFolder && props.activeFolder !== 'all' ? [props.activeFolder, ...props.offspringIds] : [],
@ -620,6 +639,7 @@
selectIds: batchParams.value.selectedIds || [],
keyword: keyword.value,
condition: {
filter: propsRes.value.filter,
keyword: keyword.value,
},
combine: {
@ -832,6 +852,7 @@
}
function successHandler() {
resetSelector();
fetchData();
}

View File

@ -8,7 +8,7 @@
:node-more-actions="caseMoreActions"
:expand-all="props.isExpandAll"
:empty-text="t('testPlan.testPlanIndex.planEmptyContent')"
draggable
:draggable="hasAnyPermission(['PROJECT_TEST_PLAN:READ+UPDATE'])"
:virtual-list-props="virtualListProps"
block-node
:field-names="{
@ -17,7 +17,7 @@
children: 'children',
count: 'count',
}"
title-tooltip-position="left"
title-tooltip-position="top"
@select="planNodeSelect"
@more-action-select="handlePlanMoreSelect"
@more-actions-close="moreActionsClose"
@ -33,6 +33,7 @@
</template>
<template #extra="nodeData">
<MsPopConfirm
v-if="hasAnyPermission(['PROJECT_TEST_PLAN:READ+ADD'])"
:visible="addSubVisible"
:is-delete="false"
:all-names="[]"
@ -50,6 +51,7 @@
</MsButton>
</MsPopConfirm>
<MsPopConfirm
v-if="hasAnyPermission(['PROJECT_TEST_PLAN:READ+UPDATE'])"
:title="t('testPlan.testPlanIndex.rename')"
:all-names="[]"
:is-delete="false"
@ -87,6 +89,7 @@
import useModal from '@/hooks/useModal';
import useAppStore from '@/store/modules/app';
import { mapTree } from '@/utils';
import { hasAnyPermission } from '@/utils/permission';
import type { CreateOrUpdateModule, UpdateModule } from '@/models/caseManagement/featureCase';
import { ModuleTreeNode } from '@/models/common';

View File

@ -287,9 +287,12 @@
if (props.planId?.length) {
const result = await getTestPlanDetail(props.planId);
form.value = cloneDeep(result);
let copyName = `copy_${result.name}`;
copyName = copyName.length > 255 ? copyName.slice(0, 255) : copyName;
form.value.name = copyName;
if (props.isCopy) {
let copyName = `copy_${result.name}`;
copyName = copyName.length > 255 ? copyName.slice(0, 255) : copyName;
form.value.name = copyName;
}
form.value.cycle = [result.plannedStartTime as number, result.plannedEndTime as number];
}
} catch (error) {

View File

@ -534,6 +534,7 @@
...tableParams.value,
...batchExecuteForm.value,
notifier: batchExecuteForm.value?.commentIds?.join(';'),
selectIds: batchParams.value.selectedIds,
});
Message.success(t('common.updateSuccess'));
resetSelector();
@ -636,6 +637,7 @@
defineExpose({
resetSelector,
loadCaseList,
});
await tableStore.initColumn(TableKeyEnum.TEST_PLAN_DETAIL_FEATURE_CASE_TABLE, columns, 'drawer', true);

View File

@ -48,7 +48,7 @@
class="mx-[8px] w-[240px]"
@search="initData"
@press-enter="initData"
@clear="initData"
@clear="resetHandler"
/>
</div>
<BugList
@ -62,26 +62,10 @@
testPlanCaseId: route.query.testPlanCaseId,
caseId: props.caseId,
}"
@link="linkDefect"
@new="createDefect"
@link="emit('link')"
@new="emit('new')"
@cancel-link="cancelLink"
/>
<LinkDefectDrawer
v-model:visible="showLinkDrawer"
:case-id="props.caseId"
:drawer-loading="drawerLoading"
@save="saveHandler"
/>
<AddDefectDrawer
v-model:visible="showDrawer"
:case-id="props.caseId"
::extra-params="{
testPlanCaseId: route.query.testPlanCaseId,
caseId: props.caseId,
testPlanId:props.testPlanId,
}"
@success="initData()"
/>
</div>
</template>
@ -92,12 +76,10 @@
import MsButton from '@/components/pure/ms-button/index.vue';
import type { MsTableColumn } from '@/components/pure/ms-table/type';
import AddDefectDrawer from '@/views/case-management/caseManagementFeature/components/tabContent/tabBug/addDefectDrawer.vue';
import BugList from '@/views/case-management/caseManagementFeature/components/tabContent/tabBug/bugList.vue';
import LinkDefectDrawer from '@/views/case-management/caseManagementFeature/components/tabContent/tabBug/linkDefectDrawer.vue';
import { getBugList, getCustomOptionHeader } from '@/api/modules/bug-management';
import { associateBugToPlan, associatedBugPage, testPlanCancelBug } from '@/api/modules/test-plan/testPlan';
import { associatedBugPage, testPlanCancelBug } from '@/api/modules/test-plan/testPlan';
import { useI18n } from '@/hooks/useI18n';
import { useAppStore } from '@/store';
import { hasAnyPermission } from '@/utils/permission';
@ -116,6 +98,12 @@
const keyword = ref<string>('');
const emit = defineEmits<{
(e: 'link'): void;
(e: 'new'): void;
(e: 'save', params: TableQueryParams): void;
}>();
const columns = ref<MsTableColumn>([
{
title: 'caseManagement.featureCase.tableColumnID',
@ -188,26 +176,16 @@
if (!hasAnyPermission(['FUNCTIONAL_CASE:READ', 'FUNCTIONAL_CASE:READ+UPDATE', 'FUNCTIONAL_CASE:READ+DELETE'])) {
return;
}
bugTableListRef.value?.searchData();
}
const showLinkDrawer = ref<boolean>(false);
function linkDefect() {
showLinkDrawer.value = true;
}
const showDrawer = ref<boolean>(false);
function createDefect() {
showDrawer.value = true;
bugTableListRef.value?.searchData(keyword.value);
}
function handleSelect(value: string | number | Record<string, any> | undefined) {
switch (value) {
case 'associated':
linkDefect();
emit('link');
break;
default:
createDefect();
emit('new');
break;
}
}
@ -232,6 +210,7 @@
}
const cancelLoading = ref<boolean>(false);
//
async function cancelLink(id: string) {
cancelLoading.value = true;
@ -261,25 +240,10 @@
}
}
const route = useRoute();
const drawerLoading = ref<boolean>(false);
//
async function saveHandler(params: TableQueryParams) {
try {
drawerLoading.value = true;
await associateBugToPlan({
...params,
caseId: props.caseId,
testPlanId: props.testPlanId,
testPlanCaseId: route.query.testPlanCaseId as string,
});
Message.success(t('caseManagement.featureCase.associatedSuccess'));
initData();
showLinkDrawer.value = false;
} catch (error) {
console.log(error);
} finally {
drawerLoading.value = false;
}
function resetHandler() {
keyword.value = '';
initData();
}
watch(
@ -296,6 +260,10 @@
initData();
initBugList();
});
defineExpose({
initData,
});
</script>
<style scoped></style>

View File

@ -1,84 +1,107 @@
<template>
<!-- TODO: 待联调 -->
<div class="review-history-list">
<div v-for="item of executeHistoryList" :key="item.id" class="review-history-list-item">
<div class="flex items-center">
<MsAvatar :avatar="item.userLogo" />
<div class="ml-[8px] flex items-center">
<a-tooltip :content="item.userName" :mouse-enter-delay="300">
<div class="one-line-text max-w-[300px] font-medium text-[var(--color-text-1)]">{{ item.userName }}</div>
</a-tooltip>
<a-divider direction="vertical" margin="8px"></a-divider>
<div v-if="item.status === 'PASS'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.caseReview.pass') }}
<a-spin :loading="loading" class="w-full">
<div class="execute-history-list">
<div v-for="item of executeHistoryList" :key="item.status" class="execute-history-list-item">
<div class="flex items-center">
<MsAvatar :avatar="item.userLogo" />
<div class="ml-[8px] flex items-center">
<a-tooltip :content="item.userName" :mouse-enter-delay="300">
<div class="one-line-text max-w-[300px] font-medium text-[var(--color-text-1)]">{{ item.userName }}</div>
</a-tooltip>
<a-divider direction="vertical" margin="8px"></a-divider>
<div v-if="item.status === 'SUCCESS'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('common.success') }}
</div>
<div v-if="item.status === 'BLOCKED'" class="flex items-center">
<MsIcon type="icon-icon_block_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('common.block') }}
</div>
<div v-if="item.status === 'ERROR'" class="flex items-center">
<MsIcon type="icon-icon_close_filled" class="mr-[4px] text-[rgb(var(--danger-6))]" />
{{ t('common.fail') }}
</div>
</div>
<div v-else-if="item.status === 'UN_PASS'" class="flex items-center">
<MsIcon type="icon-icon_close_filled" class="mr-[4px] text-[rgb(var(--danger-6))]" />
{{ t('caseManagement.caseReview.fail') }}
</div>
<div v-else-if="item.status === 'UNDER_REVIEWED'" class="flex items-center">
<MsIcon type="icon-icon_warning_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('caseManagement.caseReview.suggestion') }}
</div>
<div v-else-if="item.status === 'RE_REVIEWED'" class="flex items-center">
<MsIcon type="icon-icon_resubmit_filled" class="mr-[4px] text-[rgb(var(--warning-6))]" />
{{ t('caseManagement.caseReview.reReview') }}
</div>
<div v-if="item.status === 'PASSED'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.featureCase.execute.success') }}
</div>
<div v-if="item.status === 'BLOCKED'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.featureCase.execute.blocked') }}
</div>
<div v-if="item.status === 'FAILED'" class="flex items-center">
<MsIcon type="icon-icon_succeed_filled" class="mr-[4px] text-[rgb(var(--success-6))]" />
{{ t('caseManagement.featureCase.execute.failed') }}
</div>
<div class="markdown-body" style="margin-left: 48px" v-html="item.contentText"></div>
<div class="ml-[48px] mt-[8px] flex text-[var(--color-text-4)]">
{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss') }}
<div>
<a-tooltip :content="item.userName" :mouse-enter-delay="300" :disabled="!item.userName">
<span v-if="item.deleted" class="one-line-text ml-[16px] max-w-[300px] break-words break-all">
{{ characterLimit(item.userName) }}
</span>
</a-tooltip>
</div>
</div>
</div>
<div class="markdown-body" style="margin-left: 48px" v-html="item.contentText"></div>
<div class="ml-[48px] mt-[8px] flex text-[var(--color-text-4)]">
{{ dayjs(item.createTime).format('YYYY-MM-DD HH:mm:ss') }}
<div>
<a-tooltip :content="item.reviewName" :mouse-enter-delay="300">
<span v-if="item.deleted" class="one-line-text ml-[16px] max-w-[300px] break-words break-all">
{{ characterLimit(item.reviewName) }}
</span>
<span
v-else
class="one-line-text ml-[16px] max-w-[300px] cursor-pointer break-words break-all text-[rgb(var(--primary-5))]"
>
{{ characterLimit(item.reviewName) }}
</span>
</a-tooltip>
</div>
</div>
<MsEmpty v-if="executeHistoryList.length === 0" />
</div>
<MsEmpty v-if="executeHistoryList.length === 0" />
</div>
</a-spin>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import dayjs from 'dayjs';
import MsAvatar from '@/components/pure/ms-avatar/index.vue';
import MsEmpty from '@/components/pure/ms-empty/index.vue';
import { CommentItem, CommentParams } from '@/components/business/ms-comment/types';
import { executeHistory } from '@/api/modules/test-plan/testPlan';
import { useI18n } from '@/hooks/useI18n';
import { characterLimit } from '@/utils';
import type { ExecuteHistoryItem } from '@/models/testPlan/testPlan';
const { t } = useI18n();
const props = defineProps<{
caseId: string;
}>();
const executeHistoryList = ref<CommentItem[]>([]);
const executeHistoryList = ref<ExecuteHistoryItem[]>([]);
const route = useRoute();
const loading = ref<boolean>(false);
async function initList() {
loading.value = true;
try {
executeHistoryList.value = await executeHistory({
caseId: props.caseId,
id: route.query.testPlanCaseId as string,
testPlanId: route.query.id as string,
});
} catch (error) {
console.log(error);
} finally {
loading.value = false;
}
}
onBeforeMount(() => {
initList();
});
watch(
() => props.caseId,
(val) => {
if (val) {
initList();
}
}
);
</script>
<style scoped></style>
<style scoped lang="less">
.execute-history-list {
height: calc(100vh - 240px);
@apply overflow-auto;
.ms-scroll-bar();
.execute-history-list-item {
&:not(:last-child) {
margin-bottom: 16px;
}
}
}
</style>

View File

@ -120,7 +120,33 @@
size="16"
/>
</a-tooltip>
<!-- TODO: 缺陷 -->
<MsTag type="danger" theme="light" size="medium" class="ml-4">
<MsIcon type="icon-icon_defect" class="!text-[14px] text-[rgb(var(--danger-6))]" size="16" />
<span class="ml-1 text-[rgb(var(--danger-6))]"> {{ t('testPlan.featureCase.bug') }}</span>
<span class="ml-1 text-[rgb(var(--danger-6))]">{{ bugCount }}</span>
</MsTag>
<a-dropdown @select="handleSelect">
<a-button type="outline" size="mini" class="ml-1">
<template #icon> <icon-plus class="text-[12px]" /> </template>
</a-button>
<template #content>
<a-doption value="new">{{ t('common.newCreate') }}</a-doption>
<a-doption v-if="createdBugCount > 0" value="link">{{ t('common.associated') }}</a-doption>
<a-popover v-else title="" position="left">
<a-doption :disabled="true" value="link">{{ t('common.associated') }}</a-doption>
<template #content>
<div class="flex items-center text-[14px]">
<span class="text-[var(--color-text-4)]">{{
t('testPlan.featureCase.noBugDataTooltip')
}}</span>
<MsButton type="text" @click="handleSelect('new')">
{{ t('testPlan.featureCase.noBugDataNewBug') }}
</MsButton>
</div>
</template>
</a-popover>
</template>
</a-dropdown>
</div>
</div>
<ExecuteSubmit
@ -134,19 +160,39 @@
</div>
<BugList
v-if="activeTab === 'defectList'"
:case-id="caseDetail.id"
ref="bugRef"
:case-id="activeCaseId"
:test-plan-id="route.query.id as string"
@link="linkDefect"
@new="addBug"
/>
<ExecutionHistory v-if="activeTab === 'executionHistory'" :case-id="caseDetail.id" />
<ExecutionHistory v-if="activeTab === 'executionHistory'" :case-id="activeCaseId" />
</div>
</a-spin>
</div>
</MsCard>
<EditCaseDetailDrawer v-model:visible="editCaseVisible" :case-id="activeCaseId" @load-case="loadCase" />
<LinkDefectDrawer
v-model:visible="showLinkDrawer"
:case-id="activeCaseId"
:drawer-loading="drawerLoading"
@save="associateSuccessHandler"
/>
<AddDefectDrawer
v-model:visible="showDrawer"
:case-id="activeCaseId"
::extra-params="{
testPlanCaseId: route.query.testPlanCaseId,
caseId: activeCaseId,
testPlanId:route.query.id as string,
}"
@success="addSuccess"
/>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import dayjs from 'dayjs';
import MsCard from '@/components/pure/ms-card/index.vue';
@ -154,19 +200,31 @@
import MsEmpty from '@/components/pure/ms-empty/index.vue';
import MsPagination from '@/components/pure/ms-pagination/index';
import MsTab from '@/components/pure/ms-tab/index.vue';
import MsTag from '@/components/pure/ms-tag/ms-tag.vue';
import ExecuteResult from '@/components/business/ms-case-associate/executeResult.vue';
import MsStatusTag from '@/components/business/ms-status-tag/index.vue';
import BugList from './bug/index.vue';
import ExecuteSubmit from './executeSubmit.vue';
import AddDefectDrawer from '@/views/case-management/caseManagementFeature/components/tabContent/tabBug/addDefectDrawer.vue';
import LinkDefectDrawer from '@/views/case-management/caseManagementFeature/components/tabContent/tabBug/linkDefectDrawer.vue';
import CaseTabDetail from '@/views/case-management/caseManagementFeature/components/tabContent/tabDetail.vue';
import EditCaseDetailDrawer from '@/views/case-management/caseReview/components/editCaseDetailDrawer.vue';
import ExecutionHistory from '@/views/test-plan/testPlan/detail/featureCase/detail/executionHistory/index.vue';
import { getCaseDetail, getPlanDetailFeatureCaseList, getTestPlanDetail } from '@/api/modules/test-plan/testPlan';
import { getBugList } from '@/api/modules/bug-management';
import {
associateBugToPlan,
associatedBugPage,
getCaseDetail,
getPlanDetailFeatureCaseList,
getTestPlanDetail,
} from '@/api/modules/test-plan/testPlan';
import { testPlanDefaultDetail } from '@/config/testPlan';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { hasAnyPermission } from '@/utils/permission';
import type { TableQueryParams } from '@/models/common';
import type { PlanDetailFeatureCaseItem, TestPlanDetail } from '@/models/testPlan/testPlan';
import { LastExecuteResults } from '@/enums/caseEnum';
import { CaseManagementRouteEnum } from '@/enums/routeEnum';
@ -338,7 +396,6 @@
() => activeId.value,
() => {
loadCaseDetail();
// TODO
}
);
@ -389,6 +446,95 @@
}
}
const bugCount = ref<number>(0);
const showLinkDrawer = ref<boolean>(false);
const drawerLoading = ref<boolean>(false);
const showDrawer = ref<boolean>(false);
function addBug() {
showDrawer.value = true;
}
function linkDefect() {
showLinkDrawer.value = true;
}
function handleSelect(value: string | number | Record<string, any> | undefined) {
switch (value) {
case 'new':
addBug();
break;
default:
linkDefect();
break;
}
}
const bugRef = ref();
async function getBugTotal() {
try {
const params = {
testPlanCaseId: route.query.testPlanCaseId,
caseId: activeCaseId.value,
projectId: appStore.currentProjectId,
current: 1,
pageSize: 10,
};
const res = await associatedBugPage(params);
bugCount.value = res.total;
} catch (error) {
console.log(error);
}
}
function addSuccess() {
if (activeTab.value === 'defectList') {
bugRef.value?.initData();
} else {
getBugTotal();
}
}
async function associateSuccessHandler(params: TableQueryParams) {
try {
drawerLoading.value = true;
await associateBugToPlan({
...params,
caseId: activeCaseId.value,
testPlanId: route.query.id as string,
testPlanCaseId: route.query.testPlanCaseId as string,
});
Message.success(t('caseManagement.featureCase.associatedSuccess'));
showLinkDrawer.value = false;
addSuccess();
} catch (error) {
console.log(error);
} finally {
drawerLoading.value = false;
}
}
const createdBugCount = ref<number>(0);
async function initBugList() {
if (!hasAnyPermission(['PROJECT_BUG:READ'])) {
return;
}
const res = await getBugList({
current: 1,
pageSize: 10,
sort: {},
filter: {},
keyword: '',
combine: {},
searchMode: 'AND',
projectId: appStore.currentProjectId,
});
createdBugCount.value = res.total;
}
onBeforeMount(async () => {
const lastPageParams = window.history.state.params ? JSON.parse(window.history.state.params) : null; //
if (lastPageParams) {
@ -404,7 +550,11 @@
moduleIds,
};
}
if (activeTab.value === 'detail') {
getBugTotal();
}
getPlanDetail();
initBugList();
await loadCase();
});
</script>

View File

@ -76,4 +76,12 @@
function initModuleTree(tree: ModuleTreeNode[]) {
moduleTree.value = unref(tree);
}
function getCaseTableList() {
caseTableRef.value?.loadCaseList();
}
defineExpose({
getCaseTableList,
});
</script>

View File

@ -101,11 +101,21 @@
</MsCard>
<!-- special-height的174: 上面卡片高度158 + mt的16 -->
<MsCard class="mt-[16px]" :special-height="174" simple has-breadcrumb no-content-padding>
<FeatureCase v-if="activeTab === 'featureCase'" :repeat-case="detail.repeatCase" @refresh="getStatistics" />
<FeatureCase
v-if="activeTab === 'featureCase'"
ref="featureCaseRef"
:repeat-case="detail.repeatCase"
@refresh="getStatistics"
/>
<!-- TODO 先不上 -->
<!-- <BugManagement v-if="activeTab === 'defectList'" :plan-id="detail.id" /> -->
</MsCard>
<AssociateDrawer v-model:visible="caseAssociateVisible" :associated-ids="hasSelectedIds" @success="success" />
<AssociateDrawer
v-model:visible="caseAssociateVisible"
:associated-ids="detail.repeatCase ? hasSelectedIds : []"
:save-api="associationCaseToPlan"
@success="handleSuccess"
/>
<CreateAndEditPlanDrawer
v-model:visible="showPlanDrawer"
:plan-id="planId"
@ -117,7 +127,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import { cloneDeep } from 'lodash-es';
@ -153,12 +163,7 @@
import { characterLimit } from '@/utils';
import { ModuleTreeNode } from '@/models/common';
import type {
AssociateCaseRequest,
PassRateCountDetail,
TestPlanDetail,
TestPlanItem,
} from '@/models/testPlan/testPlan';
import type { PassRateCountDetail, TestPlanDetail, TestPlanItem } from '@/models/testPlan/testPlan';
const userStore = useUserStore();
const appStore = useAppStore();
@ -353,6 +358,7 @@
function successHandler() {
initDetail();
}
const testPlanTree = ref<ModuleTreeNode[]>([]);
async function initPlanTree() {
try {
@ -362,19 +368,10 @@
}
}
//
async function success(params: AssociateCaseRequest) {
try {
await associationCaseToPlan({
functionalSelectIds: params.selectIds,
testPlanId: planId.value,
});
Message.success(t('ms.case.associate.associateSuccess'));
caseAssociateVisible.value = false;
initDetail();
} catch (error) {
console.log(error);
}
const featureCaseRef = ref<InstanceType<typeof FeatureCase>>();
function handleSuccess() {
initDetail();
featureCaseRef.value?.getCaseTableList();
}
onBeforeMount(() => {

View File

@ -32,7 +32,7 @@
isExpandAll ? t('testPlan.testPlanIndex.collapseAll') : t('testPlan.testPlanIndex.expandAll')
"
>
<MsButton type="icon" status="secondary" class="!mr-0 p-[4px]" @click="expandHandler">
<MsButton type="icon" status="secondary" class="!mr-0 p-[4px]" position="top" @click="expandHandler">
<MsIcon :type="isExpandAll ? 'icon-icon_folder_collapse1' : 'icon-icon_folder_expansion1'" />
</MsButton>
</a-tooltip>

View File

@ -19,6 +19,7 @@ export default {
'testPlan.testPlanIndex.passRate': 'Pass Rate',
'testPlan.testPlanIndex.useCount': 'Use cases',
'testPlan.testPlanIndex.bugCount': 'bug count',
'testPlan.featureCase.bug': 'bug',
'testPlan.testPlanIndex.belongModule': 'belong module',
'testPlan.testPlanIndex.createTime': 'create time',
'testPlan.testPlanIndex.operation': 'operation',

View File

@ -88,6 +88,7 @@ export default {
'testPlan.bugManagement.defectState': '缺陷状态',
'testPlan.bugManagement.caseClassification': '用例分类',
'testPlan.featureCase.bugCount': '缺陷数',
'testPlan.featureCase.bug': '缺陷',
'testPlan.featureCase.executor': '执行人',
'testPlan.featureCase.changeExecutor': '修改执行人',
'testPlan.featureCase.batchChangeExecutor': '批量修改执行人',