Files
ftb-project-management/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
Script Generator c7bccd7819 fix: 测试用例提Bug按钮 + 全局侧边栏阴影统一
1. 测试用例"提 BUG"按钮接入工作台:
   - TestCaseDetailDrawer 传入 onCreateBug callback
   - 点击"提 BUG"关闭测试详情,弹出 BugCreateModal
   - 创建后 Bug 自动关联该测试用例

2. 全局侧边弹窗阴影统一为 shadow-2xl:
   - PlanDetailDrawer: shadow-2xl ✓
   - DevTaskDetailDrawer: shadow-[var(--shadow-lg)] → shadow-2xl
   - TestCaseDetailDrawer: shadow-[var(--shadow-lg)] → shadow-2xl
   - BugDetailDrawer: shadow-[var(--shadow-lg)] → shadow-2xl
   - 所有 Drawer 样式一致,阴影明显

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

231 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, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil, ArrowRightLeft } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours, calcActualHoursByDates } from '@/lib/dev-task';
import type { DevTaskStatus } from '@/lib/dev-task';
interface Props {
taskId: string;
allTaskIds: string[];
onClose: () => void;
}
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) {
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const task = tasks.find((t) => t.id === taskId);
if (!task) return null;
const category = categories.find((c) => c.id === task.categoryId);
const requirement = requirements.find((r) => r.id === task.requirementId);
const allTasks = tasks.filter((t) => allTaskIds.includes(t.id));
const predecessors = (task.predecessorIds || []).map((id) => allTasks.find((t) => t.id === id)).filter(Boolean);
const nextStatuses = ALLOWED_TRANSITIONS[task.status] || [];
const [blockReason, setBlockReason] = useState(task.blockReason || '');
const [showBlockInput, setShowBlockInput] = useState(false);
const handleTransition = (to: DevTaskStatus) => {
const result = changeStatus(task.id, to);
if (!result.ok) alert(result.message);
};
const handleBlock = () => {
if (!blockReason.trim()) return;
setBlocked(task.id, true, blockReason.trim());
setShowBlockInput(false);
};
const handleUnblock = () => {
setBlocked(task.id, 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()}>
{/* 顶栏 */}
<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)]">{task.taskNo}</span>
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
</div>
<div className="flex items-center gap-1">
{task.status !== 'submitted' && (
<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 (confirm('确定删除此任务?')) { deleteTask(task.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 !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<button onClick={() => { if (transferTo) { updateTask(task.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">
{/* 卡片1关联需求 */}
{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>
)}
{/* 卡片2状态 & 操作 */}
<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">
<span className={`inline-flex items-center rounded-lg px-3 py-1.5 text-[13px] font-semibold ${DEV_TASK_STATUS_COLOR[task.status]}`}>
{DEV_TASK_STATUS_LABEL[task.status]}
</span>
{task.isBlocked && (
<span className="inline-flex items-center gap-1 text-[11px] text-red-600 bg-red-50 border border-red-200 px-2 py-1 rounded-lg">
<AlertTriangle className="h-3 w-3" />
</span>
)}
</div>
{/* 流转按钮 */}
{nextStatuses.length > 0 && (
<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 bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors">
{DEV_TASK_STATUS_LABEL[s]}
</button>
))}
</div>
)}
{/* 阻塞管理 */}
<div className="pt-2 border-t border-[var(--line)]">
{task.isBlocked ? (
<div className="space-y-2">
<div className="flex items-start gap-1.5 text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{task.blockReason}</span>
</div>
<button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline"></button>
</div>
) : (
showBlockInput ? (
<div className="flex gap-2">
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} 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={handleBlock} disabled={!blockReason.trim()} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
) : (
<button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline"></button>
)
)}
</div>
</div>
{/* 卡片3基本信息 */}
<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 className="flex items-center gap-2">
<Tag className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<CategoryChip category={category} />
</div>
<div className="flex items-center gap-2">
<User className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.assigneeId}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.priority}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{formatHours(task.estimateHours)}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)] font-medium">{task.startDate ? formatHours(calcActualHoursByDates(task.startDate, task.completedAt)) : '-'}</span>
</div>
{task.startDate && (
<div className="flex items-center gap-2">
<Play className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{task.startDate.slice(0, 16).replace('T', ' ')}</span>
</div>
)}
{task.dueDate && (
<div className="flex items-center gap-2">
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{task.dueDate}</span>
</div>
)}
{task.completedAt && (
<div className="flex items-center gap-2">
<Calendar className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="text-[var(--ink-muted)]"></span>
<span className="text-emerald-600 font-medium">{task.completedAt.slice(0, 16).replace('T', ' ')}</span>
</div>
)}
</div>
{task.description && (
<div className="mt-3 pt-3 border-t border-[var(--line)]">
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed">{task.description}</p>
</div>
)}
</div>
{/* 卡片4前置任务 */}
{predecessors.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="space-y-2">
{predecessors.map((p: any) => (
<div key={p.id} className="flex items-center gap-2 text-[12px] px-2.5 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
<Link2 className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="font-mono text-[var(--ink-muted)]">{p.taskNo}</span>
<span className="text-[var(--ink)] flex-1 truncate">{p.title}</span>
<StatusBadge status={p.status} />
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}