Files
ftb-project-management/apps/web/components/test-case/TestCaseTab.tsx
Script Generator 3fc387eac5 feat: 开发任务和测试用例增加批量删除 + 单条删改
- DevTaskTab: 列表行前增加 checkbox,支持全选/批量删除
- DevTaskDetailDrawer: 顶部增加删除按钮(带确认)
- TestCaseTab: 同上,批量删除时校验关联 Bug(有 Bug 不可删)
- TestCaseDetailDrawer: 顶部增加删除按钮(有关联 Bug 时提示不可删)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 15:08:18 +08:00

204 lines
11 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, Filter, 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 { 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 { Pagination, usePagination } from '@/components/Pagination';
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);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const versionCases = useMemo(
() => testCases.filter((c) => c.versionId === versionId),
[testCases, versionId],
);
// 提测状态判断
const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]);
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const stats = calcTestProgress(versionCases);
// 筛选
const [filterAssignee, setFilterAssignee] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const filteredCases = useMemo(() => {
let result = versionCases;
if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
if (filterStatus) result = result.filter((c) => c.status === filterStatus);
return result;
}, [versionCases, filterAssignee, filterStatus]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 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 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 = () => {
if (isAllSelected) setSelectedIds(new Set());
else setSelectedIds(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));
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<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 assignees = useMemo(() => {
const names = new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[]);
return Array.from(names);
}, [versionCases]);
const hasFilter = !!(filterAssignee || filterStatus);
return (
<div className="space-y-3">
{/* 提测通知 */}
{allSubmitted && (
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-50 border border-emerald-200">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-[12px] font-medium text-emerald-700"></span>
{versionDevTasks.length > 0 && <span className="text-[11px] text-emerald-600 ml-auto">{versionDevTasks.length} </span>}
</div>
)}
{versionDevTasks.length > 0 && !allSubmitted && (
<div className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-orange-50 border border-orange-200">
<span className="text-[12px] text-orange-700">{versionDevTasks.filter((t) => t.status === 'submitted').length}/{versionDevTasks.length}</span>
</div>
)}
{/* 统计栏 */}
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4 text-[var(--accent)]" />
<span className="text-[13px] font-medium text-[var(--ink)]"> {stats.completionRate}%</span>
</div>
<div className="h-2 flex-1 rounded-full bg-[var(--bg)] overflow-hidden">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${stats.completionRate}%` }} />
</div>
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums">
{stats.total} · {stats.passed} · {stats.failed} · {stats.blocked}
</span>
<span className="text-[11px] text-[var(--ink-muted)]"> {stats.passRate}%</span>
<button onClick={() => setShowCreate(true)} className="ml-auto flex items-center gap-1 h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
<Plus className="h-3 w-3" />
</button>
</div>
{/* 筛选 */}
<div className="flex items-center gap-2 px-1">
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 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-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 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(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline"></button>}
{selectedIds.size > 0 && (
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-7 px-3 rounded-md text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
<Trash2 className="h-3 w-3" /> {selectedIds.size}
</button>
)}
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredCases.length} </span>
</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>
) : (
<>
<div className="flex items-center gap-2 px-4 py-1.5">
<input type="checkbox" checked={isAllSelected} onChange={toggleAll} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
<span className="text-[11px] text-[var(--ink-muted)]"></span>
</div>
{Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = reqId === '__none__' ? null : requirements.find((r) => r.id === 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">
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-subtle)] border-b border-[var(--line)]">
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req?.code || '通用'}</span>
<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>
{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} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} 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} onClose={() => setShowCreate(false)} />}
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { setBugForCaseId(id); }} />}
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
</div>
);
}