提交 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
...@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse ...@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings from app.config import settings
from app.database import init_db 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( logging.basicConfig(
...@@ -244,6 +244,13 @@ app.include_router( ...@@ -244,6 +244,13 @@ app.include_router(
tags=["功能测试报告"] 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 ...@@ -25,6 +25,7 @@ from app.models.performance import PerformanceTask, PerformanceSnapshot
from app.models.performance_output import PerformanceTaskOutput from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset from app.models.api_preset import ApiPreset
from app.models.erp_config import ErpConfig from app.models.erp_config import ErpConfig
from app.models.report_template import ReportTemplate
__all__ = [ __all__ = [
"Module", "Module",
...@@ -47,4 +48,5 @@ __all__ = [ ...@@ -47,4 +48,5 @@ __all__ = [
"PerformanceTaskOutput", "PerformanceTaskOutput",
"ApiPreset", "ApiPreset",
"ErpConfig", "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): ...@@ -153,12 +153,13 @@ async def generate_report(request: GenerateRequest):
service = get_report_service() service = get_report_service()
try: try:
result = service.analyze_and_generate( result = await service.analyze_and_generate(
testcase_path=testcase_path, testcase_path=testcase_path,
buglist_path=buglist_path, buglist_path=buglist_path,
project_name=request.project_name, project_name=request.project_name,
report_format=request.report_format, report_format=request.report_format,
session_id=request.session_id, session_id=request.session_id,
template_id=request.template_id,
) )
# 缓存分析结果到会话目录 # 缓存分析结果到会话目录
analysis_cache = { 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): ...@@ -26,6 +26,7 @@ class GenerateRequest(BaseModel):
session_id: str = Field(..., description="会话ID") session_id: str = Field(..., description="会话ID")
project_name: str = Field("", description="项目名称") project_name: str = Field("", description="项目名称")
report_format: str = Field("docx", description="报告格式: docx/md/both") report_format: str = Field("docx", description="报告格式: docx/md/both")
template_id: Optional[int] = Field(None, description="报告模板ID(None 时使用默认模板或系统默认配置)")
# ERP上传选项(可选,勾选后在生成报告后自动上传) # ERP上传选项(可选,勾选后在生成报告后自动上传)
upload_to_erp: bool = Field(False, description="是否上传到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
...@@ -317,3 +317,83 @@ def get_report_number(project_name: str, date: str = None) -> str: ...@@ -317,3 +317,83 @@ def get_report_number(project_name: str, date: str = None) -> str:
project_name=project_name, project_name=project_name,
date=date date=date
) )
# ================================
# 模板配置工具函数
# ================================
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 ( ...@@ -53,6 +53,9 @@ from .config import (
DEFAULT_SYSTEM_VERSION, DEFAULT_SYSTEM_VERSION,
DEFAULT_SERVER_ADDRESS, DEFAULT_SERVER_ADDRESS,
DEFAULT_DEPLOYMENT_ENV, DEFAULT_DEPLOYMENT_ENV,
# 模板配置工具函数
get_default_config,
merge_template_config,
) )
...@@ -99,7 +102,8 @@ def generate_markdown_report( ...@@ -99,7 +102,8 @@ def generate_markdown_report(
output_path: str, output_path: str,
project_name: str, project_name: str,
cases: List[TestCase], cases: List[TestCase],
bugs: List[Bug] bugs: List[Bug],
template_config: Optional[Dict] = None
) -> str: ) -> str:
""" """
生成Markdown格式功能测试报告 生成Markdown格式功能测试报告
...@@ -114,10 +118,23 @@ def generate_markdown_report( ...@@ -114,10 +118,23 @@ def generate_markdown_report(
project_name: 项目名称 project_name: 项目名称
cases: 测试用例列表 cases: 测试用例列表
bugs: BUG列表 bugs: BUG列表
template_config: 模板配置(可选),None 时使用默认值(章节全开+硬编码默认值)
Returns: 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) current_date = datetime.now().strftime(DATE_FORMAT)
report_number = get_report_number(project_name, current_date) report_number = get_report_number(project_name, current_date)
...@@ -129,7 +146,9 @@ def generate_markdown_report( ...@@ -129,7 +146,9 @@ def generate_markdown_report(
content_parts.append(f"# {project_name}功能测试报告\n") content_parts.append(f"# {project_name}功能测试报告\n")
# ==================== 一、报告基本信息 ==================== # ==================== 一、报告基本信息 ====================
content_parts.append("## 一、报告基本信息\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") content_parts.append("### 1.1 报告标识\n")
headers = ["项目", "内容"] headers = ["项目", "内容"]
...@@ -146,9 +165,9 @@ def generate_markdown_report( ...@@ -146,9 +165,9 @@ def generate_markdown_report(
headers = ["项目", "内容"] headers = ["项目", "内容"]
rows = [ rows = [
["项目名称", project_name], ["项目名称", project_name],
["测试负责人", extract_test_lead(bugs) if bugs else DEFAULT_TEST_LEAD], ["测试负责人", extract_test_lead(bugs) if bugs else defaults.get("test_lead", DEFAULT_TEST_LEAD)],
["测试人员", extract_testers(bugs) if bugs else DEFAULT_TESTER], ["测试人员", extract_testers(bugs) if bugs else defaults.get("tester", DEFAULT_TESTER)],
["测试环境", extract_server_info(cases) if cases else DEFAULT_TEST_ENV], ["测试环境", 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(render_markdown_table(headers, rows) + "\n")
...@@ -156,9 +175,9 @@ def generate_markdown_report( ...@@ -156,9 +175,9 @@ def generate_markdown_report(
content_parts.append("### 1.3 被测系统信息\n") content_parts.append("### 1.3 被测系统信息\n")
headers = ["项目", "内容"] headers = ["项目", "内容"]
# 提取自动填充数据 # 提取自动填充数据
system_version = extract_version_info_from_bug_list(bugs) if bugs else DEFAULT_SYSTEM_VERSION 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 DEFAULT_SERVER_ADDRESS 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 DEFAULT_DEPLOYMENT_ENV deployment_env = extract_deployment_env(cases) if cases else defaults.get("deployment_env", DEFAULT_DEPLOYMENT_ENV)
# Markdown支持多行显示,将版本信息格式化为代码块 # Markdown支持多行显示,将版本信息格式化为代码块
if "\n" in system_version: if "\n" in system_version:
...@@ -175,7 +194,9 @@ def generate_markdown_report( ...@@ -175,7 +194,9 @@ def generate_markdown_report(
content_parts.append(render_markdown_table(headers, rows) + "\n") content_parts.append(render_markdown_table(headers, rows) + "\n")
# ==================== 二、测试执行摘要 ==================== # ==================== 二、测试执行摘要 ====================
content_parts.append("## 二、测试执行摘要\n") if chapters.get("execution_summary", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "二", "测试执行摘要", level=2))
content_parts.append("### 2.1 测试结果统计\n") content_parts.append("### 2.1 测试结果统计\n")
total = case_analysis.total_cases total = case_analysis.total_cases
...@@ -194,6 +215,7 @@ def generate_markdown_report( ...@@ -194,6 +215,7 @@ def generate_markdown_report(
content_parts.append("### 2.2 BUG统计汇总\n") content_parts.append("### 2.2 BUG统计汇总\n")
total_bugs = bug_analysis.total_bugs total_bugs = bug_analysis.total_bugs
headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"] headers = ["BUG等级", "数量", "占比", "已关闭", "未关闭"]
rows = []
for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]: for level in [BUG_LEVEL_1, BUG_LEVEL_2, BUG_LEVEL_3, BUG_LEVEL_4]:
count = bug_analysis.level_distribution.get(level, 0) count = bug_analysis.level_distribution.get(level, 0)
percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0 percentage = round(count / total_bugs * 100, 2) if total_bugs > 0 else 0
...@@ -229,7 +251,9 @@ def generate_markdown_report( ...@@ -229,7 +251,9 @@ def generate_markdown_report(
content_parts.append(f"{conclusion}\n") content_parts.append(f"{conclusion}\n")
# ==================== 三、测试范围 ==================== # ==================== 三、测试范围 ====================
content_parts.append("## 三、测试范围\n") if chapters.get("test_scope", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "三", "测试范围", level=2))
content_parts.append("### 3.1 测试模块覆盖\n") content_parts.append("### 3.1 测试模块覆盖\n")
headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"] headers = ["序号", "功能模块", "用例数量", "通过", "失败", "未执行", "覆盖率"]
...@@ -247,7 +271,9 @@ def generate_markdown_report( ...@@ -247,7 +271,9 @@ def generate_markdown_report(
content_parts.append(render_markdown_table(headers, module_rows) + "\n") content_parts.append(render_markdown_table(headers, module_rows) + "\n")
# ==================== 四、测试用例执行详情 ==================== # ==================== 四、测试用例执行详情 ====================
content_parts.append("## 四、测试用例执行详情\n") if chapters.get("case_details", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "四", "测试用例执行详情", level=2))
# 通过用例列表 # 通过用例列表
content_parts.append("### 4.1 通过用例列表\n") content_parts.append("### 4.1 通过用例列表\n")
...@@ -316,7 +342,9 @@ def generate_markdown_report( ...@@ -316,7 +342,9 @@ def generate_markdown_report(
content_parts.append("*无未执行用例*\n") content_parts.append("*无未执行用例*\n")
# ==================== 五、BUG详细列表 ==================== # ==================== 五、BUG详细列表 ====================
content_parts.append("## 五、BUG详细列表\n") 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") content_parts.append("### 5.1 BUG详细记录\n")
if bugs: if bugs:
...@@ -354,7 +382,9 @@ def generate_markdown_report( ...@@ -354,7 +382,9 @@ def generate_markdown_report(
content_parts.append("*无遗留BUG*\n") content_parts.append("*无遗留BUG*\n")
# ==================== 六、图表分析 ==================== # ==================== 六、图表分析 ====================
content_parts.append("## 六、图表分析\n") if chapters.get("chart_analysis", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "六", "图表分析", level=2))
# 添加图表 # 添加图表
if "bug_level" in chart_paths: if "bug_level" in chart_paths:
...@@ -379,7 +409,9 @@ def generate_markdown_report( ...@@ -379,7 +409,9 @@ def generate_markdown_report(
content_parts.append(f"![各模块BUG分布]({chart_rel_path})\n") content_parts.append(f"![各模块BUG分布]({chart_rel_path})\n")
# ==================== 七、测试执行记录 ==================== # ==================== 七、测试执行记录 ====================
content_parts.append("## 七、测试执行记录\n") if chapters.get("execution_records", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "七", "测试执行记录", level=2))
content_parts.append("### 7.1 执行时间统计\n") content_parts.append("### 7.1 执行时间统计\n")
headers = ["统计项", "时间"] headers = ["统计项", "时间"]
...@@ -399,7 +431,9 @@ def generate_markdown_report( ...@@ -399,7 +431,9 @@ def generate_markdown_report(
content_parts.append(render_markdown_table(headers, rows) + "\n") 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("### 8.1 测试截图\n")
content_parts.append("*无测试截图*\n") content_parts.append("*无测试截图*\n")
...@@ -408,7 +442,9 @@ def generate_markdown_report( ...@@ -408,7 +442,9 @@ def generate_markdown_report(
content_parts.append("*无测试日志*\n") content_parts.append("*无测试日志*\n")
# ==================== 九、风险评估与建议 ==================== # ==================== 九、风险评估与建议 ====================
content_parts.append("## 九、风险评估与建议\n") 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("### 9.1 质量风险分析\n")
content_parts.append("*无特别风险*\n") content_parts.append("*无特别风险*\n")
...@@ -434,7 +470,9 @@ def generate_markdown_report( ...@@ -434,7 +470,9 @@ def generate_markdown_report(
content_parts.append("待补充\n") content_parts.append("待补充\n")
# ==================== 十、附录 ==================== # ==================== 十、附录 ====================
content_parts.append("## 十、附录\n") if chapters.get("appendix", True):
ch_idx += 1
content_parts.append(_title(ch_idx, "十", "附录", level=2))
content_parts.append("### 10.1 用例与BUG关联表\n") content_parts.append("### 10.1 用例与BUG关联表\n")
headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"] headers = ["用例编号", "用例名称", "关联BUG编号", "BUG状态"]
......
...@@ -9,12 +9,18 @@ ...@@ -9,12 +9,18 @@
最后修改:2026-08-12 最后修改:2026-08-12
""" """
import json
import logging import logging
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple 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.excel_reader import read_test_cases, read_bug_list, TestCase, Bug
from .functional.data_analyzer import ( from .functional.data_analyzer import (
analyze_test_cases, analyze_test_cases,
...@@ -78,13 +84,54 @@ class FunctionalReportService: ...@@ -78,13 +84,54 @@ class FunctionalReportService:
logger.info(f"文件已保存: {file_path}") logger.info(f"文件已保存: {file_path}")
return str(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, self,
testcase_path: str, testcase_path: str,
buglist_path: str, buglist_path: str,
project_name: str, project_name: str,
report_format: str = "docx", report_format: str = "docx",
session_id: str = "", session_id: str = "",
template_id: Optional[int] = None,
) -> Dict: ) -> Dict:
""" """
执行完整分析并生成报告 执行完整分析并生成报告
...@@ -95,6 +142,7 @@ class FunctionalReportService: ...@@ -95,6 +142,7 @@ class FunctionalReportService:
project_name: 项目名称 project_name: 项目名称
report_format: 报告格式(docx/md/both) report_format: 报告格式(docx/md/both)
session_id: 会话ID(沿用上传的session,保证报告与上传文件在同一目录) session_id: 会话ID(沿用上传的session,保证报告与上传文件在同一目录)
template_id: 报告模板ID(可选,None 时使用默认模板,无模板时使用系统硬编码默认值)
Returns: Returns:
{ {
...@@ -107,6 +155,9 @@ class FunctionalReportService: ...@@ -107,6 +155,9 @@ class FunctionalReportService:
"reports": list[str], "reports": list[str],
} }
""" """
# 0. 加载报告模板配置(如指定模板ID或存在默认模板)
template_config = await self._load_template_config(template_id)
# 1. 读取Excel数据 # 1. 读取Excel数据
logger.info("步骤1: 读取测试数据...") logger.info("步骤1: 读取测试数据...")
test_cases = read_test_cases(testcase_path) test_cases = read_test_cases(testcase_path)
...@@ -133,7 +184,7 @@ class FunctionalReportService: ...@@ -133,7 +184,7 @@ class FunctionalReportService:
session_id = session_id or self._generate_session_id() session_id = session_id or self._generate_session_id()
charts_dir = self._get_charts_dir(session_id) charts_dir = self._get_charts_dir(session_id)
logger.info("步骤3: 生成统计图表...") 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)} 个图表") logger.info(f" [OK] 生成了 {len(chart_paths)} 个图表")
# 4. 生成报告 # 4. 生成报告
...@@ -156,6 +207,7 @@ class FunctionalReportService: ...@@ -156,6 +207,7 @@ class FunctionalReportService:
project_name=project_name, project_name=project_name,
cases=test_cases, cases=test_cases,
bugs=bugs, bugs=bugs,
template_config=template_config,
) )
else: else:
report_path = generate_markdown_report( report_path = generate_markdown_report(
...@@ -168,6 +220,7 @@ class FunctionalReportService: ...@@ -168,6 +220,7 @@ class FunctionalReportService:
project_name=project_name, project_name=project_name,
cases=test_cases, cases=test_cases,
bugs=bugs, bugs=bugs,
template_config=template_config,
) )
generated_reports.append(report_path) generated_reports.append(report_path)
logger.info(f" [OK] {fmt.upper()}报告已生成: {report_path}") logger.info(f" [OK] {fmt.upper()}报告已生成: {report_path}")
......
...@@ -44,6 +44,7 @@ export const functionalReportApi = { ...@@ -44,6 +44,7 @@ export const functionalReportApi = {
sessionId: string, sessionId: string,
projectName: string, projectName: string,
reportFormat: string = 'docx', reportFormat: string = 'docx',
templateId?: number,
erpOptions?: { erpOptions?: {
upload_to_erp?: boolean upload_to_erp?: boolean
upload_to_project?: boolean upload_to_project?: boolean
...@@ -71,6 +72,7 @@ export const functionalReportApi = { ...@@ -71,6 +72,7 @@ export const functionalReportApi = {
session_id: sessionId, session_id: sessionId,
project_name: projectName, project_name: projectName,
report_format: reportFormat, report_format: reportFormat,
template_id: templateId,
...(erpOptions || {}) ...(erpOptions || {})
}) })
return response as any 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 @@ ...@@ -91,6 +91,13 @@
<el-input v-model="projectName" placeholder="请输入项目名称" /> <el-input v-model="projectName" placeholder="请输入项目名称" />
</el-form-item> </el-form-item>
</el-col> </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-col :span="10">
<el-form-item label="报告格式"> <el-form-item label="报告格式">
<el-radio-group v-model="reportFormat"> <el-radio-group v-model="reportFormat">
...@@ -311,7 +318,7 @@ ...@@ -311,7 +318,7 @@
* 3. 下载、预览报告 * 3. 下载、预览报告
*/ */
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
Refresh, Refresh,
...@@ -324,6 +331,7 @@ import { ...@@ -324,6 +331,7 @@ import {
CircleClose CircleClose
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { functionalReportApi } from '@/api/functionalReport' import { functionalReportApi } from '@/api/functionalReport'
import { reportTemplateApi } from '@/api/reportTemplate'
// ==================== 响应式数据 ==================== // ==================== 响应式数据 ====================
...@@ -339,6 +347,10 @@ const reportFormat = ref('docx') ...@@ -339,6 +347,10 @@ const reportFormat = ref('docx')
const generating = ref(false) const generating = ref(false)
const generateResult = ref<any>(null) const generateResult = ref<any>(null)
// 报告模板
const selectedTemplateId = ref<number | undefined>(undefined)
const templateList = ref<any[]>([])
// ERP 上传选项 // ERP 上传选项
const erpEnabled = ref(false) const erpEnabled = ref(false)
const projectEnabled = ref(false) const projectEnabled = ref(false)
...@@ -376,6 +388,19 @@ const chartLabels: Record<string, string> = { ...@@ -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 () => { const handleUpload = async () => {
if (!testcaseFile.value || !buglistFile.value) { if (!testcaseFile.value || !buglistFile.value) {
...@@ -420,6 +445,7 @@ const handleGenerate = async () => { ...@@ -420,6 +445,7 @@ const handleGenerate = async () => {
sessionId.value, sessionId.value,
projectName.value, projectName.value,
reportFormat.value, reportFormat.value,
selectedTemplateId.value,
{ {
upload_to_erp: erpEnabled.value, upload_to_erp: erpEnabled.value,
upload_to_project: projectEnabled.value, upload_to_project: projectEnabled.value,
...@@ -471,6 +497,7 @@ const resetAll = () => { ...@@ -471,6 +497,7 @@ const resetAll = () => {
sessionId.value = '' sessionId.value = ''
projectName.value = '' projectName.value = ''
reportFormat.value = 'docx' reportFormat.value = 'docx'
selectedTemplateId.value = undefined
generateResult.value = null generateResult.value = null
previewVisible.value = false previewVisible.value = false
erpEnabled.value = false erpEnabled.value = false
......
此差异已折叠。
...@@ -8,9 +8,7 @@ ...@@ -8,9 +8,7 @@
<template> <template>
<div class="functional-report-page"> <div class="functional-report-page">
<div class="page-header"> <!-- 子页面自带标题(生成报告 / ERP配置),父容器不再重复渲染标题 -->
<h2>功能测试报告</h2>
</div>
<router-view /> <router-view />
</div> </div>
</template> </template>
...@@ -18,9 +16,10 @@ ...@@ -18,9 +16,10 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 功能测试报告模块容器组件 * 功能测试报告模块容器组件
* 个子页面通过路由切换: * 个子页面通过路由切换:
* 1. /reports/functional/generate — 生成报告 * 1. /reports/functional/generate — 生成报告
* 2. /reports/functional/erp-config — ERP 配置 * 2. /reports/functional/template-config — 模板配置
* 3. /reports/functional/erp-config — ERP 配置
*/ */
</script> </script>
...@@ -28,18 +27,4 @@ ...@@ -28,18 +27,4 @@
.functional-report-page { .functional-report-page {
padding: 0; 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> </style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论