feat(ai): 优化拆解重跑与结果展示

This commit is contained in:
Script Generator
2026-06-26 11:01:11 +08:00
parent 55e24442ab
commit 56e0fe562d
36 changed files with 1342 additions and 186 deletions

View File

@@ -7,9 +7,14 @@ import type { VersionWithContext } from '@/lib/derive';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { api } from '@/lib/api';
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
import { DecomposeReportModal } from './DecomposeReportModal';
import type {
AgentDecomposeTarget,
AgentDecomposeRequest,
AgentDecomposeResponse,
AgentDecomposeError,
@@ -33,12 +38,18 @@ function formatElapsed(sec: number): string {
export function AiDecomposeButton({ plan, version }: Props) {
const { updatePlan } = useVersionPlanStore();
const { requirements } = useRequirementStore();
const devTasks = useDevTaskStore((s) => s.tasks);
const testCases = useTestCaseStore((s) => s.testCases);
const categories = useTaskCategoryStore((s) => s.categories);
const user = useAuthStore((s) => s.user);
const [loading, setLoading] = useState(false);
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 [tick, setTick] = useState(0);
const startedAtRef = useRef<number | null>(null);
const loading = activeTarget !== null;
// plan 上的状态(持久化在 localStorage
const persistStatus = plan.aiDecomposeStatus;
@@ -78,14 +89,20 @@ export function AiDecomposeButton({ plan, version }: Props) {
description: r.description,
}));
const handleClick = async () => {
const targetText = (target: AgentDecomposeTarget) => {
if (target === 'dev_tasks') return '开发任务';
if (target === 'test_cases') return '测试用例';
return '任务和用例';
};
const handleClick = async (target: AgentDecomposeTarget) => {
if (loading) return;
// 即便 persistStatus 是 in_progress只要超过阈值就允许重新点
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
startedAtRef.current = Date.now();
setTick(Date.now());
setLoading(true);
setActiveTarget(target);
updatePlan(plan.id, {
aiDecomposeStatus: 'in_progress',
@@ -105,6 +122,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
members,
versionId: version.id,
planId: plan.id,
target,
};
try {
@@ -119,7 +137,19 @@ export function AiDecomposeButton({ plan, version }: Props) {
aiDecomposeError: resp.error,
});
} else {
setResult(resp);
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
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,
});
setResultTarget(target);
updatePlan(plan.id, {
aiDecomposeStatus: 'completed',
aiDecomposeError: undefined,
@@ -132,7 +162,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
aiDecomposeError: msg,
});
} finally {
setLoading(false);
setActiveTarget(null);
startedAtRef.current = null;
}
};
@@ -143,39 +173,48 @@ export function AiDecomposeButton({ plan, version }: Props) {
return (
<>
<button
onClick={handleClick}
disabled={isInProgress}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
isError
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
}`}
title={
isError
? `上次失败:${persistError || '未知错误'}(点击重试)`
: wasCompleted
? '此前已拆解过,再次点击会重新拆解'
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
}
>
{isInProgress ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{formatElapsed(elapsedSec)}
</>
) : isError ? (
<>
<RotateCw className="h-3 w-3" />
</>
) : (
<>
<Sparkles className="h-3 w-3" />
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
</>
)}
</button>
<span className="inline-flex items-center gap-1.5">
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
return (
<button
key={target}
onClick={() => handleClick(target)}
disabled={isInProgress}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
isError
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
}`}
title={
isError
? `上次失败:${persistError || '未知错误'}(点击重试${targetText(target)}`
: wasCompleted
? `此前已拆解过,再次点击会重新拆解${targetText(target)}`
: `使用 AI 把原型 + 关联需求拆解成${targetText(target)}`
}
>
{showSpinner ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{formatElapsed(elapsedSec)}
</>
) : isError ? (
<>
<RotateCw className="h-3 w-3" />
{targetText(target)}
</>
) : (
<>
<Sparkles className="h-3 w-3" />
{label}
</>
)}
</button>
);
})}
</span>
{/* 错误信息:在按钮旁悬浮显示 */}
{isError && persistError && (
@@ -198,6 +237,8 @@ export function AiDecomposeButton({ plan, version }: Props) {
version={version}
plan={plan}
requirements={linkedReqs}
target={resultTarget}
dedupeSummary={dedupeSummary}
onClose={() => setResult(null)}
/>
)}