Files
ftb-project-management/apps/web/components/dev-task/DevTaskDetailDrawer.tsx
Script Generator d619846b93 fix(dev-task): 体验优化 — 新建弹窗默认值+详情卡片分区+筛选分页+胶囊联动
- 新建弹窗:负责人默认当前用户,提示版本截止日期,超期需填写原因
- 详情抽屉:重写为卡片分区布局,展示关联需求,状态流转按钮醒目化
- 详情实时更新:改为从 store 实时读取而非 prop 快照
- 列表增加筛选:负责人/状态/阻塞/任务类型 + 分页(每页20条)
- 工时展示:列表行显示 实际Xh / 预计Yh 格式
- 概览胶囊联动:dev 阶段进度由 DevTask 完成度驱动
- 概览统计卡片:替换 mock 为真实 DevTask 数据(待开发/开发中/阻塞中)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 18:51:33 +08:00

206 lines
10 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 } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
import { WorklogPanel } from './WorklogPanel';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } 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 } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore();
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-[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)]">{task.taskNo}</span>
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.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">
{/* 卡片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.actualHours > 0 ? `${task.actualHours}h` : '-'}</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}</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>
)}
{/* 卡片5工时记录 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<WorklogPanel taskId={task.id} />
</div>
</div>
</div>
</div>
);
}