feat(ai): 优化拆解目标和负责人推荐

This commit is contained in:
Script Generator
2026-06-29 16:19:52 +08:00
parent 0e27a4d4c5
commit 7f24e16491
19 changed files with 658 additions and 118 deletions

View File

@@ -11,6 +11,7 @@ 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,
@@ -41,11 +42,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
new Set(data.devTaskDrafts.map((_, i) => i)),
new Set(showDevDrafts ? data.devTaskDrafts.map((_, i) => i) : []),
);
const [selectedTcIdx, setSelectedTcIdx] = useState<Set<number>>(
new Set(data.testCaseDrafts.map((_, i) => i)),
new Set(showTestDrafts ? data.testCaseDrafts.map((_, i) => i) : []),
);
const [useRecommendedAssignees, setUseRecommendedAssignees] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
@@ -89,70 +91,72 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
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 aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
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: '',
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);
devCount++;
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);
}
}
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 aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
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: undefined,
references: refs,
aiDraft: true,
aiDraftAt: now,
createdBy: user?.name || 'AI',
} as any);
tcCount++;
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);
@@ -162,6 +166,17 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
setTimeout(() => 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 (
@@ -281,7 +296,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.devTaskDrafts.map((d, i) => (
{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"
@@ -304,6 +321,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<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>
@@ -320,7 +345,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</div>
</div>
</label>
))}
);
})}
</div>
)}
</section>
@@ -336,7 +362,9 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<p className="text-[12px] text-[var(--ink-muted)]"></p>
) : (
<div className="space-y-2">
{data.testCaseDrafts.map((d, i) => (
{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"
@@ -359,6 +387,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
<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}
@@ -375,7 +411,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</div>
</div>
</label>
))}
);
})}
</div>
)}
</section>
@@ -383,9 +420,21 @@ export function DecomposeReportModal({ result, version, plan, requirements, targ
</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)]">
{showDevDrafts ? selectedDevIdx.size : 0} + {showTestDrafts ? selectedTcIdx.size : 0}
<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