'use client'; import { useEffect, useRef, useState } from 'react'; import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react'; import { appendPlanLog } from '@/lib/version-plan'; import type { VersionPlan } from '@/lib/version-plan'; 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 { filterDecomposeResultByTarget } from '@/lib/ai-decompose-target'; import { DecomposeReportModal } from './DecomposeReportModal'; import type { AgentDecomposeTarget, AgentDecomposeRequest, AgentDecomposeResponse, AgentDecomposeError, } from '@ftb/shared'; interface Props { plan: VersionPlan; version: VersionWithContext; } interface DecomposeModalState { result: AgentDecomposeResponse; target: AgentDecomposeTarget; dedupeSummary: { removedDevTaskCount: number; removedTestCaseCount: number }; } /** in_progress 视为"卡死"的阈值(秒)— 超过这个时间,按钮允许重新点击 */ const STUCK_THRESHOLD_SEC = 240; function formatElapsed(sec: number): string { if (sec < 60) return `${sec}s`; const m = Math.floor(sec / 60); const s = sec % 60; return `${m}m${s}s`; } 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 [activeTarget, setActiveTarget] = useState(null); const [modalState, setModalState] = useState(null); const [tick, setTick] = useState(0); const startedAtRef = useRef(null); const loading = activeTarget !== null; // plan 上的状态(持久化在 localStorage) const persistStatus = plan.aiDecomposeStatus; const persistError = plan.aiDecomposeError; const persistAt = plan.aiDecomposeAt; const persistTarget = plan.aiDecomposeTarget; // 计算"已用时" const elapsedSec = (() => { if (loading && startedAtRef.current) { return Math.floor((tick - startedAtRef.current) / 1000); } if (persistStatus === 'in_progress' && persistAt) { const start = new Date(persistAt).getTime(); return Math.max(0, Math.floor((Date.now() - start) / 1000)); } return 0; })(); const isStaleInProgress = persistStatus === 'in_progress' && elapsedSec > STUCK_THRESHOLD_SEC; 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(() => { if (!isInProgress) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); }, [isInProgress]); const linkedReqs = requirements .filter((r) => r.versionId === version.id) .map((r) => ({ id: r.id, code: r.code, title: r.title, description: r.description, })); const targetText = (target: AgentDecomposeTarget) => { if (target === 'dev_tasks') return '开发任务'; if (target === 'test_cases') return '测试用例'; return '任务和用例'; }; const appendAiLog = ( target: AgentDecomposeTarget, status: 'started' | 'completed' | 'error', detail?: string, ) => { const currentPlan = useVersionPlanStore.getState().plans.find((item) => item.id === plan.id) ?? plan; const statusText = status === 'started' ? '已触发' : status === 'completed' ? '已完成' : '失败'; return appendPlanLog(currentPlan, { type: 'ai_decompose', actor: user?.name ?? plan.owner, title: `AI 拆解${targetText(target)}${statusText}`, detail, aiTarget: target, aiStatus: status, }); }; const handleClick = async (target: AgentDecomposeTarget) => { if (loading) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 if (persistStatus === 'in_progress' && !isStaleInProgress) return; startedAtRef.current = Date.now(); setTick(Date.now()); setActiveTarget(target); updatePlan(plan.id, { aiDecomposeStatus: 'in_progress', aiDecomposeBy: user?.name, aiDecomposeAt: new Date().toISOString(), aiDecomposeTarget: target, aiDecomposeError: undefined, logs: appendAiLog(target, 'started'), }); const members = (version.members ?? []).map((m) => ({ name: m.name, role: m.role, })); const payload: AgentDecomposeRequest = { prototypeUrl: plan.resultUrl || '', requirements: linkedReqs, members, versionId: version.id, planId: plan.id, target, }; try { const resp = await api.postRaw( '/ai/decompose', payload, 300000, ); if (!resp.ok) { updatePlan(plan.id, { aiDecomposeStatus: 'error', aiDecomposeTarget: target, aiDecomposeError: resp.error, logs: appendAiLog(target, 'error', resp.error), }); } else { const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target); const requirementIdSet = new Set(linkedReqs.map((req) => req.id)); const deduped = filterDuplicateDecomposeDrafts(targetFilteredResult, { existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)), existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id), categories, requirements: linkedReqs, }); setModalState({ result: { ...resp, result: filterDecomposeResultByTarget(deduped.result, target) }, target, dedupeSummary: { removedDevTaskCount: deduped.removedDevTaskCount, removedTestCaseCount: deduped.removedTestCaseCount, }, }); updatePlan(plan.id, { aiDecomposeStatus: 'completed', aiDecomposeTarget: target, aiDecomposeError: undefined, logs: appendAiLog( target, 'completed', `生成开发任务 ${deduped.result.devTaskDrafts.length} 条,测试用例 ${deduped.result.testCaseDrafts.length} 条。已过滤重复开发任务 ${deduped.removedDevTaskCount} 条,重复测试用例 ${deduped.removedTestCaseCount} 条。`, ), }); } } catch (e: any) { const msg = e?.message || '调用 AI 服务失败'; updatePlan(plan.id, { aiDecomposeStatus: 'error', aiDecomposeTarget: target, aiDecomposeError: msg, logs: appendAiLog(target, 'error', msg), }); } finally { setActiveTarget(null); startedAtRef.current = null; } }; if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) { return null; } return ( <> {(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => { const showSpinner = isInProgress && runningTarget === target; const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`; return ( ); })} {/* 错误信息:在按钮旁悬浮显示 */} {isError && persistError && ( {persistError} )} {/* 卡死提示:persistStatus 是 in_progress 但已超时 */} {isStaleInProgress && ( 上次未完成({formatElapsed(elapsedSec)}) )} {modalState && ( setModalState(null)} /> )} ); }