Files
ftb-project-management/apps/web/components/version/DecomposeReportModal.tsx
2026-07-02 18:01:38 +08:00

518 lines
23 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, useMemo } from 'react';
import { X, CheckCircle2, AlertTriangle, HelpCircle, ListChecks } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import type { VersionWithContext } from '@/lib/derive';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { findCategoryByCode, type CategoryGroup } from '@/lib/task-category';
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
import { resolveDecomposeDraftRequirementFields } from '@/lib/ai-decompose-adoption';
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
import { resolveRecommendedAssignee } from '@/lib/ai-assignee-recommendation';
import type {
AgentDecomposeTarget,
AgentDecomposeResponse,
AgentDevTaskDraft,
AgentTestCaseDraft,
} from '@ftb/shared';
interface Props {
result: AgentDecomposeResponse;
version: VersionWithContext;
plan: VersionPlan;
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
target: AgentDecomposeTarget;
dedupeSummary?: { removedDevTaskCount: number; removedTestCaseCount: number };
onClose: () => void;
onAdopted?: () => void;
onRejected?: () => void;
}
export function DecomposeReportModal({
result,
version,
plan,
requirements,
target,
dedupeSummary,
onClose,
onAdopted,
onRejected,
}: Props) {
const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore();
const { categories, ensureCategory } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const { result: data, meta } = result;
const showDevDrafts = target !== 'test_cases';
const showTestDrafts = target !== 'dev_tasks';
const filteredDuplicateCount =
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
const prototypeOnlyGroups = data.report.prototypeOnly ?? [];
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(showTestDrafts ? data.testCaseDrafts.map((_, i) => i) : []),
);
const [useRecommendedAssignees, setUseRecommendedAssignees] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const reqByCode = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
for (const r of requirements) m.set(r.code, r);
return m;
}, [requirements]);
const reqByInternalId = useMemo(() => {
const m = new Map<string, { id: string; code: string; title: string }>();
for (const r of requirements) m.set(r.id, r);
return m;
}, [requirements]);
const toggleDev = (i: number) => {
const n = new Set(selectedDevIdx);
if (n.has(i)) n.delete(i);
else n.add(i);
setSelectedDevIdx(n);
};
const toggleTc = (i: number) => {
const n = new Set(selectedTcIdx);
if (n.has(i)) n.delete(i);
else n.add(i);
setSelectedTcIdx(n);
};
const resolveDraftTaskTypeName = (
draft: AgentDevTaskDraft | AgentTestCaseDraft,
fallbackGroup: Extract<CategoryGroup, 'development' | 'testing'>,
) => {
const explicitName = draft.taskTypeName?.trim();
if (explicitName) return explicitName;
const mappedName = findCategoryByCode(categories, draft.categoryCode)?.name;
if (mappedName) return mappedName;
if (draft.categoryCode) return draft.categoryCode;
return fallbackGroup === 'testing' ? '功能测试' : '开发任务';
};
const handleAdopt = () => {
if (submitting) return;
setSubmitting(true);
const now = new Date().toISOString();
// 把每个 reference 的 requirement 类型 idAI 给的是 code映射回内部 id
const normalizeRefs = (refs: AgentDevTaskDraft['references'] | AgentTestCaseDraft['references']) =>
refs.map((ref) => {
if (ref.type === 'requirement') {
const r = reqByCode.get(ref.id) ?? reqByInternalId.get(ref.id);
if (r) return { ...ref, id: r.id, label: ref.label || `${r.code} ${r.title}` };
}
return ref;
});
if (showDevDrafts) {
for (let i = 0; i < data.devTaskDrafts.length; i++) {
if (!selectedDevIdx.has(i)) continue;
const draft = data.devTaskDrafts[i];
const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const taskTypeName = resolveDraftTaskTypeName(draft, 'development');
const categoryId = ensureCategory(taskTypeName, 'development').id;
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
createTask({
versionId: version.id,
requirementId: requirementFields.requirementId,
requirementName: requirementFields.requirementName,
title: draft.title,
description: draft.description,
categoryId,
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name ?? '' : '',
reviewerId: undefined,
priority: draft.priority,
expectedStartAt: '',
expectedEndAt: '',
estimateHours: undefined,
aiEstimateHours,
actualStartAt: undefined,
actualEndAt: undefined,
status: 'todo',
blockReason: undefined,
blockedById: undefined,
predecessorIds: undefined,
riskLevel: undefined,
delayReason: undefined,
overdueVersionReason: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
}
}
if (showTestDrafts) {
for (let i = 0; i < data.testCaseDrafts.length; i++) {
if (!selectedTcIdx.has(i)) continue;
const draft = data.testCaseDrafts[i];
const refs = normalizeRefs(draft.references);
const requirementFields = resolveDecomposeDraftRequirementFields({ ...draft, references: refs }, requirements);
const taskTypeName = resolveDraftTaskTypeName(draft, 'testing');
const categoryId = ensureCategory(taskTypeName, 'testing').id;
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
createTestCase({
versionId: version.id,
requirementId: requirementFields.requirementId || undefined,
requirementName: requirementFields.requirementName,
title: draft.title,
description: draft.description,
categoryId,
priority: draft.priority,
estimateHours: undefined,
aiEstimateHours,
assigneeId: useRecommendedAssignees ? recommendedAssignee?.name : undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
}
}
setSubmitted(true);
setSubmitting(false);
// 1.5 秒后自动关闭
setTimeout(() => {
onAdopted?.();
onClose();
}, 1500);
};
const visibleRecommendationCount = useMemo(() => {
const members = version.members ?? [];
const devCount = showDevDrafts
? data.devTaskDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
: 0;
const testCount = showTestDrafts
? data.testCaseDrafts.filter((draft) => resolveRecommendedAssignee(draft, members)).length
: 0;
return devCount + testCount;
}, [data.devTaskDrafts, data.testCaseDrafts, showDevDrafts, showTestDrafts, version.members]);
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-purple-600" />
<h3 className="text-[14px] font-semibold text-[var(--ink)]">AI </h3>
<span className="text-[11px] text-[var(--ink-muted)]">
{meta.model} · {meta.inputTokens} tok · {meta.outputTokens} tok ·{' '}
{(meta.durationMs / 1000).toFixed(1)}s
</span>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]">
<X className="h-4 w-4 text-[var(--ink-muted)]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{filteredDuplicateCount > 0 && (
<div className="rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-[12px] text-purple-700">
{dedupeSummary?.removedDevTaskCount ?? 0} {dedupeSummary?.removedTestCaseCount ?? 0}
</div>
)}
{/* 对账报告 */}
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2"></h4>
{data.report.matched.length > 0 && (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
<span className="text-[12px] font-medium text-emerald-700">
{data.report.matched.length}
</span>
</div>
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
{data.report.matched.map((m, i) => (
<li key={i} className="flex flex-wrap items-center gap-1">
<span
className="max-w-[280px] truncate font-medium"
title={formatReportRequirementLabel(m.reqId, requirements)}
>
{formatReportRequirementLabel(m.reqId, requirements)}
</span>
<span> {m.noteIds.join(', ') || '(仅需求驱动)'} {m.taskCount} </span>
</li>
))}
</ul>
</div>
)}
{data.report.reqOnly.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
<span className="text-[12px] font-medium text-amber-700"></span>
</div>
<p className="text-[11px] text-amber-700 mb-1">
</p>
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
{data.report.reqOnly.map((id, i) => {
const label = formatReportRequirementLabel(id, requirements);
return <li key={i} className="max-w-[360px] truncate" title={label}>{label}</li>;
})}
</ul>
</div>
)}
{prototypeOnlyGroups.length > 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-blue-600" />
<span className="text-[12px] font-medium text-blue-700">ID分组</span>
</div>
<p className="text-[11px] text-blue-700 mb-1">
/
</p>
<ul className="text-[12px] text-blue-800 ml-5 list-disc space-y-0.5">
{prototypeOnlyGroups.map((item, i) => (
<li key={i}>
<span className="font-medium">ID · {item.requirementName}</span>
<span>{item.noteIds.join(', ') || '无批注编号'} {item.taskCount} </span>
</li>
))}
</ul>
</div>
)}
{data.report.ambiguous.length > 0 && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 mb-2">
<div className="flex items-center gap-1.5 mb-1.5">
<HelpCircle className="h-3.5 w-3.5 text-rose-600" />
<span className="text-[12px] font-medium text-rose-700"></span>
</div>
<ul className="text-[12px] text-rose-800 ml-5 space-y-0.5">
{data.report.ambiguous.map((a, i) => (
<li key={i}>
<span className="font-medium">{a.noteId}</span>{a.reason}
</li>
))}
</ul>
</div>
)}
</section>
{/* DevTask 草案 */}
{showDevDrafts && (
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.devTaskDrafts.length}
</h4>
{data.devTaskDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.devTaskDrafts.map((d, i) => {
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
return (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedDevIdx.has(i)}
onChange={() => toggleDev(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{resolveDraftTaskTypeName(d, 'development')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{d.requirementName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
ID · {d.requirementName}
</span>
)}
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
title={recommendation.reason ?? ''}
>
{recommendation.name}
</span>
)}
</div>
{d.description && (
<p className="mt-1 text-[12px] text-[var(--ink-soft)] line-clamp-2">{d.description}</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
);
})}
</div>
)}
</section>
)}
{/* TestCase 草案 */}
{showTestDrafts && (
<section>
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
{data.testCaseDrafts.length}
</h4>
{data.testCaseDrafts.length === 0 ? (
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.testCaseDrafts.map((d, i) => {
const recommendation = resolveRecommendedAssignee(d, version.members ?? []);
return (
<label
key={i}
className="flex gap-2 p-3 rounded-lg border border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedTcIdx.has(i)}
onChange={() => toggleTc(i)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-[var(--ink)]">{d.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{resolveDraftTaskTypeName(d, 'testing')}
</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 text-zinc-600">
{d.priority}
</span>
<span className="text-[10px] text-[var(--ink-muted)]">
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
</span>
{d.requirementName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
ID · {d.requirementName}
</span>
)}
{recommendation && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200"
title={recommendation.reason ?? ''}
>
{recommendation.name}
</span>
)}
</div>
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
{d.description}
</pre>
<div className="mt-1.5 flex flex-wrap gap-1">
{d.references.map((r, j) => (
<span
key={j}
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200"
>
{r.label}
</span>
))}
</div>
</div>
</label>
);
})}
</div>
)}
</section>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t border-[var(--line)]">
<div className="flex flex-col gap-1">
<div className="text-[12px] text-[var(--ink-muted)]">
{showDevDrafts ? selectedDevIdx.size : 0} + {showTestDrafts ? selectedTcIdx.size : 0}
</div>
{visibleRecommendationCount > 0 && (
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)]">
<input
type="checkbox"
checked={useRecommendedAssignees}
onChange={(event) => setUseRecommendedAssignees(event.target.checked)}
/>
<span>{visibleRecommendationCount}</span>
</label>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => {
onRejected?.();
onClose();
}}
disabled={submitting || submitted}
className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
onClick={onClose}
disabled={submitting || submitted}
className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
onClick={handleAdopt}
disabled={submitting || submitted || selectedVisibleCount === 0}
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
</button>
</div>
</div>
</div>
</div>
);
}