Files
ftb-project-management/apps/web/components/test-case/TestCaseTab.tsx
Script Generator e4f3817719 feat: 提测通知 + 测试全通过自动完成开发任务
1. TestCaseTab 显示提测状态横幅:
   - 绿色:开发已全部提测,可以开始测试
   - 橙色:开发进行中,显示提测进度 (x/y)

2. TestCase 全部通过 + Bug 全关闭时,自动将版本下所有
   submitted 状态的 DevTask 流转为 done(无需手动操作)

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

162 lines
8.7 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 } 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 } = 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' || t.status === 'done');
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 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' || t.status === 'done').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>}
<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>
) : (
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
const req = 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) => (
<TestCaseRow key={c.id} testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
))}
</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>
);
}