提交 13a044a6 authored 作者: 陈泽健's avatar 陈泽健

feat(ui-test): 前端适配模块父子层级结构-树形选择器

- TypeScript 类型定义增加 parentId/children 字段
- API 层新增 buildModuleTree() 树形构建函数
- Cases.vue 筛选区和编辑弹窗模块选择器改为 el-tree-select
- Recorder.vue 模块选择器改为 el-tree-select
- Modules.vue 编辑弹窗支持选择父模块,含循环引用防护
- 新增 PRD 需求文档和执行计划文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 b9ba786b
# PRD 需求文档 - 前端适配模块父子层级结构
> **文档类型**: PRD 需求文档
> **创建日期**: 2026-07-22
> **作者**: czj
> **优先级**: P2
> **状态**: 待开发
---
## 一、需求背景
### 1.1 问题描述
后端已完成模块表 `parent_id` 字段的完整实现,支持模块的父子层级结构:
- **模型层**`Module` 模型新增 `parent_id` 字段和自引用关系(`parent`/`children`
- **Schema层**`ModuleCreate`/`ModuleUpdate`/`ModuleResponse` 增加 `parent_id`/`children` 字段
- **Service层**`create`/`update` 支持 `parent_id` 参数
- **API层**:响应中包含 `parentId` 字段(camelCase 转换)
但前端尚未适配,存在以下问题:
| 问题 | 影响 |
|------|------|
| TypeScript 类型定义缺少 `parentId`/`children` | 类型检查不完整,IDE 提示缺失 |
| 模块选择器是扁平下拉列表 | 无法直观展示模块层级关系 |
| Modules.vue 编辑弹窗不支持父模块选择 | 无法在 UI 层面管理模块层级 |
### 1.2 目标用户
- **测试人员**:需要按模块层级筛选和管理用例
- **管理员**:需要配置和维护模块父子层级关系
### 1.3 预期效果
1. 模块选择器展示层级结构,用户可快速定位模块
2. 编辑模块时可选择父模块,建立层级关系
3. 类型定义完整,代码可维护性提升
---
## 二、功能需求
### 2.1 需求清单
| 功能 | 描述 | 优先级 |
|------|------|--------|
| TypeScript 类型定义 | Module 接口增加 parentId/children 字段 | P0 |
| 模块选择器树形化 | Cases.vue、Recorder.vue 使用 el-tree-select | P0 |
| 编辑弹窗父模块选择 | Modules.vue 编辑弹窗支持选择父模块 | P0 |
| 循环引用防护 | 编辑时排除当前模块及其子模块 | P1 |
### 2.2 模块选择器树形化
**涉及页面**
| 页面 | 位置 | 说明 |
|------|------|------|
| Cases.vue | 筛选区模块下拉 | 筛选用例列表 |
| Cases.vue | 编辑弹窗模块选择 | 新建/编辑用例时选择所属模块 |
| Recorder.vue | 模块选择下拉 | 录制用例时选择所属模块 |
**UI 规范**
- 使用 Element Plus `el-tree-select` 组件
- props 配置:`{ label: 'name', value: 'id', children: 'children' }`
- `check-strictly`:允许选择任意层级(父级/子级均可)
- `clearable`:支持清空选择
- 树形展开,层级缩进显示
### 2.3 编辑弹窗父模块选择
**Modules.vue 编辑弹窗新增字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| 父模块 | el-tree-select | 可选,选择父模块建立层级关系 |
**循环引用防护**
- 编辑模块时,父模块选择器需排除:
1. 当前模块自身
2. 当前模块的所有子模块(递归)
防止形成循环引用:A → B → A 或 A → B → C → A
### 2.4 TypeScript 类型定义
**Module 接口扩展**
```typescript
export interface Module {
id: string
name: string
description: string
icon: string
order: number
moduleType?: string
parentId?: string | null // 新增:父模块ID
children?: Module[] // 新增:子模块列表
caseCount?: number
passRate?: number
createdAt: string
updatedAt: string
}
```
**CreateModuleRequest / UpdateModuleRequest 扩展**
```typescript
parent_id?: string | null // 新增:父模块ID
```
---
## 三、技术方案
### 3.1 数据流
```
后端 API (/api/modules)
↓ 返回扁平列表(含 parentId)
前端 API 层 (buildTree 函数)
↓ 构建树形结构
页面组件 (el-tree-select)
↓ 展示层级
```
### 3.2 树形数据构建
```typescript
/**
* 将扁平模块列表构建为树形结构
* @param items 扁平模块列表
* @returns 树形结构数据
*/
function buildTree(items: Module[]): Module[] {
const map = new Map<string, Module>()
const roots: Module[] = []
// 建立映射
items.forEach(item => map.set(item.id, { ...item, children: [] }))
// 构建树
items.forEach(item => {
const node = map.get(item.id)!
if (item.parentId && map.has(item.parentId)) {
const parent = map.get(item.parentId)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
```
### 3.3 前端文件改动
| 文件 | 改动 |
|------|------|
| `frontend/src/types/module.ts` | 增加 parentId/children 字段 |
| `frontend/src/api/modules.ts` | 新增 buildTree 辅助函数并导出 |
| `frontend/src/views/Cases.vue` | el-select → el-tree-select(2处) |
| `frontend/src/views/Recorder.vue` | el-select → el-tree-select(1处) |
| `frontend/src/views/Modules.vue` | 编辑弹窗增加父模块选择字段 |
---
## 四、界面设计
### 4.1 模块选择器(树形)
```
┌─────────────────────────────────────┐
│ 选择模块 ▼ │
├─────────────────────────────────────┤
│ ▼ 预定2.0 │
│ └── 会议管理 │
│ ▼ 数据统计 │
│ ├── 数据分析 │
│ ├── 管理看板 │
│ └── 运维管理 │
│ ▼ 集控控制 │
│ ├── 设备列表 │
│ └── 远程控制 │
│ ... │
└─────────────────────────────────────┘
```
### 4.2 模块编辑弹窗(新增父模块字段)
```
┌─────────────────────────────────────┐
│ 编辑模块 ✕ │
├─────────────────────────────────────┤
│ 模块名称: [会议管理 ] │
│ 模块类型: [标准模块 ▼ ] │
│ 父模块: [预定2.0 ▼ ] │ ← 新增
│ 模块描述: [ ] │
│ [ ] │
│ 模块图标: [folder ] │
├─────────────────────────────────────┤
│ [取消] [确定] │
└─────────────────────────────────────┘
```
---
## 五、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| TypeScript 编译 | `npm run build` 无类型错误 |
| Cases.vue 筛选模块选择器 | 展示树形层级,可筛选 |
| Cases.vue 编辑弹窗模块选择器 | 展示树形层级,可选择任意模块 |
| Recorder.vue 模块选择器 | 展示树形层级 |
| Modules.vue 编辑弹窗父模块选择 | 可选择父模块,保存后层级正确 |
| 循环引用防护 | 编辑时不能选择自己或子模块作为父模块 |
| 无父模块的叶子模块 | 正常显示在选择器中 |
| 清空父模块 | 可清空,模块变为顶级模块 |
---
## 六、相关文档
- `backend/app/models/module.py` - 模块模型(已实现 parent_id)
- `backend/app/schemas/module.py` - 模块 Schema(已实现 parentId/children)
- `frontend/src/types/module.ts` - 前端类型定义(待更新)
- `frontend/src/views/Cases.vue` - 用例管理页面
- `frontend/src/views/Recorder.vue` - 用例录制器页面
- `frontend/src/views/Modules.vue` - 模块管理页面
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
\ No newline at end of file
...@@ -62,4 +62,44 @@ export const moduleApi = { ...@@ -62,4 +62,44 @@ export const moduleApi = {
const response = await request.get(`/api/modules/${id}/stats`) const response = await request.get(`/api/modules/${id}/stats`)
return response.data return response.data
} }
}
/**
* 将扁平模块列表构建为树形结构
*
* 用于 el-tree-select 组件的数据源,将后端返回的扁平模块列表
* 根据 parentId 字段构建为父子层级结构。
*
* @param items 扁平模块列表
* @returns 树形结构数据
*
* @example
* const tree = buildModuleTree(modules)
* // tree: [{ id: '1', name: '预定2.0', children: [{ id: '2', name: '会议管理', children: [] }] }]
*/
export function buildModuleTree(items: Module[]): Module[] {
const map = new Map<string, Module>()
const roots: Module[] = []
// 建立映射:先复制并初始化 children
items.forEach(item => {
map.set(item.id, { ...item, children: [] })
})
// 构建树:根据 parentId 归类到父模块的 children 中
items.forEach(item => {
const node = map.get(item.id)!
if (item.parentId && map.has(item.parentId)) {
const parent = map.get(item.parentId)!
if (!parent.children) {
parent.children = []
}
parent.children.push(node)
} else {
// 无父模块或父模块不存在,作为根节点
roots.push(node)
}
})
return roots
} }
\ No newline at end of file
...@@ -20,6 +20,10 @@ export interface Module { ...@@ -20,6 +20,10 @@ export interface Module {
order: number order: number
/** 模块类型 standard/custom */ /** 模块类型 standard/custom */
moduleType?: string moduleType?: string
/** 父模块ID(支持层级结构) */
parentId?: string | null
/** 子模块列表(树形结构) */
children?: Module[]
/** 用例数量(扩展字段) */ /** 用例数量(扩展字段) */
caseCount?: number caseCount?: number
/** 通过率(扩展字段) */ /** 通过率(扩展字段) */
...@@ -40,6 +44,8 @@ export interface CreateModuleRequest { ...@@ -40,6 +44,8 @@ export interface CreateModuleRequest {
icon?: string icon?: string
/** 模块类型 standard/custom */ /** 模块类型 standard/custom */
module_type?: string module_type?: string
/** 父模块ID */
parent_id?: string | null
} }
/** 更新模块请求参数 */ /** 更新模块请求参数 */
...@@ -49,6 +55,8 @@ export interface UpdateModuleRequest { ...@@ -49,6 +55,8 @@ export interface UpdateModuleRequest {
icon?: string icon?: string
order?: number order?: number
module_type?: string module_type?: string
/** 父模块ID(空字符串表示清除父模块) */
parent_id?: string | null
} }
/** 模块列表响应 */ /** 模块列表响应 */
......
...@@ -11,20 +11,16 @@ ...@@ -11,20 +11,16 @@
<!-- 页面头部 --> <!-- 页面头部 -->
<div class="page-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
<el-select <el-tree-select
v-model="filterModuleId" v-model="filterModuleId"
:data="moduleTreeData"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="选择模块" placeholder="选择模块"
clearable clearable
check-strictly
style="width: 200px" style="width: 200px"
@change="loadCases" @change="loadCases"
> />
<el-option
v-for="m in modules"
:key="m.id"
:label="m.name"
:value="m.id"
/>
</el-select>
<el-input <el-input
v-model="keyword" v-model="keyword"
placeholder="搜索用例名称" placeholder="搜索用例名称"
...@@ -228,14 +224,14 @@ ...@@ -228,14 +224,14 @@
<el-input v-model="editForm.name" placeholder="请输入用例名称" /> <el-input v-model="editForm.name" placeholder="请输入用例名称" />
</el-form-item> </el-form-item>
<el-form-item label="所属模块" prop="module_id"> <el-form-item label="所属模块" prop="module_id">
<el-select v-model="editForm.module_id" placeholder="选择模块" style="width: 100%"> <el-tree-select
<el-option v-model="editForm.module_id"
v-for="m in modules" :data="moduleTreeData"
:key="m.id" :props="{ label: 'name', value: 'id', children: 'children' }"
:label="m.name" placeholder="选择模块"
:value="m.id" check-strictly
/> style="width: 100%"
</el-select> />
</el-form-item> </el-form-item>
<el-form-item label="用例类型"> <el-form-item label="用例类型">
<el-select v-model="editForm.case_type" style="width: 100%"> <el-select v-model="editForm.case_type" style="width: 100%">
...@@ -465,7 +461,7 @@ import { ...@@ -465,7 +461,7 @@ import {
Search, Plus, Upload, Download, VideoPlay, Document Search, Plus, Upload, Download, VideoPlay, Document
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { caseApi } from '@/api/cases' import { caseApi } from '@/api/cases'
import { moduleApi } from '@/api/modules' import { moduleApi, buildModuleTree } from '@/api/modules'
import { executionApi } from '@/api/executions' import { executionApi } from '@/api/executions'
import CaseStepEditor from '@/components/CaseStepEditor.vue' import CaseStepEditor from '@/components/CaseStepEditor.vue'
...@@ -505,6 +501,9 @@ const selectedCases = ref<any[]>([]) ...@@ -505,6 +501,9 @@ const selectedCases = ref<any[]>([])
// 模块列表 // 模块列表
const modules = ref<any[]>([]) const modules = ref<any[]>([])
// 模块树形数据(用于 el-tree-select)
const moduleTreeData = computed(() => buildModuleTree(modules.value))
// 编辑弹窗 // 编辑弹窗
const editDialogVisible = ref(false) const editDialogVisible = ref(false)
const editMode = ref<'create' | 'edit'>('create') const editMode = ref<'create' | 'edit'>('create')
......
...@@ -75,6 +75,17 @@ ...@@ -75,6 +75,17 @@
<el-option label="项目定制模块" value="custom" /> <el-option label="项目定制模块" value="custom" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="父模块">
<el-tree-select
v-model="formData.parent_id"
:data="parentModuleTreeData"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="选择父模块(可选)"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="模块描述"> <el-form-item label="模块描述">
<el-input <el-input
v-model="formData.description" v-model="formData.description"
...@@ -109,7 +120,7 @@ import { useRouter, useRoute } from 'vue-router' ...@@ -109,7 +120,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Folder, Document, TrendCharts } from '@element-plus/icons-vue' import { Plus, Refresh, Folder, Document, TrendCharts } from '@element-plus/icons-vue'
import type { Module, CreateModuleRequest, UpdateModuleRequest } from '@/types/module' import type { Module, CreateModuleRequest, UpdateModuleRequest } from '@/types/module'
import { moduleApi } from '@/api/modules' import { moduleApi, buildModuleTree } from '@/api/modules'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
...@@ -125,11 +136,30 @@ const editingId = ref('') ...@@ -125,11 +136,30 @@ const editingId = ref('')
/** 当前模块类型(来自路由参数,默认 standard) */ /** 当前模块类型(来自路由参数,默认 standard) */
const currentType = computed(() => (route.params.type as string) || 'standard') const currentType = computed(() => (route.params.type as string) || 'standard')
/** 父模块树形数据(编辑时排除当前模块及其子模块,防循环引用) */
const parentModuleTreeData = computed(() => {
if (!isEdit.value || !editingId.value) {
return buildModuleTree(modules.value)
}
// 递归获取所有子模块ID
const getChildIds = (parentId: string): string[] => {
const children = modules.value.filter(m => m.parentId === parentId)
return children.flatMap(c => [c.id, ...getChildIds(c.id)])
}
const excludeIds = new Set([editingId.value, ...getChildIds(editingId.value)])
const filteredModules = modules.value.filter(m => !excludeIds.has(m.id))
return buildModuleTree(filteredModules)
})
const formData = ref<CreateModuleRequest>({ const formData = ref<CreateModuleRequest>({
name: '', name: '',
description: '', description: '',
icon: 'folder', icon: 'folder',
module_type: 'standard' module_type: 'standard',
parent_id: null
}) })
// ==================== 方法定义 ==================== // ==================== 方法定义 ====================
...@@ -150,7 +180,7 @@ const loadModules = async () => { ...@@ -150,7 +180,7 @@ const loadModules = async () => {
const showCreateDialog = () => { const showCreateDialog = () => {
isEdit.value = false isEdit.value = false
editingId.value = '' editingId.value = ''
formData.value = { name: '', description: '', icon: 'folder', module_type: currentType.value } formData.value = { name: '', description: '', icon: 'folder', module_type: currentType.value, parent_id: null }
dialogVisible.value = true dialogVisible.value = true
} }
...@@ -162,7 +192,8 @@ const showEditDialog = (module: Module) => { ...@@ -162,7 +192,8 @@ const showEditDialog = (module: Module) => {
name: module.name, name: module.name,
description: module.description, description: module.description,
icon: module.icon, icon: module.icon,
module_type: module.moduleType || 'standard' module_type: module.moduleType || 'standard',
parent_id: module.parentId || null
} }
dialogVisible.value = true dialogVisible.value = true
} }
......
...@@ -29,19 +29,15 @@ ...@@ -29,19 +29,15 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="所属模块" required> <el-form-item label="所属模块" required>
<el-select <el-tree-select
v-model="recorderForm.moduleId" v-model="recorderForm.moduleId"
:data="moduleTreeData"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="选择模块" placeholder="选择模块"
check-strictly
style="width: 200px" style="width: 200px"
:disabled="isRecording" :disabled="isRecording"
> />
<el-option
v-for="m in modules"
:key="m.id"
:label="m.name"
:value="m.id"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="用例名称" required> <el-form-item label="用例名称" required>
<el-input <el-input
...@@ -256,7 +252,7 @@ ...@@ -256,7 +252,7 @@
* 6. 保存为测试用例 * 6. 保存为测试用例
*/ */
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
...@@ -266,7 +262,7 @@ import { ...@@ -266,7 +262,7 @@ import {
FolderChecked, FolderChecked,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { Module } from '@/types/module' import type { Module } from '@/types/module'
import { moduleApi } from '@/api/modules' import { moduleApi, buildModuleTree } from '@/api/modules'
import { recorderApi } from '@/api/recorder' import { recorderApi } from '@/api/recorder'
const router = useRouter() const router = useRouter()
...@@ -284,6 +280,9 @@ const recorderForm = ref({ ...@@ -284,6 +280,9 @@ const recorderForm = ref({
/** 模块列表 */ /** 模块列表 */
const modules = ref<Module[]>([]) const modules = ref<Module[]>([])
/** 模块树形数据(用于 el-tree-select) */
const moduleTreeData = computed(() => buildModuleTree(modules.value))
/** 是否录制中 */ /** 是否录制中 */
const isRecording = ref(false) const isRecording = ref(false)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论