Files
ftb-project-management/apps/web/lib/version-plan.ts
2026-06-26 14:05:01 +08:00

181 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
export type ProductPlanReviewFailureType =
| 'requirement_mismatch'
| 'information_architecture'
| 'interaction_flow'
| 'state_coverage'
| 'edge_case_missing'
| 'business_rule_gap'
| 'role_permission_gap'
| 'data_rule_gap'
| 'copywriting_ambiguity'
| 'risk_dependency';
export const PRODUCT_PLAN_KIND_LABEL: Record<ProductPlanKind, string> = {
design: '设计方案',
review: '方案评审',
};
export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record<ProductPlanReviewResult, string> = {
passed: '评审通过',
failed: '评审不通过',
};
export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [
{ value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' },
{ value: 'information_architecture', label: '信息架构或页面层级不清晰' },
{ value: 'interaction_flow', label: '关键交互流程不闭环' },
{ value: 'state_coverage', label: '缺少状态、异常或空数据场景' },
{ value: 'edge_case_missing', label: '边界场景考虑不足' },
{ value: 'business_rule_gap', label: '业务规则、审批或口径缺失' },
{ value: 'role_permission_gap', label: '角色权限与可见范围不明确' },
{ value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' },
{ value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' },
{ value: 'risk_dependency', label: '依赖、风险或上线影响未说明' },
];
export interface PlanTask {
id: string;
title: string;
status: PlanTaskStatus;
}
export interface VersionPlan {
id: string;
versionId: string;
type: 'research' | 'product' | 'ui';
title: string;
owner: string;
startTime: string;
endTime: string;
status: 'pending' | 'in_progress' | 'completed';
tasks?: PlanTask[];
completedRequirementIds?: string[];
linkedRequirementIds?: string[];
productPlanKind?: ProductPlanKind;
resultType?: 'link' | 'file';
resultTitle?: string;
resultUrl?: string;
resultFileName?: string;
resultFileData?: string;
prototypeReviewConfirmed?: boolean;
reviewResult?: ProductPlanReviewResult;
reviewFailureTypes?: ProductPlanReviewFailureType[];
reviewFailureReason?: string;
remark?: string;
overdueReason?: string;
actualStartAt?: string;
createdAt: string;
completedAt?: string;
addedBy: string;
aiDecomposeStatus?: 'idle' | 'in_progress' | 'completed' | 'error';
aiDecomposeAt?: string;
aiDecomposeBy?: string;
aiDecomposeError?: string;
}
export type PlanType = VersionPlan['type'];
function getPlanCreatedAtTime(plan: VersionPlan): number {
const time = new Date(plan.createdAt).getTime();
return Number.isFinite(time) ? time : 0;
}
function getPlanIdTime(plan: VersionPlan): number {
const match = /^plan-(\d+)$/.exec(plan.id);
if (!match) return 0;
const time = Number(match[1]);
return Number.isFinite(time) ? time : 0;
}
export function sortPlansNewestFirst(plans: VersionPlan[]): VersionPlan[] {
return [...plans].sort((a, b) => {
const createdDiff = getPlanCreatedAtTime(b) - getPlanCreatedAtTime(a);
if (createdDiff !== 0) return createdDiff;
return getPlanIdTime(b) - getPlanIdTime(a);
});
}
export function calcPlanProgress(tasks?: PlanTask[]): number {
if (!tasks || tasks.length === 0) return 0;
const completed = tasks.filter((t) => t.status === 'completed').length;
return Math.round((completed / tasks.length) * 100);
}
export function calcLinkedReqProgress(linkedIds?: string[], completedIds?: string[]): number {
if (!linkedIds || linkedIds.length === 0) return 0;
const done = (completedIds || []).filter((id) => linkedIds.includes(id)).length;
return Math.round((done / linkedIds.length) * 100);
}
export function calcPlanDuration(start: string, end: string): { days: number; hours: number } {
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (isNaN(s) || isNaN(e) || e < s) return { days: 0, hours: 0 };
const diffHours = (e - s) / (1000 * 60 * 60);
const hours = Math.round(diffHours * 2) / 2; // 精确到0.5h
const days = Math.ceil(diffHours / 24);
return { days, hours };
}
export function formatDuration(days: number, hours: number): string {
if (hours <= 0) return '0h';
if (hours < 24) return `${hours}h`;
if (hours % 24 === 0) return `${hours / 24}`;
return `${hours}h`;
}
export function calcTotalDuration(plans: VersionPlan[]): string {
const today = new Date().toISOString().slice(0, 10);
const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today);
if (activePlans.length === 0) return '0天';
const intervals = activePlans.map((p) => {
const start = new Date(p.startTime).getTime();
const end = p.completedAt
? new Date(p.completedAt).getTime()
: p.startTime <= today
? new Date(today).getTime()
: new Date(p.startTime).getTime();
return { start, end };
}).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
if (intervals.length === 0) return '0天';
let totalMs = 0;
let currentStart = intervals[0].start;
let currentEnd = intervals[0].end;
for (let i = 1; i < intervals.length; i++) {
if (intervals[i].start <= currentEnd) {
currentEnd = Math.max(currentEnd, intervals[i].end);
} else {
totalMs += currentEnd - currentStart;
currentStart = intervals[i].start;
currentEnd = intervals[i].end;
}
}
totalMs += currentEnd - currentStart;
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;
}