+
+ { 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,
+
+
-
- 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 && (
)}
{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 && { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除}
+ {hasFilter && { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除}
{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 顶部三段式正确,概览总人天两行正确