Files
ftb-project-management/apps/web/components/test-case/TestCaseDetailDrawer.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

170 lines
9.9 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 } from 'react';
import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2 } from 'lucide-react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import type { TestCaseStatus } from '@/lib/test-case';
interface Props {
testCaseId: string;
onClose: () => void;
onCreateBug?: (testCaseId: string) => void;
}
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props) {
const { testCases, changeStatus, deleteTestCase } = useTestCaseStore();
const { bugs } = useBugStore();
const { requirements } = useRequirementStore();
const tc = testCases.find((c) => c.id === testCaseId);
if (!tc) return null;
const requirement = requirements.find((r) => r.id === tc.requirementId);
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
const [failReason, setFailReason] = useState('');
const [blockReason, setBlockReason] = useState('');
const [showFailInput, setShowFailInput] = useState(false);
const [showBlockInput, setShowBlockInput] = useState(false);
const handleTransition = (to: TestCaseStatus) => {
if (to === 'failed') { setShowFailInput(true); return; }
if (to === 'blocked') { setShowBlockInput(true); return; }
changeStatus(tc.id, to);
};
const confirmFail = () => {
changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
setShowFailInput(false);
setFailReason('');
};
const confirmBlock = () => {
changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
setShowBlockInput(false);
setBlockReason('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-[var(--shadow-lg)] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => { if (relatedBugs.length > 0) { alert('该用例有关联 Bug无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* 关联需求 */}
{requirement && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5"></div>
<div className="flex items-center gap-2">
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
<span className="text-[13px] text-[var(--ink)]">{requirement.title}</span>
</div>
</div>
)}
{/* 状态 & 操作 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"> & </div>
<div className="flex items-center gap-3">
<TestCaseStatusBadge status={tc.status} />
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]"> {tc.executedAt}</span>}
</div>
{nextStatuses.length > 0 && !showFailInput && !showBlockInput && (
<div className="flex items-center gap-2 pt-1">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{nextStatuses.map((s) => (
<button key={s} onClick={() => handleTransition(s)} className={`h-8 px-4 rounded-lg text-[12px] font-medium transition-colors ${s === 'passed' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : s === 'failed' ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]'}`}>
{TEST_CASE_STATUS_LABEL[s]}
</button>
))}
</div>
)}
{showFailInput && (
<div className="flex gap-2 pt-1">
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="失败原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white"></button>
<button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
)}
{showBlockInput && (
<div className="flex gap-2 pt-1">
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmBlock(); }} placeholder="阻塞原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
<button onClick={confirmBlock} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-orange-500 text-white"></button>
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
)}
{tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.failReason}</div>}
{tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />{tc.blockReason}</div>}
</div>
{/* 基本信息 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3"></div>
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
</div>
</div>
{/* 用例描述 */}
{tc.description && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2"> & </div>
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{tc.description}</p>
</div>
)}
{/* 关联 Bug + 提BUG按钮 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"> Bug ({relatedBugs.length})</div>
{tc.status === 'failed' && onCreateBug && (
<button onClick={() => onCreateBug(tc.id)} className="flex items-center gap-1 h-7 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
<BugIcon className="h-3 w-3" /> BUG
</button>
)}
</div>
{relatedBugs.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"> Bug</p>
) : (
<div className="space-y-2">
{relatedBugs.map((bug) => (
<div key={bug.id} className="flex items-center gap-2 text-[12px] px-2.5 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
<span className="font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
<span className="text-[var(--ink)] flex-1 truncate">{bug.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
<BugStatusBadge status={bug.status} />
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}