feat(test-case+bug): 测试用例模块 + Bug 模块完整实现

需求验收体系:
- 测试用例:五态状态机(待执行/执行中/通过/失败/阻塞)
- Bug:六态状态机(待修复/修复中/已修复/验证中/已关闭/已拒绝)
- Bug 通过测试用例间接关联需求(不冗余存版本)
- Bug 默认修复人 = 关联需求的开发任务负责人
- 测试进度 = 已执行用例/总用例, 通过率 = 通过/已执行

UI:
- 版本详情页新增"测试用例" Tab + "BUG" Tab
- 测试用例:统计栏+筛选+按需求分组列表+详情抽屉+提BUG入口
- Bug:统计栏+筛选+列表+详情抽屉(链式跳转用例→需求)
- 概览胶囊"测试"阶段进度联动
- "与我相关"新增测试用例/Bug分组

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-12 13:11:08 +08:00
parent 75b0d12fa0
commit 7fe3a3854a
16 changed files with 1296 additions and 5 deletions

69
apps/web/lib/test-case.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { Priority } from './derive';
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
export interface TestCase {
id: string;
caseNo: string;
requirementId: string;
title: string;
description?: string;
priority: Priority;
assigneeId?: string;
status: TestCaseStatus;
executedAt?: string;
executedBy?: string;
failReason?: string;
blockReason?: string;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
pending: '待执行',
running: '执行中',
passed: '通过',
failed: '失败',
blocked: '阻塞',
};
export const TEST_CASE_STATUS_COLOR: Record<TestCaseStatus, string> = {
pending: 'bg-zinc-100 text-zinc-600',
running: 'bg-blue-50 text-blue-600',
passed: 'bg-emerald-50 text-emerald-600',
failed: 'bg-red-50 text-red-600',
blocked: 'bg-orange-50 text-orange-600',
};
export const TC_ALLOWED_TRANSITIONS: Record<TestCaseStatus, TestCaseStatus[]> = {
pending: ['running'],
running: ['passed', 'failed', 'blocked'],
passed: ['running'],
failed: ['running'],
blocked: ['running'],
};
export function canTcTransition(from: TestCaseStatus, to: TestCaseStatus): boolean {
return TC_ALLOWED_TRANSITIONS[from].includes(to);
}
export function generateCaseNo(existingCases: TestCase[]): string {
const maxNum = existingCases.reduce((max, c) => {
const num = parseInt(c.caseNo.replace('TC-', ''), 10);
return isNaN(num) ? max : Math.max(max, num);
}, 0);
return `TC-${String(maxNum + 1).padStart(3, '0')}`;
}
export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
const total = cases.length;
if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
const passed = cases.filter((c) => c.status === 'passed').length;
const failed = cases.filter((c) => c.status === 'failed').length;
const blocked = cases.filter((c) => c.status === 'blocked').length;
const executed = passed + failed + blocked;
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
const completionRate = Math.round((executed / total) * 100);
return { total, executed, passed, failed, blocked, passRate, completionRate };
}