485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
'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, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||
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 } = 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 [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 handleAdopt = () => {
|
||
if (submitting) return;
|
||
setSubmitting(true);
|
||
const now = new Date().toISOString();
|
||
|
||
// 把每个 reference 的 requirement 类型 id(AI 给的是 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 reqRef = refs.find((r) => r.type === 'requirement');
|
||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||
|
||
createTask({
|
||
requirementId,
|
||
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 reqRef = refs.find((r) => r.type === 'requirement');
|
||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||
const recommendedAssignee = resolveRecommendedAssignee(draft, version.members ?? []);
|
||
|
||
createTestCase({
|
||
versionId: version.id,
|
||
requirementId: reqRef?.id,
|
||
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>
|
||
)}
|
||
|
||
{data.report.noteOnly.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">仅原型未见需求</span>
|
||
</div>
|
||
<p className="text-[11px] text-blue-700 mb-1">
|
||
以下原型批注在需求清单中未提及,已跳过拆解(可能是漏录需求):
|
||
</p>
|
||
<ul className="text-[12px] text-blue-800 ml-5 list-disc">
|
||
{data.report.noteOnly.map((id, i) => <li key={i}>{id}</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)]">
|
||
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
|
||
</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>
|
||
{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)]">
|
||
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
|
||
</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>
|
||
{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>
|
||
);
|
||
}
|