fix: polish bark battle creation flow
This commit is contained in:
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal file
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } 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 { 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<BarkBattleImageGenerationBatchResult>
|
||||
>();
|
||||
|
||||
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('|');
|
||||
}
|
||||
|
||||
export function BarkBattleGeneratingView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onComplete,
|
||||
onError,
|
||||
}: BarkBattleGeneratingViewProps) {
|
||||
const startedDraftIdRef = useRef<string | null>(null);
|
||||
const [slotFailures, setSlotFailures] =
|
||||
useState<BarkBattleImageGenerationFailures>({});
|
||||
const [previewDraft, setPreviewDraft] = useState(draft);
|
||||
const [slotStatuses, setSlotStatuses] = useState<
|
||||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||||
>({});
|
||||
const primaryFailureMessage = useMemo(
|
||||
() => resolvePrimaryFailureMessage(slotFailures),
|
||||
[slotFailures],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewDraft(draft);
|
||||
setSlotStatuses(
|
||||
GENERATION_STEPS.reduce<
|
||||
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||||
>((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<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
|
||||
>((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 (
|
||||
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回编辑
|
||||
</button>
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
|
||||
生成中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
自动生成素材
|
||||
</div>
|
||||
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
{draft.title || '未命名声浪竞技场'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{GENERATION_STEPS.map((step) => {
|
||||
const status =
|
||||
slotStatuses[step.slot] ??
|
||||
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
|
||||
const ready = status === 'ready';
|
||||
const failed =
|
||||
status === 'failed' || Boolean(slotFailures[step.slot]);
|
||||
const statusLabel = ready
|
||||
? `${step.label}已生成`
|
||||
: failed
|
||||
? `${step.label}生成失败`
|
||||
: `${step.label}生成中`;
|
||||
return (
|
||||
<div
|
||||
key={step.slot}
|
||||
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<span className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{step.label}
|
||||
</span>
|
||||
{ready ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : failed ? (
|
||||
<AlertCircle className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error || primaryFailureMessage ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error ?? primaryFailureMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewDraft} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarkBattleGeneratingView;
|
||||
|
||||
Reference in New Issue
Block a user