feat(版本): 优化概览与只读状态

关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -1,13 +1,15 @@
import type { Stage } from './stage';
import { STAGES, type Stage } from './stage';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug, BugSeverity } from './bug';
import type { OvertimeRecord } from './overtime';
import type { VersionStatus } from './version-status';
import { getActualHours as getDevTaskActualHours } from './dev-task';
import { getTestCaseActualHours } from './test-case';
import { getBugActualHours } from './bug';
import { calcActualElapsedHours } from './work-hours';
import { formatDateTime } from './format';
export interface StageEffortMetric {
actualHours: number;
@@ -16,6 +18,13 @@ export interface StageEffortMetric {
showEstimates?: boolean;
}
export interface StageProgressState {
percent: number;
status: 'idle' | 'active' | 'done';
}
export type StageProgressWithEffort = StageProgressState & StageEffortMetric;
export interface PersonalEffortItem {
name: string;
actualHours: number;
@@ -37,6 +46,16 @@ export interface VersionOverviewEffortTotals {
overtimeHours: number;
}
export interface VersionTimelineSummary {
actualStartIso: string | null;
expectedReleaseIso: string | null;
actualReleaseIso: string | null;
actualEndIso: string | null;
isTerminalVersion: boolean;
actualHours: number;
overdueDays: number;
}
function roundHalf(hours: number): number {
return Math.round(hours * 2) / 2;
}
@@ -117,6 +136,100 @@ export function calcStageEffortMetrics(input: {
};
}
export function mergeStageProgressWithEffort(
progress: Partial<Record<Stage, StageProgressState>>,
effortMetrics: Record<Stage, StageEffortMetric>,
): Record<Stage, StageProgressWithEffort> {
return STAGES.reduce((acc, stage) => {
const state = progress[stage.key];
acc[stage.key] = {
percent: state?.percent ?? 0,
status: state?.status ?? 'idle',
...effortMetrics[stage.key],
};
return acc;
}, {} as Record<Stage, StageProgressWithEffort>);
}
export function getVersionCardDefaultExpanded(status: VersionStatus): boolean {
return status === 'developing';
}
export function formatVersionOverviewDateTime(value?: string | null): string {
if (!value) return '-';
return value.includes('T') ? formatDateTime(value) : value;
}
export function buildVersionTimelineSummary(input: {
status: VersionStatus;
startDate?: string | null;
expectedReleaseDate?: string | null;
releaseDate?: string | null;
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
bugs: Bug[];
now?: Date;
}): VersionTimelineSummary {
const now = input.now ?? new Date();
const startDates: string[] = [];
input.plans.forEach((plan) => {
if (plan.actualStartAt) {
startDates.push(plan.actualStartAt);
} else if (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= now) {
startDates.push(plan.startTime);
}
});
input.devTasks.forEach((task) => {
if (task.actualStartAt) startDates.push(task.actualStartAt);
});
input.testCases.forEach((testCase) => {
if (testCase.startedAt) startDates.push(testCase.startedAt);
});
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (input.startDate ?? null);
const endDates: string[] = [];
input.plans.forEach((plan) => {
if (plan.completedAt) endDates.push(plan.completedAt);
});
input.devTasks.forEach((task) => {
if (task.actualEndAt) endDates.push(task.actualEndAt);
});
input.testCases.forEach((testCase) => {
if (testCase.completedAt) endDates.push(testCase.completedAt);
});
input.bugs.forEach((bug) => {
if (bug.closedAt) endDates.push(bug.closedAt);
else if (bug.resolvedAt) endDates.push(bug.resolvedAt);
else if ((bug.status === 'closed' || bug.status === 'rejected') && bug.updatedAt) endDates.push(bug.updatedAt);
});
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
const isTerminalVersion = input.status === 'released' || input.status === 'closed';
const actualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (input.expectedReleaseDate && input.releaseDate) {
const endDate = new Date(input.releaseDate);
const deadlineDate = new Date(input.expectedReleaseDate);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
}
return {
actualStartIso,
expectedReleaseIso: input.expectedReleaseDate ?? null,
actualReleaseIso: input.releaseDate ?? null,
actualEndIso,
isTerminalVersion,
actualHours,
overdueDays,
};
}
export function calcVersionOverviewEffortTotals(input: {
plans: VersionPlan[];
devTasks: DevTask[];