十三、【核心功能篇】测试计划管理:组织和编排测试用例
【核心功能篇】测试计划管理:组织和编排测试用例
- 前言
- 准备工作
- 第一部分:后端实现 (Django)
- 1. 定义 `TestPlan` 模型
- 2. 生成并应用数据库迁移
- 3. 创建 `TestPlanSerializer`
- 4. 创建 `TestPlanViewSet`
- 5. 注册路由
- 6. 注册到 Django Admin
- 第二部分:前端实现 (Vue3)
- 1. 创建 `TestPlan` 相关的 API 服务 (`src/api/testplan.ts`)
- 2. 添加测试计划的路由
- 3. 创建测试计划编辑页面 (`src/views/testplan/TestPlanEditView.vue`)
- 4. 创建测试计划列表页面 (`src/views/testplan/TestPlanListView.vue`)
- 5. 在主布局侧边栏添加入口
- 第五步:测试完整流程
- 总结
前言
随着测试用例数量的增加,如何有效地组织和管理这些用例以进行特定目的的测试(例如回归测试、新功能测试)就变得至关重要。测试计划 (Test Plan) 允许我们将相关的测试用例组合成一个可执行的单元。
这篇文章将带你:
- 在后端 Django 中设计和实现
TestPlan
数据模型及其 API。 - 在前端 Vue3 中创建测试计划的管理页面,包括列表展示。
- 设计并实现一个用户友好的界面,用于创建和编辑测试计划,特别是如何从现有用例库中选择测试用例并关联到计划中。
我们将使用 Element Plus 的 ElTransfer
(穿梭框) 组件来实现测试用例的选择功能。
一个测试计划通常包含以下信息:
- 基本信息: 计划名称、描述、所属项目等。
- 包含的测试用例: 一个计划会包含一个或多个选定的测试用例。
- (可选) 执行策略、环境配置等: 这些我们暂时不在此篇详细展开,但会为数据模型留有余地。
我们的目标是让用户能够:
- 创建新的测试计划,并为其关联项目。
- 从指定项目的测试用例库中,方便地选择一批用例加入到测试计划中。
- 编辑已有的测试计划,可以修改基本信息或增删其包含的测试用例。
- 查看测试计划列表,并能删除不再需要的计划。
准备工作
- 前端项目就绪:
test-platform/frontend
项目可以正常运行 (npm run dev
)。 - 后端 API 运行中: Django 后端服务运行(
python manage.py runserver
)。项目、模块、测试用例的 API 均可用。 - Axios 和 API 服务已封装:
utils/request.ts
及api/project.ts
,api/module.ts
,api/testcase.ts
已配置。 - 测试用例管理功能基本可用: 我们需要有测试用例数据才能将其添加到计划中。
第一部分:后端实现 (Django)
1. 定义 TestPlan
模型
打开 test-platform/api/models.py
,添加 TestPlan
模型:
# test-platform/api/models.py
# ... (BaseModel, Project, Module, TestCase 定义保持不变) ...class TestPlan(BaseModel): # 继承自我们的 BaseModel"""测试计划表"""project = models.ForeignKey(Project, on_delete=models.CASCADE, verbose_name="所属项目", related_name="test_plans")test_cases = models.ManyToManyField(TestCase, verbose_name="包含用例", related_name="test_plans_containing", blank=True)# creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建人") # 如果有用户系统# status_choices = [ (0, '草稿'), (1, '待执行'), (2, '执行中'), (3, '已完成') ]# status = models.PositiveSmallIntegerField(choices=status_choices, default=0, verbose_name="计划状态")class Meta:verbose_name = "测试计划"verbose_name_plural = "测试计划列表"unique_together = ('project', 'name') # 同一项目下的测试计划名称应唯一ordering = ['-create_time']def __str__(self):return f"{self.project.name} - {self.name}"
关键点:
project
: 外键关联到Project
,一个测试计划属于一个项目。test_cases
: 多对多字段关联到TestCase
,一个计划可以包含多个用例,一个用例也可以属于多个计划。blank=True
允许创建时没有用例。
2. 生成并应用数据库迁移
在项目根目录 ( test-platform
,取决于你的 manage.py
位置) 的终端中运行:
python manage.py makemigrations api
python manage.py migrate
3. 创建 TestPlanSerializer
打开 test-platform/api/serializers.py
,添加:
# test-platform/api/serializers.py
# ... (ProjectSerializer, ModuleSerializer, TestCaseSerializer 定义保持不变) ...
from .models import TestPlan # 确保导入 TestPlanclass TestPlanSerializer(serializers.ModelSerializer):"""测试计划序列化器"""project_name = serializers.CharField(source='project.name', read_only=True)# test_cases 字段默认会序列化为关联 TestCase 的 ID 列表,这对于创建/更新是合适的# 如果在获取详情时希望看到用例的更多信息,可以考虑嵌套序列化或 SerializerMethodField# 使用 SerializerMethodField 在获取详情时返回用例的详细信息(例如 id 和 name)test_case_details = serializers.SerializerMethodField(read_only=True)class Meta:model = TestPlanfields = ['id', 'name', 'description', 'project', 'project_name', 'test_cases', 'test_case_details', 'create_time', 'update_time']extra_kwargs = {'test_cases': {'write_only': False, 'required': False, 'help_text': "关联的测试用例ID列表"},# 'test_cases' 在创建/更新时接收ID列表,在读取时也会显示ID列表。# 如果不希望读取时显示 test_cases ID 列表 (因为有了 test_case_details), 可以设置 'read_only': False, 'write_only': True# 但通常保留ID列表在读取时也是有用的。}def get_test_case_details(self, obj: TestPlan):# obj 是 TestPlan 实例# 返回一个包含所选测试用例的 id 和 name 的列表# 这样前端在显示已选测试用例时,除了ID还能看到名称# 注意:这可能会导致 N+1 查询问题,如果用例数量很多,需要优化 (例如使用 prefetch_related)return obj.test_cases.values('id', 'name') # .all() 返回 QuerySet, .values() 返回字典列表
关键点:
project_name
: 只读字段,显示项目名称。test_cases
:- 对于写操作 (POST/PUT/PATCH),DRF 的
ManyToManyField
默认期望接收一个主键 ID 列表。例如[1, 2, 3]
。 - 对于读操作 (GET),默认也会返回主键 ID 列表。
- 对于写操作 (POST/PUT/PATCH),DRF 的
test_case_details
: 使用SerializerMethodField
在 GET 请求的响应中额外提供关联测试用例的ID和名称。这对于前端在编辑测试计划时,回显已选测试用例的名称非常有用,而不必再次查询每个用例的名称。
4. 创建 TestPlanViewSet
打开 test-platform/api/views.py
,添加:
# test-platform/api/views.py
# ... (其他 ViewSet 定义保持不变) ...
from .models import TestPlan # 确保导入
from .serializers import TestPlanSerializer # 确保导入class TestPlanViewSet(viewsets.ModelViewSet):"""测试计划管理视图集"""queryset = TestPlan.objects.all().order_by('-update_time')serializer_class = TestPlanSerializerfilter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]filterset_fields = {'project_id': ['exact'], # /api/testplans/?project_id=1}search_fields = ['name', 'description']ordering_fields = ['id', 'name', 'project', 'update_time']def get_queryset(self):# 预取关联的 test_cases 来优化 get_test_case_details 序列化方法字段的性能return super().get_queryset().prefetch_related('test_cases', 'project')
关键点:
filterset_fields
: 允许通过project_id
筛选测试计划。get_queryset()
: 重写并使用prefetch_related('test_cases', 'project')
来优化序列化时对关联test_cases
和project
的访问,避免 N+1 查询。
5. 注册路由
打开 test-platform/api/urls.py
,注册 TestPlanViewSet
:
# test-platform/api/urls.py
# ... (其他 router.register 调用保持不变) ...
from .views import ProjectViewSet, ModuleViewSet, TestCaseViewSet, TestPlanViewSet # 导入 TestPlanViewSet# ...
router.register(r'testplans', TestPlanViewSet, basename='testplan') # 新增
# ...
6. 注册到 Django Admin
打开 test-platform/api/admin.py
:
# test-platform/api/admin.py
from django.contrib import admin
from .models import Project, Module, TestCase, TestPlan # 导入 TestPlan# ...
admin.site.register(TestPlan)
现在后端 API 已经准备好了。你可以启动 Django 服务,并通过可浏览 API (http://127.0.0.1:8000/api/testplans/
) 或 Postman 进行初步测试。
第二部分:前端实现 (Vue3)
1. 创建 TestPlan
相关的 API 服务 (src/api/testplan.ts
)
// test-platform/frontend/src/api/testplan.ts
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'
import type { PaginatedResponse } from './testcase' // 复用分页类型export interface TestCaseBrief { // 用于 TestPlan 中 test_case_detailsid: number;name: string;
}export interface TestPlan {id: number;name: string;description: string | null;project: number;project_name?: string;test_cases: number[]; // 关联的测试用例ID列表test_case_details?: TestCaseBrief[]; // 后端序列化器提供create_time: string;update_time: string;
}export type TestPlanListResponse = PaginatedResponse<TestPlan>export interface UpsertTestPlanData {name: string;description?: string | null;project: number;test_cases?: number[]; // 提交时用例ID列表
}// 1. 获取测试计划列表
export function getTestPlanList(params?: { page?: number; page_size?: number; project_id?: number | null; search?: string }): AxiosPromise<TestPlanListResponse> {return request({url: '/testplans/',method: 'get',params})
}// 2. 创建测试计划
export function createTestPlan(data: UpsertTestPlanData): AxiosPromise<TestPlan> {return request({url: '/testplans/',method: 'post',data})
}// 3. 获取单个测试计划详情
export function getTestPlanDetail(testPlanId: number): AxiosPromise<TestPlan> {return request({url: `/testplans/${testPlanId}/`,method: 'get'})
}// 4. 更新测试计划
export function updateTestPlan(testPlanId: number, data: Partial<UpsertTestPlanData>): AxiosPromise<TestPlan> {return request({url: `/testplans/${testPlanId}/`,method: 'put',data})
}// 5. 删除测试计划
export function deleteTestPlan(testPlanId: number): AxiosPromise<void> {return request({url: `/testplans/${testPlanId}/`,method: 'delete'})
}
关键点:
TestPlan
接口中,test_cases
是数字数组 (ID 列表),test_case_details
是可选的对象数组 (包含 ID 和 name),对应后端 Serializer 的输出。UpsertTestPlanData
中test_cases
是提交给后端的用例 ID 列表。
2. 添加测试计划的路由
打开 frontend/src/router/index.ts
:
// test-platform/frontend/src/router/index.ts
// ... (在 Layout 的 children 中添加){path: '/testplans', // 测试计划列表页name: 'testplans',component: () => import('../views/testplan/TestPlanListView.vue'), // 待创建meta: { title: '测试计划', requiresAuth: true }},{path: '/testplan/create', // 新建测试计划name: 'testplanCreate',component: () => import('../views/testplan/TestPlanEditView.vue'), // 待创建meta: { title: '新建测试计划', requiresAuth: true }},{path: '/testplan/edit/:id', // 编辑测试计划name: 'testplanEdit',component: () => import('../views/testplan/TestPlanEditView.vue'),meta: { title: '编辑测试计划', requiresAuth: true },props: true},
// ...
3. 创建测试计划编辑页面 (src/views/testplan/TestPlanEditView.vue
)
这个页面的核心是测试用例的选择,我们将使用 Element Plus 的 ElTransfer
组件。
a. 创建文件:
在 src/views/
目录下创建 testplan
文件夹,并在其中创建 TestPlanEditView.vue
。
b. 编写 TestPlanEditView.vue
:
<!-- test-platform/frontend/src/views/testplan/TestPlanEditView.vue -->
<template><div class="testplan-edit-view" v-loading="pageLoading"><el-page-header @back="goBack" :content="pageTitle" class="page-header-custom" /><el-card class="form-card"><el-formref="testPlanFormRef":model="formData":rules="formRules"label-width="120px"label-position="right"><el-form-item label="计划名称" prop="name"><el-input v-model="formData.name" placeholder="请输入计划名称" /></el-form-item><el-form-item label="所属项目" prop="project"><el-selectv-model="formData.project"placeholder="请选择所属项目"filterablestyle="width: 100%;"@change="onProjectChange"@focus="fetchProjectsForSelect":loading="projectSelectLoading":disabled="isEditMode && !!initialProject" ><el-option v-for="item in projectOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="计划描述" prop="description"><el-input v-model="formData.description" type="textarea" placeholder="请输入计划描述" /></el-form-item><el-form-item label="选择测试用例" prop="test_cases"><el-transferv-model="formData.test_cases":data="availableTestCases":titles="['可选测试用例', '已选测试用例']":props="{ key: 'id', label: 'name' }" filterablefilter-placeholder="搜索用例名称"style="width: 100%;":disabled="!formData.project || testCaseLoading"height="300px" ><template #default="{ option }"><span>{{ option.id }} - {{ option.name }}</span></template></el-transfer><div v-if="!formData.project" class="el-form-item__error" style="font-size:12px; color: #F56C6C; margin-top:5px;">请先选择所属项目以加载测试用例</div></el-form-item><el-form-item><el-button type="primary" @click="handleSubmit" :loading="submitLoading">{{ isEditMode ? '更新计划' : '创建计划' }}</el-button><el-button @click="goBack">取消</el-button></el-form-item></el-form></el-card></div>
</template><script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElPageHeader } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { createTestPlan, getTestPlanDetail, updateTestPlan, type UpsertTestPlanData,type TestPlan
} from '@/api/testplan'
import { getProjectList, type Project } from '@/api/project'
import { getTestCaseList as fetchAllTestCasesByProject, type TestCase } from '@/api/testcase'const route = useRoute()
const router = useRouter()const pageLoading = ref(false)
const submitLoading = ref(false)
const testPlanFormRef = ref<FormInstance>()const testPlanId = computed(() => route.params.id ? Number(route.params.id) : null)
const isEditMode = computed(() => !!testPlanId.value)
const pageTitle = computed(() => (isEditMode.value ? '编辑测试计划' : '新建测试计划'))const projectOptions = ref<Project[]>([])
const projectSelectLoading = ref(false)const availableTestCases = ref<TestCase[]>([]) // Transfer 左侧数据源
const testCaseLoading = ref(false)const initialFormData: UpsertTestPlanData = {name: '',description: null,project: undefined as number | undefined,test_cases: [],
}
const formData = reactive<UpsertTestPlanData>({ ...initialFormData })
const initialProject = ref<number | null>(null); // 用于编辑时存储初始项目IDconst formRules = reactive<FormRules>({name: [{ required: true, message: '计划名称不能为空', trigger: 'blur' }],project: [{ required: true, message: '请选择所属项目', trigger: 'change' }],// test_cases 穿梭框的值是数组,可以不直接校验,或校验其长度// test_cases: [{ type: 'array', required: true, message: '请至少选择一个测试用例', trigger: 'change' }]
})// 获取项目列表
const fetchProjectsForSelect = async () => {if (projectOptions.value.length > 0 && !isEditMode.value) return;projectSelectLoading.value = truetry {const response = await getProjectList({ page_size: 1000 }) // 获取所有项目projectOptions.value = response.data} catch (error) {console.error('获取项目列表失败:', error)} finally {projectSelectLoading.value = false}
}// 当项目选择变化时,加载该项目下的所有测试用例
const onProjectChange = async (projectId: number | undefined | null) => {availableTestCases.value = []formData.test_cases = []if (!projectId) {return}testCaseLoading.value = truetry {const response = await fetchAllTestCasesByProject({ module__project_id: projectId, page_size: 10000 }); console.log('Raw response from fetchAllTestCasesByProject:', response);console.log('Response data from fetchAllTestCasesByProject:', response.data);if (response && Array.isArray(response.data)) {console.log('Test cases data array:', response.data);availableTestCases.value = response.data.map(tc => {// 确保 tc 对象是你期望的结构if (typeof tc.id === 'undefined' || typeof tc.name === 'undefined') {console.warn('Test case object is missing id or name:', tc);}return {...tc,// 如果 ElTransfer 的 :props="{ key: 'id', label: 'name' }" 已设置,// 那么这里不需要显式添加 key 和 label,除非你想覆盖。// key: tc.id,// label: tc.name};});console.log('Mapped availableTestCases:', availableTestCases.value);} else {// 如果 response.data 不是数组 (例如是 null, undefined, 或其他对象结构)console.warn('fetchAllTestCasesByProject did not return an array. Data:', response ? response.data : 'No response data');availableTestCases.value = [];}} catch (error) {console.error(`获取项目 ${projectId} 的测试用例失败:`, error)ElMessage.error('加载测试用例失败')availableTestCases.value = []; // 确保出错时清空} finally {testCaseLoading.value = false}
}// 加载测试计划详情 (编辑模式)
const loadTestPlanDetail = async () => {if (!isEditMode.value || !testPlanId.value) returnpageLoading.value = truetry {const response = await getTestPlanDetail(testPlanId.value)const dataFromServer = response.dataformData.name = dataFromServer.nameformData.description = dataFromServer.descriptionformData.project = dataFromServer.projectinitialProject.value = dataFromServer.project; // 记录初始项目ID// 先加载该项目下的所有可选测试用例await onProjectChange(formData.project)// 然后设置已选的测试用例 (确保是 ID 数组)formData.test_cases = dataFromServer.test_cases || [] // 如果 test_case_details 存在且包含有效数据,也可以用它来辅助,但 el-transfer v-model 直接用 ID 数组} catch (error) {ElMessage.error('获取测试计划详情失败')console.error(error)} finally {pageLoading.value = false}
}onMounted(async () => {await fetchProjectsForSelect() // 先加载项目选项if (isEditMode.value) {await loadTestPlanDetail()}
})const handleSubmit = async () => {if (!testPlanFormRef.value) returnawait testPlanFormRef.value.validate(async (valid) => {if (valid) {if (!formData.project) {ElMessage.error('请选择所属项目'); // 再次确认return;}if (formData.test_cases && formData.test_cases.length === 0) {ElMessage.warning('尚未选择任何测试用例,确定要保存吗?');// 可以选择在这里 return,或者让用户创建一个空的测试计划}submitLoading.value = trueconst dataToSubmit: UpsertTestPlanData = {name: formData.name,description: formData.description,project: formData.project!,test_cases: formData.test_cases || [],}try {if (isEditMode.value && testPlanId.value) {await updateTestPlan(testPlanId.value, dataToSubmit)ElMessage.success('测试计划更新成功!')} else {await createTestPlan(dataToSubmit)ElMessage.success('测试计划创建成功!')}router.push({ name: 'testplans' }) // 跳转到列表页} catch (error) {console.error('测试计划操作失败:', error)} finally {submitLoading.value = false}} else {ElMessage.error('请检查表单填写是否正确!')return false}})
}const goBack = () => {router.back()
}
</script><style scoped lang="scss">
.testplan-edit-view {padding: 20px;
}
.page-header-custom {margin-bottom: 20px;background-color: #fff;padding: 16px 24px;border-radius: 4px;box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
}
.form-card {padding: 20px;
}
// 调整 Transfer 组件样式,使其内部可滚动
:deep(.el-transfer-panel) {height: 300px; // 与 :height="300px" 对应
}
:deep(.el-transfer-panel__body) {height: calc(100% - 40px); // 减去头部的40px左右
}
:deep(.el-transfer-panel__list) {height: 100%;overflow-y: auto; // 允许列表内容滚动
}
</style>
代码解释与关键点:
- 表单字段: 名称、所属项目 (下拉选择)、描述。
ElTransfer
(穿梭框) 用于选择测试用例:v-model="formData.test_cases"
: 双向绑定已选择的测试用例的 ID 数组。:data="availableTestCases"
: 左侧可选测试用例的数据源。这个数组的每个元素应该是对象,并包含key
(用例ID) 和label
(用例名称) 属性,或者通过:props
指定。:props="{ key: 'id', label: 'name' }"
: 告诉ElTransfer
组件,数据源对象中用id
作为key
,用name
作为label
。:titles="['可选测试用例', '已选测试用例']"
: 设置左右两侧面板的标题。filterable
: 允许在穿梭框内部搜索。height="300px"
: 设置穿梭框的高度。注意: 可能需要配合 SCSS 中的:deep
选择器调整内部列表的高度以实现真正的滚动。:disabled="!formData.project || testCaseLoading"
: 当未选择项目或用例正在加载时,禁用穿梭框。- 自定义渲染 (
<template #default="{ option }">
): 可以自定义每个条目的显示内容,这里显示 “ID - 名称”。
- 项目选择联动 (
onProjectChange
): 当用户选择了项目后,调用fetchAllTestCasesByProject
API (在api/testcase.ts
中) 获取该项目下的所有测试用例,并填充到availableTestCases
中作为穿梭框的左侧数据源。注意,这里假设测试用例数量不多,一次性加载。如果用例非常多,需要实现穿梭框的远程搜索或分页加载。 - 编辑模式加载 (
loadTestPlanDetail
):- 获取计划详情后,设置表单的基础信息。
- 调用
onProjectChange(formData.project)
来加载该计划所属项目下的所有可选测试用例。 - 将
dataFromServer.test_cases
(已关联的用例ID列表) 赋值给formData.test_cases
,这样穿梭框会自动将这些用例移动到右侧已选区域。 - 编辑时禁用项目选择:
initialProject.value
用于记录初始项目ID,并在编辑模式下禁用项目选择框,因为通常不建议在编辑测试计划时更改其所属项目(这会使已选的用例失效)。如果确实需要更改项目,流程会更复杂(需要提示用户已选用例将被清空等)。
- 提交 (
handleSubmit
):- 将
formData
(包括test_cases
ID 列表) 发送给后端。
- 将
4. 创建测试计划列表页面 (src/views/testplan/TestPlanListView.vue
)
这个页面与 TestCaseListView.vue
类似,包含筛选、表格和分页。
a. 创建文件:
b. 编写 TestPlanListView.vue
:
<!-- test-platform/frontend/src/views/testplan/TestPlanListView.vue -->
<template><div class="testplan-list-view" v-loading="pageLoading"><el-card class="filter-card"><el-form :inline="true" :model="queryParams" ref="queryFormRef" @submit.prevent="handleSearch"><el-form-item label="所属项目" prop="project_id"><el-selectv-model="queryParams.project_id"placeholder="请选择项目"clearablestyle="width: 200px"@focus="fetchProjectsForSelect":loading="projectSelectLoading"><el-option v-for="item in projectOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="计划名称" prop="search"><el-input v-model="queryParams.search" placeholder="搜索计划名称/描述" clearable style="width: 220px" /></el-form-item><el-form-item><el-button type="primary" :icon="SearchIcon" @click="handleSearch">搜索</el-button><el-button :icon="RefreshIcon" @click="handleReset">重置</el-button></el-form-item></el-form></el-card><el-card class="table-card"><template #header><div class="card-header"><span>测试计划列表</span><el-button type="primary" :icon="PlusIcon" @click="navigateToCreate">新建计划</el-button></div></template><el-table :data="testPlans" v-loading="tableLoading" style="width: 100%" empty-text="暂无测试计划数据"><el-table-column prop="id" label="ID" width="80" sortable /><el-table-column prop="name" label="计划名称" min-width="200" show-overflow-tooltip sortable><template #default="scope"><el-link type="primary" @click="handleEdit(scope.row.id)">{{ scope.row.name }}</el-link></template></el-table-column><el-table-column prop="project_name" label="所属项目" width="180" show-overflow-tooltip /><el-table-column label="包含用例数" width="120"><template #default="scope">{{ scope.row.test_cases?.length || 0 }}</template></el-table-column><el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip /><el-table-column prop="update_time" label="最后更新" width="170" sortable><template #default="scope">{{ formatDateTime(scope.row.update_time) }}</template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="scope"><el-button size="small" type="warning" :icon="EditIcon" @click="handleEdit(scope.row.id)">编辑</el-button><el-popconfirmtitle="确定要删除这个计划吗?"@confirm="handleDelete(scope.row.id)"><template #reference><el-button size="small" type="danger" :icon="DeleteIcon">删除</el-button></template></el-popconfirm></template></el-table-column></el-table><el-paginationv-if="totalPlans > 0"class="pagination-container":current-page="queryParams.page":page-size="queryParams.page_size":page-sizes="[10, 20, 50, 100]"layout="total, sizes, prev, pager, next, jumper":total="totalPlans"@size-change="handleSizeChange"@current-change="handlePageChange"/></el-card></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { Search as SearchIcon, Refresh as RefreshIcon, Plus as PlusIcon, Edit as EditIcon, Delete as DeleteIcon } from '@element-plus/icons-vue'
import { getTestPlanList, deleteTestPlan, type TestPlan } from '@/api/testplan'
import { getProjectList, type Project } from '@/api/project'const router = useRouter()const pageLoading = ref(false)
const tableLoading = ref(false)
const queryFormRef = ref<FormInstance>()const testPlans = ref<TestPlan[]>([])
const totalPlans = ref(0)const queryParams = reactive({page: 1,page_size: 10,project_id: null as number | null,search: '',
})const projectOptions = ref<Project[]>([])
const projectSelectLoading = ref(false)const fetchTestPlans = async () => {tableLoading.value = truetry {const response = await getTestPlanList(queryParams)console.log('完整的测试计划返回数据:', response) // 打印完整响应// 检查响应结构if (response && response.data) {// 直接检查 response.data 是否为数组if (Array.isArray(response.data)) {testPlans.value = response.datatotalPlans.value = response.data.length} // 检查标准分页格式else if (response.data.results && Array.isArray(response.data.results)) {testPlans.value = response.data.resultstotalPlans.value = response.data.count || response.data.results.length}// 其他可能的数据格式else {console.error('未识别的API返回格式:', response.data)testPlans.value = []totalPlans.value = 0}} else {console.error('API响应无效:', response)testPlans.value = []totalPlans.value = 0}} catch (error) {console.error('获取测试计划列表失败:', error)testPlans.value = []totalPlans.value = 0} finally {tableLoading.value = false}
}const fetchProjectsForSelect = async () => {if (projectOptions.value.length > 0) return;projectSelectLoading.value = truetry {const response = await getProjectList({ page_size: 1000 })projectOptions.value = response.data} catch (error) {console.error('获取项目选项失败:', error)} finally {projectSelectLoading.value = false}
}onMounted(async () => {pageLoading.value = true;await fetchProjectsForSelect();await fetchTestPlans();pageLoading.value = false;
})const handleSearch = () => {queryParams.page = 1fetchTestPlans()
}const handleReset = () => {queryFormRef.value?.resetFields()queryParams.project_id = null; // 手动重置 SelectqueryParams.page = 1;queryParams.search = '';fetchTestPlans()
}const handlePageChange = (newPage: number) => {queryParams.page = newPagefetchTestPlans()
}const handleSizeChange = (newSize: number) => {queryParams.page_size = newSizequeryParams.page = 1fetchTestPlans()
}const navigateToCreate = () => {router.push({ name: 'testplanCreate' })
}const handleEdit = (id: number) => {router.push({ name: 'testplanEdit', params: { id } })
}const handleDelete = async (id: number) => {try {await ElMessageBox.confirm('此操作将永久删除该测试计划,是否继续?', '警告', {confirmButtonText: '确定删除',cancelButtonText: '取消',type: 'warning',});tableLoading.value = true;await deleteTestPlan(id);ElMessage.success('测试计划删除成功!');if (testPlans.value.length === 1 && queryParams.page! > 1) {queryParams.page!--;}fetchTestPlans();} catch (error) {if (error !== 'cancel') {console.error('删除测试计划失败:', error);}} finally {tableLoading.value = false;}
}const formatDateTime = (dateTimeStr: string) => {if (!dateTimeStr) return ''return new Date(dateTimeStr).toLocaleString()
}
</script><style scoped lang="scss">
.testplan-list-view {padding: 20px;.filter-card {margin-bottom: 20px;}.table-card {.card-header {display: flex;justify-content: space-between;align-items: center;}}.pagination-container {margin-top: 20px;display: flex;justify-content: flex-end;}
}
</style>
5. 在主布局侧边栏添加入口
打开 frontend/src/layout/index.vue
,在侧边栏菜单中添加“测试计划”的入口:
<!-- test-platform/frontend/src/layout/index.vue -->
// ...<el-menu-item index="/testcases"><el-icon><List /></el-icon><span>用例管理</span></el-menu-item><el-menu-item index="/testplans"> <!-- 新增测试计划入口 --><el-icon><Memo /></el-icon> <!-- Memo 是一个合适的图标 --><span>测试计划</span></el-menu-item><el-menu-item index="/reports">
// ...// 导入 Memo 图标
import { ArrowDown, HomeFilled, Folder, List, DataAnalysis, Memo } from '@element-plus/icons-vue' // 添加 Memo
第五步:测试完整流程
- 确保前后端服务运行正常,CORS 和 API 可用。
- 通过侧边栏进入“测试计划”列表页:
- 新建测试计划:
- 点击“新建计划”按钮。
- 填写计划名称、选择所属项目。
- 项目选择后,穿梭框左侧应加载该项目下的测试用例。
- 从左侧选择一些用例到右侧。
- 点击“创建计划”。
- 应提示成功并跳转回列表页,新创建的计划应显示在列表中,包含用例数正确。
- 编辑测试计划:
- 点击某个计划的“编辑”按钮。
- 表单数据应正确回填,特别是穿梭框中已选的用例应在右侧。
- 修改计划信息,增删用例。
- 点击“更新计划”。
- 验证更新成功和数据正确性。
- 删除测试计划:
- 点击删除,确认。
- 计划应从列表中移除。
- 列表页筛选和分页测试。
总结
在这篇文章中,我们成功实现了测试平台中“测试计划/套件管理”的核心功能:
- ✅ 后端:
- 定义了
TestPlan
Django 模型,包含与Project
的外键和与TestCase
的多对多关系。 - 创建了
TestPlanSerializer
,并使用SerializerMethodField
来优化读取时关联测试用例的显示。 - 创建了
TestPlanViewSet
,支持按项目筛选,并通过prefetch_related
优化了性能。 - 注册了相应的 API 路由。
- 定义了
- ✅ 前端:
- 创建了
api/testplan.ts
服务文件,封装了测试计划的 CRUD API 调用。 - 添加了测试计划相关页面的路由。
- 实现了
TestPlanEditView.vue
(新建/编辑测试计划页面):- 包含计划基本信息表单。
- 使用
ElTransfer
(穿梭框) 组件,实现了从指定项目下选择测试用例并关联到计划的功能。 - 处理了项目选择与穿梭框数据源的联动加载。
- 正确处理了编辑模式下数据的回填,特别是穿梭框已选用例的回显。
- 实现了
TestPlanListView.vue
(测试计划列表页面):- 包含按项目和名称搜索的筛选功能。
- 使用表格展示计划列表,包括计划包含的用例数量。
- 实现了分页功能。
- 提供了新建、编辑、删除计划的操作入口。
- 在主布局的侧边栏添加了“测试计划”的导航入口。
- 创建了
- ✅ 指导了如何测试测试计划管理的完整 CRUD 流程。
通过本篇文章,我们的测试平台现在可以将零散的测试用例有效地组织起来,为后续的测试执行做好准备。
在下一篇文章中,我们将进入测试执行环节,设计后端如何接收执行指令,并实际去请求被测接口。
相关文章:

十三、【核心功能篇】测试计划管理:组织和编排测试用例
【核心功能篇】测试计划管理:组织和编排测试用例 前言准备工作第一部分:后端实现 (Django)1. 定义 TestPlan 模型2. 生成并应用数据库迁移3. 创建 TestPlanSerializer4. 创建 TestPlanViewSet5. 注册路由6. 注册到 Django Admin 第二部分:前端…...

手撕 K-Means
1. K-means 的原理 K-means 是一种经典的无监督学习算法,用于将数据集划分为 kk 个簇(cluster)。其核心思想是通过迭代优化,将数据点分配到最近的簇中心,并更新簇中心,直到簇中心不再变化或达到最大迭代次…...

SmolVLA: 让机器人更懂 “看听说做” 的轻量化解决方案
🧭 TL;DR 今天,我们希望向大家介绍一个新的模型: SmolVLA,这是一个轻量级 (450M 参数) 的开源视觉 - 语言 - 动作 (VLA) 模型,专为机器人领域设计,并且可以在消费级硬件上运行。 SmolVLAhttps://hf.co/lerobot/smolvla…...

day45python打卡
知识点回顾: tensorboard的发展历史和原理tensorboard的常见操作tensorboard在cifar上的实战:MLP和CNN模型 效果展示如下,很适合拿去组会汇报撑页数: 作业:对resnet18在cifar10上采用微调策略下,用tensorbo…...

AIGC赋能前端开发
一、引言:AIGC对前端开发的影响 1. AIGC与前端开发的关系 从“写代码”到“生成代码”传统开发痛点:重复性编码工作、UI 设计稿还原、问题定位与调试...核心场景的AI化:需求转代码(P2C)、设计稿转代码(D2…...

Web 3D协作平台开发案例:构建制造业远程设计与可视化协作
HOOPS Communicator为开发者提供了丰富的定制化能力,助力他们在实现强大 Web 3D 可视化功能的同时,灵活构建符合特定业务需求的工程应用。对于希望构建在线协同设计工具的企业而言,如何在保障性能与用户体验的前提下实现高效开发,…...

AI Agent开发第78课-大模型结合Flink构建政务类长公文、长文件、OA应用Agent
开篇 AI Agent2025确定是进入了爆发期,到处都在冒出各种各样的实用AI Agent。很多人、组织都投身于开发AI Agent。 但是从3月份开始业界开始出现了一种这样的声音: AI开发入门并不难,一旦开发完后没法用! 经历过至少一个AI Agent从开发到上线的小伙伴们其实都听到过这种…...
极空间z4pro配置gitea mysql,内网穿透
极空间z4pro配置gitea mysql等记录,内网穿透 1、mysql、gitea镜像下载,极空间不成功,先用自己电脑科学后下载镜像,拉取代码: docker pull --platform linux/amd64 gitea/gitea:1.23 docker pull --platform linux/amd64 mysql:5.…...

第三方测试机构进行科技成果鉴定测试有什么价值
在当今科技创新的浪潮中,科技成果的鉴定测试至关重要,而第三方测试机构凭借其独特优势,在这一领域发挥着不可替代的作用。那么,第三方测试机构进行科技成果鉴定测试究竟有什么价值呢? 一、第三方测试机构能提供独立、公…...

华为云Flexus+DeepSeek征文|基于华为云Flexus X和DeepSeek-R1打造个人知识库问答系统
目录 前言 1 快速部署:一键搭建Dify平台 1.1 部署流程详解 1.2 初始配置与登录 2 构建专属知识库 2.1 进入知识库模块并创建新库 2.2 选择数据源导入内容 2.3 上传并识别多种文档格式 2.4 文本处理与索引构建 2.5 保存并完成知识库创建 3接入ModelArts S…...

【数据结构】_排序
【本节目标】 排序的概念及其运用常见排序算法的实现排序算法复杂度及稳定性分析 1.排序的概念及其运用 1.1排序的概念 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。 1.2特性…...
《前端面试题:JS数据类型》
JavaScript 数据类型指南:从基础到高级全解析 一、JavaScript 数据类型概述 JavaScript 作为一门动态类型语言,其数据类型系统是理解这门语言的核心基础。在 ECMAScript 标准中,数据类型分为两大类: 1. 原始类型(Pr…...

PPT转图片拼贴工具 v4.3
软件介绍 这个软件就是将PPT文件转换为图片并且拼接起来。 效果展示 支持导入文件和支持导入文件夹,也支持手动输入文件/文件夹路径 软件界面 这一次提供了源码和开箱即用版本,exe就是直接用就可以了。 软件源码 import os import re import sys …...

Chrome安装代理插件ZeroOmega(保姆级别)
目录 本文直接讲解一下怎么本地安装ZeroOmega一、下载文件在GitHub直接下ZeroOmega 的文件(下最新版即可) 二、安装插件打开 Chrome 浏览器,访问 chrome://extensions/ 页面(扩展程序管理页面),并打开开发者…...

Transformer-BiGRU多变量时序预测(Matlab完整源码和数据)
Transformer-BiGRU多变量时序预测(Matlab完整源码和数据) 目录 Transformer-BiGRU多变量时序预测(Matlab完整源码和数据)效果一览基本介绍程序设计参考资料 效果一览 基本介绍 1.Matlab实现Transformer-BiGRU多变量时间序列预测&…...

新华三H3CNE网络工程师认证—Easy IP
Easy IP 就是“用路由器自己的公网IP,给全家所有设备当共享门牌号”的技术!(省掉额外公网IP,省钱又省配置!) 生活场景对比,想象你住在一个小区:普通动态NAT:物业申请了 …...
《视觉SLAM十四讲》自用笔记 第二讲:SLAM系统概述
在rm队伍里作为算法组梯队队员度过了一个赛季,为了促进和负责其他工作的算法组成员的交流,我决定在接下来的半个学期里(可能更快)读完这本书,并将其中的部分理论应用于我自制的雷达导航小车上。 以下为第二讲的部分笔记…...
vscode 插件 eslint, 检查 js 语法
1. 起因, 目的: 我的需求 vscode 写js代码, 有什么插件能进行语法检查。 比如某个函数没有定义,getName(), 但是却调用了。 那么这个插件会给出警告,在 getName() 给出红色波浪线。类似这种效果的插件, 有吗…...

Excel 模拟分析之单变量求解简单应用
正向求解 利用公式根据贷款总额、还款期限、贷款利率,求每月还款金额 反向求解 根据每月还款能力,求最大能承受贷款金额 参数: 目标单元格:求的值所在的单元格 目标值:想要达到的预期值 可变单元格:变…...

装备制造项目管理具备什么特征?如何选择适配的项目管理软件系统进行项目管控?
国内某大型半导体装备制造企业与奥博思软件达成战略合作,全面引入奥博思 PowerProject 打造企业专属项目管理平台,进一步提升智能制造领域的项目管理效率与协同能力。 该项目管理平台聚焦半导体装备研发与制造的业务特性,实现了从项目立项、…...

FPGA 动态重构配置流程
触发FPGA 进行配置的方式有两种,一种是断电后上电,另一种是在FPGA运行过程中,将PROGRAM 管脚拉低。将PROGRAM 管脚拉低500ns 以上就可以触发FPGA 进行重构。 FPGA 的配置过程大致可以分为:配置的触发和建立阶段、加载配置文件和建…...
Elasticsearch的审计日志(Audit Logging)介绍
Elasticsearch 的审计日志(Audit Logging)是一种记录与安全相关事件的功能,用于监控和追踪对集群的访问行为。通过审计日志,管理员可以了解谁在何时对哪些资源执行了什么操作,从而满足合规性要求、进行安全分析和排查异常行为。 一、审计日志的核心功能 记录安全事件捕获…...
软件测试:质量保障的基石与未来趋势
软件测试作为软件开发生命周期中的关键环节,不仅是发现和修复缺陷的手段,更是确保产品质量、提升用户体验和降低开发成本的重要保障。在当今快速迭代的互联网时代,测试已从单纯的验证活动演变为贯穿整个开发过程的质量管理体系。本文将系统阐…...

网络安全逆向分析之rust逆向技巧
rust逆向技巧 rust逆向三板斧: 快速定位关键函数 (真正的main函数):观察输出、输入,字符串搜索,断点等方法。定位关键 加密区 :根据输入的flag,打硬件断点,快速捕获程序中对flag访问的位置&am…...
Docker容器化技术概述与实践
哈喽,大家好,我是左手python! Docker 容器化的基本概念 Docker 容器化是一种轻量级的虚拟化技术,通过将应用程序及其依赖项打包到一个可移植的容器中,使其在任何兼容 Docker 的环境中都能运行。与传统的虚拟机技术不同…...
win中将pdf转为图片
0 资料 博客 1 正文 直接使用这个软件即可https://sourceforge.net/projects/pkpdfconverter/...

Leetcode 2494. 合并在同一个大厅重叠的活动
1.题目基本信息 1.1.题目描述 表: HallEvents ----------------- | Column Name | Type | ----------------- | hall_id | int | | start_day | date | | end_day | date | ----------------- 该表可能包含重复字段。 该表的每一行表示活动的开始日期和结束日期&…...

vue+elementui 网站首页顶部菜单上下布局
菜单集合后台接口动态获取,保存到store vuex状态管理器 <template><div id"app"><el-menu:default-active"activeIndex2"class"el-menu-demo"mode"horizontal"select"handleSelect"background-…...

网络安全-等级保护(等保) 3-3-1 GB/T 36627-2018 附录A (资料性附录) 测评后活动、附 录 B (资料性附录)渗透测试的有关概念说明
################################################################################ GB/T 36627-2018 《信息安全技术 网络安全等级保护测试评估技术指南》对网络安全等级保护测评中的相关测评技术进行明确的分类和定义,系统地归纳并阐述测评的技术方法,概述技术性安全测试和…...

pytorch3d+pytorch1.10+MinkowskiEngine安装
1、配置pytorch1.10cuda11.0 pip install torch1.10.1cu111 torchvision0.11.2cu111 torchaudio0.10.1 -f https://download.pytorch.org/whl/cu111/torch_stable.html 2、配置 MinkowskiEngine库 不按下面步骤,出现错误 1、下载MinkowskiEngine0.5.4到本地 2、查看…...