import { ArrowLeft } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleAssetSlot, BarkBattleGeneratedImageAssets, BarkBattleImageGenerationBatchResult, BarkBattleImageGenerationFailures, } from '../../services/bark-battle-creation'; import { generateAllBarkBattleImageAssets, updateBarkBattleDraftConfig, } from '../../services/bark-battle-creation'; import { GenerationCurrentStepCard, GenerationPageBackdrop, GenerationProgressHero, } from '../GenerationProgressHero'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; type BarkBattleGeneratingViewProps = { draft: BarkBattleDraftConfig; isBusy?: boolean; error?: string | null; onBack: () => void; onComplete: (draft: BarkBattleDraftConfig, partialFailed: boolean) => void; onError: (message: string | null) => void; }; type BarkBattleGeneratingSlotStatus = 'generating' | 'ready' | 'failed'; const GENERATION_STEPS = [ { slot: 'player-character', label: '玩家形象' }, { slot: 'opponent-character', label: '对手形象' }, { slot: 'ui-background', label: '竞技背景' }, ] as const satisfies readonly { slot: BarkBattleAssetSlot; label: string; }[]; const activeBarkBattleGenerationTasks = new Map< string, Promise >(); function applyGeneratedAssets( draft: BarkBattleDraftConfig, assets: BarkBattleGeneratedImageAssets, ): BarkBattleDraftConfig { const nextDraft: BarkBattleDraftConfig = { ...draft, updatedAt: new Date().toISOString(), }; if (assets['player-character']?.imageSrc) { nextDraft.playerCharacterImageSrc = assets['player-character'].imageSrc; } if (assets['opponent-character']?.imageSrc) { nextDraft.opponentCharacterImageSrc = assets['opponent-character'].imageSrc; } if (assets['ui-background']?.imageSrc) { nextDraft.uiBackgroundImageSrc = assets['ui-background'].imageSrc; } return nextDraft; } function hasSlotAsset(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) { if (slot === 'player-character') { return Boolean(draft.playerCharacterImageSrc?.trim()); } if (slot === 'opponent-character') { return Boolean(draft.opponentCharacterImageSrc?.trim()); } return Boolean(draft.uiBackgroundImageSrc?.trim()); } function mergeSlotAsset( draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot, imageSrc: string, ): BarkBattleDraftConfig { if (slot === 'player-character') { return { ...draft, playerCharacterImageSrc: imageSrc }; } if (slot === 'opponent-character') { return { ...draft, opponentCharacterImageSrc: imageSrc }; } return { ...draft, uiBackgroundImageSrc: imageSrc }; } function isDraftPersistable(draft: BarkBattleDraftConfig) { return Boolean(draft.draftId?.trim() && draft.workId?.trim()); } function resolvePrimaryFailureMessage( failures: BarkBattleImageGenerationFailures, ) { for (const step of GENERATION_STEPS) { const message = failures[step.slot]?.trim(); if (message) { return message; } } return null; } function buildDraftGenerationKey(draft: BarkBattleDraftConfig) { return [ draft.draftId, draft.playerCharacterImageSrc ?? '', draft.opponentCharacterImageSrc ?? '', draft.uiBackgroundImageSrc ?? '', ].join('|'); } function getSlotStatusLabel(status: BarkBattleGeneratingSlotStatus) { if (status === 'ready') { return '完成'; } if (status === 'failed') { return '失败'; } return '进行中'; } function formatGenerationDuration(ms: number) { const totalSeconds = Math.max(1, Math.ceil(Math.max(0, ms) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes <= 0) { return `${seconds} 秒`; } if (seconds === 0) { return `${minutes} 分钟`; } return `${minutes} 分 ${seconds} 秒`; } function resolveBarkBattleProgressValue( slotStatuses: Partial< Record >, ) { const readyCount = GENERATION_STEPS.filter( (step) => slotStatuses[step.slot] === 'ready', ).length; return Math.round((readyCount / GENERATION_STEPS.length) * 100); } function resolveCurrentBarkBattleStep( slotStatuses: Partial< Record >, ) { return ( GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'generating') ?? GENERATION_STEPS.find((step) => slotStatuses[step.slot] === 'failed') ?? GENERATION_STEPS.find((step) => slotStatuses[step.slot] !== 'ready') ?? GENERATION_STEPS[GENERATION_STEPS.length - 1] ); } export function BarkBattleGeneratingView({ draft, isBusy = false, error = null, onBack, onComplete, onError, }: BarkBattleGeneratingViewProps) { const startedDraftIdRef = useRef(null); const [slotFailures, setSlotFailures] = useState({}); const [previewDraft, setPreviewDraft] = useState(draft); const [slotStatuses, setSlotStatuses] = useState< Partial> >({}); const [elapsedMs, setElapsedMs] = useState(0); const primaryFailureMessage = useMemo( () => resolvePrimaryFailureMessage(slotFailures), [slotFailures], ); const progressValue = resolveBarkBattleProgressValue(slotStatuses); const currentStep = resolveCurrentBarkBattleStep(slotStatuses); const currentStepStatus = currentStep ? (slotStatuses[currentStep.slot] ?? (hasSlotAsset(previewDraft, currentStep.slot) ? 'ready' : 'generating')) : 'generating'; const currentStepProgress = currentStepStatus === 'ready' ? 100 : currentStepStatus === 'failed' ? 100 : 36; const currentStepLabel = currentStep?.label ?? '竞技素材'; const currentStepStatusLabel = getSlotStatusLabel(currentStepStatus); useEffect(() => { const startedAtMs = Date.now(); const timerId = window.setInterval(() => { setElapsedMs(Date.now() - startedAtMs); }, 1000); setElapsedMs(0); return () => { window.clearInterval(timerId); }; }, [draft.draftId]); useEffect(() => { setPreviewDraft(draft); setSlotStatuses( GENERATION_STEPS.reduce< Partial> >((statuses, step) => { statuses[step.slot] = hasSlotAsset(draft, step.slot) ? 'ready' : 'generating'; return statuses; }, {}), ); }, [draft]); useEffect(() => { if ( !draft.draftId || (() => { const draftGenerationKey = buildDraftGenerationKey(draft); return startedDraftIdRef.current === draftGenerationKey; })() ) { return; } const startedDraftKey = buildDraftGenerationKey(draft); startedDraftIdRef.current = startedDraftKey; let cancelled = false; const generationTask = generateAllBarkBattleImageAssets({ config: draft, draftId: draft.draftId, onSlotComplete: (slot, result) => { if (cancelled || startedDraftIdRef.current !== startedDraftKey) { return; } if (result.status === 'fulfilled') { setPreviewDraft((currentDraft) => mergeSlotAsset(currentDraft, slot, result.asset.imageSrc), ); setSlotStatuses((current) => ({ ...current, [slot]: 'ready' })); setSlotFailures((current) => { const next = { ...current }; delete next[slot]; return next; }); return; } setSlotStatuses((current) => ({ ...current, [slot]: 'failed' })); setSlotFailures((current) => ({ ...current, [slot]: result.message })); }, }); activeBarkBattleGenerationTasks.set(startedDraftKey, generationTask); onError(null); setSlotFailures({}); setPreviewDraft(draft); setSlotStatuses( GENERATION_STEPS.reduce< Partial> >((statuses, step) => { statuses[step.slot] = hasSlotAsset(draft, step.slot) ? 'ready' : 'generating'; return statuses; }, {}), ); void generationTask .then(async ({ assets, failures }) => { if (cancelled) { return; } setSlotFailures(failures); const primaryMessage = resolvePrimaryFailureMessage(failures); if (primaryMessage) { onError(primaryMessage); } const generatedDraft = applyGeneratedAssets(draft, assets); const partialFailed = GENERATION_STEPS.some( (step) => !hasSlotAsset(generatedDraft, step.slot), ); if (!isDraftPersistable(generatedDraft)) { onComplete(generatedDraft, partialFailed); return; } try { const persistedDraft = await updateBarkBattleDraftConfig({ draftId: generatedDraft.draftId, workId: generatedDraft.workId, configVersion: generatedDraft.configVersion, rulesetVersion: generatedDraft.rulesetVersion, title: generatedDraft.title, description: generatedDraft.description, themeDescription: generatedDraft.themeDescription, playerImageDescription: generatedDraft.playerImageDescription, opponentImageDescription: generatedDraft.opponentImageDescription, onomatopoeia: generatedDraft.onomatopoeia, playerCharacterImageSrc: generatedDraft.playerCharacterImageSrc, opponentCharacterImageSrc: generatedDraft.opponentCharacterImageSrc, uiBackgroundImageSrc: generatedDraft.uiBackgroundImageSrc, difficultyPreset: generatedDraft.difficultyPreset, }); const updatedDraft = applyGeneratedAssets(persistedDraft, assets); if (!cancelled) { onComplete(updatedDraft, partialFailed); } } catch (persistError) { if (cancelled) { return; } onError( persistError instanceof Error ? persistError.message : '汪汪声浪素材保存失败。', ); onComplete(generatedDraft, true); } }) .catch((generationError) => { if (cancelled) { return; } onError( generationError instanceof Error ? generationError.message : '汪汪声浪素材生成失败。', ); onComplete(draft, true); }) .finally(() => { if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) { activeBarkBattleGenerationTasks.delete(startedDraftKey); } }); return () => { cancelled = true; // 中文注释:离开生成页后不再全局复用同一 Promise,避免悬挂生成任务导致再次进入时一直转圈。 if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) { activeBarkBattleGenerationTasks.delete(startedDraftKey); } if (startedDraftIdRef.current === startedDraftKey) { startedDraftIdRef.current = null; } }; }, [draft, onComplete, onError]); return (
生成中
{error || primaryFailureMessage ? (
{error ?? primaryFailureMessage}
) : null}
预览信息
); } export default BarkBattleGeneratingView;