diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 8e6b320..a9c63c2 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -8,7 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { getVersionDetail } from '@/lib/derive'; import { STAGES } from '@/lib/stage'; -import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; +import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend'; @@ -57,6 +57,27 @@ export default function VersionDetailPage() { const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); + // 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段 + useEffect(() => { + if (!version || !plans.length) return; + if (version.status !== 'planned' && version.status !== 'developing') return; + const today = new Date().toISOString().slice(0, 10); + const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const; + const stageOrder: string[] = ['requirement', 'product_design', 'ui_design']; + const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today); + if (versionPlans.length === 0) return; + let targetStage = ''; + for (const p of versionPlans) { + const s = stageMap[p.type]; + if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) { + targetStage = s; + } + } + if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) { + updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any }); + } + }, [plans, version, versionId, updateVersion]); + const elapsedDays = useMemo(() => { if (!version?.startDate) return 0; const start = new Date(version.startDate); @@ -171,7 +192,7 @@ export default function VersionDetailPage() { )} - {VERSION_STATUS_LABEL[version.status]} + {getVersionDisplayStatus(version.status, version.currentStage)} @@ -221,7 +242,50 @@ export default function VersionDetailPage() { )} {/* Capsule stages */} - + {(() => { + const vPlans = plans.filter((p) => p.versionId === version.id); + const researchPlans = vPlans.filter((p) => p.type === 'research'); + const productPlans = vPlans.filter((p) => p.type === 'product'); + const uiPlans = vPlans.filter((p) => p.type === 'ui'); + + const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => { + if (group.length === 0) return 0; + if (type === 'research') { + const totals = group.reduce((acc, p) => { + const tasks = p.tasks || []; + acc.total += tasks.length; + acc.done += tasks.filter((t) => t.status === 'completed').length; + return acc; + }, { total: 0, done: 0 }); + return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0; + } + const totals = group.reduce((acc, p) => { + const linked = p.linkedRequirementIds || []; + const completed = p.completedRequirementIds || []; + acc.total += linked.length; + acc.done += completed.filter((id) => linked.includes(id)).length; + return acc; + }, { total: 0, done: 0 }); + return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0; + }; + + const today = new Date().toISOString().slice(0, 10); + const calcDays = (group: typeof vPlans) => { + if (group.length === 0) return 0; + const starts = group.map((p) => p.startTime).sort(); + const startDate = starts[0]; + if (startDate > today) return 0; + const diff = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)); + return Math.max(1, diff); // 当天开始至少算1天 + }; + + const stageProgress: Record = {}; + if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) }; + if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) }; + if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) }; + + return ; + })()} {/* 双栏:加班排名 + 原因占比 */}
@@ -289,11 +353,34 @@ export default function VersionDetailPage() {
参与人员
- {version.members && version.members.length > 0 ? ( - - ) : ( - 暂无成员 - )} + {(() => { + const planMembers = plans + .filter((p) => p.versionId === version.id) + .map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner })); + const roleLabel: Record = { research: '调研', product: '产品', ui: 'UI' }; + // 按 role+name 去重(同阶段同一人只显示一次) + const seen = new Set(); + const dedupPlanMembers = planMembers.filter((m) => { + const key = `${m.role}:${m.name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const existingKeys = new Set((version.members ?? []).map((m) => `${m.role}:${m.name}`)); + const extraMembers = dedupPlanMembers.filter((m) => !existingKeys.has(`${m.role}:${m.name}`)); + const allMembers = [...(version.members ?? []), ...extraMembers]; + if (allMembers.length === 0) return 暂无成员; + return ( +
+ {allMembers.map((m, i) => ( + + {roleLabel[m.role] ?? m.role} + {m.name} + + ))} +
+ ); + })()}
@@ -345,6 +432,12 @@ export default function VersionDetailPage() { if ((pt === 'product') && data.linkedRequirementIds?.length) { data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner })); } + // 同步版本状态:计划开始时间<=今天,版本进入对应阶段 + const today = new Date().toISOString().slice(0, 10); + if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) { + const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const; + updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] }); + } }} onUpdate={(id, data) => { updatePlan(id, data); diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 0f9b56a..411c883 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -7,7 +7,7 @@ import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { flattenVersions, flattenProjects } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive'; -import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status'; +import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, getVersionDisplayStatus } from '@/lib/version-status'; import { STAGES } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage'; import { calcOverallProgress } from '@/lib/risk'; @@ -76,15 +76,7 @@ function sortVersions(versions: VersionWithContext[]): VersionWithContext[] { } function getStageLabel(version: VersionWithContext): string { - if (version.status === 'planned') return '规划中'; - if (version.status === 'released') return '已发布'; - if (version.status === 'paused') return '已暂停'; - if (version.status === 'closed') return '已关闭'; - if (version.currentStage) { - const stage = STAGES.find((s) => s.key === version.currentStage); - return stage?.label ?? '-'; - } - return '-'; + return getVersionDisplayStatus(version.status as VersionStatus, version.currentStage); } const STAGE_ROLE_MAP: Record = { diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx new file mode 100644 index 0000000..e3fffcd --- /dev/null +++ b/apps/web/app/workspace/page.tsx @@ -0,0 +1,328 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp } from 'lucide-react'; +import { useProductStore } from '@/stores/useProductStore'; +import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; +import { useRequirementStore } from '@/stores/useRequirementStore'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { flattenVersions } from '@/lib/derive'; +import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; +import type { PlanTask, VersionPlan } from '@/lib/version-plan'; + +type TabKey = 'all' | 'research' | 'product' | 'ui'; + +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 }, +]; + +export default function WorkspacePage() { + const router = useRouter(); + const { overview, fetchOverview } = useProductStore(); + const { plans, fetchPlans, updatePlan, completePlan } = useVersionPlanStore(); + const { requirements, fetchRequirements } = useRequirementStore(); + const user = useAuthStore((s) => s.user); + const [activeTab, setActiveTab] = useState('all'); + const [completingPlan, setCompletingPlan] = useState(null); + + useEffect(() => { fetchOverview(); }, [fetchOverview]); + useEffect(() => { fetchPlans(); }, [fetchPlans]); + useEffect(() => { fetchRequirements(); }, [fetchRequirements]); + + 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 counts = { + all: myPlans.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, + }; + + const filtered = activeTab === 'all' ? myPlans : 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 }); + }; + + return ( +
+ {/* 左侧:分组 */} +
+
+

与我相关

+
+ +
+ + {/* 右侧:待办列表 */} +
+
+

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

+ {filtered.length} 项 +
+ +
+ {filtered.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)} + /> + ); + }) + )} +
+
+ + {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; + + return ( +
+
+
+ + {isActive ? '进行中' : '未开始'} + + {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}%} +
+ + {/* 进度条 */} + {((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; + + 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}

} +
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/components/version/CapsuleStages.tsx b/apps/web/components/version/CapsuleStages.tsx index 71ad595..91da15c 100644 --- a/apps/web/components/version/CapsuleStages.tsx +++ b/apps/web/components/version/CapsuleStages.tsx @@ -1,9 +1,15 @@ import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage'; import type { RoleProgress } from '@/lib/derive'; -export function CapsuleStages({ currentStage, progress }: { +export interface StageProgressItem { + percent: number; + daysSpent: number; +} + +export function CapsuleStages({ currentStage, progress, stageProgress }: { currentStage?: Stage; progress?: RoleProgress[]; + stageProgress?: Partial>; }) { const currentIdx = currentStage !== undefined ? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage]) @@ -24,6 +30,11 @@ export function CapsuleStages({ currentStage, progress }: { }; function getStageInfo(stage: Stage) { + // 优先使用 stageProgress + if (stageProgress && stageProgress[stage]) { + const sp = stageProgress[stage]!; + return { percent: sp.percent, days: sp.daysSpent, hasData: true }; + } const roles = stageRoleMap[stage]; if (roles.length === 0) return { percent: 0, days: 0, hasData: false }; const items = roles.map((r) => progressMap[r]).filter(Boolean); @@ -50,7 +61,7 @@ export function CapsuleStages({ currentStage, progress }: { {stage.label} - {isCompleted ? (info.hasData ? `${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''} + {isCompleted ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : ''}
diff --git a/apps/web/components/version/PlanTab.tsx b/apps/web/components/version/PlanTab.tsx index 26452d6..edad12d 100644 --- a/apps/web/components/version/PlanTab.tsx +++ b/apps/web/components/version/PlanTab.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react'; -import type { VersionPlan } from '@/lib/version-plan'; -import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan'; +import type { VersionPlan, PlanTask } from '@/lib/version-plan'; +import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan'; interface Props { plans: VersionPlan[]; @@ -39,7 +39,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
{typePlans.length} 个{TYPE_LABEL[planType]}计划 - 总耗时:{totalDuration} + 已耗时:{totalDuration} {versionDeadline && 版本截止:{versionDeadline}}
+ {task.title} + + {task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'} + +
+ ))} +
+
+ )} + {/* 产品方案/UI:关联需求进度 */} + {(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && ( +
+
+
+
+
+ {calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}% +
+
+ {plan.linkedRequirementIds.map((rid) => { + const req = linkedRequirements.find((r) => r.id === rid); + const isDone = (plan.completedRequirementIds || []).includes(rid); + return req ? ( +
+ + {req.code} + {req.title} +
+ ) : null; + })} +
+ {calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && effectiveStatus !== 'completed' && ( +
+ 所有需求已完成,请提交原型成果 + +
+ )} +
+ )}
- {plan.status !== 'completed' && ( + {effectiveStatus !== 'completed' && ( <>
)} + {planType === 'research' && ( +
+ +
+ {tasks.map((task, i) => ( +
+ {task.title} + +
+ ))} +
+
+ setNewTaskTitle(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }} + placeholder="输入任务名称,回车添加" + className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" + /> + +
+
+ )}