feat(ai): 优化拆解目标和负责人推荐
This commit is contained in:
@@ -12,6 +12,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
|
||||
import { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target';
|
||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
@@ -25,6 +26,12 @@ interface Props {
|
||||
version: VersionWithContext;
|
||||
}
|
||||
|
||||
interface DecomposeModalState {
|
||||
result: AgentDecomposeResponse;
|
||||
target: AgentDecomposeTarget;
|
||||
dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number };
|
||||
}
|
||||
|
||||
/** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */
|
||||
const STUCK_THRESHOLD_SEC = 240;
|
||||
|
||||
@@ -44,9 +51,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
|
||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
|
||||
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
|
||||
const [modalState, setModalState] = useState<DecomposeModalState | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const loading = activeTarget !== null;
|
||||
@@ -55,6 +60,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const persistStatus = plan.aiDecomposeStatus;
|
||||
const persistError = plan.aiDecomposeError;
|
||||
const persistAt = plan.aiDecomposeAt;
|
||||
const persistTarget = plan.aiDecomposeTarget;
|
||||
|
||||
// 计算"已用时"
|
||||
const elapsedSec = (() => {
|
||||
@@ -72,6 +78,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const isInProgress = (loading || persistStatus === 'in_progress') && !isStaleInProgress;
|
||||
const isError = persistStatus === 'error' && !loading;
|
||||
const wasCompleted = persistStatus === 'completed' && !loading;
|
||||
const runningTarget = activeTarget ?? (isInProgress ? persistTarget ?? null : null);
|
||||
|
||||
// tick 每秒更新一次,让"已用时"实时跳
|
||||
useEffect(() => {
|
||||
@@ -108,6 +115,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'in_progress',
|
||||
aiDecomposeBy: user?.name,
|
||||
aiDecomposeAt: new Date().toISOString(),
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
|
||||
@@ -134,24 +142,29 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
if (!resp.ok) {
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: resp.error,
|
||||
});
|
||||
} else {
|
||||
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
||||
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
||||
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
|
||||
const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, {
|
||||
existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
|
||||
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
||||
categories,
|
||||
requirements: linkedReqs,
|
||||
});
|
||||
setResult({ ...resp, result: deduped.result });
|
||||
setDedupeSummary({
|
||||
removedDevTaskCount: deduped.removedDevTaskCount,
|
||||
removedTestCaseCount: deduped.removedTestCaseCount,
|
||||
setModalState({
|
||||
result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) },
|
||||
target,
|
||||
dedupeSummary: {
|
||||
removedDevTaskCount: deduped.removedDevTaskCount,
|
||||
removedTestCaseCount: deduped.removedTestCaseCount,
|
||||
},
|
||||
});
|
||||
setResultTarget(target);
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
});
|
||||
}
|
||||
@@ -159,6 +172,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const msg = e?.message || '调用 AI 服务失败';
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: msg,
|
||||
});
|
||||
} finally {
|
||||
@@ -175,10 +189,11 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
|
||||
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
|
||||
const showSpinner = isInProgress && runningTarget === target;
|
||||
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
onClick={() => handleClick(target)}
|
||||
disabled={isInProgress}
|
||||
@@ -231,15 +246,15 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
{modalState && (
|
||||
<DecomposeReportModal
|
||||
result={result}
|
||||
result={modalState.result}
|
||||
version={version}
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
target={resultTarget}
|
||||
dedupeSummary={dedupeSummary}
|
||||
onClose={() => setResult(null)}
|
||||
target={modalState.target}
|
||||
dedupeSummary={modalState.dedupeSummary}
|
||||
onClose={() => setModalState(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user