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:
@@ -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;
|
||||
}
|
||||
|
||||
62
apps/web/lib/dev-task-transitions.ts
Normal file
62
apps/web/lib/dev-task-transitions.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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<string, { actualHours: number; taskCount: number }>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
244
apps/web/lib/work-hours.ts
Normal file
244
apps/web/lib/work-hours.ts
Normal 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: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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user