Files
ftb-project-management/apps/web/components/test-case/TestCaseTab.tsx
Script Generator 452cbb4671 fix(ui): 工具栏合并为一行 — 进度+筛选+操作紧凑布局
开发任务和测试用例列表顶部从三行(进度/筛选/多选)
合并为一行工具栏:左侧进度条和统计,中间筛选下拉,右侧批量删除+新建。
多选 checkbox 内嵌到分组头和行内。

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

189 lines
10 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, 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 = () => {
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));
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(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]);
const hasFilter = !!(filterAssignee || filterStatus);
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" />
<span className="text-[12px] font-medium text-emerald-700"></span>
<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 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-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">{stats.passRate}%</span>
<span className="w-px h-4 bg-[var(--line)] mx-1 shrink-0" />
<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(''); 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={() => 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 : 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)]">
<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)]" />
<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>
);
}