关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
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' | 'research_direction_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;
|
||
completedContent?: string;
|
||
remainingContent?: string;
|
||
currentWorkStartedAt?: string;
|
||
updatedAt?: string;
|
||
updatedBy?: string;
|
||
}
|
||
|
||
export const RESEARCH_DIRECTION_PRESETS = [
|
||
'\u7ade\u54c1\u5206\u6790',
|
||
'\u7528\u6237\u8bbf\u8c08',
|
||
'\u6570\u636e\u8c03\u7814',
|
||
'\u6280\u672f\u53ef\u884c\u6027\u5206\u6790',
|
||
'\u5e02\u573a\u8c03\u7814',
|
||
'\u9700\u6c42\u5206\u6790',
|
||
];
|
||
|
||
export function getResearchDirectionPresetOptions(tasks: Array<Pick<PlanTask, 'title'>>): Array<{
|
||
title: string;
|
||
disabled: boolean;
|
||
}> {
|
||
const selectedTitles = new Set(tasks.map((task) => task.title.trim()).filter(Boolean));
|
||
return RESEARCH_DIRECTION_PRESETS.map((title) => ({
|
||
title,
|
||
disabled: selectedTitles.has(title),
|
||
}));
|
||
}
|
||
|
||
export interface VersionPlanRequirementCoverage {
|
||
requirementId: string;
|
||
status: RequirementCoverageStatus;
|
||
completedContent?: string;
|
||
remainingContent?: string;
|
||
currentWorkStartedAt?: 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;
|
||
workStartedAt?: string;
|
||
directionTaskId?: string;
|
||
directionTitle?: string;
|
||
aiTarget?: AgentDecomposeTarget;
|
||
aiStatus?: AiDecomposeLogStatus;
|
||
}
|
||
|
||
export interface RequirementCoverageWorkStartInput {
|
||
requirementId: string;
|
||
startedAt?: string;
|
||
updatedBy: string;
|
||
}
|
||
|
||
export interface RequirementCoverageUpdateInput {
|
||
requirementId: string;
|
||
status: RequirementCoverageStatus;
|
||
completedContent?: string;
|
||
remainingContent?: string;
|
||
workStartedAt?: string;
|
||
updatedAt?: string;
|
||
updatedBy: string;
|
||
requirementCode?: string;
|
||
requirementTitle?: string;
|
||
}
|
||
|
||
export interface ResearchDirectionWorkStartInput {
|
||
taskId: string;
|
||
startedAt?: string;
|
||
updatedBy: string;
|
||
}
|
||
|
||
export interface ResearchDirectionProgressUpdateInput {
|
||
taskId: string;
|
||
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
|
||
completedContent?: string;
|
||
remainingContent?: string;
|
||
workStartedAt?: string;
|
||
updatedAt?: string;
|
||
updatedBy: 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 function getResearchDirectionStatus(task: Pick<PlanTask, 'status'>): RequirementCoverageStatus {
|
||
if (task.status === 'completed') return 'completed';
|
||
if (task.status === 'in_progress') return 'partial';
|
||
return 'not_started';
|
||
}
|
||
|
||
export function getResearchDirectionProgressSummary(plan: VersionPlan): {
|
||
total: number;
|
||
completed: number;
|
||
partial: number;
|
||
notStarted: number;
|
||
percent: number;
|
||
} {
|
||
const tasks = plan.tasks ?? [];
|
||
const total = tasks.length;
|
||
const completed = tasks.filter((task) => getResearchDirectionStatus(task) === 'completed').length;
|
||
const partial = tasks.filter((task) => getResearchDirectionStatus(task) === '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 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,
|
||
workStartedAt?: string,
|
||
): boolean {
|
||
if (!workStartedAt?.trim()) return false;
|
||
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 startRequirementCoverageWork(
|
||
plan: VersionPlan,
|
||
input: RequirementCoverageWorkStartInput,
|
||
): Pick<VersionPlan, 'requirementCoverage'> {
|
||
const startedAt = input.startedAt ?? new Date().toISOString();
|
||
const existing = getRequirementCoverage(plan, input.requirementId);
|
||
const nextCoverage: VersionPlanRequirementCoverage = {
|
||
requirementId: input.requirementId,
|
||
status: existing?.status ?? 'not_started',
|
||
completedContent: existing?.completedContent,
|
||
remainingContent: existing?.remainingContent,
|
||
currentWorkStartedAt: startedAt,
|
||
updatedAt: startedAt,
|
||
updatedBy: input.updatedBy,
|
||
};
|
||
|
||
return {
|
||
requirementCoverage: [
|
||
nextCoverage,
|
||
...(plan.requirementCoverage ?? []).filter((item) => item.requirementId !== input.requirementId),
|
||
],
|
||
};
|
||
}
|
||
|
||
export function updateRequirementCoverage(
|
||
plan: VersionPlan,
|
||
input: RequirementCoverageUpdateInput,
|
||
): Pick<VersionPlan, 'requirementCoverage' | 'completedRequirementIds' | 'logs'> {
|
||
const updatedAt = input.updatedAt ?? new Date().toISOString();
|
||
const existingCoverage = plan.requirementCoverage?.find((item) => item.requirementId === input.requirementId);
|
||
const workStartedAt = input.workStartedAt?.trim() || existingCoverage?.currentWorkStartedAt;
|
||
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,
|
||
workStartedAt,
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function startResearchDirectionWork(
|
||
plan: VersionPlan,
|
||
input: ResearchDirectionWorkStartInput,
|
||
): Pick<VersionPlan, 'tasks'> {
|
||
const tasks = plan.tasks ?? [];
|
||
const startedAt = input.startedAt ?? new Date().toISOString();
|
||
const nextTasks = tasks.map((task) => {
|
||
if (task.id !== input.taskId || task.status === 'completed') return task;
|
||
return {
|
||
...task,
|
||
status: 'in_progress' as const,
|
||
currentWorkStartedAt: startedAt,
|
||
updatedAt: startedAt,
|
||
updatedBy: input.updatedBy,
|
||
};
|
||
});
|
||
|
||
return { tasks: nextTasks };
|
||
}
|
||
|
||
export function updateResearchDirectionProgress(
|
||
plan: VersionPlan,
|
||
input: ResearchDirectionProgressUpdateInput,
|
||
): Pick<VersionPlan, 'tasks' | 'logs'> {
|
||
const tasks = plan.tasks ?? [];
|
||
const target = tasks.find((task) => task.id === input.taskId);
|
||
if (!target) {
|
||
return {
|
||
tasks: plan.tasks,
|
||
logs: plan.logs,
|
||
};
|
||
}
|
||
|
||
const updatedAt = input.updatedAt ?? new Date().toISOString();
|
||
const workStartedAt = input.workStartedAt?.trim() || target.currentWorkStartedAt;
|
||
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
|
||
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
|
||
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
|
||
const nextTasks = tasks.map((task) => task.id === input.taskId
|
||
? {
|
||
...task,
|
||
status: nextStatus,
|
||
completedContent,
|
||
remainingContent,
|
||
currentWorkStartedAt: undefined,
|
||
updatedAt,
|
||
updatedBy: input.updatedBy,
|
||
}
|
||
: task);
|
||
const detail = [
|
||
completedContent ? `已完成:${completedContent}` : '',
|
||
remainingContent ? `剩余:${remainingContent}` : '',
|
||
].filter(Boolean).join('\n');
|
||
|
||
return {
|
||
tasks: nextTasks,
|
||
logs: appendPlanLog(plan, {
|
||
type: 'research_direction_progress',
|
||
createdAt: updatedAt,
|
||
actor: input.updatedBy,
|
||
title: `${target.title}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`,
|
||
detail: detail || undefined,
|
||
directionTaskId: target.id,
|
||
directionTitle: target.title,
|
||
coverageStatus: input.status,
|
||
completedContent,
|
||
remainingContent,
|
||
workStartedAt,
|
||
}),
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|