Files
Genarrative/src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
高物 50a0d6f982 Refine creation tab UX, generation flow, and bindings
Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
2026-05-25 00:41:30 +08:00

413 lines
14 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';
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('|');
}
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-y-auto 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-10 mx-auto flex w-full max-w-[48rem] flex-col">
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
<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>
<section className="flex flex-col gap-4">
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
/>
<div className="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}
</div>
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
</section>
</div>
</div>
);
}
export default BarkBattleGeneratingView;