diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index d117e2f..8638ef3 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -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 = { 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('all'); const [showCompleted, setShowCompleted] = useState(true); const [selectedVersionId, setSelectedVersionId] = useState(null); + const [drawerItem, setDrawerItem] = useState(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 (
{/* 左侧第一列:产品/项目/版本树 */} @@ -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)} /> ))}
)} + + {/* Detail Drawers */} + {drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && ( + setDrawerItem(null)} /> + )} + {drawerItem && drawerItem.type === 'devTask' && ( + t.id)} onClose={() => setDrawerItem(null)} /> + )} + {drawerItem && drawerItem.type === 'testCase' && ( + setDrawerItem(null)} /> + )} + {drawerItem && drawerItem.type === 'bug' && ( + setDrawerItem(null)} /> + )} ); } @@ -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 ( -
+
{item.completed && } @@ -309,17 +306,7 @@ function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNaviga {BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''} )} - {/* 操作按钮 */} - {actions.length > 0 && ( -
- {actions.map((a) => ( - - ))} -
- )} -
@@ -328,7 +315,7 @@ function WorkItemCard({ item, onNavigate, onAction }: { item: WorkItem; onNaviga / {item.projectName} / - + {item.extra?.dueDate && 截止 {item.extra.dueDate}} {item.extra?.startTime && {item.extra.startTime.slice(0, 16).replace('T', ' ')} → {item.extra.endTime?.slice(0, 16).replace('T', ' ')}}
@@ -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 ( diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index e9fc04e..02e54a3 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -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) { {task.title}
+ {task.status !== 'submitted' && ( + + )}
+ {/* 转交 */} + {showTransfer && ( +
+ 转交给: + + + +
+ )} + {/* 滚动区域 */}
diff --git a/apps/web/components/test-case/TestCaseDetailDrawer.tsx b/apps/web/components/test-case/TestCaseDetailDrawer.tsx index 8dd9cfc..e8d22ba 100644 --- a/apps/web/components/test-case/TestCaseDetailDrawer.tsx +++ b/apps/web/components/test-case/TestCaseDetailDrawer.tsx @@ -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 {tc.title}
+ {tc.status !== 'passed' && ( + + )}
+ {/* 转交 */} + {showTransfer && ( +
+ 转交给: + + + +
+ )} +
{/* 关联需求 */} {requirement && ( diff --git a/apps/web/components/version/PlanDetailDrawer.tsx b/apps/web/components/version/PlanDetailDrawer.tsx new file mode 100644 index 0000000..d5811f9 --- /dev/null +++ b/apps/web/components/version/PlanDetailDrawer.tsx @@ -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 = { 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 = { pending: '未开始', in_progress: '进行中', completed: '已完成' }; +const TYPE_LABEL: Record = { 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) => { + 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 ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+ {TYPE_LABEL[plan.type]} + {STATUS_LABEL[plan.status]} +
+ +
+ + {/* Body */} +
+

{plan.title}

+ + {/* Info */} +
+
+ 负责人 +
{plan.owner}
+
+
+ 进度 +
{progress}%
+
+
+ 计划时间 +
{plan.startTime.slice(0, 16).replace('T', ' ')} → {plan.endTime.slice(0, 16).replace('T', ' ')}
+
+ {plan.actualStartAt && ( +
+ 实际开始 +
{plan.actualStartAt.slice(0, 16).replace('T', ' ')}
+
+ )} + {plan.completedAt && ( +
+ 完成时间 +
{plan.completedAt.slice(0, 16).replace('T', ' ')}
+
+ )} +
+ + {/* Progress Bar */} +
+
+
+ + {/* Research Tasks */} + {isResearch && plan.tasks && plan.tasks.length > 0 && ( +
+
任务清单
+ {plan.tasks.map((task) => ( +
+ + {task.title} +
+ ))} +
+ )} + + {/* Product/UI: Linked Requirements */} + {!isResearch && linkedReqs.length > 0 && ( +
+
关联需求
+ {linkedReqs.map((req) => { + const isDone = (plan.completedRequirementIds || []).includes(req.id); + return ( +
+ + {req.code} + {req.title} +
+ ); + })} +
+ )} + + {/* Result */} + {plan.status === 'completed' && plan.resultUrl && ( +
+
成果
+
+ {plan.resultType === 'link' ? : } + + {plan.resultFileName || '查看成果'} + +
+
+ )} + + {plan.remark && ( +
+
备注
+
{plan.remark}
+
+ )} + + {/* Transfer Section */} + {showTransfer && ( +
+
转交给
+ +
+ + +
+
+ )} + + {/* Complete with result */} + {showComplete && ( +
+
提交成果
+
+ + +
+ {resultType === 'link' ? ( + 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" /> + ) : ( +
+ + {fileName &&

{fileName}

} +
+ )} +
+ + +
+
+ )} +
+ + {/* Footer Actions */} + {plan.status !== 'completed' && ( +
+ {plan.status === 'pending' && ( + + )} + {plan.status === 'in_progress' && ( + + )} + +
+ )} +
+
+ ); +}