181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
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;
|
||
}
|