'use client'; import { useState, useMemo, useEffect } from 'react'; import { Plus, ClipboardCheck, CheckCircle2, 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, 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 { versionId: string; requirementIds: string[]; } export function TestCaseTab({ versionId, requirementIds }: Props) { const { testCases, fetchTestCases, 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 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 { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(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, debouncedKeyword]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20); const [showCreate, setShowCreate] = useState(false); const [selectedCaseId, setSelectedCaseId] = useState(null); const [bugForCaseId, setBugForCaseId] = useState(null); const [selectedIds, setSelectedIds] = useState>(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 groupedByReq = useMemo(() => { const map = new Map(); 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(); 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 || debouncedKeyword); return (
{allSubmitted && (
开发已全部提测,可以开始测试 {versionDevTasks.length} 个任务已交付
)} {versionDevTasks.length > 0 && !allSubmitted && (
开发进行中({versionDevTasks.filter((t) => t.status === 'submitted').length}/{versionDevTasks.length} 已提测)
)}
{stats.completionRate}%
{stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)} 通过率{stats.passRate}% {tcManhours > 0 && ( 日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)} )} { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> {hasFilter && }
{selectedIds.size > 0 && ( )}
{filteredCases.length === 0 ? (

{hasFilter ? '没有匹配的用例' : '暂无测试用例'}

{!hasFilter && }
) : ( Array.from(groupedByReq.entries()).map(([reqId, cases]) => { const req = reqId === '__none__' ? null : requirementMap.get(reqId); const reqPassed = cases.filter((c) => c.status === 'passed').length; return (
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)]" /> {req?.code || '通用'} {req?.title || '未关联需求'} {reqPassed}/{cases.length} 通过
{cases.map((c) => (
toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
setSelectedCaseId(c.id)} />
))}
); }) )} {total > 20 && } {showCreate && setShowCreate(false)} />} {selectedCaseId && setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />} {bugForCaseId && setBugForCaseId(null)} />}
); }