feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化

核心变更:
- 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>
This commit is contained in:
Script Generator
2026-06-16 17:44:59 +08:00
parent 4ac0dab4c5
commit 1f5bed0c79
25 changed files with 1192 additions and 221 deletions

View File

@@ -1,7 +1,9 @@
'use client';
import { memo } from 'react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { calcActualHoursByDates } from '@/lib/dev-task';
import { getTestCaseActualHours } from '@/lib/test-case';
import { formatWorkHours } from '@/lib/work-hours';
import type { TestCase } from '@/lib/test-case';
interface Props {
@@ -17,20 +19,23 @@ const PRIORITY_DOT: Record<string, string> = {
P3: 'bg-zinc-300',
};
export function TestCaseRow({ testCase, bugCount, onClick }: Props) {
function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) {
const actualHours = getTestCaseActualHours(testCase);
return (
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
<TestCaseStatusBadge status={testCase.status} />
{testCase.startedAt && (
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-10 text-right">{calcActualHoursByDates(testCase.startedAt, testCase.completedAt)}h</span>
{actualHours > 0 && (
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
)}
{bugCount > 0 && (
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">{bugCount} Bug</span>
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded shrink-0">{bugCount} Bug</span>
)}
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{testCase.assigneeId || '-'}</span>
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{testCase.assigneeId || '-'}</span>
</div>
);
}
export const TestCaseRow = memo(TestCaseRowImpl);

View File

@@ -11,8 +11,11 @@ import { TestCaseRow } from './TestCaseRow';
import { TestCaseCreateModal } from './TestCaseCreateModal';
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import type { TestCaseStatus } from '@/lib/test-case';
interface Props {
@@ -30,27 +33,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
const versionCases = useMemo(
() => testCases.filter((c) => c.versionId === versionId),
[testCases, versionId],
);
// 提测状态
const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]);
const versionDevTasks = useMemo(
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
[devTasks, reqIdSet],
);
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const stats = calcTestProgress(versionCases);
const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
// 筛选
const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const filteredCases = useMemo(() => {
let result = versionCases;
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword));
return result;
}, [versionCases, filterAssignee, filterStatus]);
}, [versionCases, filterAssignee, filterStatus, debouncedKeyword]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
@@ -58,18 +67,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
// 批量选择
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id));
const toggleSelect = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedIds(next);
};
const toggleAll = () => {
setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((c) => c.id)));
};
const handleBatchDelete = () => {
if (selectedIds.size === 0) return;
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
@@ -79,7 +83,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
setSelectedIds(new Set());
};
// 按需求分组
const groupedByReq = useMemo(() => {
const map = new Map<string, typeof paged>();
for (const c of paged) {
@@ -91,12 +94,18 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
return map;
}, [paged]);
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
const bugCountByCase = useMemo(() => {
const map = new Map<string, number>();
for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
return map;
}, [bugs]);
const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
const hasFilter = !!(filterAssignee || filterStatus);
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
return (
<div className="space-y-3">
{/* 提测通知 */}
{allSubmitted && (
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
@@ -110,7 +119,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
</div>
)}
{/* 工具栏:进度 + 筛选 + 操作 一行 */}
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)] flex-wrap">
<ClipboardCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="text-[12px] font-medium text-[var(--ink)] shrink-0">{stats.completionRate}%</span>
@@ -119,9 +127,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
</div>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.total} · {stats.passed} · {stats.failed}</span>
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">{stats.passRate}%</span>
{tcManhours > 0 && (
<span className="text-[11px] text-[var(--ink-muted)] shrink-0"> {formatWorkHours(tcCalendarHours)} / {formatWorkHours(tcManhours)}</span>
)}
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-6 rounded border border-[var(--line)] bg-[var(--bg-card)] px-1.5 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{user?.name && <option value={user.name}></option>}
@@ -131,7 +143,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<option value=""></option>
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0"></button>}
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0"></button>}
<div className="ml-auto flex items-center gap-2 shrink-0">
{selectedIds.size > 0 && (
@@ -145,7 +157,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
</div>
</div>
{/* 列表 */}
{filteredCases.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
@@ -153,7 +164,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
</div>
) : (
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = reqId === '__none__' ? null : requirements.find((r) => r.id === reqId);
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
const reqPassed = cases.filter((c) => c.status === 'passed').length;
return (
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
@@ -169,7 +180,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
</div>
<div className="flex-1 min-w-0">
<TestCaseRow testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
<TestCaseRow testCase={c} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
</div>
</div>
))}