Files
ftb-project-management/apps/web/components/bug/BugDetailDrawer.tsx
Script Generator 101a6be9b7 fix: Bug详情图片放大关闭不影响抽屉 + 仅修复人可操作
1. Lightbox 点击关闭时 stopPropagation,不会冒泡关闭 drawer
   - 点击背景关闭图片
   - 新增右上角 X 关闭按钮
   - 点击图片本身不触发关闭

2. 仅当前修复人(assigneeId)可操作 Bug:
   - 只有当前用户 === bug.assigneeId 时显示状态流转按钮和转交按钮
   - 其他人查看时显示提示"仅修复人可操作"

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

225 lines
13 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, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
import { BugStatusBadge } from './BugStatusBadge';
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 type { BugStatus } from '@/lib/bug';
const LOG_ACTION_LABEL: Record<string, string> = {
create: '创建',
status_change: '状态变更',
transfer: '转交',
resolve: '修复',
};
interface Props {
bugId: string;
onClose: () => void;
}
export function BugDetailDrawer({ bugId, onClose }: Props) {
const { bugs, changeStatus, transferBug } = useBugStore();
const { testCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const operator = user?.name || '系统';
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 [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 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-[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)]">{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 && operator === bug.assigneeId && (
<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 && operator !== bug.assigneeId && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {bug.assigneeId}</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>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== bug.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<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)]">{bug.reportedBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.createdAt.slice(0, 16).replace('T', ' ')}</span></div>
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.resolvedAt.slice(0, 16).replace('T', ' ')}</span></div>}
{bug.closedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-emerald-600">{bug.closedAt.slice(0, 16).replace('T', ' ')}</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>
)}
{/* 操作日志 */}
{bug.logs && bug.logs.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-3"></div>
<div className="space-y-2.5">
{[...bug.logs].reverse().map((log) => (
<div key={log.id} className="flex gap-2.5 text-[11px]">
<span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{log.createdAt.slice(0, 16).replace('T', ' ')}</span>
<div className="flex-1">
<span className="font-medium text-[var(--ink)]">{log.operator}</span>
<span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span>
{log.fromValue && log.toValue && (
<span className="text-[var(--ink-muted)]"> {log.fromValue} {log.toValue}</span>
)}
{log.remark && <span className="text-[var(--ink-muted)]"> ({log.remark})</span>}
</div>
</div>
))}
</div>
</div>
)}
</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>
);
}