feat: 与我相关点击打开侧边详情 + 开发/测试转交功能

一、工作台交互改为侧边栏详情(点击卡片打开):
- 调研/产品/UI:新建 PlanDetailDrawer
  - 子任务勾选、进度条、上传成果(链接/文件)
  - 开始/提交完成操作、转交功能
- 开发任务:复用 DevTaskDetailDrawer
  - 状态流转、阻塞管理、前置任务等完整功能
- 测试用例:复用 TestCaseDetailDrawer
  - 测试步骤、预期结果、关联Bug、提Bug
- Bug:复用 BugDetailDrawer
  - 描述、截图、操作日志、状态转换

二、开发任务增加转交功能:
- DevTaskDetailDrawer 顶部增加转交按钮
- 从 members 列表选择新负责人
- 更新 assigneeId

三、测试用例增加转交功能:
- TestCaseDetailDrawer 顶部增加转交按钮
- 人员离职等场景可转交

四、版本详情同步:
- 所有 Drawer 改动同时生效于版本详情页
  (因为复用同一组件)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 17:30:16 +08:00
parent 10131f9db7
commit 98251f1c38
4 changed files with 320 additions and 73 deletions

View File

@@ -13,6 +13,10 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
import type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer';
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
@@ -39,15 +43,16 @@ const PLAN_STATUS_LABEL: Record<string, string> = { pending: '未开始', in_pro
export default function WorkspacePage() {
const router = useRouter();
const { overview, fetchOverview } = useProductStore();
const { plans, fetchPlans, updatePlan } = useVersionPlanStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks, updateTask } = useDevTaskStore();
const { testCases, fetchTestCases, updateTestCase } = useTestCaseStore();
const { bugs, fetchBugs, updateBug } = useBugStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState<TabKey>('all');
const [showCompleted, setShowCompleted] = useState(true);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
@@ -121,27 +126,6 @@ export default function WorkspacePage() {
const pendingCount = pendingByTab.all;
const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length;
const handleItemAction = (item: WorkItem, action: string) => {
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
if (action === 'start') updatePlan(item.id, { status: 'in_progress' });
if (action === 'complete') updatePlan(item.id, { status: 'completed', completedAt: new Date().toISOString() });
}
if (item.type === 'devTask') {
if (action === 'start') updateTask(item.id, { status: 'in_progress' });
if (action === 'testing') updateTask(item.id, { status: 'testing' });
if (action === 'submit') updateTask(item.id, { status: 'submitted', completedAt: new Date().toISOString() });
}
if (item.type === 'testCase') {
if (action === 'pass') updateTestCase(item.id, { status: 'passed', completedAt: new Date().toISOString() });
if (action === 'fail') updateTestCase(item.id, { status: 'failed' });
}
if (item.type === 'bug') {
if (action === 'fix') updateBug(item.id, { status: 'fixing' });
if (action === 'fixed') updateBug(item.id, { status: 'fixed' });
if (action === 'close') updateBug(item.id, { status: 'closed', closedAt: new Date().toISOString() });
}
};
return (
<div className="flex h-full">
{/* 左侧第一列:产品/项目/版本树 */}
@@ -226,13 +210,27 @@ export default function WorkspacePage() {
key={item.id}
item={item}
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
onAction={(action) => handleItemAction(item, action)}
onClick={() => setDrawerItem(item)}
/>
))}
</div>
)}
</div>
</div>
{/* Detail Drawers */}
{drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && (
<PlanDetailDrawer planId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'devTask' && (
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'testCase' && (
<TestCaseDetailDrawer testCaseId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'bug' && (
<BugDetailDrawer bugId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
</div>
);
}
@@ -287,12 +285,11 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
);
}
function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNavigate: () => void; onAction: (action: string) => void }) {
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
const statusBadge = getStatusBadge(item);
const actions = getActions(item);
return (
<div className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] ${item.completed ? 'opacity-60' : ''}`}>
<div onClick={onClick} className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] cursor-pointer ${item.completed ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-3">
{item.completed && <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />}
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)] shrink-0">
@@ -309,17 +306,7 @@ function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNaviga
{BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''}
</span>
)}
{/* 操作按钮 */}
{actions.length > 0 && (
<div className="flex items-center gap-1 shrink-0">
{actions.map((a) => (
<button key={a.key} onClick={() => onAction(a.key)} className={`h-6 px-2 rounded text-[10px] font-medium transition-colors ${a.style}`}>
{a.label}
</button>
))}
</div>
)}
<button onClick={onNavigate} className="h-6 w-6 flex items-center justify-center rounded text-[var(--ink-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-subtle)] shrink-0" title="跳转到版本详情">
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="h-6 w-6 flex items-center justify-center rounded text-[var(--ink-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-subtle)] shrink-0" title="跳转到版本详情">
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
@@ -328,7 +315,7 @@ function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNaviga
<span className="text-[var(--line)]">/</span>
<span>{item.projectName}</span>
<span className="text-[var(--line)]">/</span>
<button onClick={onNavigate} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
{item.extra?.dueDate && <span className="ml-2"> {item.extra.dueDate}</span>}
{item.extra?.startTime && <span className="ml-2">{item.extra.startTime.slice(0, 16).replace('T', ' ')} {item.extra.endTime?.slice(0, 16).replace('T', ' ')}</span>}
</div>
@@ -336,34 +323,6 @@ function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNaviga
);
}
function getActions(item: WorkItem): { key: string; label: string; style: string }[] {
if (item.completed) return [];
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
if (item.status === 'pending') return [{ key: 'start', label: '开始', style: 'text-blue-600 hover:bg-blue-50 border border-blue-200' }];
if (item.status === 'in_progress') return [{ key: 'complete', label: '完成', style: 'text-emerald-600 hover:bg-emerald-50 border border-emerald-200' }];
}
if (item.type === 'devTask') {
if (item.status === 'todo') return [{ key: 'start', label: '开始', style: 'text-blue-600 hover:bg-blue-50 border border-blue-200' }];
if (item.status === 'in_progress') return [{ key: 'testing', label: '提测', style: 'text-purple-600 hover:bg-purple-50 border border-purple-200' }];
if (item.status === 'testing') return [{ key: 'submit', label: '完成', style: 'text-emerald-600 hover:bg-emerald-50 border border-emerald-200' }];
}
if (item.type === 'testCase') {
if (item.status === 'pending' || item.status === 'running' || item.status === 'failed') {
return [
{ key: 'pass', label: '通过', style: 'text-emerald-600 hover:bg-emerald-50 border border-emerald-200' },
{ key: 'fail', label: '失败', style: 'text-red-600 hover:bg-red-50 border border-red-200' },
];
}
}
if (item.type === 'bug') {
if (item.status === 'open') return [{ key: 'fix', label: '修复中', style: 'text-blue-600 hover:bg-blue-50 border border-blue-200' }];
if (item.status === 'fixing') return [{ key: 'fixed', label: '已修复', style: 'text-purple-600 hover:bg-purple-50 border border-purple-200' }];
if (item.status === 'fixed' || item.status === 'verifying') return [{ key: 'close', label: '关闭', style: 'text-emerald-600 hover:bg-emerald-50 border border-emerald-200' }];
}
return [];
}
function getStatusBadge(item: WorkItem) {
if (item.type === 'devTask') {
return (

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil } from 'lucide-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';
@@ -17,9 +18,12 @@ interface Props {
}
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) {
const { tasks, changeStatus, setBlocked, deleteTask } = useDevTaskStore();
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;
@@ -59,11 +63,27 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) {
<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">

View File

@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2 } from 'lucide-react';
import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRightLeft } from 'lucide-react';
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
import { calcActualHoursByDates } from '@/lib/dev-task';
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
@@ -19,9 +20,12 @@ interface Props {
}
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props) {
const { testCases, changeStatus, deleteTestCase } = useTestCaseStore();
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
const { bugs } = useBugStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const tc = testCases.find((c) => c.id === testCaseId);
if (!tc) return null;
@@ -62,11 +66,27 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
</div>
<div className="flex items-center gap-1">
{tc.status !== 'passed' && (
<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 (relatedBugs.length > 0) { alert('该用例有关联 Bug无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.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 !== tc.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<button onClick={() => { if (transferTo) { updateTestCase(tc.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">
{/* 关联需求 */}
{requirement && (

View File

@@ -0,0 +1,248 @@
'use client';
import { useState } from 'react';
import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
interface Props {
planId: string;
onClose: () => void;
}
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
export function PlanDetailDrawer({ planId, onClose }: Props) {
const { plans, updatePlan, completePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const [showTransfer, setShowTransfer] = useState(false);
const [transferTo, setTransferTo] = useState('');
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultUrl, setResultUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const [showComplete, setShowComplete] = useState(false);
const plan = plans.find((p) => p.id === planId);
if (!plan) return null;
const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date());
const handleToggleTask = (task: PlanTask) => {
if (!canInteract) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
const handleToggleReq = (reqId: string) => {
if (!canInteract) return;
const current = plan.completedRequirementIds || [];
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
updatePlan(plan.id, { completedRequirementIds: next });
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const handleSubmitResult = () => {
const url = resultType === 'link' ? resultUrl.trim() : fileData;
if (!url) return;
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
setShowComplete(false);
};
const handleTransfer = () => {
if (!transferTo) return;
updatePlan(plan.id, { owner: transferTo });
setShowTransfer(false);
setTransferTo('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md bg-[var(--bg-card)] border-l border-[var(--line)] shadow-2xl h-full flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<h3 className="text-[15px] font-semibold text-[var(--ink)]">{plan.title}</h3>
{/* Info */}
<div className="grid grid-cols-2 gap-3 text-[12px]">
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.owner}</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{progress}%</div>
</div>
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.startTime.slice(0, 16).replace('T', ' ')} {plan.endTime.slice(0, 16).replace('T', ' ')}</div>
</div>
{plan.actualStartAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-blue-600 mt-0.5">{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</div>
</div>
)}
{plan.completedAt && (
<div>
<span className="text-[var(--ink-muted)]"></span>
<div className="font-medium text-emerald-600 mt-0.5">{plan.completedAt.slice(0, 16).replace('T', ' ')}</div>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-2 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
{/* Research Tasks */}
{isResearch && plan.tasks && plan.tasks.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canInteract}
onClick={() => handleToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
</div>
))}
</div>
)}
{/* Product/UI: Linked Requirements */}
{!isResearch && linkedReqs.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{linkedReqs.map((req) => {
const isDone = (plan.completedRequirementIds || []).includes(req.id);
return (
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canInteract}
onClick={() => handleToggleReq(req.id)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
<span className={`flex-1 text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
</div>
);
})}
</div>
)}
{/* Result */}
{plan.status === 'completed' && plan.resultUrl && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="flex items-center gap-1.5">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)}
{plan.remark && (
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
<div className="text-[11px] text-[var(--ink-muted)] mb-1"></div>
<div className="text-[12px] text-[var(--ink)]">{plan.remark}</div>
</div>
)}
{/* Transfer Section */}
{showTransfer && (
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
<div className="text-[11px] font-medium 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-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== plan.owner).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<div className="flex gap-2">
<button onClick={handleTransfer} disabled={!transferTo} className="h-7 px-3 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>
)}
{/* Complete with result */}
{showComplete && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
<div className="text-[11px] font-medium text-emerald-700"></div>
<div className="flex gap-2">
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
</div>
{resultType === 'link' ? (
<input value={resultUrl} onChange={(e) => setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
<div className="flex gap-2">
<button onClick={handleSubmitResult} disabled={resultType === 'link' ? !resultUrl.trim() : !fileData} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50"></button>
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
</div>
</div>
)}
</div>
{/* Footer Actions */}
{plan.status !== 'completed' && (
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
{plan.status === 'pending' && (
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
<Play className="h-3 w-3" />
</button>
)}
{plan.status === 'in_progress' && (
<button onClick={() => setShowComplete(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50">
</button>
)}
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
<ArrowRightLeft className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
);
}