Files
ftb-project-management/apps/web/components/test-case/TestCaseDetailDrawer.tsx
Script Generator 4ac0dab4c5 fix: 时间显示统一使用本地时区(修复UTC显示问题)
之前 ISO 时间戳直接 slice(0, 16).replace('T', ' ') 显示 UTC 时间
(北京时间晚 8 小时),现统一用 formatDateTime 转本地时区。

新增 lib/format.ts:
- formatDateTime(iso) → YYYY-MM-DD HH:mm(本地时区)
- formatDate(iso) → YYYY-MM-DD(本地时区)
- formatDateTimeShort(iso) → MM-DD HH:mm(本地时区)

替换文件:
- versions/[id]/page.tsx (实际开始/截止)
- workspace/page.tsx (计划任务时间)
- BugDetailDrawer.tsx (基本信息+操作日志)
- TestCaseDetailDrawer.tsx (执行时间)
- DevTaskDetailDrawer.tsx (开始/提测时间)
- PlanDetailDrawer.tsx (计划/实际/完成时间)
- PlanTab.tsx (计划任务列表时间)

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

201 lines
12 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, ArrowRightLeft } 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 { useMemberStore } from '@/stores/useMemberStore';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
import { calcActualHoursByDates } from '@/lib/dev-task';
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;
}
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel }: Props) {
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
const { bugs } = useBugStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
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-2xl flex flex-col" 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 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">
{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>
)}
<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 && (
<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>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-7 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== tc.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<button onClick={() => { 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 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>
{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">{calcActualHoursByDates(tc.startedAt, tc.completedAt)}h</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>
);
}