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

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

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

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

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

299 lines
13 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.

'use client';
import { useState } from 'react';
import { FilterSelect } from '@/components/FilterSelect';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import {
canSaveRequirementCoverageDraft,
canOpenRequirementCoverageRecord,
getRequirementCoverage,
getRequirementCoverageStatus,
getRequirementCoverageSummary,
REQUIREMENT_COVERAGE_LABEL,
updateRequirementCoverage,
} from '@/lib/version-plan';
type RequirementOption = Pick<Requirement, 'id' | 'code' | 'title'> & { isHistorical?: boolean };
interface CoverageProps {
plan: VersionPlan;
requirements: RequirementOption[];
canEdit: boolean;
currentUserName: string;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
}
interface LogTimelineProps {
logs?: Array<VersionPlanLog | VersionPlanLogView>;
className?: string;
fillHeight?: boolean;
}
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
partial: 'border-amber-200 bg-amber-50 text-amber-700',
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
function getLogTone(log: VersionPlanLog): { badge: string } {
if (log.aiStatus === 'error') {
return {
badge: 'border-red-200 bg-red-50 text-red-700',
};
}
if (log.type === 'ai_decompose') {
return {
badge: 'border-violet-200 bg-violet-50 text-violet-700',
};
}
if (log.coverageStatus === 'completed') {
return {
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
};
}
if (log.coverageStatus === 'partial') {
return {
badge: 'border-amber-200 bg-amber-50 text-amber-700',
};
}
return {
badge: 'border-zinc-200 bg-zinc-50 text-zinc-600',
};
}
function getLogTypeLabel(log: VersionPlanLog): string {
if (log.type === 'ai_decompose') return 'AI 拆解';
if (log.type === 'requirement_progress') return '需求进度';
return '系统记录';
}
function getLogMonthKey(createdAt: string): string | null {
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function getLogMonthLabel(monthKey: string): string {
const [year, month] = monthKey.split('-');
return `${year}${month}`;
}
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
const [completedContent, setCompletedContent] = useState('');
const [remainingContent, setRemainingContent] = useState('');
const summary = getRequirementCoverageSummary(plan);
if (requirements.length === 0) return null;
const openEditor = (req: RequirementOption) => {
const coverage = getRequirementCoverage(plan, req.id);
setEditingRequirementId(req.id);
setCompletedContent(coverage?.completedContent ?? '');
setRemainingContent(coverage?.remainingContent ?? '');
};
const closeEditor = () => {
setEditingRequirementId(null);
setCompletedContent('');
setRemainingContent('');
};
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
const patch = updateRequirementCoverage(plan, {
requirementId: req.id,
status,
completedContent: status === 'partial' ? completedContent : undefined,
remainingContent: status === 'partial' ? remainingContent : undefined,
updatedBy: currentUserName,
requirementCode: req.code,
requirementTitle: req.title,
});
onUpdate(plan.id, patch);
closeEditor();
};
return (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
{summary.completed} / {summary.total}
{summary.partial > 0 && <span className="ml-2 text-amber-700"> {summary.partial}</span>}
</div>
</div>
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
</div>
</div>
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
{requirements.map((req) => {
const status = getRequirementCoverageStatus(plan, req.id);
const coverage = getRequirementCoverage(plan, req.id);
const isEditing = editingRequirementId === req.id;
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
return (
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2">
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
{REQUIREMENT_COVERAGE_LABEL[status]}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600"></span>}
</div>
{(coverage?.completedContent || coverage?.remainingContent) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{coverage.completedContent && <div className="line-clamp-2">{coverage.completedContent}</div>}
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">{coverage.remainingContent}</div>}
</div>
)}
</div>
{canEdit && (canOpenRecord || isEditing) && (
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => isEditing ? closeEditor() : openEditor(req)}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{isEditing ? '收起' : '记录'}
</button>
</div>
)}
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<textarea
value={completedContent}
onChange={(e) => setCompletedContent(e.target.value)}
rows={2}
placeholder="本次已完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<textarea
value={remainingContent}
onChange={(e) => setRemainingContent(e.target.value)}
rows={2}
placeholder="剩余未完成的内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => saveCoverage(req, 'completed')}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
>
</button>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={closeEditor}
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
type="button"
onClick={() => saveCoverage(req, 'partial')}
disabled={!canSavePartial}
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
const [selectedMonth, setSelectedMonth] = useState('all');
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const monthOptions = Array.from(new Set(sortedLogs.map((log) => getLogMonthKey(log.createdAt)).filter(Boolean) as string[]))
.sort((a, b) => b.localeCompare(a));
const effectiveMonth = selectedMonth === 'all' || monthOptions.includes(selectedMonth) ? selectedMonth : 'all';
const visibleLogs = effectiveMonth === 'all'
? sortedLogs
: sortedLogs.filter((log) => getLogMonthKey(log.createdAt) === effectiveMonth);
const frameClass = className || 'border-l border-[var(--line)] pl-4';
const listClass = fillHeight
? 'mt-3 min-h-0 flex-1 overflow-y-auto pr-1'
: 'mt-3 max-h-80 overflow-y-auto pr-1';
return (
<aside className={frameClass}>
<div className="shrink-0 border-b border-[var(--line)] pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[13px] font-semibold text-[var(--ink)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-muted)]"> AI </div>
</div>
<FilterSelect
value={effectiveMonth}
onChange={setSelectedMonth}
options={monthOptions.map((month) => ({ value: month, label: getLogMonthLabel(month) }))}
allLabel="全部月份"
/>
</div>
</div>
{visibleLogs.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-8 text-center text-[12px] text-[var(--ink-muted)]">
</div>
) : (
<div className={listClass}>
<div>
{visibleLogs.map((log) => {
const tone = getLogTone(log);
return (
<div key={log.id} className="border-b border-[var(--line)] py-3 first:pt-0 last:border-b-0 last:pb-0">
<div className="min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`inline-flex rounded-md border px-1.5 py-0.5 text-[10px] font-semibold ${tone.badge}`}>
{getLogTypeLabel(log)}
</span>
<span className="min-w-0 truncate text-[11px] text-[var(--ink-muted)]">{log.actor}</span>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
</div>
<div className="mt-1.5 text-[12px] font-semibold leading-5 text-[var(--ink)]">{log.title}</div>
{'planTitle' in log && log.planTitle && (
<div className="mt-1 truncate text-[11px] text-[var(--ink-soft)]" title={log.planTitle}>
{log.planTitle}
</div>
)}
{log.detail && (
<div className="mt-2 border-l-2 border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-[11px] leading-5 text-[var(--ink-soft)]">
<div className="whitespace-pre-wrap">{log.detail}</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</aside>
);
}