核心变更: - DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算 - 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因 - 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径) - 新建 lib/dev-task-transitions.ts:状态切换守卫 - 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入) - 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配 - 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo - 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天) 新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import type { Priority } from './derive';
|
||
import { calcWorkHours, type TimeInterval } from './work-hours';
|
||
|
||
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
|
||
|
||
export interface TestCase {
|
||
id: string;
|
||
caseNo: string;
|
||
versionId: string;
|
||
requirementId?: string;
|
||
title: string;
|
||
description?: string;
|
||
priority: Priority;
|
||
assigneeId?: string;
|
||
status: TestCaseStatus;
|
||
startedAt?: string;
|
||
completedAt?: string;
|
||
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 };
|
||
}
|
||
|
||
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||
if (!tc.startedAt) return 0;
|
||
const end = tc.completedAt ?? now.toISOString();
|
||
return calcWorkHours(tc.startedAt, end);
|
||
}
|
||
|
||
export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number {
|
||
let sum = 0;
|
||
for (const c of cases) sum += getTestCaseActualHours(c, now);
|
||
return Math.round(sum * 2) / 2;
|
||
}
|
||
|
||
/**
|
||
* 抽取每条已开始用例的 [startedAt, completedAt ?? now] 时间区间
|
||
* 用于双口径耗时统计(calcTwoMetrics)
|
||
*/
|
||
export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): TimeInterval[] {
|
||
const out: TimeInterval[] = [];
|
||
const nowIso = now.toISOString();
|
||
for (const c of cases) {
|
||
if (!c.startedAt) continue;
|
||
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso });
|
||
}
|
||
return out;
|
||
}
|