Files
ftb-project-management/apps/web/lib/version-plan.ts
Script Generator 04520bd8c5 feat(版本): 记录计划需求进展活动
关键改动:

- 记录产品/UI 计划需求覆盖进展,并写入工作活动与实体日志

- 日报和小宝预警补充需求进展活动证据

- 调整版本列表与需求覆盖面板展示,并补充测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 15:35:36 +08:00

378 lines
12 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.

import type { AgentDecomposeTarget } from '@ftb/shared';
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
export type RequirementCoverageStatus = 'not_started' | 'partial' | 'completed';
export type VersionPlanLogType = 'requirement_progress' | 'ai_decompose' | 'system';
export type AiDecomposeLogStatus = 'started' | 'completed' | 'error';
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 VersionPlanRequirementCoverage {
requirementId: string;
status: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
updatedAt: string;
updatedBy: string;
}
export interface VersionPlanLog {
id: string;
type: VersionPlanLogType;
createdAt: string;
actor: string;
title: string;
detail?: string;
requirementId?: string;
requirementCode?: string;
requirementTitle?: string;
coverageStatus?: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
aiTarget?: AgentDecomposeTarget;
aiStatus?: AiDecomposeLogStatus;
}
export interface RequirementCoverageUpdateInput {
requirementId: string;
status: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
updatedAt?: string;
updatedBy: string;
requirementCode?: string;
requirementTitle?: string;
}
export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
id?: string;
createdAt?: string;
};
export type VersionPlanLogView = VersionPlanLog & {
planId: string;
planTitle: string;
};
export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, string> = {
not_started: '未开始',
partial: '部分完成',
completed: '完全完成',
};
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[];
requirementCoverage?: VersionPlanRequirementCoverage[];
logs?: VersionPlanLog[];
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;
aiDecomposeTarget?: AgentDecomposeTarget;
aiDecomposeError?: string;
}
export type PlanType = VersionPlan['type'];
function makePlanLogId(createdAt: string): string {
const time = new Date(createdAt).getTime();
const suffix = Math.random().toString(36).slice(2, 8);
return `plan-log-${Number.isFinite(time) ? time : Date.now()}-${suffix}`;
}
export function getRequirementCoverage(plan: VersionPlan, requirementId: string): VersionPlanRequirementCoverage | undefined {
const explicit = plan.requirementCoverage?.find((item) => item.requirementId === requirementId);
if (explicit) return explicit;
if ((plan.completedRequirementIds ?? []).includes(requirementId)) {
return {
requirementId,
status: 'completed',
updatedAt: plan.completedAt ?? plan.createdAt,
updatedBy: plan.owner,
};
}
return undefined;
}
export function getRequirementCoverageStatus(plan: VersionPlan, requirementId: string): RequirementCoverageStatus {
return getRequirementCoverage(plan, requirementId)?.status ?? 'not_started';
}
export function getRequirementCoverageSummary(plan: VersionPlan): {
total: number;
completed: number;
partial: number;
notStarted: number;
percent: number;
} {
const linkedIds = plan.linkedRequirementIds ?? [];
const total = linkedIds.length;
const completed = linkedIds.filter((id) => getRequirementCoverageStatus(plan, id) === 'completed').length;
const partial = linkedIds.filter((id) => getRequirementCoverageStatus(plan, id) === 'partial').length;
const notStarted = Math.max(total - completed - partial, 0);
return {
total,
completed,
partial,
notStarted,
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
};
}
export function canSaveRequirementCoverageDraft(
status: RequirementCoverageStatus,
completedContent?: string,
remainingContent?: string,
): boolean {
if (status === 'completed') return true;
if (status === 'partial') {
return Boolean(completedContent?.trim()) && Boolean(remainingContent?.trim());
}
return false;
}
export function canOpenRequirementCoverageRecord(
status: RequirementCoverageStatus,
canEdit: boolean,
): boolean {
return canEdit && status !== 'completed';
}
export function appendPlanLog(plan: VersionPlan, draft: PlanLogDraft): VersionPlanLog[] {
const createdAt = draft.createdAt ?? new Date().toISOString();
const log: VersionPlanLog = {
...draft,
id: draft.id ?? makePlanLogId(createdAt),
createdAt,
};
return [log, ...(plan.logs ?? [])];
}
function getPlanLogCreatedAtTime(log: VersionPlanLog): number {
const time = new Date(log.createdAt).getTime();
return Number.isFinite(time) ? time : 0;
}
export function getPlanLogsForPlans(plans: VersionPlan[]): VersionPlanLogView[] {
return plans
.flatMap((plan) => (plan.logs ?? []).map((log) => ({
...log,
planId: plan.id,
planTitle: plan.title,
})))
.sort((a, b) => getPlanLogCreatedAtTime(b) - getPlanLogCreatedAtTime(a));
}
export function updateRequirementCoverage(
plan: VersionPlan,
input: RequirementCoverageUpdateInput,
): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
const updatedAt = input.updatedAt ?? new Date().toISOString();
const nextCoverage: VersionPlanRequirementCoverage = {
requirementId: input.requirementId,
status: input.status,
completedContent: input.completedContent?.trim() || undefined,
remainingContent: input.remainingContent?.trim() || undefined,
updatedAt,
updatedBy: input.updatedBy,
};
const requirementCoverage = [
nextCoverage,
...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId),
];
const completedSet = new Set(plan.completedRequirementIds ?? []);
if (input.status === 'completed') completedSet.add(input.requirementId);
else completedSet.delete(input.requirementId);
const detail = [
nextCoverage.completedContent ? `已完成:${nextCoverage.completedContent}` : '',
nextCoverage.remainingContent ? `剩余:${nextCoverage.remainingContent}` : '',
].filter(Boolean).join('\n');
const reqLabel = [input.requirementCode, input.requirementTitle].filter(Boolean).join(' ');
return {
requirementCoverage,
completedRequirementIds: Array.from(completedSet),
logs: appendPlanLog(plan, {
type: 'requirement_progress',
createdAt: updatedAt,
actor: input.updatedBy,
title: `${reqLabel || '需求'}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`,
detail: detail || undefined,
requirementId: input.requirementId,
requirementCode: input.requirementCode,
requirementTitle: input.requirementTitle,
coverageStatus: input.status,
completedContent: nextCoverage.completedContent,
remainingContent: nextCoverage.remainingContent,
}),
};
}
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;
}