From 5db00f01ff5ecb27f559347dc5427d2395cf4cf2 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 15 Jun 2026 16:51:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8E=E6=88=91=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20=E2=80=94=20workspace-engine=20=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构:新建 lib/workspace-engine.ts - aggregateWorkItems(): 从所有 store 统一聚合"与我相关"数据 - 每个 WorkItem 包含:type, title, status, completed, productName, projectName, versionName, versionId - 新增模块只需在引擎中添加聚合逻辑,工作台自动展示 - 解决了之前各模块增加内容但工作台无法关联的问题 功能改动: 1. 每个任务显示完整上下文:产品 / 项目 / 版本 2. 版本名可点击跳转到版本详情 3. 所有任务(含已完成)统一展示,已完成半透明+绿色勾 4. 左侧增加"显示已完成"开关 5. 待办/已完成计数统计 6. 统一卡片样式:类型标签 + 状态 + 编号 + 标题 + 优先级 7. Bug 额外显示严重程度标签 8. 列表默认排序:未完成在前,已完成在后 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/workspace/page.tsx | 492 +++++++++---------------------- apps/web/lib/workspace-engine.ts | 127 ++++++++ 2 files changed, 273 insertions(+), 346 deletions(-) create mode 100644 apps/web/lib/workspace-engine.ts diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 4ed7ecd..d9854e8 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -2,109 +2,113 @@ import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp, Code2, ClipboardCheck, Bug as BugIcon } from 'lucide-react'; +import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2 } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; -import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { flattenVersions } from '@/lib/derive'; -import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; -import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } from '@/lib/dev-task'; +import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; +import type { WorkItem, WorkItemType } from '@/lib/workspace-engine'; +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'; -import type { PlanTask, VersionPlan } from '@/lib/version-plan'; -import type { DevTask } from '@/lib/dev-task'; -type TabKey = 'all' | 'research' | 'product' | 'ui' | 'devTask' | 'testCase' | 'bug'; +type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug'; const TABS: { key: TabKey; label: string; icon: any }[] = [ - { key: 'all', label: '全部待办', icon: ClipboardList }, - { key: 'research', label: '调研', icon: Search }, - { key: 'product', label: '产品方案', icon: FileText }, - { key: 'ui', label: 'UI设计', icon: Palette }, + { key: 'all', label: '全部', icon: ClipboardList }, + { key: 'plan_research', label: '调研', icon: Search }, + { key: 'plan_product', label: '产品方案', icon: FileText }, + { key: 'plan_ui', label: 'UI设计', icon: Palette }, { key: 'devTask', label: '开发任务', icon: Code2 }, { key: 'testCase', label: '测试用例', icon: ClipboardCheck }, { key: 'bug', label: 'Bug', icon: BugIcon }, ]; +const PLAN_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 PLAN_STATUS_LABEL: Record = { pending: '未开始', in_progress: '进行中', completed: '已完成' }; + export default function WorkspacePage() { const router = useRouter(); const { overview, fetchOverview } = useProductStore(); - const { plans, fetchPlans, updatePlan, completePlan } = useVersionPlanStore(); + const { plans, fetchPlans } = useVersionPlanStore(); const { requirements, fetchRequirements } = useRequirementStore(); const { tasks: devTasks, fetchTasks } = useDevTaskStore(); - const { categories, fetchCategories } = useTaskCategoryStore(); const { testCases, fetchTestCases } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const user = useAuthStore((s) => s.user); const [activeTab, setActiveTab] = useState('all'); - const [completingPlan, setCompletingPlan] = useState(null); + const [showCompleted, setShowCompleted] = useState(true); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchPlans(); }, [fetchPlans]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchTasks(); }, [fetchTasks]); - useEffect(() => { fetchCategories(); }, [fetchCategories]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); const userName = user?.name ?? ''; const allVersions = useMemo(() => flattenVersions(overview), [overview]); - // 我负责的所有未完成计划 - const myPlans = useMemo(() => - plans.filter((p) => p.owner === userName && p.status !== 'completed'), - [plans, userName] + const versionMap = useMemo(() => { + const map = new Map(); + allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName })); + return map; + }, [allVersions]); + + const requirementVersionMap = useMemo(() => { + const map = new Map(); + requirements.forEach((r) => { if (r.versionId) map.set(r.id, r.versionId); }); + return map; + }, [requirements]); + + const workItems = useMemo(() => + aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap), + [userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap] ); - // 我负责的所有未完成开发任务 - const myDevTasks = useMemo(() => - devTasks.filter((t) => t.assigneeId === userName && t.status !== 'submitted'), - [devTasks, userName] - ); + const filteredItems = useMemo(() => { + let items = activeTab === 'all' ? workItems : workItems.filter((i) => i.type === activeTab); + if (!showCompleted) items = items.filter((i) => !i.completed); + return items.sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + return 0; + }); + }, [workItems, activeTab, showCompleted]); - // 我负责的待执行/执行中/失败测试用例 - const myTestCases = useMemo(() => - testCases.filter((c) => c.assigneeId === userName && (c.status === 'pending' || c.status === 'running' || c.status === 'failed')), - [testCases, userName] - ); + const counts = useMemo(() => ({ + all: workItems.length, + plan_research: workItems.filter((i) => i.type === 'plan_research').length, + plan_product: workItems.filter((i) => i.type === 'plan_product').length, + plan_ui: workItems.filter((i) => i.type === 'plan_ui').length, + devTask: workItems.filter((i) => i.type === 'devTask').length, + testCase: workItems.filter((i) => i.type === 'testCase').length, + bug: workItems.filter((i) => i.type === 'bug').length, + }), [workItems]); - // 我负责的未关闭 Bug - const myBugs = useMemo(() => - bugs.filter((b) => b.assigneeId === userName && b.status !== 'closed' && b.status !== 'rejected'), - [bugs, userName] - ); - - const counts = { - all: myPlans.length + myDevTasks.length + myTestCases.length + myBugs.length, - research: myPlans.filter((p) => p.type === 'research').length, - product: myPlans.filter((p) => p.type === 'product').length, - ui: myPlans.filter((p) => p.type === 'ui').length, - devTask: myDevTasks.length, - testCase: myTestCases.length, - bug: myBugs.length, - }; - - const filtered = activeTab === 'all' ? myPlans : (activeTab === 'devTask' || activeTab === 'testCase' || activeTab === 'bug') ? [] : myPlans.filter((p) => p.type === activeTab); - const today = new Date().toISOString().slice(0, 10); - - const toggleTask = (plan: VersionPlan, task: PlanTask) => { - const next: PlanTask['status'] = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending'; - const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: next } : t); - updatePlan(plan.id, { tasks: updatedTasks }); - }; + const pendingCount = workItems.filter((i) => !i.completed).length; + const completedCount = workItems.filter((i) => i.completed).length; return (
- {/* 左侧:分组 */}

与我相关

+
+
+ 待办 {pendingCount} + 已完成 {completedCount} +
+
+
+ +
- {/* 右侧:待办列表 */}
-

- {TABS.find((t) => t.key === activeTab)?.label} -

- {activeTab === 'devTask' ? myDevTasks.length : activeTab === 'testCase' ? myTestCases.length : activeTab === 'bug' ? myBugs.length : filtered.length} 项 +

{TABS.find((t) => t.key === activeTab)?.label}

+ {filteredItems.length} 项
-
- {activeTab === 'devTask' ? ( - myDevTasks.length === 0 ? ( -
-

暂无开发任务

-
- ) : ( - myDevTasks.map((task) => { - const cat = categories.find((c) => c.id === task.categoryId); - const req = requirements.find((r) => r.id === task.requirementId); - return ( -
-
-
- - {DEV_TASK_STATUS_LABEL[task.status]} - - {task.isBlocked && 阻塞} - {task.taskNo} - {task.title} -
- {task.priority} -
-
- {req && {req.code} {req.title}} - {cat && {cat.name}} - 预计 {formatHours(task.estimateHours)} - {task.actualHours > 0 && 已投入 {task.actualHours}h} - {task.dueDate && 截止 {task.dueDate}} -
-
- ); - }) - ) - ) : activeTab === 'testCase' ? ( - myTestCases.length === 0 ? ( -
-

暂无测试用例

-
- ) : ( - myTestCases.map((tc) => ( -
-
-
- - {TEST_CASE_STATUS_LABEL[tc.status]} - - {tc.caseNo} - {tc.title} -
- {tc.priority} -
-
- )) - ) - ) : activeTab === 'bug' ? ( - myBugs.length === 0 ? ( -
-

暂无 Bug

-
- ) : ( - myBugs.map((bug) => ( -
-
-
- - {BUG_STATUS_LABEL[bug.status]} - - {BUG_SEVERITY_LABEL[bug.severity]} - {bug.bugNo} - {bug.title} -
- {bug.priority} -
-
- )) - ) - ) : filtered.length === 0 ? ( +
+ {filteredItems.length === 0 ? (
-

暂无待办事项

+

暂无相关任务

) : ( - filtered.map((plan) => { - const version = allVersions.find((v) => v.id === plan.versionId); - const isActive = plan.startTime <= today; - const typeLabel = plan.type === 'research' ? '调研' : plan.type === 'product' ? '产品方案' : 'UI设计'; - const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[]; - - return ( - toggleTask(plan, task)} - onToggleReq={(reqId) => { - const current = plan.completedRequirementIds || []; - const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId]; - updatePlan(plan.id, { completedRequirementIds: next }); - }} - onAddTask={(title) => { - const newTask: PlanTask = { id: `task-${Date.now()}`, title, status: 'pending' }; - updatePlan(plan.id, { tasks: [...(plan.tasks || []), newTask] }); - }} - onComplete={() => setCompletingPlan(plan)} - onJumpVersion={() => version && router.push(`/versions/${version.id}`)} - onUpdate={(data) => updatePlan(plan.id, data)} - /> - ); - }) +
+ {filteredItems.map((item) => ( + item.versionId && router.push(`/versions/${item.versionId}`)} /> + ))} +
)}
- - {completingPlan && ( - setCompletingPlan(null)} - onSubmit={(result) => { - completePlan(completingPlan.id, result); - setCompletingPlan(null); - }} - /> - )}
); } -function PlanCard({ plan, versionName, versionInfo, versionId, typeLabel, isActive, linkedReqs, onToggleTask, onToggleReq, onAddTask, onComplete, onJumpVersion, onUpdate }: { - plan: VersionPlan; - versionName?: string; - versionInfo: string; - versionId?: string; - typeLabel: string; - isActive: boolean; - linkedReqs: { id: string; code: string; title: string }[]; - onToggleTask: (task: PlanTask) => void; - onToggleReq: (reqId: string) => void; - onAddTask: (title: string) => void; - onComplete: () => void; - onJumpVersion: () => void; - onUpdate: (data: Partial) => void; -}) { - const [newTaskTitle, setNewTaskTitle] = useState(''); - const isResearch = plan.type === 'research'; - const progress = isResearch - ? calcPlanProgress(plan.tasks) - : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds); - const allReqsDone = !isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && progress === 100; +function WorkItemCard({ item, onNavigate }: { item: WorkItem; onNavigate: () => void }) { + const statusBadge = getStatusBadge(item); return ( -
-
-
- - {isActive ? '进行中' : '未开始'} +
+
+ {/* 完成标记 */} + {item.completed && } + + {/* 类型标签 */} + + {WORK_ITEM_TYPE_LABEL[item.type]} + + + {/* 状态 */} + {statusBadge} + + {/* 编号 */} + {item.extra?.taskNo && {item.extra.taskNo}} + {item.extra?.caseNo && {item.extra.caseNo}} + {item.extra?.bugNo && {item.extra.bugNo}} + + {/* 标题 */} + {item.title} + + {/* 优先级 */} + {item.priority && {item.priority}} + + {/* Bug 严重程度 */} + {item.extra?.severity && ( + + {BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''} - {typeLabel} - {plan.title} -
-
- {(allReqsDone || (isResearch && plan.tasks && plan.tasks.length > 0)) && ( - - )} -
+ )} + + {/* 跳转 */} +
-
- {versionInfo} / - - {plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)} - {progress > 0 && {progress}%} + {/* 上下文:产品 / 项目 / 版本 */} +
+ {item.productName} + / + {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', ' ')}}
- - {/* 进度条 */} - {((isResearch && plan.tasks && plan.tasks.length > 0) || (!isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0)) && ( -
-
-
- )} - - {/* 调研:任务清单 */} - {isResearch && ( -
- {(plan.tasks || []).map((task) => ( -
- - - {task.title} - - - {task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'} - -
- ))} -
- setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter' && newTaskTitle.trim()) { onAddTask(newTaskTitle.trim()); setNewTaskTitle(''); } }} - placeholder="添加任务,回车确认" - className="flex-1 h-7 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" - /> -
-
- )} - - {/* 产品方案/UI:关联需求清单 */} - {!isResearch && linkedReqs.length > 0 && ( -
- {linkedReqs.map((req) => { - const isDone = (plan.completedRequirementIds || []).includes(req.id); - return ( -
- - {req.code} - {req.title} -
- ); - })} - {allReqsDone && ( -
- 所有需求已完成,请提交原型成果 -
- )} -
- )} - {!isResearch && linkedReqs.length === 0 && ( -
- 未关联需求,关联 -
- )}
); } -function CompleteModal({ onClose, onSubmit }: { - onClose: () => void; - onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void; -}) { - const [resultType, setResultType] = useState<'link' | 'file'>('link'); - const [url, setUrl] = useState(''); - const [fileName, setFileName] = useState(''); - const [fileData, setFileData] = useState(''); - - 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 canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0; - +function getStatusBadge(item: WorkItem) { + if (item.type === 'devTask') { + return ( + + {DEV_TASK_STATUS_LABEL[item.status as keyof typeof DEV_TASK_STATUS_LABEL] || item.status} + + ); + } + if (item.type === 'testCase') { + return ( + + {TEST_CASE_STATUS_LABEL[item.status as keyof typeof TEST_CASE_STATUS_LABEL] || item.status} + + ); + } + if (item.type === 'bug') { + return ( + + {BUG_STATUS_LABEL[item.status as keyof typeof BUG_STATUS_LABEL] || item.status} + + ); + } return ( -
-
e.stopPropagation()}> -

提交成果

-
-
- - -
- {resultType === 'link' ? ( - setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> - ) : ( -
- - {fileName &&

已选:{fileName}

} -
- )} -
- - -
-
-
-
+ + {PLAN_STATUS_LABEL[item.status] || item.status} + ); } diff --git a/apps/web/lib/workspace-engine.ts b/apps/web/lib/workspace-engine.ts new file mode 100644 index 0000000..57bea9c --- /dev/null +++ b/apps/web/lib/workspace-engine.ts @@ -0,0 +1,127 @@ +import type { VersionPlan } from './version-plan'; +import type { DevTask } from './dev-task'; +import type { TestCase } from './test-case'; +import type { Bug } from './bug'; + +export type WorkItemType = 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug'; + +export interface WorkItem { + id: string; + type: WorkItemType; + title: string; + status: string; + completed: boolean; + productName: string; + projectName: string; + versionName: string; + versionId: string; + priority?: string; + extra?: Record; + raw: VersionPlan | DevTask | TestCase | Bug; +} + +interface VersionContext { + id: string; + name: string; + productName: string; + projectName: string; +} + +export function aggregateWorkItems( + userName: string, + plans: VersionPlan[], + devTasks: DevTask[], + testCases: TestCase[], + bugs: Bug[], + versionMap: Map, + requirementVersionMap: Map, +): WorkItem[] { + const items: WorkItem[] = []; + + // Plans: owner === userName + plans.filter((p) => p.owner === userName).forEach((p) => { + const ver = versionMap.get(p.versionId); + items.push({ + id: p.id, + type: p.type === 'research' ? 'plan_research' : p.type === 'product' ? 'plan_product' : 'plan_ui', + title: p.title, + status: p.status, + completed: p.status === 'completed', + productName: ver?.productName ?? '-', + projectName: ver?.projectName ?? '-', + versionName: ver?.name ?? '-', + versionId: p.versionId, + extra: { startTime: p.startTime, endTime: p.endTime }, + raw: p, + }); + }); + + // DevTasks: assigneeId === userName + devTasks.filter((t) => t.assigneeId === userName).forEach((t) => { + const versionId = requirementVersionMap.get(t.requirementId) ?? ''; + const ver = versionMap.get(versionId); + items.push({ + id: t.id, + type: 'devTask', + title: t.title, + status: t.status, + completed: t.status === 'submitted', + productName: ver?.productName ?? '-', + projectName: ver?.projectName ?? '-', + versionName: ver?.name ?? '-', + versionId, + priority: t.priority, + extra: { taskNo: t.taskNo, dueDate: t.dueDate }, + raw: t, + }); + }); + + // TestCases: assigneeId === userName + testCases.filter((c) => c.assigneeId === userName).forEach((c) => { + const ver = versionMap.get(c.versionId); + items.push({ + id: c.id, + type: 'testCase', + title: c.title, + status: c.status, + completed: c.status === 'passed', + productName: ver?.productName ?? '-', + projectName: ver?.projectName ?? '-', + versionName: ver?.name ?? '-', + versionId: c.versionId, + priority: c.priority, + extra: { caseNo: c.caseNo }, + raw: c, + }); + }); + + // Bugs: assigneeId === userName + bugs.filter((b) => b.assigneeId === userName).forEach((b) => { + const ver = versionMap.get(b.versionId); + items.push({ + id: b.id, + type: 'bug', + title: b.title, + status: b.status, + completed: b.status === 'closed' || b.status === 'rejected', + productName: ver?.productName ?? '-', + projectName: ver?.projectName ?? '-', + versionName: ver?.name ?? '-', + versionId: b.versionId, + priority: b.priority, + extra: { bugNo: b.bugNo, severity: b.severity }, + raw: b, + }); + }); + + return items; +} + +export const WORK_ITEM_TYPE_LABEL: Record = { + plan_research: '调研', + plan_product: '产品方案', + plan_ui: 'UI设计', + devTask: '开发任务', + testCase: '测试用例', + bug: 'Bug', +};