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

feat: 功能测试报告新增模板配置功能(多模板管理)

- 新增 ReportTemplate 模型(name/is_default/config JSON字段)
- 新增模板 CRUD API(/api/functional-report/templates)
- 新增前端 TemplateConfig.vue 模板配置管理页面
- 新增模板选择下拉框(FunctionalReport.vue 生成报告时可选模板)
- 改造 word_generator/chart_generator/markdown_generator 支持 template_config 参数
- 修复 MySQL 下 config 字段 dict→JSON 序列化问题(json.dumps)
- 注册路由到 main.py,导入模型到 models/__init__.py
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 7c1c0488
......@@ -3,7 +3,8 @@
> **生成时间**: 2026-08-13
> **当前分支**: `platform-auto-test`
> **最近提交**: `f3f5d771` feat(frontend): 菜单重构为自动化测试父菜单,性能/功能报告独立,修复性能任务编辑详情加载
> **状态**: ✅ 功能测试报告模块全部完成(后端服务 + API + 前端页面 + ERP 上传 + ERP 配置持久化)
> **状态**: ✅ 功能测试报告模块全部完成(后端服务 + API + 前端页面 + ERP 上传 + ERP 配置持久化 + **报告模板配置多模板管理**)
> **部署状态**: ✅ 已部署至 192.168.5.60(Docker 容器 plat-auto-test-app,端口 80),前后端均已更新并验证通过
---
......@@ -35,6 +36,17 @@
5. ✅ 报告下载 + Markdown 在线预览
6. ✅ 上传报告到 ERP 系统(测试单报告 + 项目资料 + 协作文档)
### 任务 2:报告模板配置(多模板管理)— 2026-08-13 新增
**核心能力**:
1. ✅ 多模板 CRUD — 创建/编辑/删除/设为默认命名模板
2. ✅ 报告章节开关 — 10 个章节独立开关,动态编号
3. ✅ Word 样式可配置 — 字体/字号/标题字号/颜色/首行缩进
4. ✅ 图表样式可配置 — DPI/尺寸/中文字体/8 种颜色
5. ✅ 报告默认值可配置 — 测试负责人/测试人员/测试环境/系统版本/服务器地址/部署环境
6. ✅ 生成报告时下拉选择模板
7. ✅ 模板配置存储为 JSON 字段(`config` Text),与 `ErpConfig` 同模式
### 参考工具 bug 修复记录
| # | 问题 | 根因 | 修复 |
......@@ -64,14 +76,32 @@
| `backend/app/services/erp_service.py` | ERP 上传服务:人员列表查询、姓名-ID 匹配、图片上传、报告创建(Word→HTML→图片→创建报告)、项目资料上传、协作文档上传 |
| `backend/app/schemas/functional_report.py` | Pydantic 数据模型:UploadResponse、GenerateRequest/Response、DownloadResponse、UploadToErpRequest/Response、ErpConfigResponse、ErpConfigUpdateRequest、ReportInfoResponse |
| `backend/app/routers/functional_report.py` | API 路由:7 个端点(upload/generate/download/preview/info/upload-to-erp/config) |
| `backend/app/models/report_template.py` | **2026-08-13 新增** — 报告模板 ORM 模型(id/name/is_default/config JSON/created_at/updated_at) |
| `backend/app/schemas/report_template.py` | **2026-08-13 新增** — 模板 CRUD 的 Pydantic 模型(Create/Update/ListItem/Response) |
| `backend/app/routers/report_template.py` | **2026-08-13 新增** — 模板 CRUD API 路由(6 个端点) |
| `frontend/src/api/reportTemplate.ts` | **2026-08-13 新增** — 模板 CRUD API 封装(list/get/create/update/delete/setDefault) |
| `frontend/src/views/functional-report/TemplateConfig.vue` | **2026-08-13 新增** — 模板配置管理页面(左侧模板列表 + 右侧配置表单,含章节开关/Word样式/图表样式/默认值) |
### 2.2 后端修改文件
| 文件 | 修改内容 |
|------|---------|
| `backend/app/config.py` | 新增 5 个 ERP 配置项:`ERP_API_KEY`、`ERP_BASE_URL`、`ERP_DEVELOPTESTING_ID`、`ERP_TYPE_ID`、`ERP_IMAGE_URL_PREFIX` |
| `backend/app/main.py` | 导入并注册 `functional_report.router`,前缀 `/api/functional-report` |
| `backend/app/main.py` | 导入并注册 `functional_report.router`,前缀 `/api/functional-report`;**2026-08-13 新增**注册 `report_template.router`(前缀同) |
| `backend/requirements.txt` | 新增 4 个依赖:`python-docx`、`mammoth`、`Pillow`、`matplotlib` |
| `backend/app/models/__init__.py` | **2026-08-13 新增** `ReportTemplate` 模型注册 |
| `backend/app/services/functional/config.py` | **2026-08-13 新增** `get_default_config()` / `merge_template_config()` |
| `backend/app/services/functional/word_generator.py` | **2026-08-13 新增** `template_config` 参数(章节开关+Word样式+默认值) |
| `backend/app/services/functional/chart_generator.py` | **2026-08-13 新增** `template_config` 参数(chart_style 配置) |
| `backend/app/services/functional/markdown_generator.py` | **2026-08-13 新增** `template_config` 参数(章节开关+默认值) |
| `backend/app/services/functional_report_service.py` | **2026-08-13 新增** `template_id` 参数,加载模板配置并传递给各生成器 |
| `backend/app/schemas/functional_report.py` | **2026-08-13 新增** GenerateRequest 增加 `template_id` 字段 |
| `backend/app/routers/functional_report.py` | **2026-08-13 新增** generate 端点传入 `template_id` |
| `frontend/src/api/functionalReport.ts` | **2026-08-13 新增** `generate()` 增加 `templateId` 参数,请求体含 `template_id` |
| `frontend/src/views/functional-report/FunctionalReport.vue` | **2026-08-13 新增** 报告模板下拉选择器,`handleGenerate()` 传入模板 ID |
| `frontend/src/views/functional-report/index.vue` | **2026-08-13 修改** 注释更新为三个子页面 |
| `frontend/src/router/index.ts` | 新增 `/reports/functional` 独立路由,指向 `FunctionalReport.vue`(在 catch-all `/reports/:type?` 之前);**2026-08-13 新增** `template-config` 子路由 |
| `frontend/src/App.vue` | **2026-08-13 新增** 菜单"模板配置"子项 + `FUNCTIONAL_LABELS` |
### 2.3 前端新增文件
......@@ -90,8 +120,8 @@
| 文件 | 说明 |
|------|------|
| `Docs/PRD/需求文档/报告中心/_PRD_功能测试报告模块.md` | 需求文档 |
| `Docs/PRD/需求文档/报告中心/_PRD_功能测试报告模块_计划执行.md` | 计划执行文档 |
| `Docs/PRD/功能测试/需求文档/_PRD_功能测试报告模块.md` | 需求文档 |
| `Docs/PRD/功能测试/需求文档/_PRD_功能测试报告模块_计划执行.md` | 计划执行文档 |
---
......@@ -101,7 +131,7 @@
```
POST /api/functional-report/upload → 上传测试用例 Excel + BUG 列表 Excel
POST /api/functional-report/generate → 基于已上传文件生成报告
POST /api/functional-report/generate → 基于已上传文件生成报告(支持 template_id)
GET /api/functional-report/download/{session_id} → 下载报告文件
GET /api/functional-report/preview/{session_id} → 预览 Markdown 报告(HTML)
GET /api/functional-report/info/{session_id} → 获取会话报告信息
......@@ -110,6 +140,59 @@ GET /api/functional-report/config → 获取 ERP 配置(DB 优先,
PUT /api/functional-report/config → 更新 ERP 配置(持久化到数据库)
```
### 报告模板配置端点(2026-08-13 新增,前缀 `/api/functional-report/templates`)
```
GET /templates → 获取模板列表(含默认标记,不含 config)
GET /templates/{id} → 获取单个模板完整配置
POST /templates → 创建模板(首个模板自动设为默认)
PUT /templates/{id} → 更新模板(name/config/is_default 可独立更新)
DELETE /templates/{id} → 删除模板(不允许删除最后一个)
PUT /templates/{id}/default → 设为默认模板(全局唯一,自动切换)
```
**config JSON 结构**(创建/更新时与默认配置合并,`merge_template_config` 保证结构完整):
```json
{
"chapters": { "report_basic_info": true, "execution_summary": true, "test_scope": true,
"case_details": true, "bug_list": true, "chart_analysis": true,
"execution_records": true, "attachments": true, "risk_assessment": true,
"appendix": true },
"defaults": { "test_lead": "未填写", "tester": "未填写", "test_env": "未填写",
"system_version": "未填写", "server_address": "未填写", "deployment_env": "未填写" },
"word_style": { "font_name": "宋体", "font_size": 11, "title_level_1_size": 21,
"title_level_2_size": 19, "title_level_3_size": 17, "title_level_4_size": 15,
"paragraph_first_line_indent": 2, "title_color": "#000000" },
"chart_style": { "dpi": 300, "figure_width": 10, "figure_height": 6, "chinese_font": "Microsoft YaHei",
"colors": { "pass": "#04AA6D", "fail": "#bb4069", "unverified": "#f0c040",
"undeveloped": "#dcbdbd", "bug_1": "#d32f2f", "bug_2": "#f57c00",
"bug_3": "#fbc02d", "bug_4": "#388e3c" } }
}
```
**Query 示例**:
```http
POST /api/functional-report/templates
{
"name": "精简模板",
"config": { "chapters": { "attachments": false, "appendix": false } }
}
```
生成报告时传 `template_id`:
```http
POST /api/functional-report/generate
{
"session_id": "xxx",
"project_name": "项目名称",
"report_format": "docx",
"template_id": 2
}
```
### 上传接口
```http
......@@ -175,12 +258,19 @@ backend/app/schemas/functional_report.py # 数据模型
用户上传 Excel → 后端保存到 data/functional_report/{session_id}/
→ excel_reader 解析 Excel → TestCase[] / Bug[] dataclass
→ data_analyzer 分析 → TestCaseAnalysis / BugAnalysis
→ chart_generator 生成图表 → charts/ 目录(4 张 PNG)
→ word_generator / markdown_generator 生成报告 → reports/ 目录
→ chart_generator 生成图表(读取 template_config.chart_style)→ charts/ 目录(4 张 PNG)
→ word_generator / markdown_generator 生成报告(读取 template_config 的 chapters/word_style/defaults)
→ 用户下载/预览
→ 用户触发 ERP 上传 → erp_service:
Word → HTML(mammoth) → 逐张图片上传 ERP → 创建测试报告
(可选) 上传项目资料 / 协作文档
模板配置数据流:
用户创建/编辑模板 → POST/PUT /api/functional-report/templates
→ report_template.py 接收 → merge_template_config 合并配置
→ 存储到 report_templates 表的 config JSON 字段
用户生成报告时选择模板 → POST /api/functional-report/generate 传入 template_id
→ functional_report_service.py 加载模板 → 传递给各生成器
```
### ERP 上传流程
......@@ -258,6 +348,13 @@ ERP 请求参数:超时 30s,最多重试 3 次(间隔 5s),4xx 错误
| 前端菜单 | ✅ 改造 | 功能测试报告改为父菜单,含"生成报告"/"ERP 配置"子项 |
| 前端构建 | ✅ 通过 | vue-tsc 类型检查 + Vite 构建均通过 |
| 后端导入 | ✅ 通过 | 所有模块可正常 import |
| **报告模板配置** | ✅ **2026-08-13 新增** | 完整多模板管理(CRUD + 默认模板 + 章节开关 + Word样式 + 图表样式 + 默认值) |
| **模板模型 + API** | ✅ 5 个文件 | `report_template.py`(模型/路由/ schema)+ `config.py` 工具函数 + 生成器改造 |
| **模板配置页面** | ✅ TemplateConfig.vue | 左侧模板列表 + 右侧完整配置表单,含颜色选择器/章节开关/字号设置 |
| **生成器改造** | ✅ 3 个生成器 | word_generator / chart_generator / markdown_generator 均支持 `template_config` 参数 |
| **生成报告模板选择** | ✅ FunctionalReport.vue | 新增模板下拉选择器,生成时传入 template_id |
| **部署至 5.60 服务器** | ✅ 验证通过 | Docker 容器健康运行,`GET /api/functional-report/templates` 返回 `[]` |
| **缺失文件补传** | ✅ 已修复 | `performance_output.py` / `api_preset.py` / `api_preset_schema` / `api_preset_service` 一并补传 |
---
......@@ -269,7 +366,9 @@ ERP 请求参数:超时 30s,最多重试 3 次(间隔 5s),4xx 错误
|--------|------|------|
| **P0** | 后端启动验证 | 启动后端服务,用示例 Excel 测试完整流程(上传→生成→下载) |
| **P0** | 前端联调验证 | 启动前端服务,访问 `/reports/functional`,确认上传/生成/下载/预览正常,ERP 配置页加载/保存正常 |
| **P0** | 模板配置验证 | 创建模板 → 编辑模板 → 设为默认 → 删除模板 → 生成报告时选择模板,确认样式生效 |
| **P1** | 系统设置页 ERP 配置 | 在 Settings.vue 中增加 ERP 配置项(API Key/URL/默认测试单 ID) |
| **P1** | 部署自动检查 | 增加一个部署前自动检查脚本,检测 `routers/__init__.py` 未 export 的模块,避免容器启动失败 |
| **P2** | 前端页面加载状态细化 | 上传/生成过程中的 loading 状态、进度提示等 |
| **P2** | 报告历史管理 | 支持查看历史报告列表、删除、重新生成 |
| **P3** | 批量生成 | 支持多项目批量生成报告 |
......@@ -285,6 +384,11 @@ ERP 请求参数:超时 30s,最多重试 3 次(间隔 5s),4xx 错误
| F5 | 前端 `baseURL` 双重前缀 404 | axios baseURL 设为 `/api` 后路径又写 `/api/...` | baseURL 留空,路径写完整 `/api/...` |
| F6 | `updateErpConfig`/`getErpConfig` 导入报 TS 错误 | `functionalReport.ts` 导出的是对象 `functionalReportApi`,非命名导出 | 用 `functionalReportApi.getErpConfig()` 调用 |
| F7 | PUT config 后 api_key 返回空字符串 | DB 记录只更新了非空字段,空字段未回退环境变量 | GET/PUT 返回时空字段一律回退到 `settings` 默认值 |
| F8 | TypeScript `TS18048` 报错 | `config.word_style` 等字段为可选项(`?:`),模板中访问报 undefined | `reportTemplate.ts` 中所有 config 字段改为必填(保持与后端 `get_default_config()` 结构一致) |
| F9 | TypeScript `TS7053` 报错 | `chapterOptions` 未显式声明类型,`config.chapters[key]` 索引报 any | `chapterOptions` 类型标注为 `{ key: keyof ReportTemplateConfig['chapters']; label: string }[]` |
| F10 | 部署脚本 Python SyntaxError | `replace('\', '/')` 反斜杠未转义 | 改用 `os.sep` 或 `replace('\\\\', '/')` |
| F11 | **服务器 API 路由未更新(重点)** | 部署时以为宿主机直跑 uvicorn,实际是 **Docker 容器**(`plat-auto-test-app`)在跑 | **必须 `docker restart plat-auto-test-app` 而非宿主 pkill**,且宿主 `/data/.../backend/` 是容器挂载卷,直接改文件即生效 |
| F12 | **容器启动失败 ImportError** | 服务器后端缺少本地已有的 `performance_output.py`/`api_preset.py` 及 `api_preset` schema/service(routers/__init__.py 不 export,靠 main.py 直接导入) | 补传缺失文件后再 `docker restart`,成功恢复 healthy |
---
......@@ -302,10 +406,18 @@ npm run dev
# 访问
# 前端: http://localhost:3000/reports/functional/generate(生成报告)
# 前端: http://localhost:3000/reports/functional/template-config(模板配置)
# 前端: http://localhost:3000/reports/functional/erp-config(ERP 配置)
# API 文档: http://localhost:8001/docs(搜 functional-report 标签)
# 健康检查: http://localhost:8001/health
# 生产环境 5.60 服务器(Docker 部署)
# 服务器: 192.168.5.60 (SSH ubains / Ubains@123)
# 部署目录: /data/third_party/plat-auto-test/(backend 挂载到容器 /app)
# 容器: plat-auto-test-app(deploy-app 镜像,uvicorn 端口 80)
# 更新代码后: sudo docker restart plat-auto-test-app(bind mount 直接生效)
# 缺失模块排查: sudo docker logs plat-auto-test-app --tail 50
# 测试用示例 Excel 文件
# 参考工具目录: E:\GithubData\ubains-module-test\develop\AuxiliaryTool\FunctionalTestReportGeneration\
# 示例文件: data/input/用例模板.xlsx, data/input/BUG列表模板.xlsx
......@@ -315,7 +427,7 @@ npm run dev
## 九、文件完整清单
### 后端新增(15 个文件)
### 后端新增(15 个文件)→ 另有报告模板相关(2026-08-13)
```
backend/app/services/functional/__init__.py
......@@ -333,16 +445,38 @@ backend/app/routers/functional_report.py
backend/app/models/erp_config.py — ERP 配置数据库模型
```
### 后端修改(4 个文件)
### 报告模板配置新增(2026-08-13,4 个文件)
```
backend/app/models/report_template.py — 报告模板 ORM 模型
backend/app/schemas/report_template.py — 模板 CRUD 的 Pydantic 模型
backend/app/routers/report_template.py — 模板 CRUD API 路由(6 个端点)
frontend/src/api/reportTemplate.ts — 前端模板 CRUD API 封装
frontend/src/views/functional-report/TemplateConfig.vue — 模板配置页面
```
### 后端修改(4 个文件)→ 另有报告模板相关(2026-08-13)
```
backend/app/config.py — 新增 ERP 配置项
backend/app/main.py — 注册 functional_report 路由
backend/app/main.py — 注册 functional_report 路由 + 2026-08-13 注册 report_template 路由
backend/requirements.txt — 新增 python-docx/mammoth/Pillow/matplotlib
backend/app/models/__init__.py — 注册 ErpConfig 模型
backend/app/models/__init__.py — 注册 ErpConfig 模型 + 2026-08-13 注册 ReportTemplate 模型
```
### 后端生成器改造(2026-08-13,5 个文件)
```
backend/app/services/functional/config.py — 新增 get_default_config() / merge_template_config()
backend/app/services/functional/word_generator.py — 支持 template_config 参数
backend/app/services/functional/chart_generator.py — 支持 template_config 参数
backend/app/services/functional/markdown_generator.py — 支持 template_config 参数
backend/app/services/functional_report_service.py — 支持 template_id 参数
backend/app/schemas/functional_report.py — GenerateRequest 增加 template_id
backend/app/routers/functional_report.py — generate 端点传入 template_id
```
### 前端新增(4 个文件)
### 前端新增(4 个文件)→ 另有报告模板相关(2026-08-13)
```
frontend/src/api/functionalReport.ts
......@@ -351,18 +485,21 @@ frontend/src/views/functional-report/index.vue — 父容器组件
frontend/src/views/functional-report/ErpConfig.vue — ERP 配置页面
```
### 前端修改(2 个文件)
### 前端修改(2 个文件)→ 另有报告模板相关(2026-08-13)
```
frontend/src/router/index.ts — 改为父路由 + 子路由结构
frontend/src/App.vue — 菜单改为 el-sub-menu 子菜单结构
frontend/src/router/index.ts — 改为父路由 + 子路由结构;2026-08-13 新增 template-config 子路由
frontend/src/App.vue — 菜单改为 el-sub-menu 子菜单结构;2026-08-13 新增模板配置子项
frontend/src/api/functionalReport.ts — 2026-08-13 generate() 增加 templateId 参数
frontend/src/views/functional-report/FunctionalReport.vue — 2026-08-13 新增模板选择下拉框
frontend/src/views/functional-report/index.vue — 2026-08-13 注释更新
```
### 文档(2 个文件)
```
Docs/PRD/需求文档/报告中心/_PRD_功能测试报告模块.md
Docs/PRD/需求文档/报告中心/_PRD_功能测试报告模块_计划执行.md
Docs/PRD/功能测试/需求文档/_PRD_功能测试报告模块.md
Docs/PRD/功能测试/需求文档/_PRD_功能测试报告模块_计划执行.md
```
---
......
# 功能测试报告-模板配置 · 需求文档(PRD)
> **文档状态**:已定稿 · **版本**:v1.0 · **最后更新**:2026-08-13
> **维护者**:czj · **所属模块**:报告中心-功能测试
---
## 1. 背景与目标
### 1.1 背景
功能测试报告模块目前以固定样式生成 Word 报告和 Markdown 报告,所有样式参数(字体、字号、颜色、图表尺寸、DPI 等)以及报告章节均硬编码在 `backend/app/services/functional/config.py` 中。用户无法自定义:
- 报告包含哪些章节(如不需要"测试附件"或"附录")
- 报告默认值(如测试负责人、测试环境等)
- Word 文档的字体、字号、标题颜色
- 图表的颜色、分辨率、尺寸
随着功能测试报告模块在多个项目中使用,不同的项目有不同的报告样式需求,硬编码方式已无法满足灵活配置的要求。
### 1.2 目标
在功能测试报告模块下新增**模板配置**子功能,实现:
```
多模板管理(增删改) → 自定义章节开关 / 样式 / 默认值 → 生成报告时选择模板套用
```
具体目标:
1. **多模板管理**:支持创建多个命名模板,每个模板包含完整的样式配置
2. **默认模板**:可设置一个模板为默认模板,生成报告时自动选中
3. **章节开关**:控制 Word/Markdown 报告中包含哪些章节(10 个章节可独立开关)
4. **Word 样式自定义**:字体、字号、标题颜色、首行缩进
5. **图表样式自定义**:DPI、图表尺寸、颜色方案、中文字体
6. **报告默认值自定义**:测试负责人、测试人员、测试环境等字段
7. **模板选择**:生成报告时从下拉列表中选择模板
### 1.3 参考实现
本功能参考现有 `ErpConfig` 模型的单表配置持久化模式(`backend/app/models/erp_config.py`),将配置存储为 JSON 字段,避免频繁改表结构。
---
## 2. 功能需求
### 2.1 功能清单
| 编号 | 功能 | 说明 | 优先级 |
|------|------|------|--------|
| T1 | 模板列表 | 查看所有模板,显示名称和默认标记 | P0 |
| T2 | 创建模板 | 新建模板,填写名称和完整配置 | P0 |
| T3 | 编辑模板 | 修改模板名称或配置 | P0 |
| T4 | 删除模板 | 删除模板(不允许删除最后一个) | P0 |
| T5 | 设为默认 | 选择一个模板作为默认模板(唯一) | P0 |
| T6 | 章节开关 | 控制 10 个报告章节的启用/禁用 | P0 |
| T7 | Word 样式配置 | 自定义字体、字号、标题颜色等 | P0 |
| T8 | 图表样式配置 | 自定义 DPI、尺寸、颜色方案 | P0 |
| T9 | 报告默认值配置 | 自定义测试负责人、环境等默认字段 | P0 |
| T10 | 生成报告时选择模板 | 生成报告表单中新增模板下拉选择器 | P0 |
### 2.2 功能详细说明
#### T1-T5. 模板 CRUD
- **模板列表**:左侧面板展示所有模板,每行显示模板名称和默认标记(星标/标签),支持设为默认、编辑、删除操作
- **创建模板**:点击"新建模板",在右侧表单中填写名称和完整配置,保存后写入数据库
- **编辑模板**:选中已有模板,右侧表单加载配置,修改后保存
- **删除模板**:确认弹窗后删除,若只剩最后一个模板则禁止删除并提示
- **设为默认**:将指定模板设为默认,原默认模板自动取消默认标记,确保全局唯一
#### T6. 章节开关
提供 10 个开关,对应 Word/Markdown 报告中的 10 个章节:
| 章节标识 | 章节名称 | 默认值 |
|----------|----------|--------|
| `report_basic_info` | 报告基本信息 | 开启 |
| `execution_summary` | 测试执行摘要 | 开启 |
| `test_scope` | 测试范围 | 开启 |
| `case_details` | 测试用例执行详情 | 开启 |
| `bug_list` | BUG 详细列表 | 开启 |
| `chart_analysis` | 图表分析 | 开启 |
| `execution_records` | 测试执行记录 | 开启 |
| `attachments` | 测试附件 | 开启 |
| `risk_assessment` | 风险评估与建议 | 开启 |
| `appendix` | 附录 | 开启 |
关闭的章节在生成报告时跳过,不占用序号;剩余章节序号保持连续。
#### T7. Word 样式配置
| 配置项 | 说明 | 默认值 | 控件类型 |
|--------|------|--------|----------|
| 字体名称 | 正文字体 | 宋体 | 文本输入 |
| 正文字号 | 正文段落字号(pt) | 11 | 数字输入 |
| 一级标题字号 | 报告标题字号(pt) | 21 | 数字输入 |
| 二级标题字号 | 章节标题字号(pt) | 19 | 数字输入 |
| 三级标题字号 | 子章节标题字号(pt) | 17 | 数字输入 |
| 四级标题字号 | 小节标题字号(pt) | 15 | 数字输入 |
| 标题颜色 | 所有标题的字体颜色 | #000000 | 颜色选择器 |
| 首行缩进 | 段落首行缩进字符数 | 2 | 数字输入 |
#### T8. 图表样式配置
| 配置项 | 说明 | 默认值 | 控件类型 |
|--------|------|--------|----------|
| DPI | 图表分辨率 | 300 | 数字输入 |
| 图表宽度 | 图宽(英寸) | 10 | 数字输入 |
| 图表高度 | 图高(英寸) | 6 | 数字输入 |
| 中文字体 | 图表中文显示字体 | Microsoft YaHei | 文本输入 |
| 通过颜色 | 通过状态颜色 | #04AA6D | 颜色选择器 |
| 失败颜色 | 失败状态颜色 | #bb4069 | 颜色选择器 |
| 未验证颜色 | 未验证状态颜色 | #f0c040 | 颜色选择器 |
| 未开发颜色 | 未开发状态颜色 | #dcbdbd | 颜色选择器 |
| BUG 1级颜色 | 致命 BUG 颜色 | #d32f2f | 颜色选择器 |
| BUG 2级颜色 | 严重 BUG 颜色 | #f57c00 | 颜色选择器 |
| BUG 3级颜色 | 一般 BUG 颜色 | #fbc02d | 颜色选择器 |
| BUG 4级颜色 | 轻微 BUG 颜色 | #388e3c | 颜色选择器 |
#### T9. 报告默认值配置
| 配置项 | 说明 | 默认值 | 控件类型 |
|--------|------|--------|----------|
| 测试负责人 | 测试负责人姓名 | 未填写 | 文本输入 |
| 测试人员 | 测试人员姓名 | 未填写 | 文本输入 |
| 测试环境 | 测试环境描述 | 未填写 | 文本输入 |
| 系统版本 | 被测系统版本号 | 未填写 | 文本输入 |
| 服务器地址 | 服务器地址 | 未填写 | 文本输入 |
| 部署环境 | 部署环境说明 | 未填写 | 文本输入 |
#### T10. 生成报告时选择模板
- 在生成报告表单(步骤2:项目配置)中新增"报告模板"下拉选择器
- 下拉列表调用 `GET /templates` 接口加载所有模板,默认选中默认模板
- 用户选择模板后,生成请求中携带 `template_id`
- 后端根据模板配置生成报告,样式和章节动态匹配
---
## 3. 数据格式规范
### 3.1 模板配置 JSON Schema
```json
{
"chapters": {
"report_basic_info": true,
"execution_summary": true,
"test_scope": true,
"case_details": true,
"bug_list": true,
"chart_analysis": true,
"execution_records": true,
"attachments": true,
"risk_assessment": true,
"appendix": true
},
"defaults": {
"test_lead": "未填写",
"tester": "未填写",
"test_env": "未填写",
"system_version": "未填写",
"server_address": "未填写",
"deployment_env": "未填写"
},
"word_style": {
"font_name": "宋体",
"font_size": 11,
"title_level_1_size": 21,
"title_level_2_size": 19,
"title_level_3_size": 17,
"title_level_4_size": 15,
"paragraph_first_line_indent": 2,
"title_color": "#000000"
},
"chart_style": {
"dpi": 300,
"figure_width": 10,
"figure_height": 6,
"chinese_font": "Microsoft YaHei",
"colors": {
"pass": "#04AA6D",
"fail": "#bb4069",
"unverified": "#f0c040",
"undeveloped": "#dcbdbd",
"bug_1": "#d32f2f",
"bug_2": "#f57c00",
"bug_3": "#fbc02d",
"bug_4": "#388e3c"
}
}
}
```
### 3.2 模板数据表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | Integer PK auto | 主键 |
| `name` | String(100) | 模板名称(如"默认模板"、"精简模板") |
| `is_default` | Boolean | 是否默认模板(全局唯一为 True) |
| `config` | Text (JSON) | 完整配置 JSON 字符串 |
| `created_at` | DateTime | 创建时间 |
| `updated_at` | DateTime | 更新时间 |
---
## 4. 非功能需求
| 需求 | 说明 |
|------|------|
| 向后兼容 | 生成报告时未传 template_id,使用默认模板配置;无任何模板时使用硬编码默认值 |
| 数据完整性 | 删除模板时检查是否最后一个,不允许删除导致模板为空 |
| 配置校验 | 创建/更新模板时校验 config JSON 的完整性,缺失字段使用默认值填充 |
| 响应性能 | 模板列表 API 响应时间 < 100ms(数据量小,预计 < 50 条) |
---
## 5. 技术方案
### 5.1 架构图
```
┌──────────────────────────────────────────────────────────────────┐
│ 前端 (TemplateConfig.vue) │
│ ┌──────────────┐ ┌───────────────────────────────────────────┐ │
│ │ 模板列表 │ │ 配置表单 │ │
│ │ ┌──────────┐│ │ - 基本信息(名称/默认) │ │
│ │ │ 模板 A ★ ││ │ - 章节开关 × 10 │ │
│ │ │ 模板 B ││ │ - Word 样式(字体/字号/颜色等) │ │
│ │ │ 模板 C ││ │ - 图表样式(DPI/尺寸/颜色方案) │ │
│ │ └──────────┘│ │ - 报告默认值 × 6 │ │
│ │ [+ 新建] │ │ [保存] │ │
│ └──────────────┘ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ GET/POST/PUT/DELETE /templates
┌──────────────────────────────────────────────────────────────────┐
│ 后端 API (routers/report_template.py) │
│ GET /templates → 列表 │
│ GET /templates/{id} → 详情 │
│ POST /templates → 创建 │
│ PUT /templates/{id} → 更新 │
│ DELETE /templates/{id} → 删除 │
│ PUT /templates/{id}/default → 设为默认 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 模型 (models/report_template.py) → report_templates 表 │
│ │
│ 配置工具 (services/functional/config.py) │
│ - get_default_config() → 返回硬编码默认值作为字典 │
│ - merge_template_config() → 合并模板配置与默认值 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 生成器改造 (接收 template_config 字典) │
│ - word_generator.py → 章节开关 + Word 样式 + 默认值 │
│ - chart_generator.py → 图表样式 │
│ - markdown_generator.py → 章节开关 + 默认值 │
│ - functional_report_service.py → 加载模板配置并传递 │
└──────────────────────────────────────────────────────────────────┘
```
### 5.2 复用策略
- **配置持久化模式**:参考 `ErpConfig` 模型(单行表,JSON 字段存储完整配置)
- **生成器改造**:各生成器函数新增 `template_config: Optional[Dict]` 参数,非侵入式,不传则行为不变
- **章节跳过**:在每个章节生成前加 `if not chapters.get("xxx", True): continue`,序号根据已启用章节动态生成
---
## 6. 接口设计
### 6.1 API 接口
**前缀**`/api/functional-report/templates`
| 方法 | 路径 | 请求体 | 响应 | 说明 |
|------|------|--------|------|------|
| GET | `/templates` | — | `[{id, name, is_default, updated_at}]` | 获取模板列表(不含 config 详情) |
| GET | `/templates/{id}` | — | `{id, name, is_default, config, created_at, updated_at}` | 获取单个模板完整信息 |
| POST | `/templates` | `{name, config}` | `{id, name, is_default, config, ...}` | 创建模板 |
| PUT | `/templates/{id}` | `{name?, config?, is_default?}` | `{id, name, is_default, config, ...}` | 更新模板 |
| DELETE | `/templates/{id}` | — | `{success: true}` | 删除模板(最后一个禁止删除) |
| PUT | `/templates/{id}/default` | — | `{id, name, is_default, config, ...}` | 设为默认模板(原默认自动取消) |
**生成报告请求扩展**
| 方法 | 路径 | 新增字段 | 说明 |
|------|------|----------|------|
| POST | `/api/functional-report/generate` | `template_id: Optional[int]` | 选择模板 ID,为空则使用默认模板或硬编码默认值 |
### 6.2 前端路由
| 路径 | 组件 | 说明 |
|------|------|------|
| `/reports/functional/template-config` | `TemplateConfig.vue` | 模板配置管理页面 |
---
## 7. 依赖清单
| 依赖 | 说明 | 类型 |
|------|------|------|
| `backend/app/models/report_template.py` | 新增 ORM 模型 | 新增 |
| `backend/app/schemas/report_template.py` | 新增 Pydantic 校验 | 新增 |
| `backend/app/routers/report_template.py` | 新增 API 路由 | 新增 |
| `backend/app/services/functional/config.py` | 修改:新增配置工具函数 | 修改 |
| `backend/app/services/functional/word_generator.py` | 修改:支持 template_config | 修改 |
| `backend/app/services/functional/chart_generator.py` | 修改:支持 chart_style 配置 | 修改 |
| `backend/app/services/functional/markdown_generator.py` | 修改:支持 template_config | 修改 |
| `backend/app/services/functional_report_service.py` | 修改:加载模板配置并传递 | 修改 |
| `backend/app/routers/functional_report.py` | 修改:generate 端点传入 template_id | 修改 |
| `backend/app/schemas/functional_report.py` | 修改:GenerateRequest 新增 template_id | 修改 |
| `backend/app/main.py` | 修改:注册 report_template 路由 | 修改 |
| `backend/app/models/__init__.py` | 修改:导入 ReportTemplate | 修改 |
| `frontend/src/api/reportTemplate.ts` | 新增 API 封装 | 新增 |
| `frontend/src/views/functional-report/TemplateConfig.vue` | 新增页面组件 | 新增 |
| `frontend/src/views/functional-report/FunctionalReport.vue` | 修改:新增模板下拉选择 | 修改 |
| `frontend/src/router/index.ts` | 修改:新增 template-config 子路由 | 修改 |
| `frontend/src/App.vue` | 修改:新增菜单项 + 标签映射 | 修改 |
---
## 8. 风险与应对
| 风险 | 影响 | 概率 | 应对 |
|------|------|------|------|
| 模板配置与生成器耦合紧密 | 改造成本高 | 中 | 采用非侵入式参数设计,不传参则行为不变 |
| 章节开关导致序号不连续 | 报告可读性降低 | 低 | 动态编号,只对启用章节分配序号 |
| 图表颜色配置与现有颜色不兼容 | 图表风格不一致 | 低 | 用户可自定义,默认值与现有硬编码一致 |
| 删除模板导致数据丢失 | 用户误操作 | 低 | 确认弹窗 + 禁止删除最后一条 |
\ No newline at end of file
# 功能测试报告-模板配置 · 计划执行文档
> **文档状态**:已定稿 · **版本**:v1.0 · **最后更新**:2026-08-13
> **维护者**:czj · **所属模块**:报告中心-功能测试
> **对应 PRD**:`_PRD_功能测试报告_模板配置.md`
---
## 一、执行计划总览
| 阶段 | 内容 | 预估工时 | 产出物 |
|------|------|----------|--------|
| Phase 1 | 后端:模板模型 + 配置工具函数 | 1h | `report_template.py` / `__init__.py` / `config.py` 改造 |
| Phase 2 | 后端:生成器改造(word / chart / markdown) | 2h | 三个生成器支持 `template_config` |
| Phase 3 | 后端:服务层 + API 路由 + 注册 | 1h | `report_template.py` 路由 / `functional_report.py` 扩展 / `main.py` 注册 |
| Phase 4 | 前端:模板配置页面 + API 封装 | 2h | `TemplateConfig.vue` / `reportTemplate.ts` |
| Phase 5 | 前端:路由 + 菜单 + 生成页模板下拉框 | 1h | `router/index.ts` / `App.vue` / `FunctionalReport.vue` |
| Phase 6 | 验证与部署 | 1h | 端到端验证 + 部署到 5.60 |
---
## 二、Phase 1:后端模型与配置工具
### 1.1 新建 `backend/app/models/report_template.py`
```python
class ReportTemplate(Base):
__tablename__ = "report_templates"
id: int = Column(Integer, primary_key=True, autoincrement=True, comment="主键")
name: str = Column(String(100), nullable=False, comment="模板名称")
is_default: bool = Column(Boolean, nullable=False, default=False, comment="是否默认模板")
config: str = Column(Text, nullable=False, comment="完整配置 JSON 字符串")
created_at: datetime = Column(DateTime, default=datetime.now, comment="创建时间")
updated_at: datetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
def to_dict(self):
"""转为字典,config 反序列化为 dict"""
return {
"id": self.id,
"name": self.name,
"is_default": self.is_default,
"config": json.loads(self.config) if self.config else {},
"created_at": self.created_at.strftime(DATE_FORMAT) if self.created_at else None,
"updated_at": self.updated_at.strftime(DATE_FORMAT) if self.updated_at else None,
}
def to_list_item(self):
"""列表项,不含 config 详情"""
return {
"id": self.id,
"name": self.name,
"is_default": self.is_default,
"updated_at": self.updated_at.strftime(DATE_FORMAT) if self.updated_at else None,
}
```
### 1.2 修改 `backend/app/models/__init__.py`
- 第 25 行附近新增 `from app.models.report_template import ReportTemplate`
- `__all__` 中新增 `"ReportTemplate"`
### 1.3 修改 `backend/app/services/functional/config.py`
新增两个工具函数(保留原有常量不变,确保向后兼容):
```python
def get_default_config() -> dict:
"""返回当前硬编码默认值作为默认模板配置"""
return {
"chapters": {
"report_basic_info": True, "execution_summary": True,
"test_scope": True, "case_details": True, "bug_list": True,
"chart_analysis": True, "execution_records": True,
"attachments": True, "risk_assessment": True, "appendix": True,
},
"defaults": {
"test_lead": DEFAULT_TEST_LEAD, "tester": DEFAULT_TESTER,
"test_env": DEFAULT_TEST_ENV, "system_version": DEFAULT_SYSTEM_VERSION,
"server_address": DEFAULT_SERVER_ADDRESS, "deployment_env": DEFAULT_DEPLOYMENT_ENV,
},
"word_style": {
"font_name": WORD_FONT_NAME, "font_size": WORD_FONT_SIZE,
"title_level_1_size": WORD_TITLE_LEVEL_1_SIZE,
"title_level_2_size": WORD_TITLE_LEVEL_2_SIZE,
"title_level_3_size": WORD_TITLE_LEVEL_3_SIZE,
"title_level_4_size": WORD_TITLE_LEVEL_4_SIZE,
"paragraph_first_line_indent": WORD_PARAGRAPH_FIRST_LINE_INDENT,
"title_color": "#000000",
},
"chart_style": {
"dpi": CHART_DPI, "figure_width": CHART_FIGURE_SIZE[0],
"figure_height": CHART_FIGURE_SIZE[1],
"chinese_font": CHINESE_FONT,
"colors": dict(CHART_COLORS),
},
}
def merge_template_config(base_config: dict, template_config: dict) -> dict:
"""将模板配置与默认配置深合并,缺失字段用默认值填充"""
def deep_merge(base, override):
result = dict(base)
for k, v in (override or {}).items():
if isinstance(v, dict) and isinstance(result.get(k), dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
return deep_merge(base_config, template_config or {})
```
---
## 三、Phase 2:生成器改造
### 2.1 修改 `backend/app/services/functional/word_generator.py`
`generate_report()` 新增可选参数 `template_config: Optional[Dict] = None`
1. **加载样式**:函数开头 `cfg = merge_template_config(get_default_config(), template_config)`,从中读取:
- `cfg["word_style"]["font_name"]` / `font_size` / `title_level_*_size` / `title_color` / `paragraph_first_line_indent`
- `cfg["defaults"]["test_lead"]` / `tester` 等(替代硬编码 `DEFAULT_*` 常量)
2. **章节开关**`chapters = cfg["chapters"]`,每个章节生成逻辑前加:
```python
if not chapters.get("report_basic_info", True):
print("[word] 跳过章节: 报告基本信息")
else:
# 原有章节生成逻辑
```
3. **动态编号**:章节标题序号不硬编码,维护一个 `chapter_index` 计数器,仅对启用的章节自增
4. **标题样式**`_add_title(doc, title, level)` 函数从 `cfg["word_style"]` 读取字号映射和标题颜色
### 2.2 修改 `backend/app/services/functional/chart_generator.py`
`generate_all_charts()` 新增可选参数 `template_config: Optional[Dict] = None`
- `template_config["chart_style"]` 读取 `dpi``figure_width``figure_height``chinese_font``colors`
- `_setup_chinese_font()` 优先使用配置的中文字体,失败时回退到候选列表
- 各图表函数的 `plt.rcParams` 设置(dpi、字体)动态传入
### 2.3 修改 `backend/app/services/functional/markdown_generator.py`
word_generator 类似,`generate_markdown_report()` 新增 `template_config: Optional[Dict] = None`
- 章节开关:跳过被关闭的章节
- 默认值:从 `cfg["defaults"]` 读取
---
## 四、Phase 3:服务层与 API 路由
### 3.1 新建 `backend/app/schemas/report_template.py`
```python
class ReportTemplateResponse(BaseModel):
id: int
name: str
is_default: bool
config: dict = {}
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ReportTemplateListItem(BaseModel):
id: int
name: str
is_default: bool
updated_at: Optional[str] = None
class ReportTemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
config: dict = Field(default_factory=dict, description="完整配置 JSON")
class ReportTemplateUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
config: Optional[dict] = None
is_default: Optional[bool] = None
```
### 3.2 新建 `backend/app/routers/report_template.py`
注册到 `backend/app/main.py`,前缀 `/api/functional-report/templates`:
| 方法 | 端点 | 实现要点 |
|------|------|----------|
| GET | `/` | 查询全部模板,按 is_default desc + id asc 排序,返回 ListItem 列表 |
| GET | `/{template_id}` | 查询单个模板,404 兜底 |
| POST | `/` | 创建模板;若配置为空则用 `get_default_config()` 填充;若库中无模板则自动设为默认 |
| PUT | `/{template_id}` | 更新 name / config / is_default;若置为新默认则先批量取消其他默认 |
| DELETE | `/{template_id}` | 校验非最后一条,否则 400 拒绝 |
| PUT | `/{template_id}/default` | 事务内先 `UPDATE ... SET is_default=False`,再设置目标为 True |
### 3.3 修改 `backend/app/services/functional_report_service.py`
- `analyze_and_generate()` 与 `generate_word_report()` / `generate_markdown_report()` 新增 `template_id: Optional[int] = None` 参数
- 生成时:若 `template_id` 提供 → 加载该模板 config;否则查询默认模板(`is_default=True`);均无 → `template_config=None`(生成器内部回退硬编码)
- 使用 `get_db()` 会话加载(与 ERP 配置模式一致)
### 3.4 修改 `backend/app/routers/functional_report.py` + `schemas/functional_report.py`
- `GenerateRequest` 新增 `template_id: Optional[int] = Field(None, description="报告模板ID")`
- generate 端点将 `template_id` 传入 service
### 3.5 修改 `backend/app/main.py`
```python
from app.routers import report_template
app.include_router(report_template.router, prefix="/api/functional-report", tags=["功能测试报告-模板配置"])
```
---
## 五、Phase 4:前端页面与 API
### 4.1 新建 `frontend/src/api/reportTemplate.ts`
```typescript
export interface ReportTemplateListItem { id: number; name: string; is_default: boolean; updated_at?: string }
export interface ReportTemplateConfig { chapters: Record<string, boolean>; defaults: Record<string, string>; word_style: {...}; chart_style: {...} }
export interface ReportTemplate extends ReportTemplateListItem { config: ReportTemplateConfig; created_at?: string }
export const reportTemplateApi = {
list(): request.get('/api/functional-report/templates'),
get(id: number): request.get(`/api/functional-report/templates/${id}`),
create(data: {name: string; config: object}): request.post('/api/functional-report/templates', data),
update(id: number, data: {name?: string; config?: object; is_default?: boolean}): request.put(`/api/functional-report/templates/${id}`, data),
remove(id: number): request.delete(`/api/functional-report/templates/${id}`),
setDefault(id: number): request.put(`/api/functional-report/templates/${id}/default`)
}
```
### 4.2 新建 `frontend/src/views/functional-report/TemplateConfig.vue`
页面布局:
```
┌────────────────────────────────────────────┐
[el-row gutter=16]
│ ┌────────────┬─────────────────────────┐ │
│ │ 左列 320px │ 右列 flex-1 │ │
│ │ [el-card][el-card] │ │
│ │ 模板列表 │ 配置表单 │ │
│ │ [+ 新建] │ 1) 基本信息 │ │
│ │ 模板行 │ 2) 章节开关 × 10 │ │
│ │ [默认][编辑]│ 3) Word 样式 │ │
│ │ [删除] │ 4) 图表样式 │ │
│ │ │ 5) 报告默认值 │ │
│ │ │ [保存] │ │
│ └────────────┴─────────────────────────┘ │
└────────────────────────────────────────────┘
```
- 左侧 `el-card`:模板列表(`el-table`),行内操作按钮:设为默认(el-button link)/ 编辑(切换选中)/ 删除(`ElMessageBox.confirm`)
- 顶部"新建模板"按钮 → 右侧表单重置为默认配置(调 `GET /config` 或前端硬编码默认结构)
- 右侧 `el-form`,分 5 个区块(`el-divider` 分隔),控件类型见 PRD §2.2:
- 章节开关:`el-switch` × 10,中英文标签并排
- Word 样式:`el-input`(字体)+ `el-input-number`(字号/缩进)+ `el-color-picker`(标题颜色)
- 图表样式:`el-input-number`(DPI/尺寸)+ `el-input`(字体)+ `el-color-picker` × 8
- 报告默认值:`el-input` × 6
- 保存:调 `create` 或 `update`,成功后刷新列表并 `ElMessage.success`
- 加载中 `v-loading`,无模板时右侧显示空状态提示
---
## 六、Phase 5:前端路由、菜单与生成页接入
### 5.1 修改 `frontend/src/router/index.ts`
在 `/reports/functional` children 中新增:
```typescript
{
path: 'template-config',
name: 'TemplateConfig',
component: () => import('@/views/functional-report/TemplateConfig.vue'),
meta: { title: '模板配置' }
}
```
### 5.2 修改 `frontend/src/App.vue`
- 功能父菜单下新增子菜单项:`<el-menu-item index="/reports/functional/template-config">模板配置</el-menu-item>`(放在"生成报告"之后、"ERP 配置"之前或之后均可)
- `FUNCTIONAL_LABELS` 新增:`'template-config': '模板配置'`
### 5.3 修改 `frontend/src/views/functional-report/FunctionalReport.vue`
- 步骤2项目配置中,在"报告格式"下拉框旁新增"报告模板" `el-select`
- `onMounted` 时 `reportTemplateApi.list()` 加载模板,默认选中 `is_default` 的模板
- 生成时在请求参数中携带 `template_id`
---
## 七、验证方案
### 7.1 后端验证
| 测试项 | 步骤 | 预期 |
|--------|------|------|
| 建表 | 重启后端 | 自动创建 `report_templates` 表 |
| 创建模板 | POST /templates | 返回新模板,第一条自动为默认 |
| 列表 | GET /templates | 返回列表含 is_default 标记 |
| 设为默认 | PUT /templates/{id}/default | 原默认取消,新默认生效 |
| 删除保护 | 删除唯一模板 | 返回 400 且不删除 |
| 生成报告(硬件码回退) | POST /generate(无 template_id) | 报告正常生成,样式与之前一致 |
| 生成报告(带模板) | 创建关闭附件的模板 → generate 带 template_id | Word 报告中无"测试附件"章节 |
| 生成报告(样式) | 修改模板字号/颜色 → generate | Word 中字号/颜色与配置一致 |
### 7.2 前端验证
| 测试项 | 步骤 | 预期 |
|--------|------|------|
| 页面加载 | 访问 /reports/functional/template-config | 左侧模板列表 + 右侧配置表单正常渲染 |
| 新建模板 | 点击"新建模板" | 右侧表单重置,可保存 |
| 编辑模板 | 选中模板修改配置并保存 | 列表刷新,数据持久化 |
| 章节开关 | 关闭某章节并保存 | 列表/详情中该开关为关闭态 |
| 生成页选择 | 访问 /reports/functional/generate | 模板下拉框出现,默认选中默认模板 |
### 7.3 部署验证
1. `cd frontend && npm run build` 构建前端
2. 使用 `_upload_frontend_fix.py` 上传 dist 并验证 HTTP 状态
3. 后端代码 `scp` 到 5.60,`docker compose restart app`
4. 访问 http://192.168.5.60/reports/functional/template-config 端到端验证
---
## 八、文件变更清单
### 后端新增
| 文件 | 说明 |
|------|------|
| `backend/app/models/report_template.py` | 模板 ORM 模型 |
| `backend/app/schemas/report_template.py` | 模板 Pydantic 校验 |
| `backend/app/routers/report_template.py` | 模板 CRUD API 路由 |
### 后端修改
| 文件 | 修改内容 |
|------|---------|
| `backend/app/models/__init__.py` | 导入 ReportTemplate |
| `backend/app/services/functional/config.py` | 新增 get_default_config() / merge_template_config() |
| `backend/app/services/functional/word_generator.py` | 支持 template_config(章节开关+样式+默认值) |
| `backend/app/services/functional/chart_generator.py` | 支持 chart_style 配置 |
| `backend/app/services/functional/markdown_generator.py` | 支持 template_config(章节开关+默认值) |
| `backend/app/services/functional_report_service.py` | 加载模板配置并传递给生成器 |
| `backend/app/routers/functional_report.py` | generate 端点传入 template_id |
| `backend/app/schemas/functional_report.py` | GenerateRequest 新增 template_id |
| `backend/app/main.py` | 注册 report_template 路由 |
### 前端新增
| 文件 | 说明 |
|------|------|
| `frontend/src/api/reportTemplate.ts` | 模板 CRUD API 封装 |
| `frontend/src/views/functional-report/TemplateConfig.vue` | 模板配置管理页面 |
### 前端修改
| 文件 | 修改内容 |
|------|---------|
| `frontend/src/router/index.ts` | 新增 template-config 子路由 |
| `frontend/src/App.vue` | 新增菜单项 + FUNCTIONAL_LABELS |
| `frontend/src/views/functional-report/FunctionalReport.vue` | 新增模板下拉选择 |
---
## 九、实施顺序
```
Phase 1: 模板模型 → 注册 __init__ → config.py 配置工具函数
Phase 2: word_generator 改造 → chart_generator 改造 → markdown_generator 改造
Phase 3: schemas → report_template 路由 → 服务层改造 → generate 扩展 → main.py 注册
Phase 4: reportTemplate.ts → TemplateConfig.vue
Phase 5: router → App.vue 菜单 → FunctionalReport.vue 模板下拉
Phase 6: 后端启动验证 → 前端构建验证 → 部署 5.60 → 端到端验证
```
\ No newline at end of file
......@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template
# 配置日志
logging.basicConfig(
......@@ -244,6 +244,13 @@ app.include_router(
tags=["功能测试报告"]
)
# 报告模板配置路由(自身 prefix=/templates,注册在 /api/functional-report 下)
app.include_router(
report_template.router,
prefix="/api/functional-report",
tags=["功能测试报告"]
)
# ==================== 根路径 ====================
......
......@@ -25,6 +25,7 @@ from app.models.performance import PerformanceTask, PerformanceSnapshot
from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
from app.models.erp_config import ErpConfig
from app.models.report_template import ReportTemplate
__all__ = [
"Module",
......@@ -47,4 +48,5 @@ __all__ = [
"PerformanceTaskOutput",
"ApiPreset",
"ErpConfig",
"ReportTemplate",
]
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:report_template.py
模块描述:功能测试报告模板配置数据库模型定义
作者:czj
创建日期:2026-08-13
最后修改:2026-08-13
"""
import json
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, Integer, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class ReportTemplate(Base):
"""
功能测试报告模板配置数据库模型
存储报告模板配置,包括章节开关、Word样式、图表样式、报告默认值。
支持多模板管理,其中一个模板可标记为默认。
Attributes:
id (int): 主键,自增
name (str): 模板名称
is_default (bool): 是否默认模板(全局唯一为 True)
config (str): 完整配置 JSON 字符串
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
"""
__tablename__ = "report_templates"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模板名称")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认模板")
config: Mapped[str] = mapped_column(Text, nullable=False, default="{}", comment="完整配置JSON字符串")
created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.now, comment="创建时间"
)
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间"
)
def __repr__(self) -> str:
return f"<ReportTemplate(id={self.id}, name='{self.name}', is_default={self.is_default})>"
def to_dict(self) -> dict:
"""转为完整字典,config 反序列化为 dict"""
from app.services.functional.config import DATE_FORMAT
return {
"id": self.id,
"name": self.name,
"is_default": self.is_default,
"config": json.loads(self.config) if self.config else {},
"created_at": self.created_at.strftime(DATE_FORMAT) if self.created_at else None,
"updated_at": self.updated_at.strftime(DATE_FORMAT) if self.updated_at else None,
}
def to_list_item(self) -> dict:
"""转为列表项,不含 config 详情"""
from app.services.functional.config import DATE_FORMAT
return {
"id": self.id,
"name": self.name,
"is_default": self.is_default,
"updated_at": self.updated_at.strftime(DATE_FORMAT) if self.updated_at else None,
}
\ No newline at end of file
......@@ -153,12 +153,13 @@ async def generate_report(request: GenerateRequest):
service = get_report_service()
try:
result = service.analyze_and_generate(
result = await service.analyze_and_generate(
testcase_path=testcase_path,
buglist_path=buglist_path,
project_name=request.project_name,
report_format=request.report_format,
session_id=request.session_id,
template_id=request.template_id,
)
# 缓存分析结果到会话目录
analysis_cache = {
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:report_template.py
模块描述:功能测试报告模板配置 API 路由,提供模板的增删改查、设置默认等接口
作者:czj
创建日期:2026-08-13
最后修改:2026-08-13
"""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ReportTemplate
from app.schemas.report_template import (
ReportTemplateCreateRequest,
ReportTemplateUpdateRequest,
ReportTemplateListItem,
ReportTemplateResponse,
)
from app.services.functional.config import get_default_config, merge_template_config
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/templates",
tags=["报告模板配置"],
)
async def _list_default_template(db: AsyncSession) -> ReportTemplate:
"""查找当前默认模板(不存在返回 None)"""
result = await db.execute(
select(ReportTemplate).where(ReportTemplate.is_default == True) # noqa: E712
)
return result.scalar_one_or_none()
async def _clear_default_flag(db: AsyncSession) -> None:
"""清除所有模板的默认标记"""
default = await _list_default_template(db)
if default:
default.is_default = False
async def _ensure_one_default(db: AsyncSession, current: ReportTemplate) -> None:
"""确保至少保留一个默认模板:如果没有任何默认模板,将当前模板设为默认"""
result = await db.execute(select(ReportTemplate))
total = len(result.scalars().all())
if total == 1 and not current.is_default:
current.is_default = True
def _validate_config(config: dict) -> dict:
"""校验模板配置:合并到默认配置,保证结构完整,移除无效键"""
merged = merge_template_config(get_default_config(), config)
return merged
# ==================== 模板列表 ====================
@router.get("", response_model=list[ReportTemplateListItem], summary="获取模板列表")
async def list_templates(db: AsyncSession = Depends(get_db)):
"""
获取所有报告模板列表(不含 config 详情)
Returns:
List[ReportTemplateListItem]: 模板列表
"""
result = await db.execute(select(ReportTemplate).order_by(ReportTemplate.updated_at.desc()))
templates = result.scalars().all()
return [t.to_list_item() for t in templates]
# ==================== 模板详情 ====================
@router.get("/{template_id}", response_model=ReportTemplateResponse, summary="获取模板详情")
async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""
获取单个报告模板的完整配置
Args:
template_id: 模板ID
Returns:
ReportTemplateResponse: 模板完整信息(含config)
"""
template = await db.get(ReportTemplate, template_id)
if not template:
raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")
return template.to_dict()
# ==================== 创建模板 ====================
@router.post("", response_model=ReportTemplateResponse, summary="创建模板")
async def create_template(
request: ReportTemplateCreateRequest,
db: AsyncSession = Depends(get_db),
):
"""
创建新的报告模板
- config 会与默认配置合并,保证结构完整
- 如果请求 is_default=True,则清除其他模板的默认标记
- 当系统中还没有任何模板时,自动将新模板设为默认
"""
# 检查名称是否重复
result = await db.execute(select(ReportTemplate).where(ReportTemplate.name == request.name))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"模板名称已存在: {request.name}")
# 合并配置,保证结构完整
config = _validate_config(request.config)
# 当系统还没有模板时,强制设为默认
count_result = await db.execute(select(ReportTemplate.id))
existing_count = len(count_result.scalars().all())
is_default = request.is_default or existing_count == 0
# 若设为默认,先清除其他默认标记
if is_default:
await _clear_default_flag(db)
template = ReportTemplate(
name=request.name,
is_default=is_default,
config=json.dumps(config, ensure_ascii=False),
)
db.add(template)
await db.commit()
await db.refresh(template)
logger.info(f"创建报告模板: {template.name} (id={template.id}, is_default={template.is_default})")
return template.to_dict()
# ==================== 更新模板 ====================
@router.put("/{template_id}", response_model=ReportTemplateResponse, summary="更新模板")
async def update_template(
template_id: int,
request: ReportTemplateUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""
更新报告模板
- 可单独更新 name / config / is_default
- 更新后的 config 会与默认配置合并
- 若请求 is_default=True,则清除其他模板的默认标记
"""
template = await db.get(ReportTemplate, template_id)
if not template:
raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")
# 名称重复校验(排除自身)
if request.name is not None and request.name != template.name:
result = await db.execute(
select(ReportTemplate).where(ReportTemplate.name == request.name)
)
duplicate = result.scalar_one_or_none()
if duplicate and duplicate.id != template_id:
raise HTTPException(status_code=400, detail=f"模板名称已存在: {request.name}")
if request.name is not None:
template.name = request.name
if request.config is not None:
template.config = json.dumps(_validate_config(request.config), ensure_ascii=False)
if request.is_default:
await _clear_default_flag(db)
template.is_default = True
await db.commit()
# 无论如何保留一个默认模板
await _ensure_one_default(db, template)
await db.commit()
await db.refresh(template)
logger.info(f"更新报告模板: {template.name} (id={template.id})")
return template.to_dict()
# ==================== 设为默认 ====================
@router.put("/{template_id}/default", response_model=ReportTemplateResponse, summary="设为默认模板")
async def set_default_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""
将指定模板设为默认模板(全局唯一)
"""
template = await db.get(ReportTemplate, template_id)
if not template:
raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")
if not template.is_default:
await _clear_default_flag(db)
template.is_default = True
await db.commit()
await db.refresh(template)
logger.info(f"设为默认模板: {template.name} (id={template.id})")
return template.to_dict()
# ==================== 删除模板 ====================
@router.delete("/{template_id}", summary="删除模板")
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
"""
删除报告模板
- 不允许删除最后一个模板(至少保留一个)
"""
template = await db.get(ReportTemplate, template_id)
if not template:
raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")
# 检查剩余模板数量
result = await db.execute(select(ReportTemplate.id))
total = len(result.scalars().all())
if total <= 1:
raise HTTPException(status_code=400, detail="至少需要保留一个模板,不能删除最后一个")
was_default = template.is_default
await db.delete(template)
await db.commit()
logger.info(f"删除报告模板: {template.name} (id={template_id})")
# 如果删除的是默认模板,把剩余的第一个设为默认
if was_default:
remaining = await db.execute(select(ReportTemplate).order_by(ReportTemplate.updated_at.desc()))
first = remaining.scalars().first()
if first:
first.is_default = True
await db.commit()
logger.info(f"删除默认模板后,自动将 {first.name} (id={first.id}) 设为默认")
return {"success": True, "message": "模板已删除"}
\ No newline at end of file
......@@ -26,6 +26,7 @@ class GenerateRequest(BaseModel):
session_id: str = Field(..., description="会话ID")
project_name: str = Field("", description="项目名称")
report_format: str = Field("docx", description="报告格式: docx/md/both")
template_id: Optional[int] = Field(None, description="报告模板ID(None 时使用默认模板或系统默认配置)")
# ERP上传选项(可选,勾选后在生成报告后自动上传)
upload_to_erp: bool = Field(False, description="是否上传到ERP测试单")
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:report_template.py
模块描述:功能测试报告模板配置 Pydantic 数据模型
作者:czj
创建日期:2026-08-13
最后修改:2026-08-13
"""
from typing import Dict, Optional
from pydantic import BaseModel, Field
class ReportTemplateCreateRequest(BaseModel):
"""创建报告模板请求"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
is_default: bool = Field(False, description="是否设为默认模板")
config: Dict = Field(default_factory=dict, description="模板完整配置(章节开关/样式/默认值)")
class ReportTemplateUpdateRequest(BaseModel):
"""更新报告模板请求"""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
is_default: Optional[bool] = Field(None, description="是否设为默认模板")
config: Optional[Dict] = Field(None, description="模板完整配置")
class ReportTemplateListItem(BaseModel):
"""报告模板列表项(不含 config 详情)"""
id: int = Field(..., description="模板ID")
name: str = Field(..., description="模板名称")
is_default: bool = Field(False, description="是否默认模板")
updated_at: Optional[str] = Field(None, description="更新时间")
class ReportTemplateResponse(BaseModel):
"""报告模板完整响应"""
id: int = Field(..., description="模板ID")
name: str = Field(..., description="模板名称")
is_default: bool = Field(False, description="是否默认模板")
config: Dict = Field(default_factory=dict, description="模板完整配置")
created_at: Optional[str] = Field(None, description="创建时间")
updated_at: Optional[str] = Field(None, description="更新时间")
\ No newline at end of file
......@@ -16,7 +16,7 @@ import matplotlib
matplotlib.use('Agg') # 使用非交互式后端
import matplotlib.pyplot as plt
from pathlib import Path
from typing import Dict, List, Tuple
from typing import Dict, List, Tuple, Optional
import os
from .data_analyzer import TestCaseAnalysis, BugAnalysis
......@@ -38,15 +38,21 @@ from .config import (
BUG_LEVEL_4,
# BUG等级描述
BUG_LEVEL_DESCRIPTION,
# 模板配置工具函数
get_default_config,
merge_template_config,
)
def _setup_chinese_font() -> None:
def _setup_chinese_font(chinese_font: str = None) -> None:
"""
配置中文字体
使用 matplotlib.font_manager 主动查找系统中已有的中文字体,
并将字体文件路径显式注册到 fontManager,避免因字体缓存导致的中文乱码。
Args:
chinese_font: 优先使用的中文字体名称(来自模板配置),None 时使用默认候选列表
"""
import matplotlib.font_manager as fm
......@@ -61,6 +67,10 @@ def _setup_chinese_font() -> None:
"PingFang SC", "Heiti SC", "STHeiti",
]
# 如果模板配置指定了中文字体,优先使用
if chinese_font:
candidate_names.insert(0, chinese_font)
# 常见中文字体文件路径(用于 addfont 显式注册)
candidate_paths = [
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
......@@ -127,7 +137,8 @@ def _get_value_with_percentage(value: int, total: int) -> str:
def generate_bug_level_chart(
analysis: BugAnalysis,
output_path: str = None
output_path: str = None,
chart_style: dict = None
) -> str:
"""
生成BUG等级分布图(饼图)
......@@ -135,12 +146,21 @@ def generate_bug_level_chart(
Args:
analysis: BUG分析结果
output_path: 输出文件路径(可选,默认使用临时目录)
chart_style: 图表样式配置(来自模板配置),None 时使用默认值
Returns:
图片文件路径
"""
# 读取配置
cs = chart_style or {}
_dpi = cs.get("dpi", CHART_DPI)
_fig_w = cs.get("figure_width", CHART_FIGURE_SIZE[0])
_fig_h = cs.get("figure_height", CHART_FIGURE_SIZE[1])
_font = cs.get("chinese_font", CHINESE_FONT)
_colors = cs.get("colors", CHART_COLORS)
# 配置中文字体
_setup_chinese_font()
_setup_chinese_font(_font)
# 准备数据
levels = [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]
......@@ -167,14 +187,14 @@ def generate_bug_level_chart(
labels, values = zip(*filtered_data)
# 创建图表
fig, ax = plt.subplots(figsize=CHART_FIGURE_SIZE)
fig, ax = plt.subplots(figsize=(_fig_w, _fig_h))
# 颜色
colors = [
CHART_COLORS["bug_1"],
CHART_COLORS["bug_2"],
CHART_COLORS["bug_3"],
CHART_COLORS["bug_4"],
_colors["bug_1"],
_colors["bug_2"],
_colors["bug_3"],
_colors["bug_4"],
]
# 绘制饼图
......@@ -205,7 +225,7 @@ def generate_bug_level_chart(
# 确保输出目录存在
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
plt.savefig(output_path, dpi=CHART_DPI, bbox_inches='tight')
plt.savefig(output_path, dpi=_dpi, bbox_inches='tight')
plt.close()
return str(output_path)
......@@ -213,7 +233,8 @@ def generate_bug_level_chart(
def generate_test_result_chart(
analysis: TestCaseAnalysis,
output_path: str = None
output_path: str = None,
chart_style: dict = None
) -> str:
"""
生成用例执行结果分布图(饼图)
......@@ -221,12 +242,21 @@ def generate_test_result_chart(
Args:
analysis: 用例分析结果
output_path: 输出文件路径(可选,默认使用临时目录)
chart_style: 图表样式配置(来自模板配置),None 时使用默认值
Returns:
图片文件路径
"""
# 读取配置
cs = chart_style or {}
_dpi = cs.get("dpi", CHART_DPI)
_fig_w = cs.get("figure_width", CHART_FIGURE_SIZE[0])
_fig_h = cs.get("figure_height", CHART_FIGURE_SIZE[1])
_font = cs.get("chinese_font", CHINESE_FONT)
_colors = cs.get("colors", CHART_COLORS)
# 配置中文字体
_setup_chinese_font()
_setup_chinese_font(_font)
# 准备数据
results = [
......@@ -259,14 +289,14 @@ def generate_test_result_chart(
labels, values = zip(*filtered_data)
# 创建图表
fig, ax = plt.subplots(figsize=CHART_FIGURE_SIZE)
fig, ax = plt.subplots(figsize=(_fig_w, _fig_h))
# 颜色
colors = [
CHART_COLORS["pass"],
CHART_COLORS["fail"],
CHART_COLORS["unverified"],
CHART_COLORS["undeveloped"],
_colors["pass"],
_colors["fail"],
_colors["unverified"],
_colors["undeveloped"],
]
# 绘制饼图
......@@ -297,7 +327,7 @@ def generate_test_result_chart(
# 确保输出目录存在
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
plt.savefig(output_path, dpi=CHART_DPI, bbox_inches='tight')
plt.savefig(output_path, dpi=_dpi, bbox_inches='tight')
plt.close()
return str(output_path)
......@@ -305,7 +335,8 @@ def generate_test_result_chart(
def generate_pass_rate_chart(
analysis: TestCaseAnalysis,
output_path: str = None
output_path: str = None,
chart_style: dict = None
) -> str:
"""
生成用例通过率图(柱状图)
......@@ -313,12 +344,21 @@ def generate_pass_rate_chart(
Args:
analysis: 用例分析结果
output_path: 输出文件路径(可选,默认使用临时目录)
chart_style: 图表样式配置(来自模板配置),None 时使用默认值
Returns:
图片文件路径
"""
# 读取配置
cs = chart_style or {}
_dpi = cs.get("dpi", CHART_DPI)
_fig_w = cs.get("figure_width", CHART_FIGURE_SIZE[0])
_fig_h = cs.get("figure_height", CHART_FIGURE_SIZE[1])
_font = cs.get("chinese_font", CHINESE_FONT)
_colors = cs.get("colors", CHART_COLORS)
# 配置中文字体
_setup_chinese_font()
_setup_chinese_font(_font)
# 准备数据
categories = ['通过', '失败', '未验证', '未开发']
......@@ -330,14 +370,14 @@ def generate_pass_rate_chart(
]
# 创建图表
fig, ax = plt.subplots(figsize=CHART_FIGURE_SIZE)
fig, ax = plt.subplots(figsize=(_fig_w, _fig_h))
# 颜色
colors = [
CHART_COLORS["pass"],
CHART_COLORS["fail"],
CHART_COLORS["unverified"],
CHART_COLORS["undeveloped"],
_colors["pass"],
_colors["fail"],
_colors["unverified"],
_colors["undeveloped"],
]
# 绘制柱状图
......@@ -387,7 +427,7 @@ def generate_pass_rate_chart(
# 确保输出目录存在
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
plt.savefig(output_path, dpi=CHART_DPI, bbox_inches='tight')
plt.savefig(output_path, dpi=_dpi, bbox_inches='tight')
plt.close()
return str(output_path)
......@@ -395,7 +435,8 @@ def generate_pass_rate_chart(
def generate_bug_by_module_chart(
analysis: BugAnalysis,
output_path: str = None
output_path: str = None,
chart_style: dict = None
) -> str:
"""
生成按模块分布的BUG图(堆叠柱状图)
......@@ -403,12 +444,19 @@ def generate_bug_by_module_chart(
Args:
analysis: BUG分析结果
output_path: 输出文件路径(可选,默认使用临时目录)
chart_style: 图表样式配置(来自模板配置),None 时使用默认值
Returns:
图片文件路径
"""
# 读取配置
cs = chart_style or {}
_dpi = cs.get("dpi", CHART_DPI)
_font = cs.get("chinese_font", CHINESE_FONT)
_colors = cs.get("colors", CHART_COLORS)
# 配置中文字体
_setup_chinese_font()
_setup_chinese_font(_font)
# 准备数据
modules = list(analysis.module_distribution.keys())
......@@ -433,16 +481,16 @@ def generate_bug_by_module_chart(
width = 0.6
x = range(len(modules))
ax.bar(x, level_1_data, width, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_1]}', color=CHART_COLORS["bug_1"])
ax.bar(x, level_2_data, width, bottom=level_1_data, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_2]}', color=CHART_COLORS["bug_2"])
ax.bar(x, level_1_data, width, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_1]}', color=_colors["bug_1"])
ax.bar(x, level_2_data, width, bottom=level_1_data, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_2]}', color=_colors["bug_2"])
# 计算第三层的底部(1级+2级)
level_3_bottom = [level_1_data[i] + level_2_data[i] for i in range(len(modules))]
ax.bar(x, level_3_data, width, bottom=level_3_bottom, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_3]}', color=CHART_COLORS["bug_3"])
ax.bar(x, level_3_data, width, bottom=level_3_bottom, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_3]}', color=_colors["bug_3"])
# 计算第四层的底部(1级+2级+3级)
level_4_bottom = [level_3_bottom[i] + level_3_data[i] for i in range(len(modules))]
ax.bar(x, level_4_data, width, bottom=level_4_bottom, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_4]}', color=CHART_COLORS["bug_4"])
ax.bar(x, level_4_data, width, bottom=level_4_bottom, label=f'{BUG_LEVEL_DESCRIPTION[BUG_LEVEL_4]}', color=_colors["bug_4"])
# 设置标题和标签
ax.set_title('各模块BUG分布(按等级)', fontsize=16, fontweight='bold', pad=20)
......@@ -468,7 +516,7 @@ def generate_bug_by_module_chart(
# 确保输出目录存在
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
plt.savefig(output_path, dpi=CHART_DPI, bbox_inches='tight')
plt.savefig(output_path, dpi=_dpi, bbox_inches='tight')
plt.close()
return str(output_path)
......@@ -477,7 +525,8 @@ def generate_bug_by_module_chart(
def generate_all_charts(
case_analysis: TestCaseAnalysis,
bug_analysis: BugAnalysis,
charts_dir: str
charts_dir: str,
template_config: Optional[Dict] = None
) -> Dict[str, str]:
"""
生成所有图表
......@@ -486,38 +535,43 @@ def generate_all_charts(
case_analysis: 用例分析结果
bug_analysis: BUG分析结果
charts_dir: 图表输出目录
template_config: 模板配置(可选),None 时使用默认值
Returns:
图表文件路径字典 {图表类型: 文件路径}
"""
# 合并模板配置(图表样式)
cfg = merge_template_config(get_default_config(), template_config)
chart_style = cfg.get("chart_style", {})
charts_dir = Path(charts_dir)
charts_dir.mkdir(parents=True, exist_ok=True)
charts = {}
try:
charts["bug_level"] = generate_bug_level_chart(
bug_analysis, str(charts_dir / f"bug_level_chart{CHART_FORMAT}")
bug_analysis, str(charts_dir / f"bug_level_chart{CHART_FORMAT}"), chart_style
)
except Exception as e:
print(f"生成BUG等级分布图失败: {str(e)}")
try:
charts["test_result"] = generate_test_result_chart(
case_analysis, str(charts_dir / f"test_result_chart{CHART_FORMAT}")
case_analysis, str(charts_dir / f"test_result_chart{CHART_FORMAT}"), chart_style
)
except Exception as e:
print(f"生成用例执行结果分布图失败: {str(e)}")
try:
charts["pass_rate"] = generate_pass_rate_chart(
case_analysis, str(charts_dir / f"pass_rate_chart{CHART_FORMAT}")
case_analysis, str(charts_dir / f"pass_rate_chart{CHART_FORMAT}"), chart_style
)
except Exception as e:
print(f"生成用例通过率图失败: {str(e)}")
try:
charts["bug_by_module"] = generate_bug_by_module_chart(
bug_analysis, str(charts_dir / f"bug_by_module_chart{CHART_FORMAT}")
bug_analysis, str(charts_dir / f"bug_by_module_chart{CHART_FORMAT}"), chart_style
)
except Exception as e:
print(f"生成各模块BUG分布图失败: {str(e)}")
......
......@@ -316,4 +316,84 @@ def get_report_number(project_name: str, date: str = None) -> str:
return REPORT_NUMBER_TEMPLATE.format(
project_name=project_name,
date=date
)
\ No newline at end of file
)
# ================================
# 模板配置工具函数
# ================================
def get_default_config() -> dict:
"""
获取默认模板配置字典
将当前硬编码常量组织为模板配置结构,供 ReportTemplate 模型和生成器使用。
当用户未创建任何模板时,生成器使用此默认配置。
Returns:
dict: 完整的默认配置字典,包含 chapters/defaults/word_style/chart_style
"""
return {
"chapters": {
"report_basic_info": True,
"execution_summary": True,
"test_scope": True,
"case_details": True,
"bug_list": True,
"chart_analysis": True,
"execution_records": True,
"attachments": True,
"risk_assessment": True,
"appendix": True,
},
"defaults": {
"test_lead": DEFAULT_TEST_LEAD,
"tester": DEFAULT_TESTER,
"test_env": DEFAULT_TEST_ENV,
"system_version": DEFAULT_SYSTEM_VERSION,
"server_address": DEFAULT_SERVER_ADDRESS,
"deployment_env": DEFAULT_DEPLOYMENT_ENV,
},
"word_style": {
"font_name": WORD_FONT_NAME,
"font_size": WORD_FONT_SIZE,
"title_level_1_size": WORD_TITLE_LEVEL_1_SIZE,
"title_level_2_size": WORD_TITLE_LEVEL_2_SIZE,
"title_level_3_size": WORD_TITLE_LEVEL_3_SIZE,
"title_level_4_size": WORD_TITLE_LEVEL_4_SIZE,
"paragraph_first_line_indent": WORD_PARAGRAPH_FIRST_LINE_INDENT,
"title_color": "#000000",
},
"chart_style": {
"dpi": CHART_DPI,
"figure_width": CHART_FIGURE_SIZE[0],
"figure_height": CHART_FIGURE_SIZE[1],
"chinese_font": CHINESE_FONT,
"colors": dict(CHART_COLORS),
},
}
def merge_template_config(base_config: dict, template_config: dict) -> dict:
"""
将模板配置与默认配置深合并,缺失字段用默认值填充
Args:
base_config: 默认配置(get_default_config() 返回值)
template_config: 用户模板配置(可能只包含部分字段)
Returns:
dict: 合并后的完整配置
"""
def deep_merge(base, override):
result = dict(base)
for k, v in (override or {}).items():
if isinstance(v, dict) and isinstance(result.get(k), dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
return deep_merge(base_config, template_config or {})
\ No newline at end of file
......@@ -53,6 +53,9 @@ from .config import (
DEFAULT_SYSTEM_VERSION,
DEFAULT_SERVER_ADDRESS,
DEFAULT_DEPLOYMENT_ENV,
# 模板配置工具函数
get_default_config,
merge_template_config,
)
......@@ -99,7 +102,8 @@ def generate_markdown_report(
output_path: str,
project_name: str,
cases: List[TestCase],
bugs: List[Bug]
bugs: List[Bug],
template_config: Optional[Dict] = None
) -> str:
"""
生成Markdown格式功能测试报告
......@@ -114,10 +118,23 @@ def generate_markdown_report(
project_name: 项目名称
cases: 测试用例列表
bugs: BUG列表
template_config: 模板配置(可选),None 时使用默认值(章节全开+硬编码默认值)
Returns:
生成的报告文件路径
"""
# 合并模板配置
cfg = merge_template_config(get_default_config(), template_config)
chapters = cfg.get("chapters", {})
defaults = cfg.get("defaults", {})
# 章节动态序号计数器(仅对启用章节自增)
ch_idx = 0
def _title(num: int, zh_num: str, name: str, level: int = 2) -> str:
"""生成章节标题(用序号+中文数字组合)"""
return f"{'#' * level} {num}.{zh_num} {name}"
# 获取当前日期
current_date = datetime.now().strftime(DATE_FORMAT)
report_number = get_report_number(project_name, current_date)
......@@ -129,342 +146,363 @@ def generate_markdown_report(
content_parts.append(f"# {project_name}功能测试报告\n")
# ==================== 一、报告基本信息 ====================
content_parts.append("## 一、报告基本信息\n")
content_parts.append("### 1.1 报告标识\n")
headers = ["项目", "内容"]
rows = [
["报告编号", report_number],
["报告标题", f"{project_name}功能测试报告"],
["报告版本", "v1.0"],
["生成日期", current_date],
["报告状态", "已生成"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 1.2 测试概览\n")
headers = ["项目", "内容"]
rows = [
["项目名称", project_name],
["测试负责人", extract_test_lead(bugs) if bugs else DEFAULT_TEST_LEAD],
["测试人员", extract_testers(bugs) if bugs else DEFAULT_TESTER],
["测试环境", extract_server_info(cases) if cases else DEFAULT_TEST_ENV],
["测试类型", "功能测试"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 1.3 被测系统信息\n")
headers = ["项目", "内容"]
# 提取自动填充数据
system_version = extract_version_info_from_bug_list(bugs) if bugs else DEFAULT_SYSTEM_VERSION
server_address = extract_server_info(cases) if cases else DEFAULT_SERVER_ADDRESS
deployment_env = extract_deployment_env(cases) if cases else DEFAULT_DEPLOYMENT_ENV
# Markdown支持多行显示,将版本信息格式化为代码块
if "\n" in system_version:
system_version_display = f"```\n{system_version}\n```"
else:
system_version_display = system_version
rows = [
["系统名称", project_name],
["系统版本", system_version_display],
["服务器地址", server_address],
["部署环境", deployment_env],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
# ==================== 二、测试执行摘要 ====================
content_parts.append("## 二、测试执行摘要\n")
content_parts.append("### 2.1 测试结果统计\n")
total = case_analysis.total_cases
headers = ["统计项", "数量", "占比"]
unexecuted = case_analysis.unverified_cases + case_analysis.undeveloped_cases
rows = [
["测试用例总数", str(total), "100%"],
["执行用例数量", str(case_analysis.executed_cases), f"{case_analysis.execution_rate}%"],
["通过用例数量", str(case_analysis.passed_cases), f"{case_analysis.pass_rate}%"],
["失败用例数量", str(case_analysis.failed_cases), f"{round(case_analysis.failed_cases / total * 100, 2)}%" if total > 0 else "0%"],
["未执行用例数量", str(unexecuted),
f"{round(unexecuted / total * 100, 2)}%" if total > 0 else "0%"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 2.2 BUG统计汇总\n")
total_bugs = bug_analysis.total_bugs
headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"]
for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]:
count = bug_analysis.level_distribution.get(level, 0)
percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0
# 计算该等级的未关闭数量
remaining = sum(
1 for bug in bugs
if bug.bug_level == level and bug.bug_status in REMAINING_BUG_STATUS
)
closed = count - remaining
rows.append([
f"{BUG_LEVEL_DESCRIPTION[level]}({level})",
str(count),
f"{percentage}%",
str(closed),
str(remaining)
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 2.3 测试结论\n")
if bug_analysis.remaining_bugs == 0 and case_analysis.failed_cases == 0:
conclusion = "✓ **测试通过** - 系统功能正常,无致命/严重缺陷,可以发布"
elif case_analysis.failed_cases > 0:
conclusion = "✗ **测试不通过** - 存在失败的测试用例,不建议发布"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
conclusion = "✗ **测试不通过** - 存在阻塞性缺陷,不建议发布"
else:
conclusion = "⚠ **有条件通过** - 存在非阻塞性缺陷,经评估可以发布"
else:
conclusion = "待评估"
content_parts.append(f"{conclusion}\n")
if chapters.get("report_basic_info", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "一", "报告基本信息", level=2))
content_parts.append("### 1.1 报告标识\n")
headers = ["项目", "内容"]
rows = [
["报告编号", report_number],
["报告标题", f"{project_name}功能测试报告"],
["报告版本", "v1.0"],
["生成日期", current_date],
["报告状态", "已生成"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
# ==================== 三、测试范围 ====================
content_parts.append("## 三、测试范围\n")
content_parts.append("### 3.1 测试模块覆盖\n")
headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"]
module_rows = []
for i, (module_name, stat) in enumerate(case_analysis.module_stats.items(), 1):
module_rows.append([
str(i),
module_name,
str(stat.total_cases),
str(stat.passed_cases),
str(stat.failed_cases),
str(stat.unverified_cases + stat.undeveloped_cases),
f"{stat.pass_rate}%"
])
content_parts.append(render_markdown_table(headers, module_rows) + "\n")
content_parts.append("### 1.2 测试概览\n")
headers = ["项目", "内容"]
rows = [
["项目名称", project_name],
["测试负责人", extract_test_lead(bugs) if bugs else defaults.get("test_lead", DEFAULT_TEST_LEAD)],
["测试人员", extract_testers(bugs) if bugs else defaults.get("tester", DEFAULT_TESTER)],
["测试环境", extract_server_info(cases) if cases else defaults.get("test_env", DEFAULT_TEST_ENV)],
["测试类型", "功能测试"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
# ==================== 四、测试用例执行详情 ====================
content_parts.append("## 四、测试用例执行详情\n")
content_parts.append("### 1.3 被测系统信息\n")
headers = ["项目", "内容"]
# 提取自动填充数据
system_version = extract_version_info_from_bug_list(bugs) if bugs else defaults.get("system_version", DEFAULT_SYSTEM_VERSION)
server_address = extract_server_info(cases) if cases else defaults.get("server_address", DEFAULT_SERVER_ADDRESS)
deployment_env = extract_deployment_env(cases) if cases else defaults.get("deployment_env", DEFAULT_DEPLOYMENT_ENV)
# 通过用例列表
content_parts.append("### 4.1 通过用例列表\n")
passed_cases = [c for c in cases if c.test_result == TEST_RESULT_PASS]
if passed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "测试结果"]
rows = []
for i, case in enumerate(passed_cases[:50], 1): # 限制显示50条
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50], # 限制长度
str(case.level),
case.test_result
])
if len(passed_cases) > 50:
rows.append(["...", f"等{len(passed_cases)}条通过用例", "", "", "", ""])
# Markdown支持多行显示,将版本信息格式化为代码块
if "\n" in system_version:
system_version_display = f"```\n{system_version}\n```"
else:
system_version_display = system_version
rows = [
["系统名称", project_name],
["系统版本", system_version_display],
["服务器地址", server_address],
["部署环境", deployment_env],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无通过用例*\n")
# 失败用例列表
content_parts.append("### 4.2 失败用例列表\n")
failed_cases = [c for c in cases if c.test_result == TEST_RESULT_FAIL]
if failed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "关联BUG"]
rows = []
for i, case in enumerate(failed_cases, 1):
related_bugs = case_bug_link.get(case.case_number, [])
bug_str = "; ".join([b.bug_name[:30] for b in related_bugs[:3]]) # 限制显示
if len(related_bugs) > 3:
bug_str += f" 等{len(related_bugs)}个"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
bug_str if bug_str else "无"
])
# ==================== 二、测试执行摘要 ====================
if chapters.get("execution_summary", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "二", "测试执行摘要", level=2))
content_parts.append("### 2.1 测试结果统计\n")
total = case_analysis.total_cases
headers = ["统计项", "数量", "占比"]
unexecuted = case_analysis.unverified_cases + case_analysis.undeveloped_cases
rows = [
["测试用例总数", str(total), "100%"],
["执行用例数量", str(case_analysis.executed_cases), f"{case_analysis.execution_rate}%"],
["通过用例数量", str(case_analysis.passed_cases), f"{case_analysis.pass_rate}%"],
["失败用例数量", str(case_analysis.failed_cases), f"{round(case_analysis.failed_cases / total * 100, 2)}%" if total > 0 else "0%"],
["未执行用例数量", str(unexecuted),
f"{round(unexecuted / total * 100, 2)}%" if total > 0 else "0%"],
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无失败用例*\n")
# 未执行用例列表
content_parts.append("### 4.3 未执行用例列表\n")
unexecuted_cases = [c for c in cases if c.test_result in [TEST_RESULT_UNVERIFIED, TEST_RESULT_UNDEVELOPED]]
if unexecuted_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "未执行原因"]
content_parts.append("### 2.2 BUG统计汇总\n")
total_bugs = bug_analysis.total_bugs
headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"]
rows = []
for i, case in enumerate(unexecuted_cases[:20], 1): # 限制显示20条
reason = "未验证" if case.test_result == TEST_RESULT_UNVERIFIED else "未开发"
for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]:
count = bug_analysis.level_distribution.get(level, 0)
percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0
# 计算该等级的未关闭数量
remaining = sum(
1 for bug in bugs
if bug.bug_level == level and bug.bug_status in REMAINING_BUG_STATUS
)
closed = count - remaining
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
reason
f"{BUG_LEVEL_DESCRIPTION[level]}({level})",
str(count),
f"{percentage}%",
str(closed),
str(remaining)
])
if len(unexecuted_cases) > 20:
rows.append(["...", f"等{len(unexecuted_cases)}条未执行用例", "", "", "", ""])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无未执行用例*\n")
# ==================== 五、BUG详细列表 ====================
content_parts.append("## 五、BUG详细列表\n")
content_parts.append("### 2.3 测试结论\n")
if bug_analysis.remaining_bugs == 0 and case_analysis.failed_cases == 0:
conclusion = "✓ **测试通过** - 系统功能正常,无致命/严重缺陷,可以发布"
elif case_analysis.failed_cases > 0:
conclusion = "✗ **测试不通过** - 存在失败的测试用例,不建议发布"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
conclusion = "✗ **测试不通过** - 存在阻塞性缺陷,不建议发布"
else:
conclusion = "⚠ **有条件通过** - 存在非阻塞性缺陷,经评估可以发布"
else:
conclusion = "待评估"
content_parts.append(f"{conclusion}\n")
content_parts.append("### 5.1 BUG详细记录\n")
if bugs:
headers = ["序号", "BUG等级", "BUG状态", "BUG名称", "BUG类型", "提单人员", "负责人员"]
rows = []
for i, bug in enumerate(bugs, 1):
rows.append([
# ==================== 三、测试范围 ====================
if chapters.get("test_scope", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "三", "测试范围", level=2))
content_parts.append("### 3.1 测试模块覆盖\n")
headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"]
module_rows = []
for i, (module_name, stat) in enumerate(case_analysis.module_stats.items(), 1):
module_rows.append([
str(i),
bug.bug_level,
bug.bug_status,
bug.bug_name[:50],
bug.bug_type,
bug.reporter,
bug.owner
module_name,
str(stat.total_cases),
str(stat.passed_cases),
str(stat.failed_cases),
str(stat.unverified_cases + stat.undeveloped_cases),
f"{stat.pass_rate}%"
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无BUG记录*\n")
content_parts.append(render_markdown_table(headers, module_rows) + "\n")
content_parts.append("### 5.2 遗留BUG说明\n")
remaining_bugs = [b for b in bugs if b.bug_status in REMAINING_BUG_STATUS]
if remaining_bugs:
headers = ["BUG编号", "BUG等级", "问题描述", "遗留原因", "后续计划"]
rows = []
for bug in remaining_bugs:
rows.append([
bug.bug_name[:30],
bug.bug_level,
bug.bug_name[:60],
"待修复",
"后续版本修复"
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无遗留BUG*\n")
# ==================== 四、测试用例执行详情 ====================
if chapters.get("case_details", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "四", "测试用例执行详情", level=2))
# 通过用例列表
content_parts.append("### 4.1 通过用例列表\n")
passed_cases = [c for c in cases if c.test_result == TEST_RESULT_PASS]
if passed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "测试结果"]
rows = []
for i, case in enumerate(passed_cases[:50], 1): # 限制显示50条
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50], # 限制长度
str(case.level),
case.test_result
])
if len(passed_cases) > 50:
rows.append(["...", f"等{len(passed_cases)}条通过用例", "", "", "", ""])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无通过用例*\n")
# 失败用例列表
content_parts.append("### 4.2 失败用例列表\n")
failed_cases = [c for c in cases if c.test_result == TEST_RESULT_FAIL]
if failed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "关联BUG"]
rows = []
for i, case in enumerate(failed_cases, 1):
related_bugs = case_bug_link.get(case.case_number, [])
bug_str = "; ".join([b.bug_name[:30] for b in related_bugs[:3]]) # 限制显示
if len(related_bugs) > 3:
bug_str += f" 等{len(related_bugs)}个"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
bug_str if bug_str else "无"
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无失败用例*\n")
# 未执行用例列表
content_parts.append("### 4.3 未执行用例列表\n")
unexecuted_cases = [c for c in cases if c.test_result in [TEST_RESULT_UNVERIFIED, TEST_RESULT_UNDEVELOPED]]
if unexecuted_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "未执行原因"]
rows = []
for i, case in enumerate(unexecuted_cases[:20], 1): # 限制显示20条
reason = "未验证" if case.test_result == TEST_RESULT_UNVERIFIED else "未开发"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
reason
])
if len(unexecuted_cases) > 20:
rows.append(["...", f"等{len(unexecuted_cases)}条未执行用例", "", "", "", ""])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无未执行用例*\n")
# ==================== 五、BUG详细列表 ====================
if chapters.get("bug_list", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "五", "BUG详细列表", level=2))
content_parts.append("### 5.1 BUG详细记录\n")
if bugs:
headers = ["序号", "BUG等级", "BUG状态", "BUG名称", "BUG类型", "提单人员", "负责人员"]
rows = []
for i, bug in enumerate(bugs, 1):
rows.append([
str(i),
bug.bug_level,
bug.bug_status,
bug.bug_name[:50],
bug.bug_type,
bug.reporter,
bug.owner
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无BUG记录*\n")
content_parts.append("### 5.2 遗留BUG说明\n")
remaining_bugs = [b for b in bugs if b.bug_status in REMAINING_BUG_STATUS]
if remaining_bugs:
headers = ["BUG编号", "BUG等级", "问题描述", "遗留原因", "后续计划"]
rows = []
for bug in remaining_bugs:
rows.append([
bug.bug_name[:30],
bug.bug_level,
bug.bug_name[:60],
"待修复",
"后续版本修复"
])
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无遗留BUG*\n")
# ==================== 六、图表分析 ====================
content_parts.append("## 六、图表分析\n")
# 添加图表
if "bug_level" in chart_paths:
content_parts.append("### 6.1 BUG等级分布\n")
# 获取图表相对路径
chart_rel_path = _get_relative_chart_path(chart_paths["bug_level"], output_path)
content_parts.append(f"![BUG等级分布]({chart_rel_path})\n")
if "test_result" in chart_paths:
content_parts.append("### 6.2 用例执行结果分布\n")
chart_rel_path = _get_relative_chart_path(chart_paths["test_result"], output_path)
content_parts.append(f"![用例执行结果分布]({chart_rel_path})\n")
if "pass_rate" in chart_paths:
content_parts.append("### 6.3 用例执行情况统计\n")
chart_rel_path = _get_relative_chart_path(chart_paths["pass_rate"], output_path)
content_parts.append(f"![用例执行情况统计]({chart_rel_path})\n")
if "bug_by_module" in chart_paths:
content_parts.append("### 6.4 各模块BUG分布\n")
chart_rel_path = _get_relative_chart_path(chart_paths["bug_by_module"], output_path)
content_parts.append(f"![各模块BUG分布]({chart_rel_path})\n")
if chapters.get("chart_analysis", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "六", "图表分析", level=2))
# 添加图表
if "bug_level" in chart_paths:
content_parts.append("### 6.1 BUG等级分布\n")
# 获取图表相对路径
chart_rel_path = _get_relative_chart_path(chart_paths["bug_level"], output_path)
content_parts.append(f"![BUG等级分布]({chart_rel_path})\n")
if "test_result" in chart_paths:
content_parts.append("### 6.2 用例执行结果分布\n")
chart_rel_path = _get_relative_chart_path(chart_paths["test_result"], output_path)
content_parts.append(f"![用例执行结果分布]({chart_rel_path})\n")
if "pass_rate" in chart_paths:
content_parts.append("### 6.3 用例执行情况统计\n")
chart_rel_path = _get_relative_chart_path(chart_paths["pass_rate"], output_path)
content_parts.append(f"![用例执行情况统计]({chart_rel_path})\n")
if "bug_by_module" in chart_paths:
content_parts.append("### 6.4 各模块BUG分布\n")
chart_rel_path = _get_relative_chart_path(chart_paths["bug_by_module"], output_path)
content_parts.append(f"![各模块BUG分布]({chart_rel_path})\n")
# ==================== 七、测试执行记录 ====================
content_parts.append("## 七、测试执行记录\n")
content_parts.append("### 7.1 执行时间统计\n")
headers = ["统计项", "时间"]
rows = [
["测试开始时间", current_date],
["测试结束时间", current_date],
["测试总耗时", "自动化生成"],
["平均用例执行时间", "N/A"]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 7.2 执行人员分工\n")
headers = ["测试人员", "负责模块", "执行用例数", "发现BUG数"]
rows = [
["自动化工具", "全模块", str(case_analysis.total_cases), str(bug_analysis.total_bugs)]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
if chapters.get("execution_records", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "七", "测试执行记录", level=2))
content_parts.append("### 7.1 执行时间统计\n")
headers = ["统计项", "时间"]
rows = [
["测试开始时间", current_date],
["测试结束时间", current_date],
["测试总耗时", "自动化生成"],
["平均用例执行时间", "N/A"]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 7.2 执行人员分工\n")
headers = ["测试人员", "负责模块", "执行用例数", "发现BUG数"]
rows = [
["自动化工具", "全模块", str(case_analysis.total_cases), str(bug_analysis.total_bugs)]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
# ==================== 八、测试附件 ====================
content_parts.append("## 八、测试附件\n")
if chapters.get("attachments", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "八", "测试附件", level=2))
content_parts.append("### 8.1 测试截图\n")
content_parts.append("*无测试截图*\n")
content_parts.append("### 8.1 测试截图\n")
content_parts.append("*无测试截图*\n")
content_parts.append("### 8.2 测试日志\n")
content_parts.append("*无测试日志*\n")
content_parts.append("### 8.2 测试日志\n")
content_parts.append("*无测试日志*\n")
# ==================== 九、风险评估与建议 ====================
content_parts.append("## 九、风险评估与建议\n")
content_parts.append("### 9.1 质量风险分析\n")
content_parts.append("*无特别风险*\n")
content_parts.append("### 9.2 发布建议\n")
# 根据测试结果自动生成发布建议
if case_analysis.failed_cases == 0 and bug_analysis.remaining_bugs == 0:
recommendation = "- [x] **建议发布** - 质量达标,风险可控"
elif case_analysis.failed_cases > 0:
recommendation = "- [ ] **建议延期** - 存在失败的测试用例,需要修复"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
recommendation = "- [ ] **建议延期** - 存在重大缺陷,需要修复"
if chapters.get("risk_assessment", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "九", "风险评估与建议", level=2))
content_parts.append("### 9.1 质量风险分析\n")
content_parts.append("*无特别风险*\n")
content_parts.append("### 9.2 发布建议\n")
# 根据测试结果自动生成发布建议
if case_analysis.failed_cases == 0 and bug_analysis.remaining_bugs == 0:
recommendation = "- [x] **建议发布** - 质量达标,风险可控"
elif case_analysis.failed_cases > 0:
recommendation = "- [ ] **建议延期** - 存在失败的测试用例,需要修复"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
recommendation = "- [ ] **建议延期** - 存在重大缺陷,需要修复"
else:
recommendation = "- [ ] **有条件发布** - 存在已知问题,需制定规避方案"
else:
recommendation = "- [ ] **有条件发布** - 存在已知问题,需制定规避方案"
else:
recommendation = "- [x] **建议发布** - 质量达标,风险可控"
content_parts.append(recommendation + "\n")
recommendation = "- [x] **建议发布** - 质量达标,风险可控"
content_parts.append(recommendation + "\n")
content_parts.append("### 9.3 改进建议\n")
content_parts.append("待补充\n")
content_parts.append("### 9.3 改进建议\n")
content_parts.append("待补充\n")
# ==================== 十、附录 ====================
content_parts.append("## 十、附录\n")
content_parts.append("### 10.1 用例与BUG关联表\n")
headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"]
rows = []
for case_number, bug_list in case_bug_link.items():
if bug_list:
for bug in bug_list[:3]: # 每个用例限制显示3个BUG
rows.append([
case_number,
"关联用例",
bug.bug_name[:30],
bug.bug_status
])
if rows:
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无关联数据*\n")
if chapters.get("appendix", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "十", "附录", level=2))
content_parts.append("### 10.2 术语表\n")
content_parts.append("*无术语定义*\n")
content_parts.append("### 10.1 用例与BUG关联表\n")
headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"]
rows = []
for case_number, bug_list in case_bug_link.items():
if bug_list:
for bug in bug_list[:3]: # 每个用例限制显示3个BUG
rows.append([
case_number,
"关联用例",
bug.bug_name[:30],
bug.bug_status
])
if rows:
content_parts.append(render_markdown_table(headers, rows) + "\n")
else:
content_parts.append("*无关联数据*\n")
content_parts.append("### 10.2 术语表\n")
content_parts.append("*无术语定义*\n")
content_parts.append("### 10.3 参考文档\n")
content_parts.append("*无参考文档*\n")
content_parts.append("### 10.3 参考文档\n")
content_parts.append("*无参考文档*\n")
content_parts.append("### 10.4 修订历史\n")
headers = ["版本", "修订日期", "修订人", "修订内容"]
rows = [
["v1.0", current_date, "自动化工具", "自动生成初始报告"]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
content_parts.append("### 10.4 修订历史\n")
headers = ["版本", "修订日期", "修订人", "修订内容"]
rows = [
["v1.0", current_date, "自动化工具", "自动生成初始报告"]
]
content_parts.append(render_markdown_table(headers, rows) + "\n")
# 合并所有内容
content = "\n".join(content_parts)
......
......@@ -46,6 +46,8 @@ from .config import (
WORD_PARAGRAPH_FIRST_LINE_INDENT,
DATE_FORMAT,
get_report_number,
get_default_config,
merge_template_config,
# 测试结果枚举
TEST_RESULT_PASS,
TEST_RESULT_FAIL,
......@@ -229,7 +231,8 @@ def generate_report(
output_path: str,
project_name: str,
cases: List[TestCase],
bugs: List[Bug]
bugs: List[Bug],
template_config: Optional[Dict] = None,
) -> str:
"""
生成功能测试报告
......@@ -244,17 +247,42 @@ def generate_report(
project_name: 项目名称
cases: 测试用例列表
bugs: BUG列表
template_config: 模板配置字典(可选,提供则覆盖硬编码默认值)
Returns:
生成的报告文件路径
"""
# 合并模板配置
cfg = merge_template_config(get_default_config(), template_config)
ws = cfg["word_style"]
chapters = cfg["chapters"]
defaults = cfg["defaults"]
# 局部别名:方便读取配置
_font_name = ws.get("font_name", WORD_FONT_NAME)
_font_size = ws.get("font_size", WORD_FONT_SIZE)
_title_sizes = {
1: ws.get("title_level_1_size", WORD_TITLE_LEVEL_1_SIZE),
2: ws.get("title_level_2_size", WORD_TITLE_LEVEL_2_SIZE),
3: ws.get("title_level_3_size", WORD_TITLE_LEVEL_3_SIZE),
4: ws.get("title_level_4_size", WORD_TITLE_LEVEL_4_SIZE),
}
_title_color = ws.get("title_color", "#000000")
_first_line_indent = ws.get("paragraph_first_line_indent", WORD_PARAGRAPH_FIRST_LINE_INDENT)
_test_lead = defaults.get("test_lead", DEFAULT_TEST_LEAD)
_tester = defaults.get("tester", DEFAULT_TESTER)
_test_env = defaults.get("test_env", DEFAULT_TEST_ENV)
_system_version = defaults.get("system_version", DEFAULT_SYSTEM_VERSION)
_server_address = defaults.get("server_address", DEFAULT_SERVER_ADDRESS)
_deployment_env = defaults.get("deployment_env", DEFAULT_DEPLOYMENT_ENV)
# 创建Word文档
doc = Document()
# 设置文档默认字体
doc.styles['Normal'].font.name = WORD_FONT_NAME
doc.styles['Normal']._element.rPr.rFonts.set(qn('w:eastAsia'), WORD_FONT_NAME)
doc.styles['Normal'].font.size = Pt(WORD_FONT_SIZE)
doc.styles['Normal'].font.name = _font_name
doc.styles['Normal']._element.rPr.rFonts.set(qn('w:eastAsia'), _font_name)
doc.styles['Normal'].font.size = Pt(_font_size)
# 获取当前日期
current_date = datetime.now().strftime(DATE_FORMAT)
......@@ -263,341 +291,427 @@ def generate_report(
# ==================== 报告标题 ====================
_add_title(doc, f"{project_name}功能测试报告", level=1)
# ==================== 一、报告基本信息 ====================
_add_title(doc, "一、报告基本信息", level=2)
_add_title(doc, "1.1 报告标识", level=3)
headers = ["项目", "内容"]
rows = [
["报告编号", report_number],
["报告标题", f"{project_name}功能测试报告"],
["报告版本", "v1.0"],
["生成日期", current_date],
["报告状态", "已生成"],
]
_create_table_with_data(doc, headers, rows)
_add_title(doc, "1.2 测试概览", level=3)
headers = ["项目", "内容"]
rows = [
["项目名称", project_name],
["测试负责人", extract_test_lead(bugs) if bugs else DEFAULT_TEST_LEAD],
["测试人员", extract_testers(bugs) if bugs else DEFAULT_TESTER],
["测试环境", extract_server_info(cases) if cases else DEFAULT_TEST_ENV],
["测试类型", "功能测试"],
]
_create_table_with_data(doc, headers, rows)
_add_title(doc, "1.3 被测系统信息", level=3)
headers = ["项目", "内容"]
# 提取自动填充数据
system_version = extract_version_info_from_bug_list(bugs) if bugs else DEFAULT_SYSTEM_VERSION
server_address = extract_server_info(cases) if cases else DEFAULT_SERVER_ADDRESS
deployment_env = extract_deployment_env(cases) if cases else DEFAULT_DEPLOYMENT_ENV
# 处理多行版本信息,在Word表格中需要特殊处理
# 如果版本信息包含换行符,需要分段显示
if "\n" in system_version:
# 将多行版本信息合并为一行显示(用空格分隔)
system_version_display = " ".join(system_version.split("\n"))
else:
system_version_display = system_version
rows = [
["系统名称", project_name],
["系统版本", system_version_display],
["服务器地址", server_address],
["部署环境", deployment_env],
]
_create_table_with_data(doc, headers, rows)
# 章节计数器(动态编号,仅对启用章节自增)
ch_idx = 0
# ==================== 二、测试执行摘要 ====================
_add_title(doc, "二、测试执行摘要", level=2)
_add_title(doc, "2.1 测试结果统计", level=3)
total = case_analysis.total_cases
headers = ["统计项", "数量", "占比"]
unexecuted = case_analysis.unverified_cases + case_analysis.undeveloped_cases
rows = [
["测试用例总数", str(total), "100%"],
["执行用例数量", str(case_analysis.executed_cases), f"{case_analysis.execution_rate}%"],
["通过用例数量", str(case_analysis.passed_cases), f"{case_analysis.pass_rate}%"],
["失败用例数量", str(case_analysis.failed_cases), f"{round(case_analysis.failed_cases / total * 100, 2)}%" if total > 0 else "0%"],
["未执行用例数量", str(unexecuted),
f"{round(unexecuted / total * 100, 2)}%" if total > 0 else "0%"],
]
_create_table_with_data(doc, headers, rows)
_add_title(doc, "2.2 BUG统计汇总", level=3)
total_bugs = bug_analysis.total_bugs
headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"]
rows = []
for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]:
count = bug_analysis.level_distribution.get(level, 0)
percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0
# 计算该等级的未关闭数量
remaining = sum(
1 for bug in bugs
if bug.bug_level == level and bug.bug_status in REMAINING_BUG_STATUS
)
closed = count - remaining
rows.append([
f"{BUG_LEVEL_DESCRIPTION[level]}({level})",
str(count),
f"{percentage}%",
str(closed),
str(remaining)
])
_create_table_with_data(doc, headers, rows)
_add_title(doc, "2.3 测试结论", level=3)
if bug_analysis.remaining_bugs == 0 and case_analysis.failed_cases == 0:
conclusion = "✓ 测试通过 - 系统功能正常,无致命/严重缺陷,可以发布"
elif case_analysis.failed_cases > 0:
conclusion = "✗ 测试不通过 - 存在失败的测试用例,不建议发布"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
conclusion = "✗ 测试不通过 - 存在阻塞性缺陷,不建议发布"
# ==================== 一、报告基本信息 ====================
def _add_title_local(title, level=1):
"""内部标题函数,使用模板配置的样式"""
heading = doc.add_heading(title, level=level)
font_size = _title_sizes.get(level, _title_sizes[1])
# 解析颜色
color_hex = _title_color.lstrip("#")
if len(color_hex) == 6:
r, g, b = int(color_hex[0:2], 16), int(color_hex[2:4], 16), int(color_hex[4:6], 16)
rgb_color = RGBColor(r, g, b)
else:
conclusion = "⚠ 有条件通过 - 存在非阻塞性缺陷,经评估可以发布"
else:
conclusion = "待评估"
_add_paragraph(doc, conclusion)
# ==================== 三、测试范围 ====================
_add_title(doc, "三、测试范围", level=2)
_add_title(doc, "3.1 测试模块覆盖", level=3)
headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"]
module_rows = []
for i, (module_name, stat) in enumerate(case_analysis.module_stats.items(), 1):
module_rows.append([
str(i),
module_name,
str(stat.total_cases),
str(stat.passed_cases),
str(stat.failed_cases),
str(stat.unverified_cases + stat.undeveloped_cases),
f"{stat.pass_rate}%"
])
_create_table_with_data(doc, headers, module_rows)
rgb_color = RGBColor(0, 0, 0)
for run in heading.runs:
run.font.size = Pt(font_size)
run.font.name = _font_name
run.font.bold = True
run.font.color.rgb = rgb_color
run._element.rPr.rFonts.set(qn('w:eastAsia'), _font_name)
heading.alignment = WD_ALIGN_PARAGRAPH.LEFT
def _add_paragraph_local(text, bold=False):
"""内部段落函数,使用模板配置的样式"""
from docx.shared import Twips
paragraph = doc.add_paragraph(text)
paragraph.paragraph_format.first_line_indent = Twips(_first_line_indent * 240)
for run in paragraph.runs:
run.font.size = Pt(_font_size)
run.font.name = _font_name
run.font.color.rgb = RGBColor(0, 0, 0)
run._element.rPr.rFonts.set(qn('w:eastAsia'), _font_name)
run.font.bold = bold
def _create_table_local(headers, rows):
"""内部表格函数,使用模板配置的样式"""
if not rows:
return
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
table.style = 'Table Grid'
header_cells = table.rows[0].cells
for i, header in enumerate(headers):
header_cells[i].text = header
for paragraph in header_cells[i].paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.bold = True
run.font.size = Pt(_font_size)
run.font.name = _font_name
run.font.color.rgb = RGBColor(0, 0, 0)
run._element.rPr.rFonts.set(qn('w:eastAsia'), _font_name)
_set_cell_border(header_cells[i])
for i, row_data in enumerate(rows):
row_cells = table.rows[i + 1].cells
for j, cell_data in enumerate(row_data):
cell_text = str(cell_data) if cell_data is not None else ""
row_cells[j].text = cell_text
for paragraph in row_cells[j].paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.size = Pt(_font_size)
run.font.name = _font_name
run.font.color.rgb = RGBColor(0, 0, 0)
run._element.rPr.rFonts.set(qn('w:eastAsia'), _font_name)
_set_cell_border(row_cells[j])
# ==================== 一、报告基本信息 ====================
if chapters.get("report_basic_info", True):
ch_idx += 1
_add_title_local("一、报告基本信息", level=2)
_add_title_local("1.1 报告标识", level=3)
headers = ["项目", "内容"]
rows = [
["报告编号", report_number],
["报告标题", f"{project_name}功能测试报告"],
["报告版本", "v1.0"],
["生成日期", current_date],
["报告状态", "已生成"],
]
_create_table_local(headers, rows)
_add_title_local("1.2 测试概览", level=3)
headers = ["项目", "内容"]
rows = [
["项目名称", project_name],
["测试负责人", extract_test_lead(bugs) if bugs else _test_lead],
["测试人员", extract_testers(bugs) if bugs else _tester],
["测试环境", extract_server_info(cases) if cases else _test_env],
["测试类型", "功能测试"],
]
_create_table_local(headers, rows)
_add_title_local("1.3 被测系统信息", level=3)
headers = ["项目", "内容"]
# 提取自动填充数据
system_version = extract_version_info_from_bug_list(bugs) if bugs else _system_version
server_address = extract_server_info(cases) if cases else _server_address
deployment_env = extract_deployment_env(cases) if cases else _deployment_env
# 处理多行版本信息,在Word表格中需要特殊处理
# 如果版本信息包含换行符,需要分段显示
if "\n" in system_version:
# 将多行版本信息合并为一行显示(用空格分隔)
system_version_display = " ".join(system_version.split("\n"))
else:
system_version_display = system_version
# ==================== 四、测试用例执行详情 ====================
_add_title(doc, "四、测试用例执行详情", level=2)
rows = [
["系统名称", project_name],
["系统版本", system_version_display],
["服务器地址", server_address],
["部署环境", deployment_env],
]
_create_table_local(headers, rows)
# 通过用例列表
_add_title(doc, "4.1 通过用例列表", level=3)
passed_cases = [c for c in cases if c.test_result == TEST_RESULT_PASS]
if passed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "测试结果"]
# ==================== 二、测试执行摘要 ====================
if chapters.get("execution_summary", True):
ch_idx += 1
_add_title_local("二、测试执行摘要", level=2)
_add_title_local("2.1 测试结果统计", level=3)
total = case_analysis.total_cases
headers = ["统计项", "数量", "占比"]
unexecuted = case_analysis.unverified_cases + case_analysis.undeveloped_cases
rows = [
["测试用例总数", str(total), "100%"],
["执行用例数量", str(case_analysis.executed_cases), f"{case_analysis.execution_rate}%"],
["通过用例数量", str(case_analysis.passed_cases), f"{case_analysis.pass_rate}%"],
["失败用例数量", str(case_analysis.failed_cases), f"{round(case_analysis.failed_cases / total * 100, 2)}%" if total > 0 else "0%"],
["未执行用例数量", str(unexecuted),
f"{round(unexecuted / total * 100, 2)}%" if total > 0 else "0%"],
]
_create_table_local(headers, rows)
_add_title_local("2.2 BUG统计汇总", level=3)
total_bugs = bug_analysis.total_bugs
headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"]
rows = []
for i, case in enumerate(passed_cases[:20], 1): # 限制显示20条
for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]:
count = bug_analysis.level_distribution.get(level, 0)
percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0
# 计算该等级的未关闭数量
remaining = sum(
1 for bug in bugs
if bug.bug_level == level and bug.bug_status in REMAINING_BUG_STATUS
)
closed = count - remaining
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50], # 限制长度
str(case.level),
case.test_result
f"{BUG_LEVEL_DESCRIPTION[level]}({level})",
str(count),
f"{percentage}%",
str(closed),
str(remaining)
])
if len(passed_cases) > 20:
rows.append(["...", f"等{len(passed_cases)}条通过用例", "", "", "", ""])
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无通过用例")
_create_table_local(headers, rows)
_add_title_local("2.3 测试结论", level=3)
if bug_analysis.remaining_bugs == 0 and case_analysis.failed_cases == 0:
conclusion = "✓ 测试通过 - 系统功能正常,无致命/严重缺陷,可以发布"
elif case_analysis.failed_cases > 0:
conclusion = "✗ 测试不通过 - 存在失败的测试用例,不建议发布"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
conclusion = "✗ 测试不通过 - 存在阻塞性缺陷,不建议发布"
else:
conclusion = "⚠ 有条件通过 - 存在非阻塞性缺陷,经评估可以发布"
else:
conclusion = "待评估"
_add_paragraph_local(conclusion)
# 失败用例列表
_add_title(doc, "4.2 失败用例列表", level=3)
failed_cases = [c for c in cases if c.test_result == TEST_RESULT_FAIL]
if failed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "关联BUG"]
rows = []
for i, case in enumerate(failed_cases, 1):
related_bugs = case_bug_link.get(case.case_number, [])
bug_str = "; ".join([b.bug_name[:30] for b in related_bugs[:3]]) # 限制显示
if len(related_bugs) > 3:
bug_str += f" 等{len(related_bugs)}个"
rows.append([
# ==================== 三、测试范围 ====================
if chapters.get("test_scope", True):
ch_idx += 1
_add_title_local("三、测试范围", level=2)
_add_title_local("3.1 测试模块覆盖", level=3)
headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"]
module_rows = []
for i, (module_name, stat) in enumerate(case_analysis.module_stats.items(), 1):
module_rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
bug_str if bug_str else "无"
module_name,
str(stat.total_cases),
str(stat.passed_cases),
str(stat.failed_cases),
str(stat.unverified_cases + stat.undeveloped_cases),
f"{stat.pass_rate}%"
])
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无失败用例")
_create_table_local(headers, module_rows)
# 未执行用例列表
_add_title(doc, "4.3 未执行用例列表", level=3)
unexecuted_cases = [c for c in cases if c.test_result in [TEST_RESULT_UNVERIFIED, TEST_RESULT_UNDEVELOPED]]
if unexecuted_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "未执行原因"]
rows = []
for i, case in enumerate(unexecuted_cases[:10], 1): # 限制显示10条
reason = "未验证" if case.test_result == TEST_RESULT_UNVERIFIED else "未开发"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
reason
])
if len(unexecuted_cases) > 10:
rows.append(["...", f"等{len(unexecuted_cases)}条未执行用例", "", "", "", ""])
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无未执行用例")
# ==================== 四、测试用例执行详情 ====================
if chapters.get("case_details", True):
ch_idx += 1
_add_title_local("四、测试用例执行详情", level=2)
# 通过用例列表
_add_title_local("4.1 通过用例列表", level=3)
passed_cases = [c for c in cases if c.test_result == TEST_RESULT_PASS]
if passed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "测试结果"]
rows = []
for i, case in enumerate(passed_cases[:20], 1): # 限制显示20条
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50], # 限制长度
str(case.level),
case.test_result
])
if len(passed_cases) > 20:
rows.append(["...", f"等{len(passed_cases)}条通过用例", "", "", "", ""])
_create_table_local(headers, rows)
else:
_add_paragraph_local("无通过用例")
# 失败用例列表
_add_title_local("4.2 失败用例列表", level=3)
failed_cases = [c for c in cases if c.test_result == TEST_RESULT_FAIL]
if failed_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "关联BUG"]
rows = []
for i, case in enumerate(failed_cases, 1):
related_bugs = case_bug_link.get(case.case_number, [])
bug_str = "; ".join([b.bug_name[:30] for b in related_bugs[:3]]) # 限制显示
if len(related_bugs) > 3:
bug_str += f" 等{len(related_bugs)}个"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
bug_str if bug_str else "无"
])
_create_table_local(headers, rows)
else:
_add_paragraph_local("无失败用例")
# 未执行用例列表
_add_title_local("4.3 未执行用例列表", level=3)
unexecuted_cases = [c for c in cases if c.test_result in [TEST_RESULT_UNVERIFIED, TEST_RESULT_UNDEVELOPED]]
if unexecuted_cases:
headers = ["序号", "用例编号", "功能模块", "用例名称", "用例等级", "未执行原因"]
rows = []
for i, case in enumerate(unexecuted_cases[:10], 1): # 限制显示10条
reason = "未验证" if case.test_result == TEST_RESULT_UNVERIFIED else "未开发"
rows.append([
str(i),
case.case_number,
case.module,
case.name[:50],
str(case.level),
reason
])
if len(unexecuted_cases) > 10:
rows.append(["...", f"等{len(unexecuted_cases)}条未执行用例", "", "", "", ""])
_create_table_local(headers, rows)
else:
_add_paragraph_local("无未执行用例")
# ==================== 五、BUG详细列表 ====================
_add_title(doc, "五、BUG详细列表", level=2)
_add_title(doc, "5.1 BUG详细记录", level=3)
if bugs:
headers = ["序号", "BUG等级", "BUG状态", "BUG名称", "BUG类型", "提单人员", "负责人员"]
rows = []
for i, bug in enumerate(bugs, 1):
rows.append([
str(i),
bug.bug_level,
bug.bug_status,
bug.bug_name[:50],
bug.bug_type,
bug.reporter,
bug.owner
])
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无BUG记录")
_add_title(doc, "5.2 遗留BUG说明", level=3)
remaining_bugs = [b for b in bugs if b.bug_status in REMAINING_BUG_STATUS]
if remaining_bugs:
headers = ["BUG编号", "BUG等级", "问题描述", "遗留原因", "后续计划"]
rows = []
for bug in remaining_bugs:
rows.append([
bug.bug_name[:30],
bug.bug_level,
bug.bug_name[:60],
"待修复",
"后续版本修复"
])
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无遗留BUG")
if chapters.get("bug_list", True):
ch_idx += 1
_add_title_local("五、BUG详细列表", level=2)
_add_title_local("5.1 BUG详细记录", level=3)
if bugs:
headers = ["序号", "BUG等级", "BUG状态", "BUG名称", "BUG类型", "提单人员", "负责人员"]
rows = []
for i, bug in enumerate(bugs, 1):
rows.append([
str(i),
bug.bug_level,
bug.bug_status,
bug.bug_name[:50],
bug.bug_type,
bug.reporter,
bug.owner
])
_create_table_local(headers, rows)
else:
_add_paragraph_local("无BUG记录")
_add_title_local("5.2 遗留BUG说明", level=3)
remaining_bugs = [b for b in bugs if b.bug_status in REMAINING_BUG_STATUS]
if remaining_bugs:
headers = ["BUG编号", "BUG等级", "问题描述", "遗留原因", "后续计划"]
rows = []
for bug in remaining_bugs:
rows.append([
bug.bug_name[:30],
bug.bug_level,
bug.bug_name[:60],
"待修复",
"后续版本修复"
])
_create_table_local(headers, rows)
else:
_add_paragraph_local("无遗留BUG")
# ==================== 六、图表分析 ====================
_add_title(doc, "六、图表分析", level=2)
if chapters.get("chart_analysis", True):
ch_idx += 1
_add_title_local("六、图表分析", level=2)
# 添加图表
if "bug_level" in chart_paths:
_add_title(doc, "6.1 BUG等级分布", level=3)
_add_image(doc, chart_paths["bug_level"])
# 添加图表
if "bug_level" in chart_paths:
_add_title_local("6.1 BUG等级分布", level=3)
_add_image(doc, chart_paths["bug_level"])
if "test_result" in chart_paths:
_add_title(doc, "6.2 用例执行结果分布", level=3)
_add_image(doc, chart_paths["test_result"])
if "test_result" in chart_paths:
_add_title_local("6.2 用例执行结果分布", level=3)
_add_image(doc, chart_paths["test_result"])
if "pass_rate" in chart_paths:
_add_title(doc, "6.3 用例执行情况统计", level=3)
_add_image(doc, chart_paths["pass_rate"])
if "pass_rate" in chart_paths:
_add_title_local("6.3 用例执行情况统计", level=3)
_add_image(doc, chart_paths["pass_rate"])
if "bug_by_module" in chart_paths:
_add_title(doc, "6.4 各模块BUG分布", level=3)
_add_image(doc, chart_paths["bug_by_module"])
if "bug_by_module" in chart_paths:
_add_title_local("6.4 各模块BUG分布", level=3)
_add_image(doc, chart_paths["bug_by_module"])
# ==================== 七、测试执行记录 ====================
_add_title(doc, "七、测试执行记录", level=2)
_add_title(doc, "7.1 执行时间统计", level=3)
headers = ["统计项", "时间"]
rows = [
["测试开始时间", current_date],
["测试结束时间", current_date],
["测试总耗时", "自动化生成"],
["平均用例执行时间", "N/A"]
]
_create_table_with_data(doc, headers, rows)
_add_title(doc, "7.2 执行人员分工", level=3)
headers = ["测试人员", "负责模块", "执行用例数", "发现BUG数"]
rows = [
["自动化工具", "全模块", str(case_analysis.total_cases), str(bug_analysis.total_bugs)]
]
_create_table_with_data(doc, headers, rows)
if chapters.get("execution_records", True):
ch_idx += 1
_add_title_local("七、测试执行记录", level=2)
_add_title_local("7.1 执行时间统计", level=3)
headers = ["统计项", "时间"]
rows = [
["测试开始时间", current_date],
["测试结束时间", current_date],
["测试总耗时", "自动化生成"],
["平均用例执行时间", "N/A"]
]
_create_table_local(headers, rows)
_add_title_local("7.2 执行人员分工", level=3)
headers = ["测试人员", "负责模块", "执行用例数", "发现BUG数"]
rows = [
["自动化工具", "全模块", str(case_analysis.total_cases), str(bug_analysis.total_bugs)]
]
_create_table_local(headers, rows)
# ==================== 八、测试附件 ====================
_add_title(doc, "八、测试附件", level=2)
if chapters.get("attachments", True):
ch_idx += 1
_add_title_local("八、测试附件", level=2)
_add_title(doc, "8.1 测试截图", level=3)
_add_paragraph(doc, "无测试截图")
_add_title_local("8.1 测试截图", level=3)
_add_paragraph_local("无测试截图")
_add_title(doc, "8.2 测试日志", level=3)
_add_paragraph(doc, "无测试日志")
_add_title_local("8.2 测试日志", level=3)
_add_paragraph_local("无测试日志")
# ==================== 九、风险评估与建议 ====================
_add_title(doc, "九、风险评估与建议", level=2)
_add_title(doc, "9.1 质量风险分析", level=3)
_add_paragraph(doc, "无特别风险")
_add_title(doc, "9.2 发布建议", level=3)
# 根据测试结果自动生成发布建议
if case_analysis.failed_cases == 0 and bug_analysis.remaining_bugs == 0:
recommendation = "建议发布 - 质量达标,风险可控"
elif case_analysis.failed_cases > 0:
recommendation = "建议延期 - 存在失败的测试用例,需要修复"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
recommendation = "建议延期 - 存在重大缺陷,需要修复"
if chapters.get("risk_assessment", True):
ch_idx += 1
_add_title_local("九、风险评估与建议", level=2)
_add_title_local("9.1 质量风险分析", level=3)
_add_paragraph_local("无特别风险")
_add_title_local("9.2 发布建议", level=3)
# 根据测试结果自动生成发布建议
if case_analysis.failed_cases == 0 and bug_analysis.remaining_bugs == 0:
recommendation = "建议发布 - 质量达标,风险可控"
elif case_analysis.failed_cases > 0:
recommendation = "建议延期 - 存在失败的测试用例,需要修复"
elif bug_analysis.remaining_bugs > 0:
critical = bug_analysis.remaining_by_level.get(BUG_LEVEL_1, 0) + \
bug_analysis.remaining_by_level.get(BUG_LEVEL_2, 0)
if critical > 0:
recommendation = "建议延期 - 存在重大缺陷,需要修复"
else:
recommendation = "有条件发布 - 存在已知问题,需制定规避方案"
else:
recommendation = "有条件发布 - 存在已知问题,需制定规避方案"
else:
recommendation = "建议发布 - 质量达标,风险可控"
_add_paragraph(doc, recommendation)
recommendation = "建议发布 - 质量达标,风险可控"
_add_paragraph_local(recommendation)
_add_title(doc, "9.3 改进建议", level=3)
_add_paragraph(doc, "待补充")
_add_title_local("9.3 改进建议", level=3)
_add_paragraph_local("待补充")
# ==================== 十、附录 ====================
_add_title(doc, "十、附录", level=2)
_add_title(doc, "10.1 用例与BUG关联表", level=3)
headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"]
rows = []
for case_number, bug_list in case_bug_link.items():
if bug_list:
for bug in bug_list[:3]: # 每个用例限制显示3个BUG
rows.append([
case_number,
"关联用例",
bug.bug_name[:30],
bug.bug_status
])
if rows:
_create_table_with_data(doc, headers, rows)
else:
_add_paragraph(doc, "无关联数据")
if chapters.get("appendix", True):
ch_idx += 1
_add_title_local("十、附录", level=2)
_add_title_local("10.1 用例与BUG关联表", level=3)
headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"]
rows = []
for case_number, bug_list in case_bug_link.items():
if bug_list:
for bug in bug_list[:3]: # 每个用例限制显示3个BUG
rows.append([
case_number,
"关联用例",
bug.bug_name[:30],
bug.bug_status
])
if rows:
_create_table_local(headers, rows)
else:
_add_paragraph_local("无关联数据")
_add_title(doc, "10.2 术语表", level=3)
_add_paragraph(doc, "无术语定义")
_add_title_local("10.2 术语表", level=3)
_add_paragraph_local("无术语定义")
_add_title(doc, "10.3 参考文档", level=3)
_add_paragraph(doc, "无参考文档")
_add_title_local("10.3 参考文档", level=3)
_add_paragraph_local("无参考文档")
_add_title(doc, "10.4 修订历史", level=3)
headers = ["版本", "修订日期", "修订人", "修订内容"]
rows = [
["v1.0", current_date, "自动化工具", "自动生成初始报告"]
]
_create_table_with_data(doc, headers, rows)
_add_title_local("10.4 修订历史", level=3)
headers = ["版本", "修订日期", "修订人", "修订内容"]
rows = [
["v1.0", current_date, "自动化工具", "自动生成初始报告"]
]
_create_table_local(headers, rows)
# ==================== 保存文档 ====================
# 确保输出目录存在
......
......@@ -9,12 +9,18 @@
最后修改:2026-08-12
"""
import json
import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from sqlalchemy import select
from app.database import async_session_maker
from app.models import ReportTemplate
from .functional.excel_reader import read_test_cases, read_bug_list, TestCase, Bug
from .functional.data_analyzer import (
analyze_test_cases,
......@@ -78,13 +84,54 @@ class FunctionalReportService:
logger.info(f"文件已保存: {file_path}")
return str(file_path)
def analyze_and_generate(
async def _load_template_config(self, template_id: Optional[int] = None) -> Optional[Dict]:
"""
从数据库加载报告模板配置
模板配置会与系统默认配置合并,保证结构完整(缺失键回退到默认值)。
无可用模板(指定ID不存在或系统中没有任何模板)时返回 None,
生成器将回退到系统硬编码默认值。
Args:
template_id: 模板ID(None 时使用默认模板,无默认模板则返回 None)
Returns:
Optional[Dict]: 合并后的模板配置字典,无可用模板时返回 None
"""
try:
from .functional.config import get_default_config, merge_template_config
async with async_session_maker() as session:
if template_id is not None:
template = await session.get(ReportTemplate, template_id)
if template is None:
logger.warning(f"指定模板不存在: {template_id},使用系统默认配置")
return None
else:
result = await session.execute(
select(ReportTemplate).where(ReportTemplate.is_default == True) # noqa: E712
)
template = result.scalar_one_or_none()
if template is None:
logger.info("系统中没有报告模板,使用系统默认配置")
return None
template_config = json.loads(template.config) if template.config else {}
merged = merge_template_config(get_default_config(), template_config)
logger.info(f"使用报告模板: {template.name} (id={template.id}, is_default={template.is_default})")
return merged
except Exception as e:
logger.error(f"加载报告模板失败,使用系统默认配置: {e}")
return None
async def analyze_and_generate(
self,
testcase_path: str,
buglist_path: str,
project_name: str,
report_format: str = "docx",
session_id: str = "",
template_id: Optional[int] = None,
) -> Dict:
"""
执行完整分析并生成报告
......@@ -95,6 +142,7 @@ class FunctionalReportService:
project_name: 项目名称
report_format: 报告格式(docx/md/both)
session_id: 会话ID(沿用上传的session,保证报告与上传文件在同一目录)
template_id: 报告模板ID(可选,None 时使用默认模板,无模板时使用系统硬编码默认值)
Returns:
{
......@@ -107,6 +155,9 @@ class FunctionalReportService:
"reports": list[str],
}
"""
# 0. 加载报告模板配置(如指定模板ID或存在默认模板)
template_config = await self._load_template_config(template_id)
# 1. 读取Excel数据
logger.info("步骤1: 读取测试数据...")
test_cases = read_test_cases(testcase_path)
......@@ -133,7 +184,7 @@ class FunctionalReportService:
session_id = session_id or self._generate_session_id()
charts_dir = self._get_charts_dir(session_id)
logger.info("步骤3: 生成统计图表...")
chart_paths = generate_all_charts(case_analysis, bug_analysis, str(charts_dir))
chart_paths = generate_all_charts(case_analysis, bug_analysis, str(charts_dir), template_config)
logger.info(f" [OK] 生成了 {len(chart_paths)} 个图表")
# 4. 生成报告
......@@ -156,6 +207,7 @@ class FunctionalReportService:
project_name=project_name,
cases=test_cases,
bugs=bugs,
template_config=template_config,
)
else:
report_path = generate_markdown_report(
......@@ -168,6 +220,7 @@ class FunctionalReportService:
project_name=project_name,
cases=test_cases,
bugs=bugs,
template_config=template_config,
)
generated_reports.append(report_path)
logger.info(f" [OK] {fmt.upper()}报告已生成: {report_path}")
......
......@@ -44,6 +44,7 @@ export const functionalReportApi = {
sessionId: string,
projectName: string,
reportFormat: string = 'docx',
templateId?: number,
erpOptions?: {
upload_to_erp?: boolean
upload_to_project?: boolean
......@@ -71,6 +72,7 @@ export const functionalReportApi = {
session_id: sessionId,
project_name: projectName,
report_format: reportFormat,
template_id: templateId,
...(erpOptions || {})
})
return response as any
......
/**
* 功能测试报告模板配置 API 接口
*
* @module api/reportTemplate
* @author czj
* @date 2026-08-13
*/
import request from '@/utils/request'
/** 报告章节开关键集合 */
export type ReportChapterKey = 'report_basic_info' | 'execution_summary' | 'test_scope' | 'case_details' | 'bug_list' | 'chart_analysis' | 'execution_records' | 'attachments' | 'risk_assessment' | 'appendix'
/** 报告章节开关 */
export type ReportChapters = Record<ReportChapterKey, boolean>
/** 模板配置结构(与后端 config.py 的 get_default_config() 对齐) */
export interface ReportTemplateConfig {
/** 章节开关 */
chapters: ReportChapters
/** 报告默认值 */
defaults: {
test_lead: string
tester: string
test_env: string
system_version: string
server_address: string
deployment_env: string
}
/** Word 样式 */
word_style: {
font_name: string
font_size: number
title_level_1_size: number
title_level_2_size: number
title_level_3_size: number
title_level_4_size: number
paragraph_first_line_indent: number
title_color: string
}
/** 图表样式 */
chart_style: {
dpi: number
figure_width: number
figure_height: number
chinese_font: string
colors: {
pass: string
fail: string
unverified: string
undeveloped: string
bug_1: string
bug_2: string
bug_3: string
bug_4: string
}
}
}
/** 模板列表项(不含 config 详情) */
export interface ReportTemplateListItem {
id: number
name: string
is_default: boolean
updated_at?: string
}
/** 模板完整响应 */
export interface ReportTemplateResponse {
id: number
name: string
is_default: boolean
config: ReportTemplateConfig
created_at?: string
updated_at?: string
}
/** 创建模板请求 */
export interface ReportTemplateCreateRequest {
name: string
is_default?: boolean
config: ReportTemplateConfig
}
/** 更新模板请求 */
export interface ReportTemplateUpdateRequest {
name?: string
is_default?: boolean
config?: ReportTemplateConfig
}
/**
* 报告模板 API 服务
*/
export const reportTemplateApi = {
/**
* 获取模板列表
*/
async list(): Promise<ReportTemplateListItem[]> {
const response = await request.get('/api/functional-report/templates')
return response as any
},
/**
* 获取单个模板详情
* @param id 模板 ID
*/
async get(id: number): Promise<ReportTemplateResponse> {
const response = await request.get(`/api/functional-report/templates/${id}`)
return response as any
},
/**
* 创建模板
* @param data 创建请求
*/
async create(data: ReportTemplateCreateRequest): Promise<ReportTemplateResponse> {
const response = await request.post('/api/functional-report/templates', data)
return response as any
},
/**
* 更新模板
* @param id 模板 ID
* @param data 更新请求
*/
async update(id: number, data: ReportTemplateUpdateRequest): Promise<ReportTemplateResponse> {
const response = await request.put(`/api/functional-report/templates/${id}`, data)
return response as any
},
/**
* 删除模板
* @param id 模板 ID
*/
async delete(id: number): Promise<void> {
const response = await request.delete(`/api/functional-report/templates/${id}`)
return response as any
},
/**
* 设为默认模板
* @param id 模板 ID
*/
async setDefault(id: number): Promise<ReportTemplateResponse> {
const response = await request.put(`/api/functional-report/templates/${id}/default`)
return response as any
}
}
\ No newline at end of file
......@@ -91,6 +91,13 @@
<el-input v-model="projectName" placeholder="请输入项目名称" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="报告模板">
<el-select v-model="selectedTemplateId" placeholder="使用默认模板" clearable style="width: 100%">
<el-option v-for="tpl in templateList" :key="tpl.id" :label="tpl.name" :value="tpl.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="报告格式">
<el-radio-group v-model="reportFormat">
......@@ -311,7 +318,7 @@
* 3. 下载、预览报告
*/
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Refresh,
......@@ -324,6 +331,7 @@ import {
CircleClose
} from '@element-plus/icons-vue'
import { functionalReportApi } from '@/api/functionalReport'
import { reportTemplateApi } from '@/api/reportTemplate'
// ==================== 响应式数据 ====================
......@@ -339,6 +347,10 @@ const reportFormat = ref('docx')
const generating = ref(false)
const generateResult = ref<any>(null)
// 报告模板
const selectedTemplateId = ref<number | undefined>(undefined)
const templateList = ref<any[]>([])
// ERP 上传选项
const erpEnabled = ref(false)
const projectEnabled = ref(false)
......@@ -376,6 +388,19 @@ const chartLabels: Record<string, string> = {
// ==================== 方法定义 ====================
/** 加载报告模板列表 */
const loadTemplates = async () => {
try {
templateList.value = await reportTemplateApi.list()
} catch (error: any) {
console.warn('加载报告模板失败:', error.message)
}
}
onMounted(() => {
loadTemplates()
})
/** 上传文件 */
const handleUpload = async () => {
if (!testcaseFile.value || !buglistFile.value) {
......@@ -420,6 +445,7 @@ const handleGenerate = async () => {
sessionId.value,
projectName.value,
reportFormat.value,
selectedTemplateId.value,
{
upload_to_erp: erpEnabled.value,
upload_to_project: projectEnabled.value,
......@@ -471,6 +497,7 @@ const resetAll = () => {
sessionId.value = ''
projectName.value = ''
reportFormat.value = 'docx'
selectedTemplateId.value = undefined
generateResult.value = null
previewVisible.value = false
erpEnabled.value = false
......
<!--
组件名称:TemplateConfig.vue
组件描述:功能测试报告模板配置管理页面,支持多模板的增删改、默认模板设置
@author czj
@date 2026-08-13
-->
<template>
<div class="template-config-page">
<!-- 左侧:模板列表 -->
<div class="template-sidebar">
<el-card shadow="never" class="list-card">
<template #header>
<div class="card-header">
<span>模板列表</span>
<el-button type="primary" size="small" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建模板
</el-button>
</div>
</template>
<div v-if="templates.length === 0" class="empty-tips">
<el-empty description="暂无模板,请新建" />
</div>
<div v-else class="template-list">
<div
v-for="tpl in templates"
:key="tpl.id"
class="template-item"
:class="{ active: selectedId === tpl.id }"
@click="selectTemplate(tpl.id)"
>
<div class="template-info">
<span class="template-name">{{ tpl.name }}</span>
<el-tag v-if="tpl.is_default" size="small" type="success" class="default-tag">默认</el-tag>
</div>
<div class="template-actions">
<el-tooltip content="设为默认" placement="top">
<el-button
v-if="!tpl.is_default"
size="small"
circle
text
@click.stop="handleSetDefault(tpl)"
>
<el-icon><Star /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
size="small"
circle
text
type="danger"
@click.stop="handleDelete(tpl)"
>
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</el-card>
</div>
<!-- 右侧:配置表单 -->
<div class="template-form">
<template v-if="selectedId">
<el-card shadow="never" class="form-card">
<template #header>
<div class="card-header">
<span>编辑模板</span>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存模板' }}
</el-button>
</div>
</template>
<el-form label-width="130px" label-position="right">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="模板名称">
<el-input v-model="editForm.name" placeholder="请输入模板名称" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="设为默认">
<el-switch v-model="editForm.is_default" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 报告章节开关 ===== -->
<el-divider content-position="left">报告章节开关</el-divider>
<el-row :gutter="20">
<el-col v-for="item in chapterOptions" :key="item.key" :span="6">
<el-form-item :label="item.label" label-width="100px">
<el-switch v-model="(editForm.config.chapters as any)[item.key]" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== Word 样式 ===== -->
<el-divider content-position="left">Word 样式</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="字体名称">
<el-input v-model="editForm.config.word_style.font_name" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="字号">
<el-input-number v-model="editForm.config.word_style.font_size" :min="8" :max="72" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="标题颜色">
<el-color-picker v-model="editForm.config.word_style.title_color" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="首行缩进">
<el-input-number v-model="editForm.config.word_style.paragraph_first_line_indent" :min="0" :max="4" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="一级标题字号">
<el-input-number v-model="editForm.config.word_style.title_level_1_size" :min="8" :max="72" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="二级标题字号">
<el-input-number v-model="editForm.config.word_style.title_level_2_size" :min="8" :max="72" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="三级标题字号">
<el-input-number v-model="editForm.config.word_style.title_level_3_size" :min="8" :max="72" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="四级标题字号">
<el-input-number v-model="editForm.config.word_style.title_level_4_size" :min="8" :max="72" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 图表样式 ===== -->
<el-divider content-position="left">图表样式</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="DPI">
<el-input-number v-model="editForm.config.chart_style.dpi" :min="72" :max="600" :step="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="图表宽度">
<el-input-number v-model="editForm.config.chart_style.figure_width" :min="4" :max="30" :step="0.5" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="图表高度">
<el-input-number v-model="editForm.config.chart_style.figure_height" :min="3" :max="20" :step="0.5" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="中文字体">
<el-input v-model="editForm.config.chart_style.chinese_font" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">图表颜色配置</el-divider>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="通过">
<el-color-picker v-model="editForm.config.chart_style.colors.pass" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="失败">
<el-color-picker v-model="editForm.config.chart_style.colors.fail" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="未验证">
<el-color-picker v-model="editForm.config.chart_style.colors.unverified" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="未开发">
<el-color-picker v-model="editForm.config.chart_style.colors.undeveloped" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="BUG-严重">
<el-color-picker v-model="editForm.config.chart_style.colors.bug_1" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="BUG-一般">
<el-color-picker v-model="editForm.config.chart_style.colors.bug_2" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="BUG-轻微">
<el-color-picker v-model="editForm.config.chart_style.colors.bug_3" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="BUG-建议">
<el-color-picker v-model="editForm.config.chart_style.colors.bug_4" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 报告默认值 ===== -->
<el-divider content-position="left">报告默认值</el-divider>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="测试负责人">
<el-input v-model="editForm.config.defaults.test_lead" placeholder="未填写" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="测试人员">
<el-input v-model="editForm.config.defaults.tester" placeholder="未填写" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="测试环境">
<el-input v-model="editForm.config.defaults.test_env" placeholder="未填写" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="系统版本">
<el-input v-model="editForm.config.defaults.system_version" placeholder="未填写" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服务器地址">
<el-input v-model="editForm.config.defaults.server_address" placeholder="未填写" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="部署环境">
<el-input v-model="editForm.config.defaults.deployment_env" placeholder="未填写" />
</el-form-item>
</el-col>
</el-row>
<el-divider />
<el-form-item>
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存模板' }}
</el-button>
<el-button size="large" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<template v-else>
<el-card shadow="never">
<el-empty description="请从左侧选择一个模板进行编辑" />
</el-card>
</template>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 功能测试报告模板配置管理页面
*
* 功能:
* 1. 左侧模板列表,支持新建、删除、设为默认
* 2. 右侧配置表单,支持章节开关、Word样式、图表样式、默认值配置
*/
import { reactive, ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Star } from '@element-plus/icons-vue'
import {
reportTemplateApi,
type ReportTemplateListItem,
type ReportTemplateConfig,
type ReportTemplateResponse,
} from '@/api/reportTemplate'
// ==================== 类型定义 ====================
interface EditForm {
name: string
is_default: boolean
config: ReportTemplateConfig
}
// ==================== 章节选项 ====================
const chapterOptions: { key: keyof ReportTemplateConfig['chapters']; label: string }[] = [
{ key: 'report_basic_info', label: '报告基本信息' },
{ key: 'execution_summary', label: '执行概况' },
{ key: 'test_scope', label: '测试范围' },
{ key: 'case_details', label: '用例详情' },
{ key: 'bug_list', label: 'BUG列表' },
{ key: 'chart_analysis', label: '图表分析' },
{ key: 'execution_records', label: '执行记录' },
{ key: 'attachments', label: '测试附件' },
{ key: 'risk_assessment', label: '风险评估' },
{ key: 'appendix', label: '附录' },
]
// ==================== 工具函数 ====================
/** 创建默认配置对象 */
function createDefaultConfig(): ReportTemplateConfig {
return {
chapters: {
report_basic_info: true,
execution_summary: true,
test_scope: true,
case_details: true,
bug_list: true,
chart_analysis: true,
execution_records: true,
attachments: true,
risk_assessment: true,
appendix: true,
},
defaults: {
test_lead: '未填写',
tester: '未填写',
test_env: '未填写',
system_version: '未填写',
server_address: '未填写',
deployment_env: '未填写',
},
word_style: {
font_name: '宋体',
font_size: 11,
title_level_1_size: 21,
title_level_2_size: 19,
title_level_3_size: 17,
title_level_4_size: 15,
paragraph_first_line_indent: 2,
title_color: '#000000',
},
chart_style: {
dpi: 300,
figure_width: 10,
figure_height: 6,
chinese_font: 'Microsoft YaHei',
colors: {
pass: '#04AA6D',
fail: '#bb4069',
unverified: '#f0c040',
undeveloped: '#dcbdbd',
bug_1: '#d32f2f',
bug_2: '#f57c00',
bug_3: '#fbc02d',
bug_4: '#388e3c',
},
},
}
}
/** 深拷贝对象 */
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
// ==================== 响应式数据 ====================
/** 模板列表 */
const templates = ref<ReportTemplateListItem[]>([])
/** 当前选中的模板 ID */
const selectedId = ref<number | null>(null)
/** 保存中 */
const saving = ref(false)
/** 编辑表单数据 */
const editForm = reactive<EditForm>({
name: '',
is_default: false,
config: createDefaultConfig(),
})
/** 原始快照(用于重置) */
let originalSnapshot = ''
// ==================== 方法 ====================
/** 加载模板列表 */
async function loadTemplates() {
try {
templates.value = await reportTemplateApi.list()
if (templates.value.length > 0 && !selectedId.value) {
selectedId.value = templates.value[0].id
await loadTemplateDetail(selectedId.value)
}
} catch (err: any) {
ElMessage.error('加载模板列表失败:' + (err?.message || '未知错误'))
}
}
/** 加载单个模板详情 */
async function loadTemplateDetail(id: number) {
try {
const detail: ReportTemplateResponse = await reportTemplateApi.get(id)
editForm.name = detail.name
editForm.is_default = detail.is_default
// 合并配置,确保所有字段存在
editForm.config = deepClone(mergeConfig(detail.config))
originalSnapshot = JSON.stringify(deepClone(editForm))
} catch (err: any) {
ElMessage.error('加载模板详情失败:' + (err?.message || '未知错误'))
}
}
/** 合并配置,补齐缺失字段 */
function mergeConfig(config: ReportTemplateConfig): ReportTemplateConfig {
const defaults = createDefaultConfig()
const result = deepClone(defaults)
if (config) {
// 合并 chapters
if (config.chapters) {
Object.assign(result.chapters!, config.chapters)
}
// 合并 defaults
if (config.defaults) {
Object.assign(result.defaults!, config.defaults)
}
// 合并 word_style
if (config.word_style) {
Object.assign(result.word_style!, config.word_style)
}
// 合并 chart_style
if (config.chart_style) {
if (config.chart_style.colors) {
Object.assign(result.chart_style!.colors!, config.chart_style.colors)
}
Object.assign(result.chart_style!, { ...config.chart_style, colors: result.chart_style!.colors })
}
}
return result
}
/** 选中模板 */
async function selectTemplate(id: number) {
if (selectedId.value === id) return
selectedId.value = id
await loadTemplateDetail(id)
}
/** 新建模板 */
async function handleCreate() {
try {
await ElMessageBox.prompt('请输入模板名称', '新建模板', {
inputPlaceholder: '模板名称',
inputPattern: /\S+/,
inputErrorMessage: '模板名称不能为空',
}).then(async ({ value }) => {
const newConfig = createDefaultConfig()
const result = await reportTemplateApi.create({
name: value,
config: newConfig,
})
ElMessage.success('模板创建成功')
await loadTemplates()
// 选中新建的模板
selectedId.value = result.id
await loadTemplateDetail(result.id)
})
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error('创建模板失败:' + (err?.message || '未知错误'))
}
}
}
/** 保存模板 */
async function handleSave() {
if (!editForm.name.trim()) {
ElMessage.warning('请输入模板名称')
return
}
if (!selectedId.value) return
saving.value = true
try {
await reportTemplateApi.update(selectedId.value, {
name: editForm.name,
is_default: editForm.is_default,
config: editForm.config,
})
ElMessage.success('模板保存成功')
originalSnapshot = JSON.stringify(deepClone(editForm))
await loadTemplates()
} catch (err: any) {
ElMessage.error('保存模板失败:' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
/** 设为默认 */
async function handleSetDefault(tpl: ReportTemplateListItem) {
try {
await reportTemplateApi.setDefault(tpl.id)
ElMessage.success(`已将「${tpl.name}」设为默认模板`)
await loadTemplates()
// 刷新当前选中模板的详情
if (selectedId.value) {
await loadTemplateDetail(selectedId.value)
}
} catch (err: any) {
ElMessage.error('设为默认失败:' + (err?.message || '未知错误'))
}
}
/** 删除模板 */
async function handleDelete(tpl: ReportTemplateListItem) {
try {
await ElMessageBox.confirm(
`确定要删除模板「${tpl.name}」吗?`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
await reportTemplateApi.delete(tpl.id)
ElMessage.success('模板已删除')
await loadTemplates()
// 如果删除的是当前选中的模板,切换到第一个
if (selectedId.value === tpl.id) {
if (templates.value.length > 0) {
selectedId.value = templates.value[0].id
await loadTemplateDetail(selectedId.value)
} else {
selectedId.value = null
}
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error('删除模板失败:' + (err?.message || '未知错误'))
}
}
}
/** 重置表单 */
function handleReset() {
if (!originalSnapshot) return
const snapshot = JSON.parse(originalSnapshot)
editForm.name = snapshot.name
editForm.is_default = snapshot.is_default
editForm.config = snapshot.config
ElMessage.success('已重置到上次保存状态')
}
// ==================== 监听:选中变化时加载详情 ====================
watch(selectedId, (newId) => {
if (newId) {
loadTemplateDetail(newId)
}
})
// ==================== 生命周期 ====================
onMounted(() => {
loadTemplates()
})
</script>
<style scoped>
.template-config-page {
display: flex;
gap: 20px;
height: 100%;
min-height: calc(100vh - 120px);
}
/* ===== 左侧模板列表 ===== */
.template-sidebar {
width: 280px;
flex-shrink: 0;
}
.list-card {
height: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.empty-tips {
padding: 40px 0;
}
.template-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.template-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
border: 1px solid transparent;
}
.template-item:hover {
background: #f5f7fa;
}
.template-item.active {
background: #ecf5ff;
border-color: #409eff;
}
.template-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.template-name {
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.default-tag {
flex-shrink: 0;
}
.template-actions {
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 0.2s;
}
.template-item:hover .template-actions {
opacity: 1;
}
/* ===== 右侧配置表单 ===== */
.template-form {
flex: 1;
min-width: 0;
overflow: auto;
}
.form-card {
min-height: 100%;
}
</style>
\ No newline at end of file
......@@ -8,9 +8,7 @@
<template>
<div class="functional-report-page">
<div class="page-header">
<h2>功能测试报告</h2>
</div>
<!-- 子页面自带标题(生成报告 / ERP配置),父容器不再重复渲染标题 -->
<router-view />
</div>
</template>
......@@ -18,9 +16,10 @@
<script setup lang="ts">
/**
* 功能测试报告模块容器组件
* 个子页面通过路由切换:
* 个子页面通过路由切换:
* 1. /reports/functional/generate — 生成报告
* 2. /reports/functional/erp-config — ERP 配置
* 2. /reports/functional/template-config — 模板配置
* 3. /reports/functional/erp-config — ERP 配置
*/
</script>
......@@ -28,18 +27,4 @@
.functional-report-page {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-header h2 {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论