feat: complete bark battle draft publish flow

This commit is contained in:
2026-05-19 15:27:50 +08:00
parent 804f1e32be
commit 23fb895e82
24 changed files with 1710 additions and 159 deletions

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react';
import { ArrowLeft, Loader2, Play } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
@@ -8,7 +8,7 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
error?: string | null;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
showBackButton?: boolean;
title?: string | null;
@@ -20,12 +20,6 @@ const THEME_OPTIONS = [
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DOG_SKIN_OPTIONS = [
{ value: 'corgi', label: '柯基' },
{ value: 'shiba', label: '柴犬' },
{ value: 'husky', label: '哈士奇' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
@@ -35,7 +29,7 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri
export function BarkBattleConfigEditor({
isBusy = false,
error: externalError = null,
onPublish,
onPreview,
onBack,
showBackButton = true,
title: headingTitle = '汪汪声浪大作战',
@@ -43,10 +37,13 @@ export function BarkBattleConfigEditor({
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手');
const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState('');
const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState('');
const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState('');
const [barkSoundSrc, setBarkSoundSrc] = useState('');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [localError, setLocalError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
@@ -56,8 +53,18 @@ export function BarkBattleConfigEditor({
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
...(playerCharacterImageSrc.trim()
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
: {}),
...(opponentCharacterImageSrc.trim()
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
: {}),
...(uiBackgroundImageSrc.trim()
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
: {}),
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
difficultyPreset,
leaderboardEnabled,
leaderboardEnabled: true,
}),
[
title,
@@ -65,24 +72,29 @@ export function BarkBattleConfigEditor({
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
barkSoundSrc,
difficultyPreset,
leaderboardEnabled,
],
);
const handlePublish = () => {
const runValidatedAction = (
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
) => {
if (!payload.title) {
setLocalError('请先填写作品标题');
return;
}
setLocalError(null);
void onPublish(payload);
void action(payload);
};
const visibleError = localError ?? externalError;
return (
<section
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden"
className="platform-remap-surface mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col overflow-y-auto overscroll-y-contain pr-0.5"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
@@ -101,7 +113,7 @@ export function BarkBattleConfigEditor({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
@@ -116,9 +128,9 @@ export function BarkBattleConfigEditor({
) : null}
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
@@ -193,63 +205,88 @@ export function BarkBattleConfigEditor({
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
<input
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setPlayerDogSkinPreset(event.target.value)
}
onChange={(event) => setPlayerDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
aria-label="玩家角色设定"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
<input
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setOpponentDogSkinPreset(event.target.value)
}
onChange={(event) => setOpponentDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
aria-label="对手角色设定"
/>
</label>
</div>
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]">
<span className="inline-flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
</span>
<input
aria-label="开启排行榜"
type="checkbox"
checked={leaderboardEnabled}
disabled={isBusy}
onChange={(event) =>
setLeaderboardEnabled(event.target.checked)
}
className="h-5 w-5 accent-[#ff4f6a]"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerCharacterImageSrc}
disabled={isBusy}
onChange={(event) => setPlayerCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="玩家形象"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentCharacterImageSrc}
disabled={isBusy}
onChange={(event) => setOpponentCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="对手形象"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
UI背景
</span>
<input
value={uiBackgroundImageSrc}
disabled={isBusy}
onChange={(event) => setUiBackgroundImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="UI背景"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={barkSoundSrc}
disabled={isBusy}
onChange={(event) => setBarkSoundSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="狗叫音效"
/>
</label>
</div>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
@@ -262,20 +299,20 @@ export function BarkBattleConfigEditor({
</div>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<div className="mt-3 flex shrink-0 flex-wrap justify-center gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-4">
<button
type="button"
disabled={isBusy}
onClick={handlePublish}
onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<WandSparkles className="h-4 w-4" />
<Play className="h-4 w-4" />
)}
<span>{isBusy ? '发布中' : '发布并试玩'}</span>
<span>{isBusy ? '处理中' : '生成草稿'}</span>
</span>
</button>
</div>