feat: complete bark battle draft publish flow
This commit is contained in:
339
src/components/bark-battle-creation/BarkBattleResultView.tsx
Normal file
339
src/components/bark-battle-creation/BarkBattleResultView.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BarkBattleConfigEditorPayload,
|
||||
BarkBattleDraftConfig,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
type BarkBattleAssetSlot,
|
||||
regenerateBarkBattleImageAsset,
|
||||
uploadBarkBattleAsset,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
|
||||
|
||||
type BarkBattleResultViewProps = {
|
||||
draft: BarkBattleDraftConfig;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onDraftChange: (draft: BarkBattleDraftConfig) => void;
|
||||
onStartTestRun: (draft: BarkBattleDraftConfig) => void;
|
||||
onPublish: (draft: BarkBattleDraftConfig) => void;
|
||||
};
|
||||
|
||||
type BarkBattleImageSlot = Exclude<BarkBattleAssetSlot, 'bark-sound'>;
|
||||
|
||||
const SLOT_LABELS = {
|
||||
'player-character': '玩家形象',
|
||||
'opponent-character': '对手形象',
|
||||
'ui-background': 'UI背景',
|
||||
'bark-sound': '狗叫音效',
|
||||
} satisfies Record<BarkBattleAssetSlot, string>;
|
||||
|
||||
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
|
||||
return {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
themePreset: draft.themePreset,
|
||||
playerDogSkinPreset: draft.playerDogSkinPreset,
|
||||
opponentDogSkinPreset: draft.opponentDogSkinPreset,
|
||||
...(draft.playerCharacterImageSrc
|
||||
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.opponentCharacterImageSrc
|
||||
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
|
||||
: {}),
|
||||
...(draft.uiBackgroundImageSrc
|
||||
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
|
||||
: {}),
|
||||
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
leaderboardEnabled: draft.leaderboardEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAssetToDraft(
|
||||
draft: BarkBattleDraftConfig,
|
||||
slot: BarkBattleAssetSlot,
|
||||
assetSrc: string,
|
||||
): BarkBattleDraftConfig {
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (slot === 'player-character') {
|
||||
return { ...draft, playerCharacterImageSrc: assetSrc, updatedAt };
|
||||
}
|
||||
if (slot === 'opponent-character') {
|
||||
return { ...draft, opponentCharacterImageSrc: assetSrc, updatedAt };
|
||||
}
|
||||
if (slot === 'ui-background') {
|
||||
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
|
||||
}
|
||||
return { ...draft, barkSoundSrc: assetSrc, updatedAt };
|
||||
}
|
||||
|
||||
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
|
||||
if (slot === 'player-character') {
|
||||
return draft.playerCharacterImageSrc ?? '';
|
||||
}
|
||||
if (slot === 'opponent-character') {
|
||||
return draft.opponentCharacterImageSrc ?? '';
|
||||
}
|
||||
if (slot === 'ui-background') {
|
||||
return draft.uiBackgroundImageSrc ?? '';
|
||||
}
|
||||
return draft.barkSoundSrc ?? '';
|
||||
}
|
||||
|
||||
function ResultActionButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
tone = 'secondary',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
tone?: 'primary' | 'secondary';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`platform-button ${
|
||||
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
|
||||
} min-h-11 justify-center disabled:cursor-not-allowed disabled:opacity-55`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BarkBattleAssetSlotControl({
|
||||
draft,
|
||||
slot,
|
||||
disabled,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
draft: BarkBattleDraftConfig;
|
||||
slot: BarkBattleAssetSlot;
|
||||
disabled: boolean;
|
||||
onChange: (draft: BarkBattleDraftConfig) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const assetSrc = getSlotAssetSrc(draft, slot);
|
||||
const isImageSlot = slot !== 'bark-sound';
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
onError(null);
|
||||
try {
|
||||
const asset = await uploadBarkBattleAsset({
|
||||
slot,
|
||||
file,
|
||||
draftId: draft.draftId,
|
||||
});
|
||||
onChange(applyAssetToDraft(draft, slot, asset.assetSrc));
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : '上传素材失败。');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!isImageSlot) {
|
||||
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegenerating(true);
|
||||
onError(null);
|
||||
try {
|
||||
const result = await regenerateBarkBattleImageAsset({
|
||||
slot: slot as BarkBattleImageSlot,
|
||||
config: mapDraftToConfig(draft),
|
||||
draftId: draft.draftId,
|
||||
});
|
||||
onChange(applyAssetToDraft(draft, slot, result.imageSrc));
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : '重新生成素材失败。');
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSlotBusy = isUploading || isRegenerating;
|
||||
|
||||
return (
|
||||
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{SLOT_LABELS[slot]}
|
||||
</h3>
|
||||
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{assetSrc || '未替换'}
|
||||
</div>
|
||||
</div>
|
||||
{isSlotBusy ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
|
||||
) : isImageSlot ? (
|
||||
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'}
|
||||
className="hidden"
|
||||
aria-label={`上传${SLOT_LABELS[slot]}文件`}
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isSlotBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isSlotBusy}
|
||||
onClick={handleRegenerate}
|
||||
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarkBattleResultView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onDraftChange,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
}: BarkBattleResultViewProps) {
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
|
||||
const visibleError = localError ?? error;
|
||||
|
||||
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-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
草稿
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="text-sm font-black text-[var(--platform-text-soft)]">
|
||||
草稿编译
|
||||
</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 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
'player-character',
|
||||
'opponent-character',
|
||||
'ui-background',
|
||||
'bark-sound',
|
||||
] as const
|
||||
).map((slot) => (
|
||||
<BarkBattleAssetSlotControl
|
||||
key={slot}
|
||||
draft={draft}
|
||||
slot={slot}
|
||||
disabled={isBusy}
|
||||
onChange={(nextDraft) => {
|
||||
setLocalError(null);
|
||||
onDraftChange(nextDraft);
|
||||
}}
|
||||
onError={setLocalError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<BarkBattlePreviewCard config={previewConfig} />
|
||||
</section>
|
||||
|
||||
{visibleError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{visibleError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
|
||||
<ResultActionButton
|
||||
disabled={isBusy}
|
||||
onClick={() => onStartTestRun(draft)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</ResultActionButton>
|
||||
<ResultActionButton
|
||||
tone="primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => onPublish(draft)}
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</ResultActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarkBattleResultView;
|
||||
Reference in New Issue
Block a user