feat(版本): 完善计划覆盖与工作台待办
关键改动: - 增加计划日志汇总、需求覆盖草稿校验与对应测试 - 抽取工作台工作项 Hook,补充待办计数能力 - 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, Clock3, Sparkles } from 'lucide-react';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
|
||||
import {
|
||||
canSaveRequirementCoverageDraft,
|
||||
getRequirementCoverage,
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -24,35 +25,62 @@ interface CoverageProps {
|
||||
}
|
||||
|
||||
interface LogTimelineProps {
|
||||
logs?: VersionPlanLog[];
|
||||
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
||||
className?: string;
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
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): { 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 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';
|
||||
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 [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
|
||||
const [completedContent, setCompletedContent] = useState('');
|
||||
const [remainingContent, setRemainingContent] = useState('');
|
||||
const summary = getRequirementCoverageSummary(plan);
|
||||
@@ -62,7 +90,6 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
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 ?? '');
|
||||
};
|
||||
@@ -71,20 +98,17 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
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 canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
|
||||
|
||||
const saveCoverage = (req: RequirementOption) => {
|
||||
if (!canEdit || !canSave) return;
|
||||
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
const patch = updateRequirementCoverage(plan, {
|
||||
requirementId: req.id,
|
||||
status: draftStatus,
|
||||
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
|
||||
remainingContent: draftStatus === 'partial' ? remainingContent : undefined,
|
||||
status,
|
||||
completedContent: status === 'partial' ? completedContent : undefined,
|
||||
remainingContent: status === 'partial' ? remainingContent : undefined,
|
||||
updatedBy: currentUserName,
|
||||
requirementCode: req.code,
|
||||
requirementTitle: req.title,
|
||||
@@ -110,7 +134,7 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
<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">
|
||||
<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);
|
||||
@@ -146,46 +170,45 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
</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>
|
||||
<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)}
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
@@ -197,35 +220,73 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
|
||||
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="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 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>
|
||||
{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>
|
||||
{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="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 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 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>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user