feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化

核心变更:
- 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) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-16 17:44:59 +08:00
parent 4ac0dab4c5
commit 1f5bed0c79
25 changed files with 1192 additions and 221 deletions

244
apps/web/lib/work-hours.ts Normal file
View File

@@ -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:0012:00 + 13:0018:00共 8h/天),周末跳过
* 精度0.5h
*
* 验证用例(注释式,供日后接 vitest 迁移):
* - 单日 11:00→14:00 = 1.5h(午休 12:0013:00 不算)
* - 单日 09:00→18:00 = 8h
* - 跨天 1月1日 09:00 → 1月2日 14:00 = 8 + 1 = 9h
* (注:旧需求是 9:0018: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;
}
/**
* 把小时数格式化为 "XhY天"Y 保留 1 位小数(整数则不带小数)
*/
export function formatWorkHours(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0h0天';
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 };
}