fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('BarkBattleConfigEditor', () => {
it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => {
it('allows creators to edit v1 descriptions and compile a Bark Battle draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -15,48 +15,92 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect(screen.queryByLabelText('资源 URL')).toBeNull();
expect(screen.queryByLabelText('玩家图片 URL')).toBeNull();
expect(screen.queryByLabelText('对手图片 URL')).toBeNull();
expect(screen.queryByLabelText('UI背景 URL')).toBeNull();
expect(screen.queryByLabelText('排行榜开关')).toBeNull();
expect(
(screen.getByLabelText('拟声词') as HTMLTextAreaElement).value,
).toContain('轰汪!');
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
await userEvent.clear(screen.getByLabelText('玩家角色设定'));
await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角');
await userEvent.clear(screen.getByLabelText('对手角色设定'));
await userEvent.type(screen.getByLabelText('对手角色设定'), '对手');
await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台');
await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '蓝头带哈士奇');
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(
screen.getByLabelText('拟声词'),
'炸场!\n冲啊 / 破阵、Boom!',
);
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.type(
screen.getByLabelText('玩家形象'),
'/generated-bark-battle/player/image.png',
);
await userEvent.type(
screen.getByLabelText('对手形象'),
'https://example.test/opponent.png',
);
await userEvent.type(
screen.getByLabelText('UI背景'),
'/generated-bark-battle/ui/background.png',
);
await userEvent.type(
screen.getByLabelText('狗叫音效'),
'/generated-bark-battle/audio/bark.mp3',
);
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPreview).toHaveBeenCalledWith({
title: '周末狗狗杯',
title: '狗狗冠军杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
themeDescription: '霓虹公园声浪擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'],
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
});
it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
expect(defaultWords.length).toBeGreaterThanOrEqual(10);
expect(defaultWords).toEqual(
expect.arrayContaining(['轰汪!', '炸场!', '破阵!', '燃起来!']),
);
expect(defaultWords.some((word) => word.includes('喵'))).toBe(false);
expect(defaultWords.some((word) => word.includes('汪'))).toBe(true);
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'星舰机甲擂台,等离子音浪爆发',
);
await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '星际猫骑士');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手');
const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
expect(updatedWords).toEqual(
expect.arrayContaining(['能量爆裂!', '超频!', '电光轰鸣!']),
);
expect(updatedWords.some((word) => word.includes('汪'))).toBe(false);
});
it('keeps creator-edited onomatopoeia when descriptions change', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台');
expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe(
'轰!\n破阵',
);
});
it('requires a non-empty title before compiling a draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -72,7 +116,7 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
error="发布失败"
error="外部错误"
isBusy={false}
onPreview={onPreview}
showBackButton={false}
@@ -83,6 +127,32 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy();
expect(screen.getByText('外部错误')).toBeTruthy();
});
it('keeps the mobile form in the parent scroll flow with a safe submit footer', () => {
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
isBusy={false}
onPreview={onPreview}
showBackButton={false}
title={null}
/>,
);
const editor = screen.getByLabelText('汪汪声浪轻配置编辑器');
expect(editor.className).toContain('overflow-visible');
expect(editor.className).toContain('lg:overflow-y-auto');
expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5');
const themeLabel = screen.getByText('主题/场景描述');
expect(themeLabel.className).toContain('bg-rose-50');
const submitFooter = screen
.getByRole('button', { name: '生成草稿' })
.closest('div');
expect(submitFooter?.className).toContain('shrink-0');
expect(submitFooter?.className).toContain('safe-area-inset-bottom');
});
});

View File

@@ -1,8 +1,9 @@
import { ArrowLeft, Loader2, Play } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
@@ -14,17 +15,26 @@ export type BarkBattleConfigEditorProps = {
title?: string | null;
};
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
const FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
const ACCENT_FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
function buildDefaultOnomatopoeiaText(params: {
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
}) {
return buildBarkBattleDefaultOnomatopoeia(params).join('\n');
}
export function BarkBattleConfigEditor({
isBusy = false,
@@ -36,46 +46,72 @@ export function BarkBattleConfigEditor({
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
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 [themeDescription, setThemeDescription] = useState(
DEFAULT_THEME_DESCRIPTION,
);
const [playerImageDescription, setPlayerImageDescription] = useState(
DEFAULT_PLAYER_IMAGE_DESCRIPTION,
);
const [opponentImageDescription, setOpponentImageDescription] = useState(
DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
);
const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] =
useState(false);
const [onomatopoeiaText, setOnomatopoeiaText] = useState(() =>
buildDefaultOnomatopoeiaText({
themeDescription: DEFAULT_THEME_DESCRIPTION,
playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION,
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}),
);
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (isOnomatopoeiaCustomized) {
return;
}
setOnomatopoeiaText(
buildDefaultOnomatopoeiaText({
themeDescription,
playerImageDescription,
opponentImageDescription,
}),
);
}, [
isOnomatopoeiaCustomized,
themeDescription,
playerImageDescription,
opponentImageDescription,
]);
const onomatopoeia = useMemo(
() =>
onomatopoeiaText
.split(/[\n,/|]+/u)
.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24),
[onomatopoeiaText],
);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
...(playerCharacterImageSrc.trim()
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
: {}),
...(opponentCharacterImageSrc.trim()
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
: {}),
...(uiBackgroundImageSrc.trim()
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
: {}),
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
themeDescription: themeDescription.trim(),
playerImageDescription: playerImageDescription.trim(),
opponentImageDescription: opponentImageDescription.trim(),
onomatopoeia,
difficultyPreset,
leaderboardEnabled: true,
}),
[
title,
description,
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
barkSoundSrc,
themeDescription,
playerImageDescription,
opponentImageDescription,
onomatopoeia,
difficultyPreset,
],
);
@@ -87,6 +123,14 @@ export function BarkBattleConfigEditor({
setLocalError('请先填写作品标题');
return;
}
if (!payload.themeDescription) {
setLocalError('请先填写主题/场景描述');
return;
}
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
setLocalError('请先填写双方形象描述');
return;
}
setLocalError(null);
void action(payload);
};
@@ -94,7 +138,7 @@ export function BarkBattleConfigEditor({
return (
<section
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"
className="platform-remap-surface mx-auto flex min-h-full w-full max-w-5xl flex-col overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain lg:pr-0.5"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
@@ -113,7 +157,7 @@ export function BarkBattleConfigEditor({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-col lg:flex-1">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
@@ -128,13 +172,11 @@ export function BarkBattleConfigEditor({
) : null}
<div
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
className={`grid gap-3 lg:flex-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<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)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<input
value={title}
disabled={isBusy}
@@ -146,9 +188,7 @@ export function BarkBattleConfigEditor({
</label>
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={description}
disabled={isBusy}
@@ -162,28 +202,7 @@ export function BarkBattleConfigEditor({
<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>
<select
value={themePreset}
disabled={isBusy}
onChange={(event) => setThemePreset(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="主题背景"
>
{THEME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<select
value={difficultyPreset}
disabled={isBusy}
@@ -202,92 +221,64 @@ export function BarkBattleConfigEditor({
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerDogSkinPreset}
disabled={isBusy}
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="玩家角色设定"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentDogSkinPreset}
disabled={isBusy}
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="对手角色设定"
/>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}>
/
</span>
<textarea
value={themeDescription}
disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={240}
placeholder=""
aria-label="主题/场景描述"
/>
</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}
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={playerImageDescription}
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="玩家形象"
onChange={(event) => setPlayerImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="玩家形象描述"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentCharacterImageSrc}
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={opponentImageDescription}
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="狗叫音效"
onChange={(event) => setOpponentImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="对手形象描述"
/>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span>
<textarea
value={onomatopoeiaText}
disabled={isBusy}
onChange={(event) => {
setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value);
}}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={260}
aria-label="拟声词"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError}
@@ -299,7 +290,7 @@ export function BarkBattleConfigEditor({
</div>
</div>
<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">
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={isBusy}

View File

@@ -0,0 +1,306 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
type BarkBattleImageGenerationBatchResult,
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { BarkBattleGeneratingView } from './BarkBattleGeneratingView';
vi.mock('../../services/bark-battle-creation', () => ({
generateAllBarkBattleImageAssets: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
}));
vi.mock('./BarkBattlePreviewCard', () => ({
BarkBattlePreviewCard: () => <div></div>,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
};
describe('BarkBattleGeneratingView', () => {
it('renders all generation slots while parallel generation is still running', async () => {
const onComplete = vi.fn();
let resolveGeneration: (
result: BarkBattleImageGenerationBatchResult,
) => void = () => {};
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
new Promise<BarkBattleImageGenerationBatchResult>((resolve) => {
resolveGeneration = resolve;
}),
);
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
expect(screen.getByText('玩家形象')).toBeTruthy();
expect(screen.getByText('对手形象')).toBeTruthy();
expect(screen.getByText('竞技背景')).toBeTruthy();
expect(onComplete).not.toHaveBeenCalled();
resolveGeneration({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
await waitFor(() => expect(onComplete).toHaveBeenCalled());
});
it('persists generated image assets before entering result view', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
});
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
configVersion: 3,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
false,
);
expect(onError).toHaveBeenCalledWith(null);
});
it('enters result view with partial failure when only part of the images are generated', async () => {
const onComplete = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
},
failures: {
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
configVersion: 3,
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
}),
true,
);
});
});
it('still enters result view when generated assets cannot be persisted', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockRejectedValue(
new Error('保存超时'),
);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
true,
);
});
expect(onError).toHaveBeenCalledWith('保存超时');
});
it('shows generation failures and enters result view when no image asset is generated', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {},
failures: {
'player-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue(draft);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
'泥点不足,本次需要 1 泥点,当前 0 泥点。',
);
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
draftId: draft.draftId,
workId: draft.workId,
title: draft.title,
}),
true,
);
});
});
});

View File

@@ -0,0 +1,357 @@
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } 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 { 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('|');
}
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 primaryFailureMessage = useMemo(
() => resolvePrimaryFailureMessage(slotFailures),
[slotFailures],
);
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="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-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
</span>
</div>
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
<div className="grid content-start gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
<Sparkles className="h-4 w-4" />
</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">
{GENERATION_STEPS.map((step) => {
const status =
slotStatuses[step.slot] ??
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
const ready = status === 'ready';
const failed =
status === 'failed' || Boolean(slotFailures[step.slot]);
const statusLabel = ready
? `${step.label}已生成`
: failed
? `${step.label}生成失败`
: `${step.label}生成中`;
return (
<div
key={step.slot}
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
aria-label={statusLabel}
>
<span className="text-sm font-black text-[var(--platform-text-strong)]">
{step.label}
</span>
{ready ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : failed ? (
<AlertCircle className="h-4 w-4 text-rose-500" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
)}
</div>
);
})}
</div>
{error || primaryFailureMessage ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error ?? primaryFailureMessage}
</div>
) : null}
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
</div>
</div>
);
}
export default BarkBattleGeneratingView;

View File

@@ -5,12 +5,6 @@ type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
};
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
@@ -18,16 +12,15 @@ const DIFFICULTY_LABELS = {
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
const hasCustomSound = Boolean(config.barkSoundSrc?.trim());
return (
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
aria-label="作品预览卡片"
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div
className="relative mb-4 grid min-h-[8.5rem] grid-cols-[1fr_auto_1fr] items-center gap-3 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-4 text-center text-3xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl"
data-testid="bark-battle-preview-stage"
aria-hidden="true"
>
{config.uiBackgroundImageSrc ? (
@@ -42,13 +35,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage
src={config.playerCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐕</span>
<span className="text-4xl sm:text-6xl">🐕</span>
)}
</span>
<span className="relative rounded-full bg-white/70 px-3 py-1 text-base font-black text-[var(--platform-text-strong)]">
<span className="relative rounded-full bg-white/70 px-2.5 py-0.5 text-xs font-black text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base">
VS
</span>
<span className="relative grid place-items-center">
@@ -56,48 +49,44 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage
src={config.opponentCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐶</span>
<span className="text-4xl sm:text-6xl">🐶</span>
)}
</span>
</div>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
<h2 className="text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-lg">
{config.title || '未命名声浪竞技场'}
</h2>
<p className="mt-2 min-h-[2.625rem] text-sm font-semibold leading-6 text-[var(--platform-text-muted)]">
<p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p>
<dl className="mt-4 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dl className="mt-2.5 grid gap-1.5 text-xs sm:mt-4 sm:gap-2 sm:text-sm">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{THEME_LABELS[config.themePreset] ?? config.themePreset}
{config.themeDescription || '声浪擂台'}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.playerDogSkinPreset || '主角'}
{config.playerImageDescription || '玩家'}
{' vs '}
{config.opponentDogSkinPreset || '对手'}
{config.opponentImageDescription || '对手'}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{[
config.playerCharacterImageSrc || config.opponentCharacterImageSrc ? '形象' : '',
config.uiBackgroundImageSrc ? 'UI' : '',
hasCustomSound ? '狗叫' : '',
].filter(Boolean).join(' / ') || '预设'}
{config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</dd>
</div>
</dl>

View File

@@ -4,7 +4,10 @@ import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { uploadBarkBattleAsset } from '../../services/bark-battle-creation';
import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
@@ -26,13 +29,12 @@ vi.mock('../ResolvedAssetImage', () => ({
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
title: '汪汪冠军杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
leaderboardEnabled: true,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -54,7 +56,7 @@ describe('BarkBattleResultView', () => {
/>,
);
expect(screen.getByText('草稿编译')).toBeTruthy();
expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft);
expect(onPublish).not.toHaveBeenCalled();
@@ -63,7 +65,27 @@ describe('BarkBattleResultView', () => {
expect(onPublish).toHaveBeenCalledWith(draft);
});
it('uploads replacement assets into the selected slot', async () => {
it('uses compact mobile-first result layout classes', () => {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className).toContain(
'text-2xl',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain('max-lg:p-2');
expect(screen.getByTestId('bark-battle-preview-stage').className).toContain(
'min-h-[5.75rem]',
);
});
it('uploads replacement image assets into the selected slot', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
@@ -83,7 +105,9 @@ describe('BarkBattleResultView', () => {
/>,
);
const playerSlot = screen.getByText('玩家形象').closest('article');
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
const fileInput = within(playerSlot as HTMLElement).getByLabelText(
'上传玩家形象文件',
@@ -107,4 +131,82 @@ describe('BarkBattleResultView', () => {
}),
);
});
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
draft={{
...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png',
}}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article');
expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect(
within(playerSlot as HTMLElement).queryByText(
'generated-bark-battle-assets/player-character/very-long-object-key.png',
),
).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull();
});
it('keeps result assets to three image slots with per-slot regeneration only', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(regenerateBarkBattleImageAsset).mockResolvedValue({
imageSrc: '/generated-bark-battle-assets/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '玩家形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '对手形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'UI背景' })).toBeTruthy();
expect(screen.queryByText('狗叫音效')).toBeNull();
expect(screen.queryByRole('button', { name: '一次生成' })).toBeNull();
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }),
);
await waitFor(() => {
expect(regenerateBarkBattleImageAsset).toHaveBeenCalledWith({
slot: 'player-character',
config: expect.objectContaining({
title: '汪汪冠军杯',
themeDescription: '霓虹公园擂台',
}),
draftId: 'bark-battle-draft-1',
});
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
});

View File

@@ -6,7 +6,6 @@ import {
Play,
RefreshCw,
Upload,
Volume2,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
@@ -31,22 +30,20 @@ type BarkBattleResultViewProps = {
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,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
@@ -56,9 +53,7 @@ function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorP
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
};
}
@@ -77,7 +72,7 @@ function applyAssetToDraft(
if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
}
return { ...draft, barkSoundSrc: assetSrc, updatedAt };
return { ...draft, updatedAt };
}
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
@@ -90,7 +85,7 @@ function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot
if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? '';
}
return draft.barkSoundSrc ?? '';
return '';
}
function ResultActionButton({
@@ -111,7 +106,7 @@ function ResultActionButton({
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`}
} min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
>
{children}
</button>
@@ -135,7 +130,7 @@ function BarkBattleAssetSlotControl({
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const isImageSlot = slot !== 'bark-sound';
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
@@ -152,7 +147,8 @@ function BarkBattleAssetSlotControl({
file,
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, asset.assetSrc));
const nextDraft = applyAssetToDraft(draft, slot, asset.assetSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。');
} finally {
@@ -161,20 +157,16 @@ function BarkBattleAssetSlotControl({
};
const handleRegenerate = async () => {
if (!isImageSlot) {
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
return;
}
setIsRegenerating(true);
onError(null);
try {
const result = await regenerateBarkBattleImageAsset({
slot: slot as BarkBattleImageSlot,
slot,
config: mapDraftToConfig(draft),
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, result.imageSrc));
const nextDraft = applyAssetToDraft(draft, slot, result.imageSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally {
@@ -185,29 +177,27 @@ function BarkBattleAssetSlotControl({
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">
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3">
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0">
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]">
<h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
{SLOT_LABELS[slot]}
</h3>
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{assetSrc || '未替换'}
<div className="mt-0.5 truncate text-[11px] font-semibold text-[var(--platform-text-soft)] sm:mt-1 sm:text-xs">
{assetStatus}
</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)]" />
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:mt-3 sm:gap-2">
<input
ref={fileInputRef}
type="file"
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'}
accept="image/png,image/jpeg,image/webp"
className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
@@ -216,7 +206,7 @@ function BarkBattleAssetSlotControl({
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"
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<Upload className="h-3.5 w-3.5" />
@@ -225,7 +215,7 @@ function BarkBattleAssetSlotControl({
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"
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
@@ -247,33 +237,34 @@ export function BarkBattleResultView({
const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error;
const isActionBusy = isBusy;
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">
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 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-4xl flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm: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' : ''}`}
disabled={isActionBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isActionBusy ? '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 className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-black text-emerald-700 sm:px-3 sm:py-1">
稿
</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)]">
<section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3">
<div className="grid gap-2.5 lg:gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4">
<div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿
</div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
<h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</div>
@@ -283,14 +274,13 @@ export function BarkBattleResultView({
'player-character',
'opponent-character',
'ui-background',
'bark-sound',
] as const
).map((slot) => (
<BarkBattleAssetSlotControl
key={slot}
draft={draft}
slot={slot}
disabled={isBusy}
disabled={isActionBusy}
onChange={(nextDraft) => {
setLocalError(null);
onDraftChange(nextDraft);
@@ -312,7 +302,7 @@ export function BarkBattleResultView({
<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}
disabled={isActionBusy}
onClick={() => onStartTestRun(draft)}
>
<Play className="h-4 w-4" />
@@ -320,7 +310,7 @@ export function BarkBattleResultView({
</ResultActionButton>
<ResultActionButton
tone="primary"
disabled={isBusy}
disabled={isActionBusy}
onClick={() => onPublish(draft)}
>
{isBusy ? (