Files
ftb-project-management/apps/web/lib/version-plan.ts
Script Generator 52a88626d4 feat(版本): 完善计划覆盖与工作台待办
关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 13:43:18 +08:00

367 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;
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 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,
}),
};
}
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;
}