feat(版本详情): 完善流程日志与需求覆盖

This commit is contained in:
Script Generator
2026-06-30 11:00:07 +08:00
parent d754585fe0
commit 1cd595c42e
38 changed files with 4123 additions and 237 deletions

View File

@@ -0,0 +1,233 @@
'use client';
import { useState } from 'react';
import { Check, Clock3, Sparkles } from 'lucide-react';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
import {
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?: VersionPlanLog[];
className?: string;
}
const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started'];
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 getLogIcon(log: VersionPlanLog) {
if (log.type === 'ai_decompose') return <Sparkles className="h-3.5 w-3.5" />;
if (log.type === 'requirement_progress') return <Check className="h-3.5 w-3.5" />;
return <Clock3 className="h-3.5 w-3.5" />;
}
function getLogTone(log: VersionPlanLog): string {
if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100';
if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100';
if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100';
return 'bg-zinc-50 text-zinc-600 ring-zinc-100';
}
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
const [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
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);
setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial');
setCompletedContent(coverage?.completedContent ?? '');
setRemainingContent(coverage?.remainingContent ?? '');
};
const closeEditor = () => {
setEditingRequirementId(null);
setCompletedContent('');
setRemainingContent('');
setDraftStatus('partial');
};
const canSave = draftStatus === 'not_started'
|| (draftStatus === 'completed' && completedContent.trim().length > 0)
|| (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0);
const saveCoverage = (req: RequirementOption) => {
if (!canEdit || !canSave) return;
const patch = updateRequirementCoverage(plan, {
requirementId: req.id,
status: draftStatus,
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
remainingContent: draftStatus === '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="max-h-56 space-y-1 overflow-y-auto 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;
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 && (
<button
type="button"
onClick={() => isEditing ? closeEditor() : openEditor(req)}
className="shrink-0 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>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<div className="grid grid-cols-3 gap-1.5">
{COVERAGE_STATUS_OPTIONS.map((statusOption) => (
<button
key={statusOption}
type="button"
onClick={() => setDraftStatus(statusOption)}
className={`h-7 rounded-md border text-[11px] font-medium transition-colors ${draftStatus === statusOption ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{REQUIREMENT_COVERAGE_LABEL[statusOption]}
</button>
))}
</div>
{draftStatus !== 'not_started' && (
<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"
/>
)}
{draftStatus === 'partial' && (
<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 justify-end gap-2">
<button type="button" onClick={closeEditor} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]"></button>
<button
type="button"
onClick={() => saveCoverage(req)}
disabled={!canSave}
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>
);
}
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const frameClass = className || 'border-l border-[var(--line)] pl-4';
return (
<aside className={frameClass}>
<div className="flex items-center justify-between">
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<span className="text-[11px] tabular-nums text-[var(--ink-soft)]">{sortedLogs.length}</span>
</div>
{sortedLogs.length === 0 ? (
<div className="mt-4 rounded-lg bg-[var(--bg-subtle)] px-3 py-4 text-center text-[11px] text-[var(--ink-muted)]"></div>
) : (
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto pr-1">
{sortedLogs.map((log) => (
<div key={log.id} className="relative pl-5">
<span className={`absolute left-0 top-0 flex h-6 w-6 -translate-x-3 items-center justify-center rounded-full ring-4 ${getLogTone(log)}`}>
{getLogIcon(log)}
</span>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[12px] font-medium leading-5 text-[var(--ink)]">{log.title}</div>
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
</div>
<div className="text-[11px] text-[var(--ink-muted)]">{log.actor}</div>
{log.detail && <div className="whitespace-pre-wrap rounded-md bg-[var(--bg-subtle)] px-2 py-1.5 text-[11px] leading-4 text-[var(--ink-soft)]">{log.detail}</div>}
</div>
</div>
))}
</div>
)}
</aside>
);
}