Files
Genarrative/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
高物 27b30f974b Update spacetime-client bindings and frontend
Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
2026-06-04 22:44:19 +08:00

407 lines
13 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.
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';
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">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span>
</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;