Files
ftb-project-management/apps/web/components/test-case/TestCaseDetailDrawer.tsx

344 lines
20 KiB
TypeScript
Raw Permalink 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, ArrowRightLeft } from 'lucide-react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
import { CommentPanel } from '@/components/comment/CommentPanel';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { CategoryChip } from '@/components/dev-task/CategoryChip';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL, canStartTestCase, getTestCaseActualHours, needsTestCaseClaim } from '@/lib/test-case';
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
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;
contextLabel?: string;
readOnly?: boolean;
}
function defaultPlanStartLocal(): string {
const d = new Date();
d.setHours(9, 0, 0, 0);
return isoToLocal(d.toISOString());
}
function defaultPlanEndLocal(): string {
const d = new Date();
d.setHours(10, 0, 0, 0);
return isoToLocal(d.toISOString());
}
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel, readOnly = false }: Props) {
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
const { bugs } = useBugStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [showPlanInput, setShowPlanInput] = useState(false);
const [planStartLocal, setPlanStartLocal] = useState('');
const [planEndLocal, setPlanEndLocal] = useState('');
const tc = testCases.find((c) => c.id === testCaseId);
if (!tc) return null;
const requirement = requirements.find((r) => r.id === tc.requirementId);
const category = categories.find((c) => c.id === tc.categoryId);
const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
const executorEstimateHours = typeof tc.estimateHours === 'number' && tc.estimateHours > 0 ? tc.estimateHours : undefined;
const aiEstimateHours = typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0 ? tc.aiEstimateHours : undefined;
const actualHours = getTestCaseActualHours(tc);
const planDisplay = tc.plannedTestAt && tc.plannedEndAt
? `${formatDateTime(tc.plannedTestAt)}${formatDateTime(tc.plannedEndAt)}`
: tc.plannedTestAt ? formatDateTime(tc.plannedTestAt) : '-';
const currentUserName = user?.name || '';
const needsClaim = needsTestCaseClaim(tc);
const startReady = canStartTestCase(tc);
const visibleNextStatuses = nextStatuses.filter((status) => status !== 'running' || startReady);
const planStartISO = localToISO(planStartLocal);
const planEndISO = localToISO(planEndLocal);
const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planEndISO > planStartISO);
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
const openPlanInput = () => {
if (readOnly) return;
setPlanStartLocal(tc.plannedTestAt ? isoToLocal(tc.plannedTestAt) : defaultPlanStartLocal());
setPlanEndLocal(tc.plannedEndAt ? isoToLocal(tc.plannedEndAt) : defaultPlanEndLocal());
setShowPlanInput(true);
};
const handleSavePlan = () => {
if (readOnly) return;
const assigneeId = tc.assigneeId || currentUserName;
if (!assigneeId) {
alert('领取前需要先登录或选择负责人');
return;
}
if (!planStartBeforeEnd || planEstimateHours <= 0 || !planStartISO || !planEndISO) return;
updateTestCase(tc.id, {
assigneeId,
plannedTestAt: planStartISO,
plannedEndAt: planEndISO,
estimateHours: planEstimateHours,
});
setShowPlanInput(false);
};
const [failReason, setFailReason] = useState('');
const [blockReason, setBlockReason] = useState('');
const [showFailInput, setShowFailInput] = useState(false);
const [showBlockInput, setShowBlockInput] = useState(false);
const handleTransition = (to: TestCaseStatus) => {
if (readOnly) return;
if (to === 'running' && !startReady) {
openPlanInput();
return;
}
if (to === 'failed') { setShowFailInput(true); return; }
if (to === 'blocked') { setShowBlockInput(true); return; }
changeStatus(tc.id, to);
};
const confirmFail = () => {
if (readOnly) return;
changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
setShowFailInput(false);
setFailReason('');
};
const confirmBlock = () => {
if (readOnly) return;
changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
setShowBlockInput(false);
setBlockReason('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
<div className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg)]" onClick={(e) => e.stopPropagation()}>
{contextLabel && (
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
</div>
)}
<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 min-w-0 flex-1 items-center gap-2 pr-3">
<span className="shrink-0 text-[12px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--ink)]">{tc.title}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{!readOnly && tc.status !== 'passed' && (
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
)}
{!readOnly && <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>
{/* 转交 */}
{showTransfer && !readOnly && (
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
<span className="text-[11px] text-[var(--ink-muted)] shrink-0"></span>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={members.filter((m) => m.name !== tc.assigneeId).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择人员"
className="flex-1"
labelClassName="max-w-[calc(100%-20px)]"
/>
<button onClick={() => { if (readOnly) return; if (transferTo) { updateTestCase(tc.id, { assigneeId: transferTo }); setShowTransfer(false); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
)}
<div className="flex-1 space-y-4 overflow-y-auto p-5">
{/* 关联需求 */}
{requirement && (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5"></div>
<div className="flex min-w-0 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="min-w-0 truncate text-[13px] text-[var(--ink)]">{requirement.title}</span>
</div>
</div>
)}
{/* 状态 & 操作 */}
<div className="space-y-4 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"> & </div>
<div className="flex flex-wrap items-center gap-2">
<TestCaseStatusBadge status={tc.status} />
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]"> {tc.executedAt}</span>}
</div>
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && !readOnly && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{visibleNextStatuses.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>
)}
{tc.status === 'pending' && !startReady && !showPlanInput && !readOnly && (
<div className="flex items-center gap-2 pt-1">
<button
onClick={openPlanInput}
disabled={needsClaim && !currentUserName}
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50"
>
{needsClaim ? '领取并填写计划' : '填写计划'}
</button>
<span className="text-[11px] text-[var(--ink-muted)]">
{needsClaim ? '领取时必须填写计划开始和计划结束' : '开始测试前需要补齐计划开始和计划结束'}
</span>
</div>
)}
{showPlanInput && !readOnly && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
<div className="text-[11px] font-medium text-orange-700">
{needsClaim ? '领取并填写计划' : '填写计划'}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-[11px] text-orange-700"></label>
<WorkDateTimePicker
value={planStartLocal}
onChange={setPlanStartLocal}
placeholder="选择计划开始"
defaultHour={9}
className="bg-white"
/>
</div>
<div>
<label className="mb-1 block text-[11px] text-orange-700"></label>
<WorkDateTimePicker
value={planEndLocal}
onChange={setPlanEndLocal}
placeholder="选择计划结束"
defaultHour={10}
popoverAlign="right"
className="bg-white"
/>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-orange-700">
{planEstimateHours > 0 ? formatWorkHours(planEstimateHours) : '请选择有效起止时间'}
</span>
<div className="flex gap-2">
<button onClick={handleSavePlan} disabled={!planStartBeforeEnd || planEstimateHours <= 0} className="h-7 px-3 rounded text-[11px] font-medium bg-[var(--accent)] text-white disabled:opacity-50"></button>
<button onClick={() => setShowPlanInput(false)} className="h-7 px-2 text-[11px] text-orange-700"></button>
</div>
</div>
</div>
)}
{showFailInput && !readOnly && (
<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 && !readOnly && (
<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-lg 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-1 gap-2 text-[12px] sm:grid-cols-2 sm:gap-x-4 [&>div]:min-w-0 [&>div]:rounded-md [&>div]:bg-[var(--bg-subtle)] [&>div]:px-3 [&>div]:py-2 [&>div]:leading-5">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{planDisplay}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
<div className="flex items-center gap-1.5"><span className="text-[var(--ink-muted)]"></span><CategoryChip category={category} /></div>
<div><span className="text-[var(--ink-muted)]"></span><span className={`font-medium ${needsClaim ? 'text-orange-600' : 'text-[var(--ink)]'}`}>{needsClaim ? '待领取' : tc.assigneeId}</span></div>
<div><span className="text-[var(--ink-muted)]">AI </span><span className="text-[var(--ink)] font-medium">{aiEstimateHours ? formatWorkHours(aiEstimateHours) : '—'}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{executorEstimateHours ? formatWorkHours(executorEstimateHours) : '待负责人填写'}</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>
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.startedAt)}</span></div>}
{tc.completedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(tc.completedAt)}</span></div>}
{tc.startedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
</div>
</div>
{/* 用例描述 */}
{tc.description && (
<div className="rounded-lg 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-lg 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 && !readOnly && (
<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>
<ActivityLogPanel sourceType="test_case" sourceId={tc.id} />
<CommentPanel
entityType="test_case"
entityId={tc.id}
entityVersionId={tc.versionId}
versionId={tc.versionId}
readOnly={readOnly}
/>
</div>
</div>
</div>
);
}