'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 { FilterSelect } from '@/components/FilterSelect'; 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 { Bug } from '@/lib/bug'; import type { DevTask } from '@/lib/dev-task'; import type { Requirement } from '@/lib/requirement'; import type { TestCase } from '@/lib/test-case'; import type { TestCaseStatus } from '@/lib/test-case'; interface Props { versionId: string; requirementIds: string[]; readOnly?: boolean; versionCases?: TestCase[]; versionBugs?: Bug[]; versionDevTasks?: DevTask[]; versionRequirements?: Requirement[]; } const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__'; function getTestCaseGroupKey(testCase: { requirementId?: string; requirementName?: string }): string { return testCase.requirementId || `${NO_REQUIREMENT_GROUP_PREFIX}${testCase.requirementName || '未命名需求'}`; } function getNoRequirementGroupName(groupKey: string): string { return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求'; } export function TestCaseTab({ versionId, requirementIds, readOnly = false, versionCases: scopedVersionCases, versionBugs: scopedVersionBugs, versionDevTasks: scopedVersionDevTasks, versionRequirements, }: 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(() => { if (!scopedVersionCases) fetchTestCases(); }, [fetchTestCases, scopedVersionCases]); useEffect(() => { if (!scopedVersionBugs) fetchBugs(); }, [fetchBugs, scopedVersionBugs]); useEffect(() => { fetchCategories(); }, [fetchCategories]); const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); const fallbackVersionCases = useMemo( () => testCases.filter((c) => c.versionId === versionId), [testCases, versionId], ); const versionCases = scopedVersionCases ?? fallbackVersionCases; 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 fallbackVersionBugs = useMemo( () => bugs.filter((bug) => bug.versionId === versionId), [bugs, versionId], ); const versionBugs = scopedVersionBugs ?? fallbackVersionBugs; const fallbackVersionDevTasks = useMemo( () => devTasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))), [devTasks, reqIdSet, versionId], ); const versionDevTasks = scopedVersionDevTasks ?? fallbackVersionDevTasks; 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(null); const [bugForCaseId, setBugForCaseId] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const toggleSelect = (id: string) => { if (readOnly) return; const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); setSelectedIds(next); }; const handleBatchDelete = () => { if (readOnly) return; 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 (readOnly) return; if (!canCreateNextRound) return; const operator = user?.name || '系统'; createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator))); handleRoundChange(nextRoundNo); }; const groupedByReq = useMemo(() => { const map = new Map(); for (const c of paged) { const key = getTestCaseGroupKey(c); const list = map.get(key) || []; list.push(c); map.set(key, list); } return map; }, [paged]); const visibleRequirements = versionRequirements ?? requirements; const requirementMap = useMemo(() => new Map(visibleRequirements.map((r) => [r.id, r])), [visibleRequirements]); const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); const categoryLabelWidthEm = useMemo( () => Math.max(4, ...categories.map((category) => category.name.length)) + 1, [categories], ); const bugCountByCase = useMemo(() => { const map = new Map(); 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 (
{stats.completionRate}%
{stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} 无 Bug 通过率 {stats.passRate}% {effortMetrics.map((metric) => ( {metric.label} {metric.value} ))} handleRoundChange(Number(value))} options={(rounds.length > 0 ? rounds : [1]).map((roundNo) => ({ value: String(roundNo), label: `第${roundNo}轮` }))} showAllOption={false} /> { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }} options={[ ...(user?.name ? [{ value: user.name, label: '我的' }] : []), ...assignees.filter((a) => a !== user?.name).map((a) => ({ value: a, label: a })), ]} allLabel="负责人" /> { setFilterStatus(value === 'all' ? '' : value); setPage(1); }} options={(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([value, label]) => ({ value, label }))} allLabel="状态" /> {hasFilter && }
{selectedIds.size > 0 && !readOnly && ( )} {!readOnly && ( <> )}
{filteredCases.length === 0 ? (

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

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