Files
ftb-project-management/apps/web/components/bug/BugDetailDrawer.tsx
Script Generator 52a88626d4 feat(版本): 完善计划覆盖与工作台待办
关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 13:43:18 +08:00

242 lines
14 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 { useMemo, useState } from 'react';
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
import { BugStatusBadge } from './BugStatusBadge';
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
import { FilterSelect } from '@/components/FilterSelect';
import { useBugStore } from '@/stores/useBugStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import { formatDateTime } from '@/lib/format';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
import type { EntityActivityLogEntry } from '@/lib/entity-activity-log';
import type { BugStatus } from '@/lib/bug';
const LOG_ACTION_LABEL: Record<string, string> = {
create: '创建',
status_change: '状态变更',
transfer: '转交',
resolve: '修复',
};
interface Props {
bugId: string;
onClose: () => void;
contextLabel?: string;
}
export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
const { bugs, changeStatus, transferBug } = useBugStore();
const { testCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
const bug = bugs.find((b) => b.id === bugId);
if (!bug) return null;
const tc = testCases.find((c) => c.id === bug.testCaseId);
const requirement = tc ? requirements.find((r) => r.id === tc.requirementId) : null;
const nextStatuses = BUG_ALLOWED_TRANSITIONS[bug.status];
const currentMember = user ? members.find((m) => m.id === user.id) : undefined;
const assigneeName = resolveMemberDisplayName(bug.assigneeId, members);
const reporterName = resolveMemberDisplayName(bug.reportedBy, members);
const isCurrentAssignee = currentMember
? isMemberReference(bug.assigneeId, currentMember)
: operator === assigneeName;
const [resolution, setResolution] = useState(bug.resolution || '');
const [showResolutionInput, setShowResolutionInput] = useState(false);
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [transferRemark, setTransferRemark] = useState('');
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const legacyLogEntries = useMemo<EntityActivityLogEntry[]>(() => {
return (bug.logs || []).map((log) => {
const from = log.fromValue ? resolveMemberDisplayName(log.fromValue, members) : '';
const to = log.toValue ? resolveMemberDisplayName(log.toValue, members) : '';
const change = from && to ? `${from}${to}` : '';
const remark = log.remark ? `${log.remark}` : '';
return {
id: `bug-log-${log.id}`,
occurredAt: log.createdAt,
actorId: resolveMemberDisplayName(log.operator, members),
label: LOG_ACTION_LABEL[log.action] || log.action,
summary: [change, remark].filter(Boolean).join(' ') || bug.title,
};
});
}, [bug.logs, bug.title, members]);
const handleTransition = (to: BugStatus) => {
if (to === 'fixed') { setShowResolutionInput(true); return; }
changeStatus(bug.id, to, operator);
};
const confirmFix = () => {
changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined });
setShowResolutionInput(false);
};
const handleTransfer = () => {
if (!transferTo) return;
transferBug(bug.id, transferTo, operator, transferRemark.trim() || undefined);
setShowTransfer(false);
setTransferTo('');
setTransferRemark('');
};
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)]">{bug.bugNo}</span>
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{bug.title}</span>
</div>
<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 className="flex-1 overflow-y-auto p-4 space-y-3">
{/* 关联链路 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 space-y-2">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"></div>
{tc && (
<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)]">{tc.caseNo}</span>
<span className="text-[13px] text-[var(--ink)]">{tc.title}</span>
</div>
)}
{requirement && (
<div className="flex items-center gap-2 pl-5">
<ChevronRight className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
<span className="text-[12px] text-[var(--ink-soft)]">{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">
<BugStatusBadge status={bug.status} />
<span className={`text-[10px] px-2 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
</div>
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && (
<div className="flex items-center gap-2 pt-1 flex-wrap">
<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 === 'closed' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : s === 'rejected' ? 'bg-zinc-400 text-white hover:bg-zinc-500' : 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]'}`}>
{BUG_STATUS_LABEL[s]}
</button>
))}
{bug.status !== 'closed' && bug.status !== 'rejected' && (
<button onClick={() => setShowTransfer(!showTransfer)} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
)}
</div>
)}
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {assigneeName}</div>
)}
{showResolutionInput && (
<div className="space-y-2 pt-1">
<input value={resolution} onChange={(e) => setResolution(e.target.value)} placeholder="修复说明" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" autoFocus />
<div className="flex gap-2">
<button onClick={confirmFix} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-[var(--accent)] text-white"></button>
<button onClick={() => setShowResolutionInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
{showTransfer && (
<div className="space-y-2 pt-1 border-t border-[var(--line)]">
<div className="text-[11px] text-[var(--ink-muted)]"></div>
<FilterSelect
value={transferTo || 'all'}
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
options={members.filter((m) => !isMemberReference(bug.assigneeId, m)).map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择接收人"
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
<input value={transferRemark} onChange={(e) => setTransferRemark(e.target.value)} placeholder="转交备注(可选)" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<div className="flex gap-2">
<button onClick={handleTransfer} disabled={!transferTo} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowTransfer(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</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">{bug.plannedFixAt ? formatDateTime(bug.plannedFixAt) : '待排期'}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{reporterName}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{assigneeName}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.createdAt)}</span></div>
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.resolvedAt)}</span></div>}
{bug.closedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-emerald-600">{formatDateTime(bug.closedAt)}</span></div>}
</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-2">Bug </div>
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{bug.description}</p>
</div>
{/* 截图 */}
{bug.images && bug.images.length > 0 && (
<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>
<div className="flex flex-wrap gap-2">
{bug.images.map((src, i) => (
<img key={i} src={src} alt={`截图${i + 1}`} className="h-24 w-24 object-cover rounded-lg border border-[var(--line)] cursor-pointer hover:ring-2 hover:ring-[var(--accent)]" onClick={() => setLightboxSrc(src)} />
))}
</div>
</div>
)}
{/* 修复说明 */}
{bug.resolution && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div className="text-[10px] text-emerald-700 uppercase tracking-wide mb-2"></div>
<p className="text-[12px] text-emerald-800 leading-relaxed whitespace-pre-wrap">{bug.resolution}</p>
</div>
)}
<ActivityLogPanel sourceType="bug" sourceId={bug.id} legacyEntries={legacyLogEntries} />
</div>
</div>
{/* Lightbox */}
{lightboxSrc && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/70" onClick={(e) => { e.stopPropagation(); setLightboxSrc(null); }}>
<img src={lightboxSrc} alt="大图预览" className="max-h-[85vh] max-w-[85vw] rounded-lg shadow-2xl" onClick={(e) => e.stopPropagation()} />
<button onClick={(e) => { e.stopPropagation(); setLightboxSrc(null); }} className="absolute top-4 right-4 h-8 w-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/40 text-white">
<X className="h-5 w-5" />
</button>
</div>
)}
</div>
);
}