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.map((tab) => { + const Icon = tab.icon; + const count = counts[tab.key]; + const active = activeTab === tab.key; + return ( + setActiveTab(tab.key)} + className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] transition-colors ${ + active + ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' + : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]' + }`} + > + + {tab.label} + + {count} + + + ); + })} + + + + {/* 右侧:待办列表 */} + + + + {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} / + {versionName || '版本'} + {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) => ( + + onToggleTask(task)} + className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${ + task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : + task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' : + 'border-[var(--line)]' + }`} + > + {task.status === 'completed' && } + {task.status === 'in_progress' && } + + + {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 ( + + onToggleReq(req.id)} + className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} + > + {isDone && } + + {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()}> + 提交成果 + + + setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>链接 + setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}>文件 + + {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}} + + )} + + 取消 + onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认 + + + + + ); +} 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}} setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"> @@ -55,41 +55,35 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl ) : ( {typePlans.map((plan) => { - const dur = plan.completedAt + // 已耗时:已完成用实际完成时间,进行中用当前时间,未开始为0 + const now = new Date().toISOString().slice(0, 10); + const effectiveStatus = plan.status === 'pending' && plan.startTime <= now ? 'in_progress' : plan.status; + const dur = effectiveStatus === 'completed' && plan.completedAt ? calcPlanDuration(plan.startTime, plan.completedAt) - : calcPlanDuration(plan.startTime, plan.endTime); - const durText = formatDuration(dur.days, dur.hours); + : effectiveStatus === 'in_progress' + ? calcPlanDuration(plan.startTime, now) + : { days: 0, hours: 0 }; + const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-'; return ( {plan.title} - - {STATUS_LABEL[plan.status]} + + {STATUS_LABEL[effectiveStatus]} - 负责人:{plan.owner} 计划:{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)} {plan.completedAt && 完成:{plan.completedAt.slice(0, 10)}} - 耗时 {durText} + 已耗时 {durText} {plan.overdueReason && ( 超期原因:{plan.overdueReason} )} - {plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && ( - - {plan.linkedRequirementIds.map((rid) => { - const req = linkedRequirements.find((r) => r.id === rid); - return req ? ( - - {req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''} - {req.productOwner && ({req.productOwner})} - - ) : null; - })} - + {(plan.type === 'product' || plan.type === 'ui') && ( + 负责人:{plan.owner} )} {plan.status === 'completed' && plan.resultUrl && ( @@ -99,9 +93,80 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl )} + {/* 调研:任务进度 */} + {plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && ( + + + + + + {calcPlanProgress(plan.tasks)}% + + + {plan.tasks.map((task) => ( + + { + const nextStatus = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending'; + const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t); + onUpdate(plan.id, { tasks: updatedTasks }); + }} + className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' : 'border-[var(--line)]'}`} + > + {task.status === 'completed' && } + {task.status === 'in_progress' && } + + {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 ? ( + + { + const current = plan.completedRequirementIds || []; + const next = isDone ? current.filter((id) => id !== rid) : [...current, rid]; + onUpdate(plan.id, { completedRequirementIds: next }); + }} + className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`} + > + {isDone && } + + {req.code} + {req.title} + + ) : null; + })} + + {calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && effectiveStatus !== 'completed' && ( + + 所有需求已完成,请提交原型成果 + setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交 + + )} + + )} - {plan.status !== 'completed' && ( + {effectiveStatus !== 'completed' && ( <> setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成"> @@ -166,6 +231,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now); const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? ''); const [remark, setRemark] = useState(initial?.remark ?? ''); + const [tasks, setTasks] = useState(initial?.tasks ?? []); + const [newTaskTitle, setNewTaskTitle] = useState(''); const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? ''); const [selectedReqs, setSelectedReqs] = useState>(new Set(initial?.linkedRequirementIds ?? [])); const showReqSelect = planType === 'product' || planType === 'ui'; @@ -184,6 +251,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU endTime, status: initial?.status ?? 'pending', linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined, + tasks: tasks.length > 0 ? tasks : undefined, remark: remark.trim() || undefined, overdueReason: isOverdue ? overdueReason.trim() : undefined, addedBy: currentUserName, @@ -246,6 +314,29 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU )} + {planType === 'research' && ( + + 任务清单 + + {tasks.map((task, i) => ( + + {task.title} + setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"> + + ))} + + + 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" + /> + { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]">添加 + + + )} 备注 setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" /> diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts index e0f72c5..d18dea3 100644 --- a/apps/web/lib/version-plan.ts +++ b/apps/web/lib/version-plan.ts @@ -1,3 +1,11 @@ +export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed'; + +export interface PlanTask { + id: string; + title: string; + status: PlanTaskStatus; +} + export interface VersionPlan { id: string; versionId: string; @@ -7,6 +15,8 @@ export interface VersionPlan { startTime: string; endTime: string; status: 'pending' | 'in_progress' | 'completed'; + tasks?: PlanTask[]; + completedRequirementIds?: string[]; linkedRequirementIds?: string[]; resultType?: 'link' | 'file'; resultUrl?: string; @@ -21,11 +31,24 @@ export interface VersionPlan { export type PlanType = VersionPlan['type']; +export function calcPlanProgress(tasks?: PlanTask[]): number { + if (!tasks || tasks.length === 0) return 0; + const completed = tasks.filter((t) => t.status === 'completed').length; + return Math.round((completed / tasks.length) * 100); +} + +export function calcLinkedReqProgress(linkedIds?: string[], completedIds?: string[]): number { + if (!linkedIds || linkedIds.length === 0) return 0; + const done = (completedIds || []).filter((id) => linkedIds.includes(id)).length; + return Math.round((done / linkedIds.length) * 100); +} + export function calcPlanDuration(start: string, end: string): { days: number; hours: number } { const s = new Date(start).getTime(); const e = new Date(end).getTime(); - if (isNaN(s) || isNaN(e) || e <= s) return { days: 0, hours: 0 }; - const totalDays = Math.ceil((e - s) / (1000 * 60 * 60 * 24)); + if (isNaN(s) || isNaN(e) || e < s) return { days: 0, hours: 0 }; + const diff = Math.ceil((e - s) / (1000 * 60 * 60 * 24)); + const totalDays = Math.max(1, diff); // 当天开始当天结束至少1天 return { days: totalDays, hours: totalDays * 8 }; } @@ -35,13 +58,19 @@ export function formatDuration(days: number, _hours: number): string { } export function calcTotalDuration(plans: VersionPlan[]): string { - const completedOrActive = plans.filter((p) => p.status !== 'pending'); - if (completedOrActive.length === 0) return '0天'; + const today = new Date().toISOString().slice(0, 10); + const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today); + if (activePlans.length === 0) return '0天'; - const intervals = completedOrActive.map((p) => ({ - start: new Date(p.startTime).getTime(), - end: p.completedAt ? new Date(p.completedAt).getTime() : new Date(p.endTime).getTime(), - })).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start); + const intervals = activePlans.map((p) => { + const start = new Date(p.startTime).getTime(); + const end = p.completedAt + ? new Date(p.completedAt).getTime() + : p.startTime <= today + ? new Date(today).getTime() + : new Date(p.startTime).getTime(); + return { start, end }; + }).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start); if (intervals.length === 0) return '0天'; diff --git a/apps/web/lib/version-status.ts b/apps/web/lib/version-status.ts index c15ac4b..f582d6a 100644 --- a/apps/web/lib/version-status.ts +++ b/apps/web/lib/version-status.ts @@ -1,7 +1,7 @@ export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed'; export const VERSION_STATUS_LABEL: Record = { - developing: '开发中', + developing: '进行中', planned: '规划中', released: '已发布', paused: '已暂停', @@ -23,3 +23,20 @@ export const VERSION_STATUS_BG: Record = { paused: 'bg-purple-100 text-purple-600', closed: 'bg-zinc-100 text-zinc-500', }; + +export const STAGE_LABEL: Record = { + requirement: '调研中', + product_design: '产品设计中', + ui_design: 'UI设计中', + dev: '开发中', + integration: '联调中', + testing: '测试中', + released: '已发布', +}; + +export function getVersionDisplayStatus(status: VersionStatus, currentStage?: string): string { + if (status === 'developing' && currentStage && STAGE_LABEL[currentStage]) { + return STAGE_LABEL[currentStage]; + } + return VERSION_STATUS_LABEL[status]; +}
暂无待办事项
已选:{fileName}