# 功能测试报告模板优化 · 计划执行文档

> **文档状态**：已定稿 · **版本**：v1.0 · **最后更新**：2026-08-17
> **维护者**：czj · **所属模块**：报告中心-功能测试
> **对应 PRD**：`_PRD_功能测试报告模板优化.md`

---

## 一、执行计划总览

| 阶段 | 内容 | 预估工时 | 产出物 |
|------|------|----------|--------|
| Phase 1 | 后端：P0 章节编号动态化（word/markdown 生成器） | 20min | 两个生成器改造 |
| Phase 2 | 后端：P1 模板描述字段 + 复制端点 + 数据库补齐 | 15min | 模型/schema/路由/database 修改 |
| Phase 3 | 前端：P1 描述输入 + 复制按钮 + API 封装 | 15min | reportTemplate.ts / TemplateConfig.vue |
| Phase 4 | 验证：编号动态化 + 描述 + 复制全流程 | 10min | 控制台验证输出 |

**总预估工时：60 分钟**

---

## 二、Phase 1：编号动态化（P0）

### 2.1 修改 `backend/app/services/functional/word_generator.py`

**改动点 1**：新增 `_zh_num()` 工具函数（在 `_set_cell_border` 之前）：

```python
_ZH_NUM_MAP = {1: "一", 2: "二", 3: "三", 4: "四", 5: "五", 6: "六", 7: "七", 8: "八", 9: "九", 10: "十"}

def _zh_num(n: int) -> str:
    """整数转中文数字（1-10），超出返回阿拉伯数字"""
    return _ZH_NUM_MAP.get(n, str(n))
```

**改动点 2**：10 处主章节标题由硬编码改为 `ch_idx` 动态生成：

| 原代码 | 新代码 |
|--------|--------|
| `_add_title_local("一、报告基本信息", level=2)` | `_add_title_local(f"{_zh_num(ch_idx)}、报告基本信息", level=2)` |
| `_add_title_local("二、测试执行摘要", level=2)` | `_add_title_local(f"{_zh_num(ch_idx)}、测试执行摘要", level=2)` |
| ...（其余 8 处同模式） | ... |

> 注意：所有 `_add_title_local(...)` 调用点必须位于对应的 `ch_idx += 1` **之后**。

**改动点 3**：主章节内的子章节标题前缀数字与 `ch_idx` 联动（约 22 处 `x.y` 前缀）：

| 原代码 | 新代码 |
|--------|--------|
| `_add_title_local("1.1 报告标识", level=3)` | `_add_title_local(f"{ch_idx}.1 报告标识", level=3)` |
| `_add_title_local("1.2 测试概览", level=3)` | `_add_title_local(f"{ch_idx}.2 测试概览", level=3)` |
| `_add_title_local("1.3 被测系统信息", level=3)` | `_add_title_local(f"{ch_idx}.3 被测系统信息", level=3)` |
| ...（其余同模式，见验证清单） | ... |

### 2.2 修改 `backend/app/services/functional/markdown_generator.py`

**改动点 1**：同样新增 `_zh_num()` 工具函数（模块级）。

**改动点 2**：`_title()` 函数的中文数字参数改为动态：

```python
def _title(num: int, name: str, level: int = 2) -> str:
    """生成章节标题（序号动态生成中文数字）"""
    return f"{'#' * level} {_zh_num(num)} {name}"
```

调用点由 `_title(ch_idx, "一", "报告基本信息")` 改为 `_title(ch_idx, "报告基本信息")`（10 处）。

**改动点 3**：子章节标题 `### 1.1 报告标识` 等约 22 处改为 `### {ch_idx}.1 报告标识` 动态格式。

### 2.3 验证清单（子章节硬编码排查）

```bash
# 在改造后确认无残留硬编码
grep -n '"一、\|"二、\|"三、\|"四、\|"五、\|"六、\|"七、\|"八、\|"九、\|"十、' word_generator.py
grep -n '"### 1\.\|"### 2\.\|"### 3\.' markdown_generator.py
# 确认子章节前缀全部为 f"{ch_idx}." 动态格式
grep -n 'f"{ch_idx}\.' word_generator.py markdown_generator.py
```

---

## 三、Phase 2：模板描述 + 复制 + 数据库（P1）

### 3.1 修改 `backend/app/models/report_template.py`

```python
description: Mapped[str] = mapped_column(String(200), nullable=True, default="", comment="模板描述")
```

- `to_dict()` 增加 `"description": self.description or ""`
- `to_list_item()` 增加 `"description": self.description or ""`

### 3.2 修改 `backend/app/schemas/report_template.py`

- `ReportTemplateCreateRequest` 增加 `description: Optional[str] = Field(None, max_length=200)`
- `ReportTemplateUpdateRequest` 增加 `description: Optional[str] = Field(None, max_length=200)`
- `ReportTemplateListItem` 增加 `description: Optional[str] = Field(None)`
- `ReportTemplateResponse` 增加 `description: Optional[str] = Field(None)`

### 3.3 修改 `backend/app/routers/report_template.py`

**改动点 1**：create_template 处理 description：

```python
template = ReportTemplate(
    name=request.name,
    is_default=is_default,
    config=json.dumps(config, ensure_ascii=False),
    description=request.description or "",
)
```

**改动点 2**：update_template 处理 description：

```python
if request.description is not None:
    template.description = request.description
```

**改动点 3**：新增复制端点：

```python
@router.post("/{template_id}/duplicate", response_model=ReportTemplateResponse, summary="复制模板")
async def duplicate_template(template_id: int, db: AsyncSession = Depends(get_db)):
    """
    复制报告模板：config 与 description 完整继承，名称加"副本"后缀
    """
    template = await db.get(ReportTemplate, template_id)
    if not template:
        raise HTTPException(status_code=404, detail=f"模板不存在: {template_id}")

    # 生成不重复的名称
    base_name = template.name
    new_name = f"{base_name}_副本"
    num = 2
    while True:
        result = await db.execute(select(ReportTemplate).where(ReportTemplate.name == new_name))
        if not result.scalar_one_or_none():
            break
        new_name = f"{base_name}_副本{num}"
        num += 1

    new_template = ReportTemplate(
        name=new_name,
        is_default=False,
        config=template.config,          # JSON 字符串直接沿用
        description=template.description or "",
    )
    db.add(new_template)
    await db.commit()
    await db.refresh(new_template)
    logger.info(f"复制报告模板: {template.name} → {new_template.name} (id={new_template.id})")
    return new_template.to_dict()
```

### 3.4 修改 `backend/app/database.py` — 数据库补齐

`_ensure_columns()` 的 `columns_to_add` 列表新增：

```python
# 功能测试报告模板：描述字段（旧库升级）
("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
```

> 该机制对 SQLite / MySQL 均适用（幂等：列已存在则跳过）。

---

## 四、Phase 3：前端（P1）

### 4.1 修改 `frontend/src/api/reportTemplate.ts`

- 各接口类型增加 `description?: string`
- 新增方法：

```typescript
/**
 * 复制模板
 * @param id 原模板 ID
 */
async duplicate(id: number): Promise<ReportTemplateResponse> {
  const response = await request.post(`/api/functional-report/templates/${id}/duplicate`)
  return response as any
}
```

### 4.2 修改 `frontend/src/views/functional-report/TemplateConfig.vue`

**改动点 1**：模板列表项显示描述副标题：

```vue
<div class="template-name">{{ tpl.name }}</div>
<span v-if="tpl.description" class="template-desc">{{ tpl.description }}</span>
```

**改动点 2**：列表操作区新增"复制"按钮（`CopyDocument` 图标）：

```vue
<el-tooltip content="复制模板" placement="top">
  <el-button size="small" circle text @click.stop="handleDuplicate(tpl)">
    <el-icon><CopyDocument /></el-icon>
  </el-button>
</el-tooltip>
```

**改动点 3**：编辑表单基本信息区新增"模板描述"：

```vue
<el-col :span="24">
  <el-form-item label="模板描述">
    <el-input v-model="editForm.description" type="textarea" :rows="2" maxlength="200" show-word-limit placeholder="模板用途说明（可选）" />
  </el-form-item>
</el-col>
```

**改动点 4**：`EditForm` 接口增加 `description: string`；`loadTemplateDetail()` 赋值 `editForm.description = detail.description || ''`；`handleSave()` 提交 `description: editForm.description`。

**改动点 5**：新增 `handleDuplicate(tpl)` 方法：

```typescript
/** 复制模板 */
async function handleDuplicate(tpl: ReportTemplateListItem) {
  try {
    const result = await reportTemplateApi.duplicate(tpl.id)
    ElMessage.success(`已复制模板「${result.name}」`)
    await loadTemplates()
    selectedId.value = result.id
    await loadTemplateDetail(result.id)
  } catch (err: any) {
    ElMessage.error('复制模板失败：' + (err?.message || '未知错误'))
  }
}
```

---

## 五、Phase 4：验证

### 5.1 编号动态化验证脚本

```python
import sys, os
sys.path.insert(0, "backend")
os.chdir("backend")
sys.path.insert(0, "app")

# 1. _zh_num 工具函数
from app.services.functional.word_generator import _zh_num
assert [_zh_num(i) for i in range(1, 11)] == ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"]
assert _zh_num(11) == "11"  # 超出回退阿拉伯数字

# 2. 生成器仍可正常 import（无语法错误）
from app.services.functional.markdown_generator import generate_markdown_report

# 3. 用最小数据生成报告，验证编号
#    见下方完整脚本
```

### 5.2 生成报告编号结构验证

用测试 Excel（3 Sheet 新版 + 空 BUG 列表）生成 docx 与 md，检查标题文本：

```python
# md 文件直接读文本断言
content = Path(md_path).read_text(encoding="utf-8")
assert "## 一、报告基本信息" in content
assert "### 1.1 报告标识" in content

# 构造关闭 report_basic_info 的模板配置
tc = {"chapters": {"report_basic_info": False}}
# 重新生成，断言：
assert "## 一、测试执行摘要" in content  # 原"二、" → "一、"
assert "### 1.1 测试结果统计" in content  # 原"2.1" → "1.1"
```

### 5.3 描述 + 复制验证

```bash
# 后端 API 验证（用 uvicorn 起来后手动测，或直接 SQLite 库操作）
POST /templates {"name":"带描述模板","description":"测试用","config":{}} → 200，返回含 description
POST /templates/1/duplicate → 200，name="带描述模板_副本"，description 继承
GET /templates → 列表项含 description
DELETE 副本验证不影响原模板
```

### 5.4 回归验证

| 验证项 | 检查方法 | 预期 |
|--------|---------|------|
| 全章节开启编号 | 生成报告读标题 | 一~十 / 1.1~10.4 与改造前一致 |
| Word 报告可打开 | python-docx 读段落 | 无异常 |
| 旧模板数据兼容 | 读无 description 的旧模板 | description="" 不报错 |

---

## 六、遇坑预案

| 风险 | 应对 |
|------|------|
| 子章节前缀遗漏某处硬编码 | 改造后 grep `"1\.\|"2\.` 等前缀，逐一核对 |
| MySQL ALTER 失败 | 仅单列 ADD COLUMN + DEFAULT ''，若失败看具体报错 |
| 复制名称撞名 | 循环加序号 `_副本` → `_副本2` → `_副本3`… |
| description 未持久化 | 检查 create/update 两处赋值 + to_dict 两处序列化 |

回退：`git checkout -- backend/app/services/functional/word_generator.py backend/app/services/functional/markdown_generator.py backend/app/models/report_template.py backend/app/schemas/report_template.py backend/app/routers/report_template.py backend/app/database.py frontend/src/api/reportTemplate.ts frontend/src/views/functional-report/TemplateConfig.vue`

---

*本文档由 Claude Code 生成，供 prd-code 流程使用。*