From 1f5bed0c79ba913720303be32b0e9737869d165c Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 16 Jun 2026 17:44:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=80=E5=8F=91=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=AD=97=E6=AE=B5=E9=87=8D=E6=9E=84=20+=20?= =?UTF-8?q?=E5=8F=8C=E5=8F=A3=E5=BE=84=E8=80=97=E6=97=B6=20+=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=20+=20=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心变更: - DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算 - 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因 - 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径) - 新建 lib/dev-task-transitions.ts:状态切换守卫 - 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入) - 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配 - 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo - 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天) 新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/projects/[id]/page.tsx | 43 ++- apps/web/app/versions/[id]/page.tsx | 54 ++-- apps/web/app/versions/page.tsx | 6 +- apps/web/app/workspace/page.tsx | 26 +- apps/web/components/SearchInput.tsx | 48 ++++ apps/web/components/bug/BugRow.tsx | 18 +- apps/web/components/bug/BugTab.tsx | 33 ++- .../dev-task/DevTaskCreateModal.tsx | 106 ++++++-- .../dev-task/DevTaskDetailDrawer.tsx | 150 +++++++---- apps/web/components/dev-task/DevTaskRow.tsx | 38 ++- apps/web/components/dev-task/DevTaskTab.tsx | 49 ++-- apps/web/components/dev-task/WorklogPanel.tsx | 9 +- apps/web/components/test-case/TestCaseRow.tsx | 17 +- apps/web/components/test-case/TestCaseTab.tsx | 47 ++-- apps/web/hooks/useDebouncedValue.ts | 15 ++ apps/web/lib/bug.ts | 30 +++ apps/web/lib/dev-task-transitions.ts | 62 +++++ apps/web/lib/dev-task.ts | 127 +++++++-- apps/web/lib/test-case.ts | 27 ++ apps/web/lib/version-plan.ts | 16 ++ apps/web/lib/work-hours.ts | 244 ++++++++++++++++++ apps/web/lib/work-item.ts | 2 +- apps/web/lib/workspace-engine.ts | 8 +- apps/web/stores/useDevTaskStore.ts | 60 +++-- ...-06-16-version-stats-two-metrics-design.md | 178 +++++++++++++ 25 files changed, 1192 insertions(+), 221 deletions(-) create mode 100644 apps/web/components/SearchInput.tsx create mode 100644 apps/web/hooks/useDebouncedValue.ts create mode 100644 apps/web/lib/dev-task-transitions.ts create mode 100644 apps/web/lib/work-hours.ts create mode 100644 docs/superpowers/specs/2026-06-16-version-stats-two-metrics-design.md diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index d03010a..859f1d2 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -13,7 +13,7 @@ import { useBugStore } from '@/stores/useBugStore'; import { getProjectDetail, VersionWithContext } from '@/lib/derive'; import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; -import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; +import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import type { VersionPlan } from '@/lib/version-plan'; @@ -31,6 +31,27 @@ function StatCard({ value, label }: { value: number | string; label: string }) { ); } +function HoursStatCard({ estimate, actual }: { estimate: number; actual: number }) { + const overrun = actual > estimate && estimate > 0; + const underrun = actual > 0 && actual < estimate; + const tone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink)]'; + const dayStr = (h: number) => { + const d = h / 8; + return Number.isInteger(d) ? String(d) : d.toFixed(1).replace(/\.0$/, ''); + }; + return ( +
+
+ {actual > 0 ? `${actual}h` : '—'} + / {estimate}h +
+
+ 实际 / 预计耗时{estimate > 0 && ({actual > 0 ? `${dayStr(actual)} / ` : ''}{dayStr(estimate)}天)} +
+
+ ); +} + /* ─── ProgressBar (for expanded released cards) ─── */ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) { return ( @@ -73,7 +94,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ if (p.actualStartAt) startDates.push(p.actualStartAt); else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); }); - vDevTasks.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); + vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate; @@ -82,7 +103,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ // 实际截止:取所有阶段最晚完成 const endDates: string[] = []; vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); - vDevTasks.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); }); + vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null; @@ -394,12 +415,12 @@ export default function ProjectDetailPage() { } if (vDevTasks.length > 0) { - const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0); + const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0); let devProgress: number; if (totalEstimate === 0) { devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length; } else { - const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0); + const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0); devProgress = weighted / totalEstimate; } segments.push(devProgress); @@ -416,14 +437,17 @@ export default function ProjectDetailPage() { }, [project, plans, requirements, devTasks, testCases]); const stats = useMemo(() => { - if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0 }; + if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 }; const total = project.versions.length; const released = project.versions.filter((v) => v.status === 'released').length; const reqCount = requirements.filter((r) => r.projectId === projectId).length; const versionIds = new Set(project.versions.map((v) => v.id)); const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length; - return { total, released, reqCount, bugCount }; - }, [project, requirements, bugs, projectId]); + const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id)); + const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId)); + const { estimate, actual } = aggregateDevTaskHours(projectDevTasks); + return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual }; + }, [project, requirements, bugs, devTasks, projectId]); const teamByRole = useMemo(() => { if (!project) return {} as Record>; @@ -464,11 +488,12 @@ export default function ProjectDetailPage() {
-
+
+
diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index be59346..93cd491 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -26,7 +26,11 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; -import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task'; +import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task'; +import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; +import { testCaseIntervals } from '@/lib/test-case'; +import { bugIntervals } from '@/lib/bug'; +import { planIntervals } from '@/lib/version-plan'; import { formatDateTime } from '@/lib/format'; const PRIORITY_STYLE: Record = { @@ -109,7 +113,7 @@ export default function VersionDetailPage() { if (p.actualStartAt) startDates.push(p.actualStartAt); else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); }); - vDevTasks.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); + vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); if (startDates.length === 0 && !version.startDate) return 0; @@ -396,14 +400,14 @@ export default function VersionDetailPage() { if (p.actualStartAt) startDates.push(p.actualStartAt); else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); }); - vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); + vDTs.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (version.startDate ?? null); // 实际截止日期 const endDates: string[] = []; vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); - vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); }); + vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); const actualEnd = endDates.length > 0 ? formatDateTime(endDates.sort().reverse()[0]) : null; @@ -702,8 +706,8 @@ export default function VersionDetailPage() { // 开发阶段 const devCal = calcCalendar( - versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!), - versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!), + versionDevTasks.filter((t) => t.actualStartAt).map((t) => t.actualStartAt!), + versionDevTasks.filter((t) => t.actualEndAt).map((t) => t.actualEndAt!), ); // 测试阶段 @@ -732,16 +736,17 @@ export default function VersionDetailPage() { // 调研/产品/UI 用 startTime→endTime 计算 versionPlans.forEach((p) => { if (!p.owner || !p.actualStartAt) return; - const hours = calcActualHoursByDates(p.actualStartAt, p.completedAt); + const hours = calcWorkHours(p.actualStartAt, p.completedAt ?? new Date().toISOString()); addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours); }); versionDevTasks.forEach((t) => { - if (!t.startDate || !t.assigneeId) return; - addHours(t.assigneeId, 'dev', calcActualHoursByDates(t.startDate, t.completedAt)); + if (!t.actualStartAt || !t.assigneeId) return; + const endIso = t.actualEndAt ?? new Date().toISOString(); + addHours(t.assigneeId, 'dev', calcWorkHours(t.actualStartAt, endIso)); }); versionTCs.forEach((c) => { if (!c.startedAt || !c.assigneeId) return; - addHours(c.assigneeId, 'test', calcActualHoursByDates(c.startedAt, c.completedAt)); + addHours(c.assigneeId, 'test', calcWorkHours(c.startedAt, c.completedAt ?? new Date().toISOString())); }); const personalRanking = Array.from(personalHours.entries()) @@ -750,6 +755,15 @@ export default function VersionDetailPage() { const maxHours = personalRanking[0]?.total || 1; const totalHours = personalRanking.reduce((s, p) => s + p.total, 0); + // 双口径:日历总耗时(合并区间)+ 人力总投入(即 totalHours) + const allIntervals = [ + ...planIntervals(versionPlans), + ...devTaskIntervals(versionDevTasks), + ...testCaseIntervals(versionTCs), + ...bugIntervals(versionBugs), + ]; + const { calendarHours: versionCalendarHours } = calcTwoMetrics(allIntervals); + return (
{/* 阶段日历耗时 */} @@ -770,10 +784,14 @@ export default function VersionDetailPage() {
))} -
+
- 总人天投入 - {totalHours}h({Math.round(totalHours / 8)}人天) + 日历总耗时 + {formatWorkHours(versionCalendarHours)} +
+
+ 人力总投入 + {formatWorkHours(totalHours)}
@@ -794,11 +812,11 @@ export default function VersionDetailPage() { {item.total}h
- {item.research > 0 &&
} - {item.product > 0 &&
} - {item.ui > 0 &&
} - {item.dev > 0 &&
} - {item.test > 0 &&
} + {item.research > 0 &&
} + {item.product > 0 &&
} + {item.ui > 0 &&
} + {item.dev > 0 &&
} + {item.test > 0 &&
}
))} diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 5417281..a1c028a 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -14,7 +14,7 @@ import type { VersionWithContext } from '@/lib/derive'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status'; import { STAGES } from '@/lib/stage'; import { ROLE_LABEL } from '@/lib/stage'; -import { STATUS_PROGRESS } from '@/lib/dev-task'; +import { STATUS_PROGRESS, getEstimateHours } from '@/lib/dev-task'; import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health'; import { Pagination, usePagination } from '@/components/Pagination'; @@ -189,12 +189,12 @@ export default function VersionsPage() { // Dev tasks progress (weighted by STATUS_PROGRESS) if (vDevTasks.length > 0) { - const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0); + const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0); let devProgress: number; if (totalEstimate === 0) { devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length; } else { - const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0); + const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0); devProgress = weighted / totalEstimate; } segments.push(devProgress); diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 2e24917..11d7d91 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -18,8 +18,9 @@ import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer'; import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer'; import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer'; import { BugCreateModal } from '@/components/bug/BugCreateModal'; -import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task'; +import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, getEstimateHours, getActualHours } from '@/lib/dev-task'; import { formatDateTime } from '@/lib/format'; +import { formatShortTime, formatWorkHours } from '@/lib/work-hours'; 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'; @@ -293,6 +294,18 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: { function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) { const statusBadge = getStatusBadge(item); + const isDevTask = item.type === 'devTask'; + const devTaskRaw = isDevTask ? (item.raw as any) : null; + const devEstimate = devTaskRaw ? getEstimateHours(devTaskRaw) : 0; + const devActual = devTaskRaw ? getActualHours(devTaskRaw) : 0; + const devOverrun = devActual > devEstimate && devEstimate > 0; + const devTimeRange = devTaskRaw ? ( + devTaskRaw.actualStartAt + ? `实际 ${formatShortTime(devTaskRaw.actualStartAt)} → ${devTaskRaw.actualEndAt ? formatShortTime(devTaskRaw.actualEndAt) : '进行中'}` + : (devTaskRaw.expectedStartAt && devTaskRaw.expectedEndAt + ? `预计 ${formatShortTime(devTaskRaw.expectedStartAt)} → ${formatShortTime(devTaskRaw.expectedEndAt)}` + : '') + ) : ''; return (
@@ -322,7 +335,16 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat {item.projectName} / - {item.extra?.dueDate && 截止 {item.extra.dueDate}} + {isDevTask && devTimeRange && ( + <> + {devTimeRange} + {devEstimate > 0 && ( + 0 ? (devOverrun ? 'text-red-600' : (devActual < devEstimate ? 'text-emerald-600' : '')) : ''}`}> + {devActual > 0 ? `${formatWorkHours(devActual)} / ${formatWorkHours(devEstimate)}` : `预 ${formatWorkHours(devEstimate)}`} + + )} + + )} {item.extra?.startTime && {formatDateTime(item.extra.startTime)} → {formatDateTime(item.extra.endTime)}}
diff --git a/apps/web/components/SearchInput.tsx b/apps/web/components/SearchInput.tsx new file mode 100644 index 0000000..84cb2e3 --- /dev/null +++ b/apps/web/components/SearchInput.tsx @@ -0,0 +1,48 @@ +'use client'; +import { Search, X } from 'lucide-react'; + +interface Props { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; +} + +/** + * 列表搜索框(标题 + 编号模糊匹配,外部需配合 useDebouncedValue 防抖) + */ +export function SearchInput({ value, onChange, placeholder = '搜索标题或编号', className = '' }: Props) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="h-6 w-72 pl-7 pr-6 rounded border border-[var(--line)] bg-[var(--bg-card)] text-[11px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none" + /> + {value && ( + + )} +
+ ); +} + +/** + * 标题 + 编号模糊匹配(case-insensitive) + */ +export function matchTitleOrNo(item: { title: string; no?: string }, keyword: string): boolean { + if (!keyword) return true; + const k = keyword.trim().toLowerCase(); + if (!k) return true; + if (item.title.toLowerCase().includes(k)) return true; + if (item.no && item.no.toLowerCase().includes(k)) return true; + return false; +} diff --git a/apps/web/components/bug/BugRow.tsx b/apps/web/components/bug/BugRow.tsx index 9568b5c..73bed67 100644 --- a/apps/web/components/bug/BugRow.tsx +++ b/apps/web/components/bug/BugRow.tsx @@ -1,7 +1,9 @@ 'use client'; +import { memo } from 'react'; import { BugStatusBadge } from './BugStatusBadge'; -import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; +import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug'; +import { formatWorkHours } from '@/lib/work-hours'; import type { Bug } from '@/lib/bug'; interface Props { @@ -17,16 +19,22 @@ const PRIORITY_DOT: Record = { P3: 'bg-zinc-300', }; -export function BugRow({ bug, testCaseNo, onClick }: Props) { +function BugRowImpl({ bug, testCaseNo, onClick }: Props) { + const actualHours = getBugActualHours(bug); return (
{bug.bugNo} {bug.title} - {BUG_SEVERITY_LABEL[bug.severity]} + {BUG_SEVERITY_LABEL[bug.severity]} - {testCaseNo && {testCaseNo}} - {bug.assigneeId} + {actualHours > 0 && ( + {formatWorkHours(actualHours)} + )} + {testCaseNo && {testCaseNo}} + {bug.assigneeId}
); } + +export const BugRow = memo(BugRowImpl); diff --git a/apps/web/components/bug/BugTab.tsx b/apps/web/components/bug/BugTab.tsx index 3f3ab13..39dd482 100644 --- a/apps/web/components/bug/BugTab.tsx +++ b/apps/web/components/bug/BugTab.tsx @@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { BugRow } from './BugRow'; import { BugDetailDrawer } from './BugDetailDrawer'; import { Pagination, usePagination } from '@/components/Pagination'; -import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL } from '@/lib/bug'; +import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug'; +import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import type { BugStatus, BugSeverity } from '@/lib/bug'; interface Props { @@ -26,48 +29,50 @@ export function BugTab({ versionId, requirementIds }: Props) { useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - // 该版本的所有 Bug(直接通过 versionId 查询) const versionBugs = useMemo( () => bugs.filter((b) => b.versionId === versionId), [bugs, versionId], ); - // 统计 const openCount = versionBugs.filter((b) => b.status === 'open').length; const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length; const fixedCount = versionBugs.filter((b) => b.status === 'fixed' || b.status === 'verifying').length; const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length; const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length; const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0; + const { calendarHours: bugCalendarHours, manhours: bugManhours } = useMemo(() => calcTwoMetrics(bugIntervals(versionBugs)), [versionBugs]); - // 筛选 const [filterAssignee, setFilterAssignee] = useState(''); const [filterStatus, setFilterStatus] = useState(''); const [filterSeverity, setFilterSeverity] = useState(''); + const [keyword, setKeyword] = useState(''); + const debouncedKeyword = useDebouncedValue(keyword, 300); const filteredBugs = useMemo(() => { let result = versionBugs; if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee); if (filterStatus) result = result.filter((b) => b.status === filterStatus); if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity); + if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword)); return result; - }, [versionBugs, filterAssignee, filterStatus, filterSeverity]); + }, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20); const [selectedBugId, setSelectedBugId] = useState(null); + const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]); + const assignees = useMemo(() => { const names = new Set(versionBugs.map((b) => b.assigneeId)); return Array.from(names); }, [versionBugs]); - const hasFilter = !!(filterAssignee || filterStatus || filterSeverity); + const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword); return (
- {/* 统计栏 */} -
+
Bug {versionBugs.length} 个 @@ -79,11 +84,14 @@ export function BugTab({ versionId, requirementIds }: Props) { {criticalCount} 致命 )} 解决率 {resolveRate}% + {bugManhours > 0 && ( + 日历 {formatWorkHours(bugCalendarHours)} / 人力 {formatWorkHours(bugManhours)} + )}
- {/* 筛选 */} -
+
+ { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> - {hasFilter && } + {hasFilter && } {filteredBugs.length} 条
- {/* 列表 */} {filteredBugs.length === 0 ? (

{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}

@@ -109,7 +116,7 @@ export function BugTab({ versionId, requirementIds }: Props) { ) : (
{paged.map((bug) => { - const tc = testCases.find((c) => c.id === bug.testCaseId); + const tc = testCaseMap.get(bug.testCaseId); return setSelectedBugId(bug.id)} />; })}
diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 23c16f0..b1fbd85 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { X, AlertTriangle } from 'lucide-react'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours'; import type { Priority } from '@/lib/derive'; interface Props { @@ -17,6 +18,18 @@ interface Props { onCreated?: () => void; } +function defaultExpectedStart(): string { + const d = new Date(); + d.setHours(9, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + +function defaultExpectedEnd(): string { + const d = new Date(); + d.setHours(18, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) { const { createTask, tasks } = useDevTaskStore(); const { categories } = useTaskCategoryStore(); @@ -39,16 +52,46 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [priority, setPriority] = useState(versionReqs[0]?.priority || 'P2'); const [priorityManuallySet, setPriorityManuallySet] = useState(false); - const [estimateHours, setEstimateHours] = useState(8); - const [dueDate, setDueDate] = useState(''); + const [expectedStartLocal, setExpectedStartLocal] = useState(defaultExpectedStart); + const [expectedEndLocal, setExpectedEndLocal] = useState(defaultExpectedEnd); const [predecessorIds, setPredecessorIds] = useState([]); const [description, setDescription] = useState(''); - const [overdueReason, setOverdueReason] = useState(''); + const [overdueVersionReason, setOverdueVersionReason] = useState(''); + + useEffect(() => { + if (categories[0] && !categoryId) setCategoryId(categories[0].id); + }, [categories, categoryId]); + + const expectedStartISO = localToISO(expectedStartLocal); + const expectedEndISO = localToISO(expectedEndLocal); + + const estimateHours = useMemo(() => { + if (!expectedStartISO || !expectedEndISO) return 0; + return calcWorkHours(expectedStartISO, expectedEndISO); + }, [expectedStartISO, expectedEndISO]); const selectedReq = versionReqs.find((r) => r.id === requirementId); const effectivePriority = priorityManuallySet ? priority : (selectedReq?.priority || priority); - const isOverdue = !!(dueDate && versionDeadline && dueDate > versionDeadline); - const canSubmit = title.trim() && requirementId && categoryId && assigneeId && estimateHours > 0 && (!isOverdue || overdueReason.trim()); + + const versionDeadlineISO = useMemo(() => { + if (!versionDeadline) return null; + const d = new Date(versionDeadline); + if (isNaN(d.getTime())) return null; + d.setHours(23, 59, 59, 999); + return d.toISOString(); + }, [versionDeadline]); + + const isOverdueVersion = !!(expectedEndISO && versionDeadlineISO && expectedEndISO > versionDeadlineISO); + const startBeforeEnd = expectedStartISO && expectedEndISO && expectedStartISO < expectedEndISO; + + const canSubmit = + title.trim() && + requirementId && + categoryId && + assigneeId && + !!startBeforeEnd && + estimateHours > 0 && + (!isOverdueVersion || overdueVersionReason.trim()); const handleSubmit = () => { if (!canSubmit) return; @@ -60,16 +103,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, assigneeId, reviewerId: undefined, priority: effectivePriority, - estimateHours, - startDate: undefined, - dueDate: dueDate || undefined, - completedAt: undefined, + expectedStartAt: expectedStartISO, + expectedEndAt: expectedEndISO, + actualStartAt: undefined, + actualEndAt: undefined, status: 'todo', blockReason: undefined, blockedById: undefined, predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined, riskLevel: undefined, - overdueReason: isOverdue ? overdueReason.trim() : undefined, + delayReason: undefined, + overdueVersionReason: isOverdueVersion ? overdueVersionReason.trim() : undefined, createdBy: user?.name || '系统', }); onCreated?.(); @@ -109,7 +153,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
-
+
+
+ + setExpectedStartLocal(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + setExpectedEndLocal(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+
- - setEstimateHours(parseFloat(e.target.value) || 0)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> -
-
- - setDueDate(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> - {versionDeadline && ( -

版本截止:{versionDeadline}

- )} + +
+ {estimateHours > 0 ? formatWorkHours(estimateHours) : (startBeforeEnd ? '0h' : '请先选择有效起止时间')} +
+

工作时段 9:00–12:00 + 13:00–18:00,跳过周末

- {isOverdue && ( + {!startBeforeEnd && expectedStartLocal && expectedEndLocal && ( +
+ 预计开始必须早于预计截止 +
+ )} + {versionDeadline && ( +

版本截止:{versionDeadline}

+ )} + {isOverdueVersion && (
- 任务截止日期超出版本截止日期({versionDeadline}),请说明原因 + 任务预计截止超出版本截止日期({versionDeadline}),请说明原因
- setOverdueReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" /> + setOverdueVersionReason(e.target.value)} placeholder="超期原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" />
)} {versionTasks.length > 0 && ( diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index f5596d5..94763ce 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -1,15 +1,23 @@ 'use client'; -import { useState } from 'react'; -import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil, ArrowRightLeft } from 'lucide-react'; +import { useState, useMemo } from 'react'; +import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, ArrowRightLeft, CalendarRange } 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 { formatDateTime } from '@/lib/format'; +import { + ALLOWED_TRANSITIONS, + DEV_TASK_STATUS_LABEL, + DEV_TASK_STATUS_COLOR, + formatHours, + getEstimateHours, + getActualHours, +} from '@/lib/dev-task'; +import { needsDelayReason } from '@/lib/dev-task-transitions'; +import { formatShortTime } from '@/lib/work-hours'; import type { DevTaskStatus } from '@/lib/dev-task'; interface Props { @@ -26,6 +34,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const { members } = useMemberStore(); const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); + const [showDelayInput, setShowDelayInput] = useState(false); + const [delayReason, setDelayReason] = useState(''); const task = tasks.find((t) => t.id === taskId); if (!task) return null; @@ -39,9 +49,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const [blockReason, setBlockReason] = useState(task.blockReason || ''); const [showBlockInput, setShowBlockInput] = useState(false); + const estimate = useMemo(() => getEstimateHours(task), [task]); + const actual = useMemo(() => getActualHours(task), [task]); + const overrun = actual > estimate && estimate > 0; + const requireDelay = task.status === 'todo' && needsDelayReason(task); + const handleTransition = (to: DevTaskStatus) => { - const result = changeStatus(task.id, to); - if (!result.ok) alert(result.message); + if (to === 'in_progress' && requireDelay && !showDelayInput) { + setShowDelayInput(true); + return; + } + const opts = to === 'in_progress' && delayReason.trim() + ? { delayReason: delayReason.trim() } + : undefined; + const result = changeStatus(task.id, to, opts); + if (!result.ok) { + alert(result.message); + return; + } + if (to === 'in_progress') { + setShowDelayInput(false); + setDelayReason(''); + } }; const handleBlock = () => { @@ -58,13 +87,11 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel return (
e.stopPropagation()}> - {/* 上下文 */} {contextLabel && (
{contextLabel}
)} - {/* 顶栏 */}
{task.taskNo} @@ -79,7 +106,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
- {/* 转交 */} {showTransfer && (
转交给: @@ -92,10 +118,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)} - {/* 滚动区域 */}
- {/* 卡片1:关联需求 */} {requirement && (
关联需求
@@ -107,7 +131,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)} - {/* 卡片2:状态 & 操作 */}
状态 & 操作
@@ -119,11 +142,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel 阻塞中 )} + {requireDelay && ( + + 超期未开始 + + )}
- {/* 流转按钮 */} - {nextStatuses.length > 0 && ( -
+ {nextStatuses.length > 0 && !showDelayInput && ( +
{nextStatuses.map((s) => (
)} - {/* 阻塞管理 */} + {showDelayInput && ( +
+
+ + 已超过预计开始时间,请说明延后原因 +
+ setDelayReason(e.target.value)} placeholder="延后原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus /> +
+ + +
+
+ )} +
{task.isBlocked ? (
@@ -157,7 +197,52 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
- {/* 卡片3:基本信息 */} +
+
时间信息
+
+
+ 预计开始 + {task.expectedStartAt ? formatShortTime(task.expectedStartAt) : '-'} +
+
+ 预计截止 + {task.expectedEndAt ? formatShortTime(task.expectedEndAt) : '-'} +
+
+ 实际开始 + {task.actualStartAt ? formatShortTime(task.actualStartAt) : '—'} +
+
+ 实际结束 + {task.actualEndAt ? formatShortTime(task.actualEndAt) : '—'} +
+
+ 预计耗时 + {formatHours(estimate)} +
+
+ 实际耗时 + 0 ? (overrun ? 'text-red-600' : (actual < estimate ? 'text-emerald-600' : 'text-[var(--ink)]')) : 'text-[var(--ink-muted)]'}`}> + {actual > 0 ? formatHours(actual) : '—'} + {task.actualStartAt && !task.actualEndAt && 进行中} + +
+
+ {task.delayReason && ( +
+
+ +
延后原因:{task.delayReason}
+
+
+ )} + {task.overdueVersionReason && ( +
+ 超出版本截止原因:{task.overdueVersionReason} +
+ )} +
+
基本信息
@@ -177,35 +262,9 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
- 预计 - {formatHours(task.estimateHours)} + 创建 + {formatShortTime(task.createdAt)}
-
- - 实际 - {task.startDate ? formatHours(calcActualHoursByDates(task.startDate, task.completedAt)) : '-'} -
- {task.startDate && ( -
- - 开始开发 - {formatDateTime(task.startDate)} -
- )} - {task.dueDate && ( -
- - 截止 - {task.dueDate} -
- )} - {task.completedAt && ( -
- - 提测 - {formatDateTime(task.completedAt)} -
- )}
{task.description && (
@@ -214,7 +273,6 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )}
- {/* 卡片4:前置任务 */} {predecessors.length > 0 && (
前置任务
diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index 079769e..bcddf92 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -3,7 +3,8 @@ import { AlertTriangle } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; -import { formatHours, calcActualHoursByDates } from '@/lib/dev-task'; +import { getEstimateHours, getActualHours } from '@/lib/dev-task'; +import { formatShortTime, formatWorkHours } from '@/lib/work-hours'; import type { DevTask } from '@/lib/dev-task'; import type { TaskCategory } from '@/lib/task-category'; @@ -20,7 +21,33 @@ const PRIORITY_DOT: Record = { P3: 'bg-zinc-300', }; +function timeRangeText(task: DevTask): { text: string; tone: string } { + if (task.status === 'submitted' && task.actualStartAt && task.actualEndAt) { + return { text: `实际 ${formatShortTime(task.actualStartAt)} → ${formatShortTime(task.actualEndAt)}`, tone: 'text-[var(--ink-muted)]' }; + } + if (task.actualStartAt) { + return { text: `实际 ${formatShortTime(task.actualStartAt)} → 进行中`, tone: 'text-emerald-600' }; + } + if (task.expectedStartAt && task.expectedEndAt) { + return { text: `预计 ${formatShortTime(task.expectedStartAt)} → ${formatShortTime(task.expectedEndAt)}`, tone: 'text-blue-600' }; + } + return { text: '', tone: 'text-[var(--ink-muted)]' }; +} + +function hoursText(estimate: number, actual: number): { text: string; tone: string } { + if (actual <= 0) return { text: `预 ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' }; + let tone = 'text-[var(--ink-soft)]'; + if (actual > estimate) tone = 'text-red-600'; + else if (actual < estimate) tone = 'text-emerald-600'; + return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone }; +} + export function DevTaskRow({ task, category, onClick }: Props) { + const estimate = getEstimateHours(task); + const actual = getActualHours(task); + const range = timeRangeText(task); + const hours = hoursText(estimate, actual); + return (
- - {task.startDate && {calcActualHoursByDates(task.startDate, task.completedAt)}h} - {task.startDate && ' / '} - {formatHours(task.estimateHours).split('(')[0]} - - {task.assigneeId} + {range.text} + {hours.text} + {task.assigneeId}
); } diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index b76eeee..f3a923a 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -11,8 +11,11 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { DevTaskRow } from './DevTaskRow'; import { DevTaskCreateModal } from './DevTaskCreateModal'; import { DevTaskDetailDrawer } from './DevTaskDetailDrawer'; -import { calcGroupProgress, DEV_TASK_STATUS_LABEL } from '@/lib/dev-task'; +import { calcGroupProgress, DEV_TASK_STATUS_LABEL, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task'; +import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { Pagination, usePagination } from '@/components/Pagination'; +import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; interface Props { @@ -33,16 +36,18 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props useEffect(() => { fetchCategories(); }, [fetchCategories]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); const versionTasks = useMemo( - () => tasks.filter((t) => requirementIds.includes(t.requirementId)), - [tasks, requirementIds], + () => tasks.filter((t) => reqIdSet.has(t.requirementId)), + [tasks, reqIdSet], ); - // 筛选 const [filterAssignee, setFilterAssignee] = useState(''); const [filterStatus, setFilterStatus] = useState(''); const [filterBlocked, setFilterBlocked] = useState(''); const [filterCategory, setFilterCategory] = useState(''); + const [keyword, setKeyword] = useState(''); + const debouncedKeyword = useDebouncedValue(keyword, 300); const filteredTasks = useMemo(() => { let result = versionTasks; @@ -51,30 +56,30 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props if (filterBlocked === 'yes') result = result.filter((t) => t.isBlocked); if (filterBlocked === 'no') result = result.filter((t) => !t.isBlocked); if (filterCategory) result = result.filter((t) => t.categoryId === filterCategory); + if (debouncedKeyword) result = result.filter((t) => matchTitleOrNo({ title: t.title, no: t.taskNo }, debouncedKeyword)); return result; - }, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory]); + }, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory, debouncedKeyword]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20); const progress = calcGroupProgress(versionTasks); - const totalEstimate = versionTasks.reduce((s, t) => s + t.estimateHours, 0); + const { estimate: totalEstimate } = aggregateDevTaskHours(versionTasks); + const { calendarHours, manhours } = useMemo(() => calcTwoMetrics(devTaskIntervals(versionTasks)), [versionTasks]); + const overrun = manhours > totalEstimate && totalEstimate > 0; + const underrun = manhours > 0 && manhours < totalEstimate; + const hoursTone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink-muted)]'; const blockedCount = versionTasks.filter((t) => t.isBlocked).length; const [showCreate, setShowCreate] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(null); - // 批量选择 const [selectedIds, setSelectedIds] = useState>(new Set()); - const isAllSelected = paged.length > 0 && paged.every((t) => selectedIds.has(t.id)); const toggleSelect = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); setSelectedIds(next); }; - const toggleAll = () => { - setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((t) => t.id))); - }; const handleBatchDelete = () => { if (selectedIds.size === 0) return; if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return; @@ -92,25 +97,27 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props return map; }, [paged]); + const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); + const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); + const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]); + const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [versionTasks]); - const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory); + const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory || debouncedKeyword); return (
- {/* 工具栏:进度 + 筛选 + 操作 合为一行 */}
- {/* 进度 */} {progress}%
- {versionTasks.length}任务 · {totalEstimate}h + {versionTasks.length}任务 · 预 {formatWorkHours(totalEstimate)}{manhours > 0 ? <> · 日历 {formatWorkHours(calendarHours)} / 人力 {formatWorkHours(manhours)} : null} {blockedCount > 0 && {blockedCount}阻塞} - {/* 筛选 */} + { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> - {hasFilter && } + {hasFilter && } - {/* 右侧操作 */}
{selectedIds.size > 0 && (
- {/* 列表 */} {filteredTasks.length === 0 ? (

{hasFilter ? '没有匹配的任务' : '暂无开发任务'}

@@ -152,7 +157,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
) : ( Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => { - const req = requirements.find((r) => r.id === reqId); + const req = requirementMap.get(reqId); const reqProgress = calcGroupProgress(reqTasks); return (
@@ -168,7 +173,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} /> + setSelectedTaskId(t.id)} />
))} @@ -180,7 +185,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props {total > 20 && } {showCreate && setShowCreate(false)} />} - {selectedTaskId && t.id)} onClose={() => setSelectedTaskId(null)} />} + {selectedTaskId && setSelectedTaskId(null)} />}
); } diff --git a/apps/web/components/dev-task/WorklogPanel.tsx b/apps/web/components/dev-task/WorklogPanel.tsx index 006621e..5060ba7 100644 --- a/apps/web/components/dev-task/WorklogPanel.tsx +++ b/apps/web/components/dev-task/WorklogPanel.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { Plus, Trash2 } from 'lucide-react'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; -import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { getTaskWorklogs } from '@/lib/task-worklog'; @@ -13,7 +12,6 @@ interface Props { export function WorklogPanel({ taskId }: Props) { const { worklogs, addWorklog, deleteWorklog } = useTaskWorklogStore(); - const { syncActualHours } = useDevTaskStore(); const user = useAuthStore((s) => s.user); const taskLogs = getTaskWorklogs(worklogs, taskId); @@ -28,16 +26,13 @@ export function WorklogPanel({ taskId }: Props) { const handleAdd = () => { if (!workContent.trim() || hours <= 0) return; addWorklog({ taskId, userId: user?.name || '', date, hours, workContent: workContent.trim() }); - const newTotal = totalHours + hours; - syncActualHours(taskId, newTotal); setWorkContent(''); setHours(1); setAdding(false); }; - const handleDelete = (id: string, logHours: number) => { + const handleDelete = (id: string) => { deleteWorklog(id); - syncActualHours(taskId, Math.max(0, totalHours - logHours)); }; return ( @@ -75,7 +70,7 @@ export function WorklogPanel({ taskId }: Props) {

{log.workContent}

{log.date} · {log.hours}h · {log.userId}

-
diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx index ae9f454..b860587 100644 --- a/apps/web/components/test-case/TestCaseRow.tsx +++ b/apps/web/components/test-case/TestCaseRow.tsx @@ -1,7 +1,9 @@ 'use client'; +import { memo } from 'react'; import { TestCaseStatusBadge } from './TestCaseStatusBadge'; -import { calcActualHoursByDates } from '@/lib/dev-task'; +import { getTestCaseActualHours } from '@/lib/test-case'; +import { formatWorkHours } from '@/lib/work-hours'; import type { TestCase } from '@/lib/test-case'; interface Props { @@ -17,20 +19,23 @@ const PRIORITY_DOT: Record = { P3: 'bg-zinc-300', }; -export function TestCaseRow({ testCase, bugCount, onClick }: Props) { +function TestCaseRowImpl({ testCase, bugCount, onClick }: Props) { + const actualHours = getTestCaseActualHours(testCase); return (
{testCase.caseNo} {testCase.title} - {testCase.startedAt && ( - {calcActualHoursByDates(testCase.startedAt, testCase.completedAt)}h + {actualHours > 0 && ( + {formatWorkHours(actualHours)} )} {bugCount > 0 && ( - {bugCount} Bug + {bugCount} Bug )} - {testCase.assigneeId || '-'} + {testCase.assigneeId || '-'}
); } + +export const TestCaseRow = memo(TestCaseRowImpl); diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index 6bb677e..3549d66 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -11,8 +11,11 @@ import { TestCaseRow } from './TestCaseRow'; import { TestCaseCreateModal } from './TestCaseCreateModal'; import { TestCaseDetailDrawer } from './TestCaseDetailDrawer'; import { BugCreateModal } from '@/components/bug/BugCreateModal'; -import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case'; +import { calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case'; +import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { Pagination, usePagination } from '@/components/Pagination'; +import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { TestCaseStatus } from '@/lib/test-case'; interface Props { @@ -30,27 +33,33 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); + const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); const versionCases = useMemo( () => testCases.filter((c) => c.versionId === versionId), [testCases, versionId], ); - // 提测状态 - const versionDevTasks = useMemo(() => devTasks.filter((t) => requirementIds.includes(t.requirementId)), [devTasks, requirementIds]); + const versionDevTasks = useMemo( + () => devTasks.filter((t) => reqIdSet.has(t.requirementId)), + [devTasks, reqIdSet], + ); const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted'); const stats = calcTestProgress(versionCases); + const { calendarHours: tcCalendarHours, manhours: tcManhours } = useMemo(() => calcTwoMetrics(testCaseIntervals(versionCases)), [versionCases]); - // 筛选 const [filterAssignee, setFilterAssignee] = useState(''); const [filterStatus, setFilterStatus] = useState(''); + const [keyword, setKeyword] = useState(''); + const debouncedKeyword = useDebouncedValue(keyword, 300); const filteredCases = useMemo(() => { let result = versionCases; if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee); if (filterStatus) result = result.filter((c) => c.status === filterStatus); + if (debouncedKeyword) result = result.filter((c) => matchTitleOrNo({ title: c.title, no: c.caseNo }, debouncedKeyword)); return result; - }, [versionCases, filterAssignee, filterStatus]); + }, [versionCases, filterAssignee, filterStatus, debouncedKeyword]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20); @@ -58,18 +67,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { const [selectedCaseId, setSelectedCaseId] = useState(null); const [bugForCaseId, setBugForCaseId] = useState(null); - // 批量选择 const [selectedIds, setSelectedIds] = useState>(new Set()); - const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id)); const toggleSelect = (id: string) => { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); setSelectedIds(next); }; - const toggleAll = () => { - setSelectedIds(isAllSelected ? new Set() : new Set(paged.map((c) => c.id))); - }; const handleBatchDelete = () => { if (selectedIds.size === 0) return; const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id)); @@ -79,7 +83,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { setSelectedIds(new Set()); }; - // 按需求分组 const groupedByReq = useMemo(() => { const map = new Map(); for (const c of paged) { @@ -91,12 +94,18 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { return map; }, [paged]); + const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); + const bugCountByCase = useMemo(() => { + const map = new Map(); + for (const b of bugs) map.set(b.testCaseId, (map.get(b.testCaseId) ?? 0) + 1); + return map; + }, [bugs]); + const assignees = useMemo(() => Array.from(new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[])), [versionCases]); - const hasFilter = !!(filterAssignee || filterStatus); + const hasFilter = !!(filterAssignee || filterStatus || debouncedKeyword); return (
- {/* 提测通知 */} {allSubmitted && (
@@ -110,7 +119,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
)} - {/* 工具栏:进度 + 筛选 + 操作 一行 */}
{stats.completionRate}% @@ -119,9 +127,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
{stats.total}例 · 通过{stats.passed} · 失败{stats.failed} 通过率{stats.passRate}% + {tcManhours > 0 && ( + 日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)} + )} + { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> - {hasFilter && } + {hasFilter && }
{selectedIds.size > 0 && ( @@ -145,7 +157,6 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
- {/* 列表 */} {filteredCases.length === 0 ? (

{hasFilter ? '没有匹配的用例' : '暂无测试用例'}

@@ -153,7 +164,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
) : ( Array.from(groupedByReq.entries()).map(([reqId, cases]) => { - const req = reqId === '__none__' ? null : requirements.find((r) => r.id === reqId); + const req = reqId === '__none__' ? null : requirementMap.get(reqId); const reqPassed = cases.filter((c) => c.status === 'passed').length; return (
@@ -169,7 +180,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} /> + setSelectedCaseId(c.id)} />
))} diff --git a/apps/web/hooks/useDebouncedValue.ts b/apps/web/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..9a6211f --- /dev/null +++ b/apps/web/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +'use client'; +import { useEffect, useState } from 'react'; + +/** + * 防抖 hook:value 变化后延迟 delay 毫秒才更新返回值 + * 用于搜索框:用户输入时不立即过滤,避免 100+ 用户同时输入造成的卡顿 + */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return debounced; +} diff --git a/apps/web/lib/bug.ts b/apps/web/lib/bug.ts index 4cd34ea..b663a0e 100644 --- a/apps/web/lib/bug.ts +++ b/apps/web/lib/bug.ts @@ -1,4 +1,5 @@ import type { Priority } from './derive'; +import { calcWorkHours, type TimeInterval } from './work-hours'; export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected'; export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial'; @@ -87,3 +88,32 @@ export function generateBugNo(existingBugs: Bug[]): string { }, 0); return `BUG-${String(maxNum + 1).padStart(3, '0')}`; } + +export function getBugActualHours(bug: Bug, now: Date = new Date()): number { + const start = bug.createdAt; + if (!start) return 0; + const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString()); + return calcWorkHours(start, end); +} + +export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number { + let sum = 0; + for (const b of bugs) sum += getBugActualHours(b, now); + return Math.round(sum * 2) / 2; +} + +/** + * 抽取每条 Bug 的 [createdAt, closedAt ?? resolvedAt ?? (终态 updatedAt) ?? now] 时间区间 + * 用于双口径耗时统计(calcTwoMetrics) + */ +export function bugIntervals(bugs: Bug[], now: Date = new Date()): TimeInterval[] { + const out: TimeInterval[] = []; + const nowIso = now.toISOString(); + for (const b of bugs) { + if (!b.createdAt) continue; + const isTerminal = b.status === 'closed' || b.status === 'rejected'; + const end = b.closedAt ?? b.resolvedAt ?? (isTerminal ? b.updatedAt : nowIso); + out.push({ start: b.createdAt, end }); + } + return out; +} diff --git a/apps/web/lib/dev-task-transitions.ts b/apps/web/lib/dev-task-transitions.ts new file mode 100644 index 0000000..b0d8874 --- /dev/null +++ b/apps/web/lib/dev-task-transitions.ts @@ -0,0 +1,62 @@ +import type { DevTask } from './dev-task'; + +/** + * 是否可以"手动"切到 in_progress + * - 仅当 todo 且未到 expectedStartAt 时返回 true + * - 已到点:自动机制接管,UI 不应再显示手动按钮 + * - 已超期:返回 false,UI 应要求填 delayReason + */ +export function canManualStart(task: DevTask, now: Date = new Date()): boolean { + if (task.status !== 'todo') return false; + if (!task.expectedStartAt) return true; + return now.getTime() < new Date(task.expectedStartAt).getTime(); +} + +/** + * 自动切换扫描:返回所有需要从 todo 切到 in_progress 的任务 + * - status === 'todo' + * - now >= expectedStartAt(且未超期超过 0 秒就自动切——超期未切的可单独走 needsDelayReason 流程) + * + * 注意:超期任务也包含在内,由调用方决定要不要扫;当前策略是 fetchTasks 调用时只扫"刚好到点", + * 超期任务保留在 todo 等用户填 delayReason。所以这里加 maxOverdueMs 参数,默认 24h 内的算到点。 + */ +export function findTasksToAutoStart( + tasks: DevTask[], + now: Date = new Date(), + maxOverdueMs: number = 24 * 60 * 60 * 1000, +): Array<{ taskId: string; actualStartAt: string }> { + const result: Array<{ taskId: string; actualStartAt: string }> = []; + for (const t of tasks) { + if (t.status !== 'todo' || !t.expectedStartAt || t.actualStartAt) continue; + const expected = new Date(t.expectedStartAt).getTime(); + if (isNaN(expected)) continue; + const diff = now.getTime() - expected; + if (diff >= 0 && diff <= maxOverdueMs) { + result.push({ taskId: t.id, actualStartAt: t.expectedStartAt }); + } + } + return result; +} + +/** + * 是否处于"超期手动开干需填延后原因"状态 + * - todo 且 now > expectedStartAt + */ +export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean { + if (task.status !== 'todo' || !task.expectedStartAt) return false; + return now.getTime() > new Date(task.expectedStartAt).getTime(); +} + +/** + * 进入 in_progress 时计算 actualStartAt + * - auto: 等于 expectedStartAt + * - manual: 等于 now + */ +export function deriveActualStartAt( + task: DevTask, + trigger: 'auto' | 'manual', + now: Date = new Date(), +): string { + if (trigger === 'auto' && task.expectedStartAt) return task.expectedStartAt; + return now.toISOString(); +} diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts index 56757c4..a9e4895 100644 --- a/apps/web/lib/dev-task.ts +++ b/apps/web/lib/dev-task.ts @@ -1,4 +1,5 @@ import type { Priority } from './derive'; +import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours'; export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted'; @@ -12,18 +13,20 @@ export interface DevTask { assigneeId: string; reviewerId?: string; priority: Priority; - estimateHours: number; - actualHours: number; - startDate?: string; - dueDate?: string; - completedAt?: string; + + expectedStartAt: string; + expectedEndAt: string; + actualStartAt?: string; + actualEndAt?: string; + status: DevTaskStatus; isBlocked: boolean; blockReason?: string; blockedById?: string; predecessorIds?: string[]; riskLevel?: 'low' | 'medium' | 'high'; - overdueReason?: string; + delayReason?: string; + overdueVersionReason?: string; createdBy: string; createdAt: string; updatedAt: string; @@ -67,28 +70,82 @@ export function calcTaskProgress(task: DevTask): number { export function calcGroupProgress(tasks: DevTask[]): number { if (tasks.length === 0) return 0; - const totalEstimate = tasks.reduce((sum, t) => sum + t.estimateHours, 0); - if (totalEstimate === 0) return 0; - const weighted = tasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0); + const totalEstimate = tasks.reduce((sum, t) => sum + getEstimateHours(t), 0); + if (totalEstimate === 0) { + const sum = tasks.reduce((s, t) => s + STATUS_PROGRESS[t.status], 0); + return Math.round(sum / tasks.length); + } + const weighted = tasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0); return Math.round(weighted / totalEstimate); } export function formatHours(hours: number): string { - if (hours < 8) return `${hours}h`; - const days = Math.floor(hours / 8); - const remainder = hours % 8; - if (remainder === 0) return `${hours}h(${days}人天)`; - return `${hours}h(≈${days}人天)`; + return formatWorkHours(hours); } -export function calcActualHoursByDates(startDate?: string, completedAt?: string): number { - if (!startDate) return 0; - const end = completedAt || new Date().toISOString(); - const startMs = new Date(startDate).getTime(); - const endMs = new Date(end).getTime(); - if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) return 0; - const diffHours = (endMs - startMs) / (1000 * 60 * 60); - return Math.round(diffHours * 2) / 2; // 精确到0.5h +export function getEstimateHours(task: DevTask): number { + if (!task.expectedStartAt || !task.expectedEndAt) return 0; + return calcWorkHours(task.expectedStartAt, task.expectedEndAt); +} + +export function getActualHours(task: DevTask, now: Date = new Date()): number { + if (!task.actualStartAt) return 0; + const end = task.actualEndAt ?? now.toISOString(); + return calcWorkHours(task.actualStartAt, end); +} + +export interface AggregatedHours { + estimate: number; + actual: number; +} + +export function aggregateDevTaskHours(tasks: DevTask[], now: Date = new Date()): AggregatedHours { + let estimate = 0; + let actual = 0; + for (const t of tasks) { + estimate += getEstimateHours(t); + actual += getActualHours(t, now); + } + return { + estimate: Math.round(estimate * 2) / 2, + actual: Math.round(actual * 2) / 2, + }; +} + +export function aggregateActualHoursByAssignee( + tasks: DevTask[], + now: Date = new Date(), +): Array<{ assigneeId: string; actualHours: number; taskCount: number }> { + const map = new Map(); + for (const t of tasks) { + const h = getActualHours(t, now); + if (h <= 0 && t.status !== 'submitted') continue; + const cur = map.get(t.assigneeId) || { actualHours: 0, taskCount: 0 }; + cur.actualHours += h; + cur.taskCount += 1; + map.set(t.assigneeId, cur); + } + return Array.from(map.entries()) + .map(([assigneeId, v]) => ({ + assigneeId, + actualHours: Math.round(v.actualHours * 2) / 2, + taskCount: v.taskCount, + })) + .sort((a, b) => b.actualHours - a.actualHours); +} + +/** + * 抽取每条已开干任务的 [actualStartAt, actualEndAt ?? now] 时间区间 + * 用于双口径耗时统计(calcTwoMetrics) + */ +export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): TimeInterval[] { + const out: TimeInterval[] = []; + const nowIso = now.toISOString(); + for (const t of tasks) { + if (!t.actualStartAt) continue; + out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso }); + } + return out; } export function generateTaskNo(existingTasks: DevTask[]): string { @@ -98,3 +155,29 @@ export function generateTaskNo(existingTasks: DevTask[]): string { }, 0); return `DEV-${String(maxNum + 1).padStart(3, '0')}`; } + +export function isLegacyTask(t: any): boolean { + if (!t || typeof t !== 'object') return false; + return ( + 'startDate' in t || + 'dueDate' in t || + 'completedAt' in t || + 'estimateHours' in t || + 'actualHours' in t || + 'overdueReason' in t + ); +} + +/** + * 兼容函数:按纯小时差计算耗时(保留给 PlanTab/TestCase 体系使用) + * DevTask 体系应改用 getActualHours/getEstimateHours 走 calcWorkHours + */ +export function calcActualHoursByDates(startDate?: string, completedAt?: string): number { + if (!startDate) return 0; + const end = completedAt || new Date().toISOString(); + const startMs = new Date(startDate).getTime(); + const endMs = new Date(end).getTime(); + if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) return 0; + const diffHours = (endMs - startMs) / (1000 * 60 * 60); + return Math.round(diffHours * 2) / 2; +} diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts index baebb07..0484bb8 100644 --- a/apps/web/lib/test-case.ts +++ b/apps/web/lib/test-case.ts @@ -1,4 +1,5 @@ import type { Priority } from './derive'; +import { calcWorkHours, type TimeInterval } from './work-hours'; export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked'; @@ -70,3 +71,29 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed: const completionRate = Math.round((executed / total) * 100); return { total, executed, passed, failed, blocked, passRate, completionRate }; } + +export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number { + if (!tc.startedAt) return 0; + const end = tc.completedAt ?? now.toISOString(); + return calcWorkHours(tc.startedAt, end); +} + +export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number { + let sum = 0; + for (const c of cases) sum += getTestCaseActualHours(c, now); + return Math.round(sum * 2) / 2; +} + +/** + * 抽取每条已开始用例的 [startedAt, completedAt ?? now] 时间区间 + * 用于双口径耗时统计(calcTwoMetrics) + */ +export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): TimeInterval[] { + const out: TimeInterval[] = []; + const nowIso = now.toISOString(); + for (const c of cases) { + if (!c.startedAt) continue; + out.push({ start: c.startedAt, end: c.completedAt ?? nowIso }); + } + return out; +} diff --git a/apps/web/lib/version-plan.ts b/apps/web/lib/version-plan.ts index 59ee789..d335f8f 100644 --- a/apps/web/lib/version-plan.ts +++ b/apps/web/lib/version-plan.ts @@ -96,3 +96,19 @@ export function calcTotalDuration(plans: VersionPlan[]): string { const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24)); return `${totalDays}天`; } + +import type { TimeInterval } from './work-hours'; + +/** + * 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间 + * 用于双口径耗时统计(calcTwoMetrics) + */ +export function planIntervals(plans: VersionPlan[], now: Date = new Date()): TimeInterval[] { + const out: TimeInterval[] = []; + const nowIso = now.toISOString(); + for (const p of plans) { + if (!p.actualStartAt) continue; + out.push({ start: p.actualStartAt, end: p.completedAt ?? nowIso }); + } + return out; +} diff --git a/apps/web/lib/work-hours.ts b/apps/web/lib/work-hours.ts new file mode 100644 index 0000000..4a34261 --- /dev/null +++ b/apps/web/lib/work-hours.ts @@ -0,0 +1,244 @@ +export const WORK_HOURS = { + morningStart: 9, + morningEnd: 12, + afternoonStart: 13, + afternoonEnd: 18, + hoursPerDay: 8, + skipWeekends: true, +} as const; + +const MS_PER_HOUR = 60 * 60 * 1000; + +function isWeekend(d: Date): boolean { + const wd = d.getDay(); + return wd === 0 || wd === 6; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +function dayBound(d: Date, hourFloat: number): Date { + const h = Math.floor(hourFloat); + const m = Math.round((hourFloat - h) * 60); + const r = new Date(d); + r.setHours(h, m, 0, 0); + return r; +} + +function dayOverlapHours(start: Date, end: Date, day: Date): number { + if (WORK_HOURS.skipWeekends && isWeekend(day)) return 0; + const morningStart = dayBound(day, WORK_HOURS.morningStart); + const morningEnd = dayBound(day, WORK_HOURS.morningEnd); + const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart); + const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd); + const morning = clamp(end.getTime(), morningStart.getTime(), morningEnd.getTime()) + - clamp(start.getTime(), morningStart.getTime(), morningEnd.getTime()); + const afternoon = clamp(end.getTime(), afternoonStart.getTime(), afternoonEnd.getTime()) + - clamp(start.getTime(), afternoonStart.getTime(), afternoonEnd.getTime()); + return Math.max(0, morning + afternoon) / MS_PER_HOUR; +} + +/** + * 计算 ISO 时间区间内属于工作时段的小时数 + * + * 工作时段:9:00–12:00 + 13:00–18:00(共 8h/天),周末跳过 + * 精度:0.5h + * + * 验证用例(注释式,供日后接 vitest 迁移): + * - 单日 11:00→14:00 = 1.5h(午休 12:00–13:00 不算) + * - 单日 09:00→18:00 = 8h + * - 跨天 1月1日 09:00 → 1月2日 14:00 = 8 + 1 = 9h + * (注:旧需求是 9:00–18:00 无午休=14h,现按 8h/天口径,结果 9h) + * - 跨周末 周五 17:00 → 周一 11:00 = 1 + 2 = 3h + * - 全在午休 12:30→13:00 = 0h + * - 全在周末 周六全天 = 0h + * - 负区间 / 非法 ISO = 0 + */ +export function calcWorkHours(startISO: string, endISO: string): number { + if (!startISO || !endISO) return 0; + const start = new Date(startISO); + const end = new Date(endISO); + if (isNaN(start.getTime()) || isNaN(end.getTime())) return 0; + if (end.getTime() <= start.getTime()) return 0; + + let total = 0; + const cursor = new Date(start); + cursor.setHours(0, 0, 0, 0); + const endDay = new Date(end); + endDay.setHours(0, 0, 0, 0); + + while (cursor.getTime() <= endDay.getTime()) { + total += dayOverlapHours(start, end, cursor); + cursor.setDate(cursor.getDate() + 1); + } + + return Math.round(total * 2) / 2; +} + +/** + * 把小时数格式化为 "Xh(Y天)",Y 保留 1 位小数(整数则不带小数) + */ +export function formatWorkHours(hours: number): string { + if (!isFinite(hours) || hours <= 0) return '0h(0天)'; + const days = hours / WORK_HOURS.hoursPerDay; + const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, ''); + return `${hours}h(${dayStr}天)`; +} + +/** + * 简短版:"Xh",不带天数(用于横向空间紧张的列表行) + */ +export function formatWorkHoursShort(hours: number): string { + if (!isFinite(hours) || hours <= 0) return '0h'; + return `${hours}h`; +} + +/** + * 仅天数版:"Y天" + */ +export function formatWorkDays(hours: number): string { + if (!isFinite(hours) || hours <= 0) return '0天'; + const days = hours / WORK_HOURS.hoursPerDay; + const dayStr = Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, ''); + return `${dayStr}天`; +} + +/** + * 给定开始 ISO + 工时小时数,返回结束 ISO(备用:旧 estimateHours 反推 expectedEndAt) + * 算法:从开始时间起,按工作时段累加,到达目标小时数即返回。 + */ +export function addWorkHours(startISO: string, hours: number): string { + const start = new Date(startISO); + if (isNaN(start.getTime()) || hours <= 0) return startISO; + let remaining = hours; + const cursor = new Date(start); + while (remaining > 0) { + const day = new Date(cursor); + day.setHours(0, 0, 0, 0); + if (WORK_HOURS.skipWeekends && isWeekend(day)) { + cursor.setDate(cursor.getDate() + 1); + cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); + continue; + } + const morningStart = dayBound(day, WORK_HOURS.morningStart); + const morningEnd = dayBound(day, WORK_HOURS.morningEnd); + const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart); + const afternoonEnd = dayBound(day, WORK_HOURS.afternoonEnd); + const segments: Array<[Date, Date]> = [ + [morningStart, morningEnd], + [afternoonStart, afternoonEnd], + ]; + let advanced = false; + for (const [segStart, segEnd] of segments) { + if (cursor.getTime() >= segEnd.getTime()) continue; + const segBegin = cursor.getTime() < segStart.getTime() ? segStart : cursor; + const availableH = (segEnd.getTime() - segBegin.getTime()) / MS_PER_HOUR; + if (availableH <= 0) continue; + if (remaining <= availableH) { + const finalMs = segBegin.getTime() + remaining * MS_PER_HOUR; + return new Date(finalMs).toISOString(); + } + remaining -= availableH; + cursor.setTime(segEnd.getTime()); + advanced = true; + } + if (!advanced) { + cursor.setDate(cursor.getDate() + 1); + cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); + } else if (cursor.getTime() >= dayBound(day, WORK_HOURS.afternoonEnd).getTime()) { + cursor.setDate(cursor.getDate() + 1); + cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0); + } + } + return cursor.toISOString(); +} + +/** + * 把 datetime-local 输入值(如 "2026-06-16T09:00")规范化为本地 ISO(含秒和毫秒) + */ +export function localToISO(local: string): string { + if (!local) return ''; + const d = new Date(local); + return isNaN(d.getTime()) ? '' : d.toISOString(); +} + +/** + * 把 ISO 时间转成 datetime-local 输入值 + */ +export function isoToLocal(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * 友好显示:MM-DD HH:mm + */ +export function formatShortTime(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export interface TimeInterval { + start: string; + end: string; +} + +/** + * 合并多个时间区间到工作时段内的总小时数(不重复计数) + * + * 算法:按 start 排序后扫描,重叠区间合并 end,对每个合并后的区间调 calcWorkHours 累加 + * 用例:A(9-11) + B(10-12) → 合并为 9-12 = 3h + */ +export function mergeWorkHours(intervals: TimeInterval[]): number { + const valid = intervals + .filter((i) => i.start && i.end) + .map((i) => ({ start: new Date(i.start).getTime(), end: new Date(i.end).getTime() })) + .filter((i) => !isNaN(i.start) && !isNaN(i.end) && i.end > i.start) + .sort((a, b) => a.start - b.start); + + if (valid.length === 0) return 0; + + const merged: Array<{ start: number; end: number }> = []; + let cur = { ...valid[0] }; + for (let i = 1; i < valid.length; i++) { + const next = valid[i]; + if (next.start <= cur.end) { + cur.end = Math.max(cur.end, next.end); + } else { + merged.push(cur); + cur = { ...next }; + } + } + merged.push(cur); + + let total = 0; + for (const m of merged) { + total += calcWorkHours(new Date(m.start).toISOString(), new Date(m.end).toISOString()); + } + return Math.round(total * 2) / 2; +} + +/** + * 双口径汇总: + * - calendarHours:合并所有重叠区间后的日历耗时(看版本实际走了几天) + * - manhours:单条独立累加的人力投入(看总人力成本) + */ +export function calcTwoMetrics(intervals: TimeInterval[]): { + calendarHours: number; + manhours: number; +} { + const calendarHours = mergeWorkHours(intervals); + let manhours = 0; + for (const i of intervals) { + if (!i.start || !i.end) continue; + manhours += calcWorkHours(i.start, i.end); + } + return { calendarHours, manhours: Math.round(manhours * 2) / 2 }; +} diff --git a/apps/web/lib/work-item.ts b/apps/web/lib/work-item.ts index caff59a..679a848 100644 --- a/apps/web/lib/work-item.ts +++ b/apps/web/lib/work-item.ts @@ -43,7 +43,7 @@ export function devTaskToWorkItem(task: DevTask, versionId: string, categoryLabe reviewerId: task.reviewerId, versionId, priority: task.priority, - dueDate: task.dueDate, + dueDate: task.expectedEndAt, categoryLabel, }; } diff --git a/apps/web/lib/workspace-engine.ts b/apps/web/lib/workspace-engine.ts index 57bea9c..9c7494b 100644 --- a/apps/web/lib/workspace-engine.ts +++ b/apps/web/lib/workspace-engine.ts @@ -71,7 +71,13 @@ export function aggregateWorkItems( versionName: ver?.name ?? '-', versionId, priority: t.priority, - extra: { taskNo: t.taskNo, dueDate: t.dueDate }, + extra: { + taskNo: t.taskNo, + expectedStartAt: t.expectedStartAt, + expectedEndAt: t.expectedEndAt, + actualStartAt: t.actualStartAt, + actualEndAt: t.actualEndAt, + }, raw: t, }); }); diff --git a/apps/web/stores/useDevTaskStore.ts b/apps/web/stores/useDevTaskStore.ts index e842b1e..5479112 100644 --- a/apps/web/stores/useDevTaskStore.ts +++ b/apps/web/stores/useDevTaskStore.ts @@ -1,9 +1,11 @@ 'use client'; import { create } from 'zustand'; import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; -import { canTransition, generateTaskNo } from '@/lib/dev-task'; +import { canTransition, generateTaskNo, isLegacyTask } from '@/lib/dev-task'; +import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions'; -const STORAGE_KEY = 'ftb_dev_tasks_v1'; +const STORAGE_KEY = 'ftb_dev_tasks_v2'; +const LEGACY_KEY = 'ftb_dev_tasks_v1'; function saveLocal(items: DevTask[]) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {} @@ -12,7 +14,17 @@ function saveLocal(items: DevTask[]) { function loadLocal(): DevTask[] | null { try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.some(isLegacyTask)) { + localStorage.removeItem(STORAGE_KEY); + return null; + } + return parsed; + } + if (localStorage.getItem(LEGACY_KEY)) { + localStorage.removeItem(LEGACY_KEY); + } } catch {} return null; } @@ -20,12 +32,11 @@ function loadLocal(): DevTask[] | null { interface DevTaskState { tasks: DevTask[]; fetchTasks: () => void; - createTask: (data: Omit) => DevTask; + createTask: (data: Omit) => DevTask; updateTask: (id: string, data: Partial) => void; deleteTask: (id: string) => void; - changeStatus: (id: string, to: DevTaskStatus) => { ok: boolean; message?: string }; + changeStatus: (id: string, to: DevTaskStatus, opts?: { delayReason?: string }) => { ok: boolean; message?: string }; setBlocked: (id: string, blocked: boolean, reason?: string, blockedById?: string) => void; - syncActualHours: (taskId: string, hours: number) => void; getByRequirement: (requirementId: string) => DevTask[]; getByVersionViaRequirements: (requirementIds: string[]) => DevTask[]; getByAssignee: (assigneeId: string) => DevTask[]; @@ -36,7 +47,21 @@ export const useDevTaskStore = create((set, get) => ({ fetchTasks: () => { const cached = loadLocal(); - if (cached) set({ tasks: cached }); + const list = cached ?? []; + const now = new Date(); + const autoStarts = findTasksToAutoStart(list, now); + if (autoStarts.length > 0) { + const map = new Map(autoStarts.map((a) => [a.taskId, a.actualStartAt])); + const updated = list.map((t) => + map.has(t.id) + ? { ...t, status: 'in_progress' as DevTaskStatus, actualStartAt: map.get(t.id)!, updatedAt: now.toISOString() } + : t, + ); + set({ tasks: updated }); + saveLocal(updated); + } else { + set({ tasks: list }); + } }, createTask: (data) => { @@ -46,7 +71,6 @@ export const useDevTaskStore = create((set, get) => ({ ...data, id: `task-${Date.now()}`, taskNo: generateTaskNo(list), - actualHours: 0, isBlocked: false, createdAt: now, updatedAt: now, @@ -71,16 +95,22 @@ export const useDevTaskStore = create((set, get) => ({ saveLocal(updated); }, - changeStatus: (id, to) => { + changeStatus: (id, to, opts) => { const task = get().tasks.find((t) => t.id === id); if (!task) return { ok: false, message: '任务不存在' }; if (!canTransition(task.status, to)) { return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` }; } - const now = new Date().toISOString(); - const startDate = (to === 'in_progress' && !task.startDate) ? now : task.startDate; - const completedAt = (to === 'submitted' && !task.completedAt) ? now : task.completedAt; - get().updateTask(id, { status: to, startDate, completedAt }); + const now = new Date(); + const patch: Partial = { status: to }; + if (to === 'in_progress' && !task.actualStartAt) { + patch.actualStartAt = deriveActualStartAt(task, 'manual', now); + if (opts?.delayReason) patch.delayReason = opts.delayReason; + } + if (to === 'submitted') { + patch.actualEndAt = now.toISOString(); + } + get().updateTask(id, patch); return { ok: true }; }, @@ -92,10 +122,6 @@ export const useDevTaskStore = create((set, get) => ({ }); }, - syncActualHours: (taskId, hours) => { - get().updateTask(taskId, { actualHours: hours }); - }, - getByRequirement: (requirementId) => { return get().tasks.filter((t) => t.requirementId === requirementId); }, diff --git a/docs/superpowers/specs/2026-06-16-version-stats-two-metrics-design.md b/docs/superpowers/specs/2026-06-16-version-stats-two-metrics-design.md new file mode 100644 index 0000000..2816066 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-version-stats-two-metrics-design.md @@ -0,0 +1,178 @@ +# 版本统计「日历耗时 + 人力投入」双口径设计 + +## Context + +当前版本详情下的开发任务/测试用例/Bug 三个 Tab 顶部统计,以及概览页的"总人天投入",都用**单条任务独立累加**计算耗时。问题: + +- 任务 A:09:00–11:00(2h) +- 任务 B:10:00–12:00(2h) + +实际只占用了 09:00–12:00 共 3h 工作时段,但当前累加算成 4h。 + +任务越多重叠越严重,"总耗时"会显著虚高,无法回答"这个版本实际走了几天"。 + +**目标**:顶部统计同时展示两个口径—— + +- **日历耗时**:合并区间后的真实工作时段占用(看版本实际走了多久) +- **人力投入**:单条独立累加(看总人力成本,原口径) + +## 决策摘要 + +| 项 | 决策 | +|---|---| +| 算法位置 | `apps/web/lib/work-hours.ts` 新增 `mergeWorkHours` + `calcTwoMetrics` | +| 区间提取 | 各模块(dev-task / test-case / bug)暴露 `xxxIntervals(items)` 函数 | +| 顶部展示 | 三段式:"预 X / 日历 Y / 人力 Z"(开发任务);"日历 Y / 人力 Z"(测试用例 / Bug) | +| 概览 总人天 | 两行展示:日历总耗时 / 人力总投入(含调研/产品/UI/开发/测试/Bug 全程) | +| 个人耗时排名 | 不动(按人汇总天然是人力维度) | +| PlanTab 顶部 | 不动(不在本次范围) | +| 老聚合函数 | 保留(向后兼容,仍然是 manhours) | +| Bug 区间 end 优先级 | `closedAt > resolvedAt > 终态 updatedAt > now` | + +## 1. 合并区间算法 + +`apps/web/lib/work-hours.ts` 新增: + +```ts +export interface TimeInterval { + start: string; + end: string; +} + +// 合并所有重叠区间后,对每段独立调 calcWorkHours 求和 +export function mergeWorkHours(intervals: TimeInterval[]): number; + +// 双口径汇总 +export function calcTwoMetrics(intervals: TimeInterval[]): { + calendarHours: number; + manhours: number; +}; +``` + +算法: + +1. 过滤掉非法(`!start || !end`)或 `end <= start` 的区间 +2. 按 `start` 升序排序 +3. 扫描合并:当前与 prev 重叠(`cur.start <= prev.end`)时合并 `prev.end = max(prev.end, cur.end)`,否则把 prev push 出去 +4. 对最终的 N 个不重叠区间,逐个调 `calcWorkHours(start, end)` 求和 → `calendarHours` +5. `manhours` = 原始 intervals 各自 `calcWorkHours` 之后求和(不合并) + +精度 0.5h,与现有口径一致。未结束任务以 `now` 作为 end 参与合并。 + +**验证用例**: +- A(9–11) + B(10–12) → 日历 3h,人力 4h +- A(9–11) + B(13–15) + C(10–14) → 合并后 9–15 减去午休 12–13 = 5h;人力 6h +- 跨周末合并:周五 17–周一 10 + 周二 9–11 → 工作时段过滤后两段独立算 + +## 2. 三个模块的 intervals 提取 + +**`apps/web/lib/dev-task.ts`** 新增: + +```ts +export function devTaskIntervals(tasks: DevTask[], now?: Date): TimeInterval[]; +// 取 [actualStartAt, actualEndAt ?? now],过滤无 actualStartAt 的 +``` + +**`apps/web/lib/test-case.ts`** 新增: + +```ts +export function testCaseIntervals(cases: TestCase[], now?: Date): TimeInterval[]; +// 取 [startedAt, completedAt ?? now],过滤无 startedAt 的 +``` + +**`apps/web/lib/bug.ts`** 新增: + +```ts +export function bugIntervals(bugs: Bug[], now?: Date): TimeInterval[]; +// 取 [createdAt, closedAt ?? resolvedAt ?? (status 终态时 updatedAt) ?? now] +``` + +**`apps/web/lib/version-plan.ts`** 新增(仅供概览总人天用): + +```ts +export function planIntervals(plans: VersionPlan[], now?: Date): TimeInterval[]; +// 取 [actualStartAt, completedAt ?? now],过滤无 actualStartAt 的 +``` + +老聚合函数(`aggregateDevTaskHours / aggregateTestCaseActualHours / aggregateBugActualHours`)保留,仍然是 manhours 累加,向后兼容(项目顶部卡片、与我相关页继续用)。 + +## 3. 4 个统计点 UI 调整 + +**(1) `DevTaskTab` 顶部**: + +``` +12任务 · 预 96h(12天) · 日历 64h(8天) / 人力 84h(10.5天) +``` + +- 染色:人力 > 预计 红、< 预计 绿、相等灰 +- 日历不染色(中性事实) + +**(2) `TestCaseTab` 顶部**: + +``` +40例 · 通过28 · 失败5 · 通过率85% · 日历 32h(4天) / 人力 56h(7天) +``` + +**(3) `BugTab` 顶部**: + +``` +Bug 12 个 · 待修复2 · 修复中3 · ... · 解决率75% · 日历 18h / 人力 24h +``` + +**(4) 概览-总人天投入**(line 778):当前一行 `总人天投入 96h(12人天)` 改为两行: + +``` +日历总耗时 104h(13天) +人力总投入 176h(22人天) +``` + +数据源:plans + devTasks + testCases + bugs 的 intervals 全部合并后调 `calcTwoMetrics`。 + +## 4. 不动的部分 + +- 个人耗时排名(按人累加,本身就是人力维度) +- 阶段耗时(项目维度)卡片(已经是日历口径,用 `calcCalendar` 算最早开始/最晚结束/天数差) +- PlanTab(调研/产品方案/UI 设计)顶部统计 +- 项目详情顶部 `HoursStatCard` +- 与我相关页 DevTask 工时显示 + +## 数据流 + +``` +DevTask + TestCase + Bug + Plan + │ + ├── devTaskIntervals(tasks) + ├── testCaseIntervals(cases) + ├── bugIntervals(bugs) + └── planIntervals(plans) + │ + └── work-hours.calcTwoMetrics(intervals) + │ + ├── DevTaskTab 顶部 + ├── TestCaseTab 顶部 + ├── BugTab 顶部 + └── 概览总人天卡片(4 个 intervals 合并) +``` + +## 实施清单 + +新增/修改: + +1. `apps/web/lib/work-hours.ts` — `TimeInterval` + `mergeWorkHours` + `calcTwoMetrics` +2. `apps/web/lib/dev-task.ts` — `devTaskIntervals` +3. `apps/web/lib/test-case.ts` — `testCaseIntervals` +4. `apps/web/lib/bug.ts` — `bugIntervals` +5. `apps/web/lib/version-plan.ts` — `planIntervals` +6. `apps/web/components/dev-task/DevTaskTab.tsx` — 三段式 +7. `apps/web/components/test-case/TestCaseTab.tsx` — 加日历/人力两段 +8. `apps/web/components/bug/BugTab.tsx` — 加日历/人力两段 +9. `apps/web/app/versions/[id]/page.tsx` — 概览"总人天"改两行 + +## 验证 + +1. 单条任务 09:00–11:00:日历=2h,人力=2h +2. 两条 A(9–11) + B(10–12):日历=3h,人力=4h +3. 三条 A(9–11) + B(13–15) + C(10–14):日历=5h(去午休),人力=6h +4. 跨周末合并:周五 17–周一 10 与周二 9–11,分两段算工作时段后再相加 +5. `pnpm type-check` + `pnpm build` 0 错误 +6. 浏览器进版本详情:开发/测试/Bug 三个 Tab 顶部三段式正确,概览总人天两行正确