Files
ftb-project-management/apps/web/components/test-case/TestCaseTab.tsx
2026-06-26 15:12:24 +08:00

274 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useMemo, useEffect } from 'react';
import { Plus, ClipboardCheck, Trash2 } from 'lucide-react';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { TestCaseRow } from './TestCaseRow';
import { TestCaseCreateModal } from './TestCaseCreateModal';
import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
import { BugCreateModal } from '@/components/bug/BugCreateModal';
import { aggregateTestCaseHours, calcTestProgress, canStartNextTestRound, copyTestCaseToRound, getNextTestRoundNo, getRequirementDeliveryStatus, getTestCaseRoundNo, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
import { calcTwoMetrics } from '@/lib/work-hours';
import { Pagination, usePagination } from '@/components/Pagination';
import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
import type { TestCaseStatus } from '@/lib/test-case';
interface Props {
versionId: string;
requirementIds: string[];
}
export function TestCaseTab({ versionId, requirementIds }: Props) {
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const { tasks: devTasks } = useDevTaskStore();
const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user);
const { categories, fetchCategories } = useTaskCategoryStore();
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
const versionCases = useMemo(
() => testCases.filter((c) => c.versionId === versionId),
[testCases, versionId],
);
const rounds = useMemo(
() => Array.from(new Set(versionCases.map((testCase) => getTestCaseRoundNo(testCase)))).sort((a, b) => a - b),
[versionCases],
);
const [activeRound, setActiveRound] = useState(1);
useEffect(() => {
if (rounds.length === 0) {
if (activeRound !== 1) setActiveRound(1);
return;
}
if (!rounds.includes(activeRound)) setActiveRound(rounds[rounds.length - 1]);
}, [rounds, activeRound]);
const activeRoundCases = useMemo(
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === activeRound),
[versionCases, activeRound],
);
const firstRoundCases = useMemo(
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === 1),
[versionCases],
);
const versionBugs = useMemo(
() => bugs.filter((bug) => bug.versionId === versionId),
[bugs, versionId],
);
const versionDevTasks = useMemo(
() => devTasks.filter((t) => reqIdSet.has(t.requirementId)),
[devTasks, reqIdSet],
);
const stats = calcTestProgress(activeRoundCases, versionBugs);
const { actual: tcActualHours } = aggregateTestCaseHours(versionCases);
const { manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]);
const effortMetrics = useMemo(
() => buildEffortSummaryMetrics({
aiEstimateHours: sumPositiveHours(versionCases, (testCase) => testCase.aiEstimateHours),
estimateHours: sumPositiveHours(versionCases, (testCase) => testCase.estimateHours),
actualHours: tcActualHours,
manhours: tcManhours,
}),
[versionCases, tcActualHours, tcManhours],
);
const canCreateNextRound = useMemo(() => canStartNextTestRound(versionCases), [versionCases]);
const nextRoundNo = useMemo(() => getNextTestRoundNo(versionCases), [versionCases]);
const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const filteredCases = useMemo(() => {
let result = activeRoundCases;
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;
}, [activeRoundCases, filterAssignee, filterStatus, debouncedKeyword]);
const orderedCases = useMemo(
() => orderByRequirementForGrouping(filteredCases, requirementIds, (testCase) => testCase.requirementId),
[filteredCases, requirementIds],
);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(orderedCases, 20);
const [showCreate, setShowCreate] = useState(false);
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelect = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
setSelectedIds(next);
};
const handleBatchDelete = () => {
if (selectedIds.size === 0) return;
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
if (hasBug) { alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。'); return; }
if (!confirm(`确定删除选中的 ${selectedIds.size} 个测试用例?`)) return;
selectedIds.forEach((id) => deleteTestCase(id));
setSelectedIds(new Set());
};
const handleRoundChange = (roundNo: number) => {
setActiveRound(roundNo);
setSelectedIds(new Set());
setPage(1);
};
const handleStartNewRound = () => {
if (!canCreateNextRound) return;
const operator = user?.name || '系统';
createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator)));
handleRoundChange(nextRoundNo);
};
const groupedByReq = useMemo(() => {
const map = new Map<string, typeof paged>();
for (const c of paged) {
const key = c.requirementId || '__none__';
const list = map.get(key) || [];
list.push(c);
map.set(key, list);
}
return map;
}, [paged]);
const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]);
const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const bugCountByCase = useMemo(() => {
const map = new Map<string, number>();
for (const b of versionBugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1);
return map;
}, [versionBugs]);
const assignees = useMemo(() => Array.from(new Set(activeRoundCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [activeRoundCases]);
const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword);
return (
<div className="space-y-3">
<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>
<div className="h-1.5 w-20 rounded-full bg-[var(--bg)] overflow-hidden shrink-0">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${stats.completionRate}%` }} />
</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">Bug通过率{stats.passRate}%</span>
{effortMetrics.map((metric) => (
<span key={metric.label} className="text-[11px] text-[var(--ink-muted)] shrink-0">
{metric.label} <span className="tabular-nums text-[var(--ink)]">{metric.value}</span>
</span>
))}
<select value={activeRound} onChange={(e) => handleRoundChange(Number(e.target.value))} 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">
{(rounds.length > 0 ? rounds : [1]).map((roundNo) => <option key={roundNo} value={roundNo}>{roundNo}</option>)}
</select>
<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>}
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select value={filterStatus} onChange={(e) => { setFilterStatus(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>
{(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(''); 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 && (
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
<Trash2 className="h-3 w-3" />{selectedIds.size}
</button>
)}
<button
onClick={handleStartNewRound}
disabled={!canCreateNextRound}
title={canCreateNextRound ? `复制第1轮用例开启第${nextRoundNo}轮测试` : '最新一轮测试用例全部测完后才能开启新一轮'}
className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-3 w-3" />
</button>
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
<Plus className="h-3 w-3" />
</button>
</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>
{!hasFilter && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline"></button>}
</div>
) : (
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = reqId === '__none__' ? null : requirementMap.get(reqId);
const reqPassed = cases.filter((c) => c.status === 'passed').length;
const deliveryStatus = req ? getRequirementDeliveryStatus(req.id, versionDevTasks) : null;
return (
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
<div className="pl-4 flex items-center">
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
</div>
<div className="flex flex-1 min-w-0 items-center gap-3 px-4 py-2">
<span className="h-2 w-2 shrink-0" />
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
{deliveryStatus ? (
<span className={`inline-flex h-5 w-24 shrink-0 items-center justify-center rounded px-1.5 text-[10px] font-medium whitespace-nowrap ${
deliveryStatus === 'submitted'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: 'bg-orange-50 text-orange-600 border border-orange-100'
}`}>
{deliveryStatus === 'submitted' ? '已提测' : '待提测'}
</span>
) : (
<span className="h-5 w-24 shrink-0" />
)}
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title || '未关联需求'}</span>
<span className="text-[11px] text-[var(--ink-muted)]">{reqPassed}/{cases.length} </span>
</div>
</div>
{cases.map((c) => (
<div key={c.id} className="flex items-center">
<div className="pl-4 flex items-center">
<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} category={categoryMap.get(c.categoryId)} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
</div>
</div>
))}
</div>
);
})
)}
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />}
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
</div>
);
}