Files
ftb-project-management/apps/web/components/version/AiDecomposeButton.tsx
2026-06-25 15:21:32 +08:00

207 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<AgentDecomposeResponse | null>(null);
const [tick, setTick] = useState(0);
const startedAtRef = useRef<number | null>(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<AgentDecomposeResponse | AgentDecomposeError>(
'/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 (
<>
<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>
{/* 错误信息:在按钮旁悬浮显示 */}
{isError && persistError && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-red-600 max-w-[260px]" title={persistError}>
<AlertCircle className="h-3 w-3 shrink-0" />
<span className="truncate">{persistError}</span>
</span>
)}
{/* 卡死提示persistStatus 是 in_progress 但已超时 */}
{isStaleInProgress && (
<span className="ml-1 text-[11px] text-amber-600" title="上次拆解记录残留,点击按钮重新拆解">
{formatElapsed(elapsedSec)}
</span>
)}
{result && (
<DecomposeReportModal
result={result}
version={version}
plan={plan}
requirements={linkedReqs}
onClose={() => setResult(null)}
/>
)}
</>
);
}