Files
ftb-project-management/apps/web/lib/version-form.ts
Script Generator 8947aeaac7 feat(工作台): 细化计划进展工时证据
关键改动:

- 支持需求覆盖和调研方向开始工作记录

- 日报按计划记录开始时间和进度下次开始时间计算证据

- 更新版本编辑校验、项目展示和 workflow 说明

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:02:38 +08:00

143 lines
4.6 KiB
TypeScript

import { isChinaWorkday } from './china-workday-calendar';
import { formatLocalDate } from './format';
export type VersionDevelopmentType = 'agile' | 'standard' | 'waterfall' | 'hotfix';
export interface VersionDevelopmentStageEstimate {
key: 'product_design' | 'development' | 'testing';
label: '产品设计' | '开发' | '测试';
workDays: number;
}
export interface VersionDevelopmentTypeOption {
key: VersionDevelopmentType;
label: string;
description: string;
stages: VersionDevelopmentStageEstimate[];
totalWorkDays: number;
}
export interface NewVersionFormState {
productId: string;
projectId: string;
versionNumber: string;
developmentType: VersionDevelopmentType | '';
expectedReleaseDate: string;
submitting: boolean;
}
export interface VersionEditFormState {
versionNumber: string;
expectedReleaseDate: string;
submitting: boolean;
}
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
{ key: 'product_design', label: '产品设计', workDays: 2 },
{ key: 'development', label: '开发', workDays: 8 },
{ key: 'testing', label: '测试', workDays: 3 },
]),
makeDevelopmentTypeOption('standard', '常规项目', '适合需求较完整的普通版本', [
{ key: 'product_design', label: '产品设计', workDays: 3 },
{ key: 'development', label: '开发', workDays: 12 },
{ key: 'testing', label: '测试', workDays: 4 },
]),
makeDevelopmentTypeOption('waterfall', '瀑布项目', '适合方案先行的大范围交付', [
{ key: 'product_design', label: '产品设计', workDays: 5 },
{ key: 'development', label: '开发', workDays: 20 },
{ key: 'testing', label: '测试', workDays: 7 },
]),
makeDevelopmentTypeOption('hotfix', '紧急修复', '适合线上缺陷或很小范围改动', [
{ key: 'product_design', label: '产品设计', workDays: 0 },
{ key: 'development', label: '开发', workDays: 2 },
{ key: 'testing', label: '测试', workDays: 1 },
]),
];
export function canSubmitNewVersionForm(state: NewVersionFormState): boolean {
return Boolean(
state.productId &&
state.projectId &&
state.versionNumber &&
state.developmentType &&
state.expectedReleaseDate &&
!state.submitting,
);
}
export function canSubmitVersionEditForm(state: VersionEditFormState): boolean {
return Boolean(
state.versionNumber.trim() &&
state.expectedReleaseDate &&
!state.submitting,
);
}
export function getEditableVersionNumber(projectName: string, versionName: string): string {
const exactPrefix = `${projectName}V`;
if (projectName && versionName.toLowerCase().startsWith(exactPrefix.toLowerCase())) {
return versionName.slice(exactPrefix.length);
}
const match = versionName.match(/V([\d.]+)$/i);
return match?.[1] ?? versionName;
}
export function buildVersionName(projectName: string, versionNumber: string): string {
return `${projectName}V${versionNumber.trim()}`;
}
export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined {
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
}
export function getRecommendedExpectedReleaseDate(
type: VersionDevelopmentType,
baseDate: Date = new Date(),
options: { productDesignCompleted?: boolean } = {},
): string {
const developmentType = getVersionDevelopmentTypeOption(type);
if (!developmentType) return '';
const totalWorkDays = getRecommendedWorkDays(developmentType, options);
return addChinaWorkDays(baseDate, totalWorkDays);
}
export function getRecommendedWorkDays(
option: VersionDevelopmentTypeOption,
options: { productDesignCompleted?: boolean } = {},
): number {
return option.stages.reduce((sum, stage) => {
if (stage.key === 'product_design' && options.productDesignCompleted) return sum;
return sum + stage.workDays;
}, 0);
}
function makeDevelopmentTypeOption(
key: VersionDevelopmentType,
label: string,
description: string,
stages: VersionDevelopmentStageEstimate[],
): VersionDevelopmentTypeOption {
return {
key,
label,
description,
stages,
totalWorkDays: stages.reduce((sum, stage) => sum + stage.workDays, 0),
};
}
function addChinaWorkDays(baseDate: Date, workDays: number): string {
const cursor = new Date(baseDate);
cursor.setHours(0, 0, 0, 0);
let remaining = Math.max(1, Math.round(workDays));
while (remaining > 0) {
if (isChinaWorkday(cursor)) remaining -= 1;
if (remaining <= 0) break;
cursor.setDate(cursor.getDate() + 1);
}
return formatLocalDate(cursor);
}