feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -0,0 +1,381 @@
'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>
);
}