a8012109ae
抽出生成页共用返回按钮壳并复用共享图标按钮能力 将自定义世界生成页接入共享返回按钮壳 将汪汪声浪生成页接入共享返回按钮壳并保留禁用态 补充两个生成页返回按钮的样式与交互回归测试
418 lines
13 KiB
TypeScript
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;
|