Files
Genarrative/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
T
kdletters a8012109ae 继续收口生成页顶部返回按钮
抽出生成页共用返回按钮壳并复用共享图标按钮能力
将自定义世界生成页接入共享返回按钮壳
将汪汪声浪生成页接入共享返回按钮壳并保留禁用态
补充两个生成页返回按钮的样式与交互回归测试
2026-06-11 04:23:55 +08:00

418 lines
13 KiB
TypeScript

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 { PlatformPillBadge } from '../common/PlatformPillBadge';
import {
GenerationCurrentStepCard,
GenerationHeaderBackButton,
GenerationPageBackdrop,
GenerationProgressHero,
} from '../GenerationProgressHero';
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('|');
}
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<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
>,
) {
const readyCount = GENERATION_STEPS.filter(
(step) => slotStatuses[step.slot] === 'ready',
).length;
return Math.round((readyCount / GENERATION_STEPS.length) * 100);
}
function resolveCurrentBarkBattleStep(
slotStatuses: Partial<
Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>
>,
) {
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<string | null>(null);
const [slotFailures, setSlotFailures] =
useState<BarkBattleImageGenerationFailures>({});
const [previewDraft, setPreviewDraft] = useState(draft);
const [slotStatuses, setSlotStatuses] = useState<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>({});
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<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="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<GenerationPageBackdrop />
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
<GenerationHeaderBackButton
label="返回编辑"
onClick={onBack}
disabled={isBusy}
disabledOpacity={0.45}
/>
<PlatformPillBadge
tone="warning"
size="xs"
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
>
生成中
</PlatformPillBadge>
</div>
<div
className="relative z-10 mx-auto flex min-h-0 w-full max-w-[48rem] flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<section className="grid content-start gap-3 overflow-hidden px-0 pb-2 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
/>
<div className="mt-5 sm:mt-[-0.15rem]">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
</div>
{error || primaryFailureMessage ? (
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error ?? primaryFailureMessage}
</div>
) : null}
</section>
</div>
</div>
);
}
export default BarkBattleGeneratingView;