Files
ftb-project-management/apps/web/components/bug/BugDetailDrawer.tsx

254 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 { CommentPanel } from '@/components/comment/CommentPanel';
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;
readOnly?: boolean;
}
export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false }: 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 (readOnly) return;
if (to === 'fixed') { setShowResolutionInput(true); return; }
changeStatus(bug.id, to, operator);
};
const confirmFix = () => {
if (readOnly) return;
changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined });
setShowResolutionInput(false);
};
const handleTransfer = () => {
if (readOnly) return;
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 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)]">{bug.bugNo}</span>
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--ink)]">{bug.title}</span>
</div>
<button onClick={onClose} className="shrink-0 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 space-y-4 overflow-y-auto p-5">
{/* 关联链路 */}
<div className="space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"></div>
{tc && (
<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)]">{tc.caseNo}</span>
<span className="min-w-0 truncate text-[13px] text-[var(--ink)]">{tc.title}</span>
</div>
)}
{requirement && (
<div className="flex min-w-0 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="min-w-0 truncate text-[12px] text-[var(--ink-soft)]">{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">
<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 && !readOnly && (
<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 && !readOnly && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {assigneeName}</div>
)}
{showResolutionInput && !readOnly && (
<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 && !readOnly && (
<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-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">{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-lg 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-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>
<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-lg 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} />
<CommentPanel
entityType="bug"
entityId={bug.id}
entityVersionId={bug.versionId}
versionId={bug.versionId}
readOnly={readOnly}
/>
</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>
);
}