'use client'; import { useEffect, useRef, useState } from 'react'; import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react'; 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 { api } from '@/lib/api'; import { DecomposeReportModal } from './DecomposeReportModal'; import type { AgentDecomposeRequest, AgentDecomposeResponse, AgentDecomposeError, } from '@ftb/shared'; interface Props { plan: VersionPlan; version: VersionWithContext; } /** 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 user = useAuthStore((s) => s.user); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [tick, setTick] = useState(0); const startedAtRef = useRef(null); // plan 上的状态(持久化在 localStorage) const persistStatus = plan.aiDecomposeStatus; const persistError = plan.aiDecomposeError; const persistAt = plan.aiDecomposeAt; // 计算"已用时" 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; // 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 handleClick = async () => { if (loading) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 if (persistStatus === 'in_progress' && !isStaleInProgress) return; startedAtRef.current = Date.now(); setTick(Date.now()); setLoading(true); updatePlan(plan.id, { aiDecomposeStatus: 'in_progress', aiDecomposeBy: user?.name, aiDecomposeAt: new Date().toISOString(), aiDecomposeError: undefined, }); 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, }; try { const resp = await api.postRaw( '/ai/decompose', payload, 300000, ); if (!resp.ok) { updatePlan(plan.id, { aiDecomposeStatus: 'error', aiDecomposeError: resp.error, }); } else { setResult(resp); updatePlan(plan.id, { aiDecomposeStatus: 'completed', aiDecomposeError: undefined, }); } } catch (e: any) { const msg = e?.message || '调用 AI 服务失败'; updatePlan(plan.id, { aiDecomposeStatus: 'error', aiDecomposeError: msg, }); } finally { setLoading(false); startedAtRef.current = null; } }; if (plan.type !== 'product' || plan.status !== 'completed' || !plan.resultUrl) { return null; } return ( <> {/* 错误信息:在按钮旁悬浮显示 */} {isError && persistError && ( {persistError} )} {/* 卡死提示:persistStatus 是 in_progress 但已超时 */} {isStaleInProgress && ( 上次未完成({formatElapsed(elapsedSec)}) )} {result && ( setResult(null)} /> )} ); }