Files
ftb-project-management/apps/web/components/version/DecomposeReportModal.tsx
2026-06-25 15:21:32 +08:00

382 lines
16 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, resolveCategoryIdFromCode } from '@/lib/task-category';
import { addWorkHours } from '@/lib/work-hours';
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import type {
AgentDecomposeResponse,
AgentDevTaskDraft,
AgentTestCaseDraft,
} from '@ftb/shared';
interface Props {
result: AgentDecomposeResponse;
version: VersionWithContext;
plan: VersionPlan;
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
onClose: () => void;
}
export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) {
const { createTask } = useDevTaskStore();
const { createTestCase } = useTestCaseStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const { result: data, meta } = result;
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(data.devTaskDrafts.map((_, i) => i)),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(data.testCaseDrafts.map((_, i) => i)),
);
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 类型 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;
});
let devCount = 0;
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 estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
const startISO = new Date().toISOString();
const endISO = addWorkHours(startISO, estimateHours);
createTask({
requirementId,
title: draft.title,
description: draft.description,
categoryId,
assigneeId: '',
reviewerId: undefined,
priority: draft.priority,
expectedStartAt: startISO,
expectedEndAt: endISO,
estimateHours,
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);
devCount++;
}
let tcCount = 0;
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 estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
createTestCase({
versionId: version.id,
requirementId: reqRef?.id,
title: draft.title,
description: draft.description,
categoryId,
priority: draft.priority,
estimateHours,
assigneeId: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
tcCount++;
}
setSubmitted(true);
setSubmitting(false);
// 1.5 秒后自动关闭
setTimeout(() => onClose(), 1500);
};
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">
{/* 对账报告 */}
<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}>
{m.reqId} {m.noteIds.join(', ') || '(仅需求驱动)'} {m.taskCount}
</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) => <li key={i}>{id}</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 草案 */}
<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) => (
<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)]">
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
</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 草案 */}
<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) => (
<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)]">
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
</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 px-5 py-3 border-t border-[var(--line)]">
<div className="text-[12px] text-[var(--ink-muted)]">
{selectedDevIdx.size} + {selectedTcIdx.size}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
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 || (selectedDevIdx.size === 0 && selectedTcIdx.size === 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>
);
}