fix: polish bark battle creation flow
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal file
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
@@ -226,6 +227,42 @@ const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
const barkBattleDraftItem: BarkBattleWorkSummary = {
|
||||
workId: 'bark-battle-work-draft-visible',
|
||||
draftId: 'bark-battle-draft-visible',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '声浪作者',
|
||||
title: '竖屏声浪草稿',
|
||||
summary: '生成完成后也必须留在我的草稿里。',
|
||||
themeDescription: '霓虹竖屏擂台',
|
||||
playerImageDescription: '红围巾选手',
|
||||
opponentImageDescription: '蓝头带对手',
|
||||
onomatopoeia: ['炸场', '破阵'],
|
||||
playerCharacterImageSrc: '/bark/player.png',
|
||||
opponentCharacterImageSrc: '/bark/opponent.png',
|
||||
uiBackgroundImageSrc: '/bark/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
const barkBattlePublishedItem: BarkBattleWorkSummary = {
|
||||
...barkBattleDraftItem,
|
||||
workId: 'bark-battle-work-published-visible',
|
||||
draftId: 'bark-battle-draft-published-visible',
|
||||
title: '竖屏声浪已发布',
|
||||
summary: '发布完成后必须留在已发布作品里。',
|
||||
authorDisplayName: '发布作者',
|
||||
status: 'published',
|
||||
playCount: 9,
|
||||
updatedAt: '2026-05-21T10:10:00.000Z',
|
||||
publishedAt: '2026-05-21T10:10:00.000Z',
|
||||
};
|
||||
|
||||
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateType = vi.fn();
|
||||
@@ -592,6 +629,47 @@ test('creation hub shows delete action for baby object match drafts', async () =
|
||||
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub works-only tab filters bark battle draft and published works', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenBarkBattleDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
mode="works-only"
|
||||
items={[]}
|
||||
barkBattleItems={[barkBattleDraftItem, barkBattlePublishedItem]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenBarkBattleDetail={onOpenBarkBattleDetail}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
|
||||
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
|
||||
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '草稿 1' }));
|
||||
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
|
||||
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '已发布 1' }));
|
||||
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
|
||||
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /查看详情《竖屏声浪已发布》/u }),
|
||||
);
|
||||
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
|
||||
});
|
||||
|
||||
test('creation hub published work delete action is revealed without opening card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDeletePuzzle = vi.fn();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -68,6 +69,9 @@ type CustomWorldCreationHubProps = {
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
|
||||
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
@@ -173,6 +177,9 @@ export function CustomWorldCreationHub({
|
||||
babyObjectMatchItems = [],
|
||||
onOpenBabyObjectMatchDetail = null,
|
||||
onDeleteBabyObjectMatch = null,
|
||||
barkBattleItems = [],
|
||||
onOpenBarkBattleDetail = null,
|
||||
onDeleteBarkBattle = null,
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
@@ -196,6 +203,7 @@ export function CustomWorldCreationHub({
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
visualNovelItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
@@ -204,6 +212,7 @@ export function CustomWorldCreationHub({
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
onOpenRpgDraft: onOpenDraft,
|
||||
onEnterRpgPublished: onEnterPublished,
|
||||
@@ -219,6 +228,8 @@ export function CustomWorldCreationHub({
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
|
||||
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
|
||||
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
|
||||
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
|
||||
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
|
||||
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
|
||||
getItemState: getWorkState,
|
||||
@@ -227,6 +238,7 @@ export function CustomWorldCreationHub({
|
||||
bigFishItems,
|
||||
isSquareHoleCreationVisible,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
items,
|
||||
match3dItems,
|
||||
onDeleteBigFish,
|
||||
@@ -235,12 +247,14 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteBabyObjectMatch,
|
||||
onDeleteBarkBattle,
|
||||
onDeleteVisualNovel,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
onOpenMatch3DDetail,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenBarkBattleDetail,
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
@@ -284,6 +298,9 @@ export function CustomWorldCreationHub({
|
||||
case 'visual-novel':
|
||||
onOpenVisualNovelDetail?.(item.source.item);
|
||||
return;
|
||||
case 'bark-battle':
|
||||
onOpenBarkBattleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
|
||||
@@ -61,6 +61,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
'visual-novel': '/creation-type-references/visual-novel.webp',
|
||||
};
|
||||
|
||||
@@ -727,6 +728,8 @@ export function CustomWorldWorkCard({
|
||||
{item.summary}
|
||||
</div>
|
||||
|
||||
<div className="creation-work-card__author">作者:{item.authorDisplayName}</div>
|
||||
|
||||
{isPublished ? (
|
||||
<div className="creation-work-card__published-info">
|
||||
{item.pointIncentive ? (
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
getCreationWorkShelfItemTime,
|
||||
hasBarkBattleRequiredImages,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
|
||||
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
@@ -50,6 +56,253 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
barkBattleItems: [
|
||||
{
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '草稿作者',
|
||||
title: '汪汪测试杯',
|
||||
summary: '',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
{
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
title: '汪汪测试杯',
|
||||
summary: '',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-14T10:02:00.000Z',
|
||||
publishedAt: '2026-05-14T10:02:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.kind).toBe('bark-battle');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('BB-TLEWORK1');
|
||||
expect(items[0]?.authorDisplayName).toBe('测试玩家');
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps separate bark battle draft and published works visible', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
barkBattleItems: [
|
||||
{
|
||||
workId: 'BB-DRAFT001',
|
||||
draftId: 'bark-battle-draft-visible',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '草稿作者',
|
||||
title: '草稿声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '草地声浪挑战',
|
||||
playerImageDescription: '柯基选手',
|
||||
opponentImageDescription: '哈士奇对手',
|
||||
playerCharacterImageSrc: '/draft-player.png',
|
||||
opponentCharacterImageSrc: '/draft-opponent.png',
|
||||
uiBackgroundImageSrc: '/draft-background.png',
|
||||
difficultyPreset: 'easy',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
{
|
||||
workId: 'BB-PUB00001',
|
||||
draftId: 'bark-battle-draft-published',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '发布作者',
|
||||
title: '已发布声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '霓虹声浪挑战',
|
||||
playerImageDescription: '柴犬选手',
|
||||
opponentImageDescription: '机器人对手',
|
||||
playerCharacterImageSrc: '/published-player.png',
|
||||
opponentCharacterImageSrc: '/published-opponent.png',
|
||||
uiBackgroundImageSrc: '/published-background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 3,
|
||||
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||
publishedAt: '2026-05-21T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.find((item) => item.status === 'draft')?.id).toBe('BB-DRAFT001');
|
||||
expect(items.find((item) => item.status === 'published')?.id).toBe(
|
||||
'BB-PUB00001',
|
||||
);
|
||||
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
|
||||
'BB-PUB00001',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
barkBattleItems: [
|
||||
{
|
||||
workId: 'BB-COVER001',
|
||||
draftId: 'bark-battle-draft-cover',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '草稿作者',
|
||||
title: '角色封面声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '草地声浪挑战',
|
||||
playerImageDescription: '柯基选手',
|
||||
opponentImageDescription: '哈士奇对手',
|
||||
playerCharacterImageSrc: '/draft-player-cover.png',
|
||||
opponentCharacterImageSrc: '/draft-opponent-cover.png',
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'easy',
|
||||
status: 'draft',
|
||||
generationStatus: 'partial_failed',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
{
|
||||
workId: 'BB-COVER002',
|
||||
draftId: 'bark-battle-draft-cover-fallback',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '草稿作者',
|
||||
title: '默认封面声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '夜市声浪挑战',
|
||||
playerImageDescription: '柴犬选手',
|
||||
opponentImageDescription: '机器人对手',
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status: 'draft',
|
||||
generationStatus: 'pending_assets',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-19T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
|
||||
'/draft-player-cover.png',
|
||||
);
|
||||
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
|
||||
'/draft-player-cover.png',
|
||||
'/draft-opponent-cover.png',
|
||||
]);
|
||||
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
|
||||
'/creation-type-references/bark-battle.webp',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps bark battle draft author display name', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
barkBattleItems: [
|
||||
{
|
||||
workId: 'bark-battle-work-draft-author',
|
||||
draftId: 'bark-battle-draft-author',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '草稿作者',
|
||||
title: '草稿声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '草地声浪挑战',
|
||||
playerImageDescription: '柯基选手',
|
||||
opponentImageDescription: '哈士奇对手',
|
||||
playerCharacterImageSrc: '/player.png',
|
||||
opponentCharacterImageSrc: '/opponent.png',
|
||||
uiBackgroundImageSrc: '/background.png',
|
||||
difficultyPreset: 'easy',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items[0]?.kind).toBe('bark-battle');
|
||||
expect(items[0]?.status).toBe('draft');
|
||||
expect(items[0]?.authorDisplayName).toBe('草稿作者');
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems falls back unknown authors to player label', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d-work-author-fallback',
|
||||
profileId: 'match3d-profile-author-fallback',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '把水果从透明罐里抓出来。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items[0]?.kind).toBe('match3d');
|
||||
expect(items[0]?.authorDisplayName).toBe('玩家');
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
const onDeletePuzzle = vi.fn();
|
||||
@@ -672,6 +925,159 @@ test('buildCreationWorkShelfItems uses match3d transparent container reference a
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps bark battle works with scene role cover and BB code', () => {
|
||||
const onOpenBarkBattleDetail = vi.fn();
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
barkBattleItems: [
|
||||
{
|
||||
workId: 'bark-battle-work-12345678',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '公园声浪赛',
|
||||
summary: '柯基和哈士奇比拼声浪。',
|
||||
themeDescription: '傍晚公园擂台',
|
||||
playerImageDescription: '红围巾柯基',
|
||||
opponentImageDescription: '蓝头带哈士奇',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 6,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
onOpenBarkBattleDetail,
|
||||
});
|
||||
|
||||
const item = items[0];
|
||||
item?.actions.open();
|
||||
|
||||
expect(item?.kind).toBe('bark-battle');
|
||||
expect(item?.publicWorkCode).toBe('BB-12345678');
|
||||
expect(item?.sharePath).toContain('/works/detail?work=BB-12345678');
|
||||
expect(item?.coverImageSrc).toBe('/generated-bark-battle/background.png');
|
||||
expect(item?.coverRenderMode).toBe('scene_with_roles');
|
||||
expect(item?.coverCharacterImageSrcs).toEqual([
|
||||
'/generated-bark-battle/player.png',
|
||||
'/generated-bark-battle/opponent.png',
|
||||
]);
|
||||
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workId: 'bark-battle-work-12345678' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('bark battle draft generating state follows pending assets or missing three images', () => {
|
||||
const draft = {
|
||||
workId: 'bark-battle-work-draft',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '草稿声浪赛',
|
||||
summary: '',
|
||||
themeDescription: '草地',
|
||||
playerImageDescription: '柯基',
|
||||
opponentImageDescription: '哈士奇',
|
||||
playerCharacterImageSrc: '/player.png',
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: '/background.png',
|
||||
difficultyPreset: 'easy' as const,
|
||||
status: 'draft' as const,
|
||||
generationStatus: 'pending_assets',
|
||||
publishReady: false,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
expect(hasBarkBattleRequiredImages(draft)).toBe(false);
|
||||
expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true);
|
||||
expect(
|
||||
isPersistedBarkBattleDraftGenerating({
|
||||
...draft,
|
||||
opponentCharacterImageSrc: '/opponent.png',
|
||||
generationStatus: 'ready',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
test('CustomWorldWorkCard renders author for draft and published works', () => {
|
||||
const buildItem = (
|
||||
status: CreationWorkShelfItem['status'],
|
||||
authorDisplayName: string,
|
||||
): CreationWorkShelfItem => ({
|
||||
id: `card-${status}`,
|
||||
kind: 'bark-battle',
|
||||
status,
|
||||
authorDisplayName,
|
||||
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
|
||||
summary: '一场轻快的汪汪声浪对决。',
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode: null,
|
||||
sharePath: null,
|
||||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
badges: [
|
||||
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
|
||||
{ id: 'type', label: '汪汪', tone: 'neutral' },
|
||||
],
|
||||
metrics: [],
|
||||
actions: { open: () => {} },
|
||||
source: {
|
||||
kind: 'bark-battle',
|
||||
item: {
|
||||
workId: `bark-battle-${status}`,
|
||||
draftId: `draft-${status}`,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName,
|
||||
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
|
||||
summary: '一场轻快的汪汪声浪对决。',
|
||||
themeDescription: '公园舞台',
|
||||
playerImageDescription: '柯基选手',
|
||||
opponentImageDescription: '哈士奇对手',
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status,
|
||||
generationStatus: 'ready',
|
||||
publishReady: status === 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: status === 'published' ? '2026-05-20T00:00:00.000Z' : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const draftHtml = renderToStaticMarkup(
|
||||
createElement(CustomWorldWorkCard, {
|
||||
item: buildItem('draft', '草稿作者'),
|
||||
onOpen: () => {},
|
||||
}),
|
||||
);
|
||||
const publishedHtml = renderToStaticMarkup(
|
||||
createElement(CustomWorldWorkCard, {
|
||||
item: buildItem('published', '发布作者'),
|
||||
onOpen: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(draftHtml).toContain('作者:草稿作者');
|
||||
expect(publishedHtml).toContain('作者:发布作者');
|
||||
});
|
||||
|
||||
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
|
||||
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
|
||||
1778457601234.567,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -9,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -19,6 +21,9 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
|
||||
'/match3d-background-references/pot-fused-reference.png';
|
||||
const BARK_BATTLE_REFERENCE_COVER_SRC =
|
||||
'/creation-type-references/bark-battle.webp';
|
||||
const DEFAULT_CREATION_WORK_AUTHOR = '玩家';
|
||||
|
||||
export type CreationWorkShelfKind =
|
||||
| 'rpg'
|
||||
@@ -27,6 +32,7 @@ export type CreationWorkShelfKind =
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
| 'visual-novel';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
@@ -84,6 +90,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'visual-novel';
|
||||
item: VisualNovelWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'bark-battle';
|
||||
item: BarkBattleWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
item: BabyObjectMatchDraft;
|
||||
@@ -103,6 +113,7 @@ export type CreationWorkShelfItem = {
|
||||
hasUnreadUpdate?: boolean;
|
||||
title: string;
|
||||
summary: string;
|
||||
authorDisplayName: string;
|
||||
updatedAt: string;
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
@@ -127,6 +138,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
@@ -134,6 +146,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteBabyObjectMatch?: boolean;
|
||||
canDeleteBarkBattle?: boolean;
|
||||
canDeleteVisualNovel?: boolean;
|
||||
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterRpgPublished?: (profileId: string) => void;
|
||||
@@ -149,6 +162,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
|
||||
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
|
||||
onOpenBarkBattleDetail?: (item: BarkBattleWorkSummary) => void;
|
||||
onDeleteBarkBattle?: (item: BarkBattleWorkSummary) => void;
|
||||
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
|
||||
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
|
||||
getItemState?: (
|
||||
@@ -163,6 +178,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
squareHoleItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
visualNovelItems = [],
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
@@ -170,6 +186,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteBabyObjectMatch = false,
|
||||
canDeleteBarkBattle = false,
|
||||
canDeleteVisualNovel = false,
|
||||
onOpenRpgDraft,
|
||||
onEnterRpgPublished,
|
||||
@@ -185,6 +202,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onDeleteBabyObjectMatch,
|
||||
onOpenBarkBattleDetail,
|
||||
onDeleteBarkBattle,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState,
|
||||
@@ -229,6 +248,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDelete: onDeleteBabyObjectMatch,
|
||||
}),
|
||||
),
|
||||
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
|
||||
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
|
||||
onOpen: onOpenBarkBattleDetail,
|
||||
onDelete: onDeleteBarkBattle,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
@@ -259,6 +284,28 @@ export function buildCreationWorkShelfItems(params: {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function mergeBarkBattleShelfSourceItems(
|
||||
items: readonly BarkBattleWorkSummary[],
|
||||
): BarkBattleWorkSummary[] {
|
||||
const byWorkId = new Map<string, BarkBattleWorkSummary>();
|
||||
for (const item of items) {
|
||||
const current = byWorkId.get(item.workId);
|
||||
if (!current) {
|
||||
byWorkId.set(item.workId, item);
|
||||
continue;
|
||||
}
|
||||
if (current.status !== 'published' && item.status === 'published') {
|
||||
byWorkId.set(item.workId, { ...current, ...item });
|
||||
continue;
|
||||
}
|
||||
if (current.status === item.status) {
|
||||
byWorkId.set(item.workId, { ...current, ...item });
|
||||
}
|
||||
}
|
||||
return Array.from(byWorkId.values());
|
||||
}
|
||||
|
||||
type RpgWorkShelfAdapter = {
|
||||
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished?: (profileId: string) => void;
|
||||
@@ -303,6 +350,7 @@ function mapRpgWorkToShelfItem(
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item, libraryEntry),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
@@ -342,6 +390,7 @@ function mapBigFishWorkToShelfItem(
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
@@ -386,6 +435,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
status,
|
||||
title: item.gameName,
|
||||
summary: item.summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
@@ -434,6 +484,7 @@ function mapPuzzleWorkToShelfItem(
|
||||
item.workDescription?.trim() ||
|
||||
item.summary.trim() ||
|
||||
(status === 'draft' ? '未填写作品描述' : ''),
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
@@ -500,6 +551,7 @@ function mapBabyObjectMatchDraftToShelfItem(
|
||||
summary:
|
||||
item.workDescription.trim() ||
|
||||
`${item.itemNames[0]}和${item.itemNames[1]}识物分类`,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
@@ -549,6 +601,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
status,
|
||||
title,
|
||||
summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
@@ -578,6 +631,72 @@ function mapVisualNovelWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapBarkBattleWorkToShelfItem(
|
||||
item: BarkBattleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<BarkBattleWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.status;
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildBarkBattlePublicWorkCode(item.workId) : null;
|
||||
const playerCharacterImageSrc = normalizeCoverImageSrc(
|
||||
item.playerCharacterImageSrc,
|
||||
);
|
||||
const opponentCharacterImageSrc = normalizeCoverImageSrc(
|
||||
item.opponentCharacterImageSrc,
|
||||
);
|
||||
const coverImageSrc =
|
||||
normalizeCoverImageSrc(item.uiBackgroundImageSrc) ??
|
||||
playerCharacterImageSrc ??
|
||||
opponentCharacterImageSrc ??
|
||||
BARK_BATTLE_REFERENCE_COVER_SRC;
|
||||
const coverCharacterImageSrcs = [
|
||||
playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc,
|
||||
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
|
||||
const canRenderSceneWithRoles =
|
||||
Boolean(normalizeCoverImageSrc(item.uiBackgroundImageSrc)) &&
|
||||
coverCharacterImageSrcs.length >= 2;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'bark-battle',
|
||||
status,
|
||||
title: item.title.trim() || '汪汪声浪大作战',
|
||||
summary:
|
||||
item.summary.trim() ||
|
||||
item.themeDescription.trim() ||
|
||||
(status === 'draft' ? '未填写作品描述' : ''),
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
|
||||
coverCharacterImageSrcs,
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '汪汪', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'bark-battle', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapSquareHoleWorkToShelfItem(
|
||||
item: SquareHoleWorkSummary,
|
||||
canDelete: boolean,
|
||||
@@ -596,6 +715,7 @@ function mapSquareHoleWorkToShelfItem(
|
||||
status,
|
||||
title: item.gameName,
|
||||
summary: item.summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
@@ -625,6 +745,26 @@ function mapSquareHoleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function resolveAuthorDisplayName(
|
||||
...sources: Array<unknown>
|
||||
) {
|
||||
for (const source of sources) {
|
||||
const authorDisplayName =
|
||||
source &&
|
||||
typeof source === 'object' &&
|
||||
'authorDisplayName' in source &&
|
||||
typeof source.authorDisplayName === 'string'
|
||||
? source.authorDisplayName.trim()
|
||||
: '';
|
||||
if (authorDisplayName) {
|
||||
return authorDisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_CREATION_WORK_AUTHOR;
|
||||
}
|
||||
|
||||
function normalizeCoverImageSrc(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
@@ -816,11 +956,34 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
case 'bark-battle':
|
||||
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPersistedBarkBattleDraftGenerating(
|
||||
item: BarkBattleWorkSummary,
|
||||
) {
|
||||
if (item.status === 'published') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
item.generationStatus === 'pending_assets' ||
|
||||
!hasBarkBattleRequiredImages(item)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBarkBattleRequiredImages(item: BarkBattleWorkSummary) {
|
||||
return Boolean(
|
||||
normalizeCoverImageSrc(item.playerCharacterImageSrc) &&
|
||||
normalizeCoverImageSrc(item.opponentCharacterImageSrc) &&
|
||||
normalizeCoverImageSrc(item.uiBackgroundImageSrc),
|
||||
);
|
||||
}
|
||||
|
||||
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
|
||||
if (item.generationStatus !== 'generating') {
|
||||
return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -67,6 +68,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
||||
return '视觉小说';
|
||||
}
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return '汪汪声浪';
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
|
||||
108
src/components/platform-entry/barkBattleWorkCache.test.ts
Normal file
108
src/components/platform-entry/barkBattleWorkCache.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
mergeBarkBattleWorksByWorkId,
|
||||
mergeBarkBattleWorkSummary,
|
||||
shouldPreserveLocalBarkBattleWorkOnRefresh,
|
||||
} from './barkBattleWorkCache';
|
||||
|
||||
function buildBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'BB-cache-race-12345678',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
title: '汪汪测试杯',
|
||||
summary: '测试声浪赛',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves local published bark battle when refresh only returns same work draft', () => {
|
||||
const published = buildBarkBattleWork({
|
||||
status: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-05-21T10:02:00.000Z',
|
||||
publishedAt: '2026-05-21T10:02:00.000Z',
|
||||
});
|
||||
const refreshedDraft = buildBarkBattleWork({
|
||||
status: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:01:00.000Z',
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(published, [refreshedDraft])).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const [merged] = mergeBarkBattleWorksByWorkId([refreshedDraft, published]);
|
||||
|
||||
expect(merged?.status).toBe('published');
|
||||
expect(merged?.publishedAt).toBe('2026-05-21T10:02:00.000Z');
|
||||
expect(merged?.playCount).toBe(3);
|
||||
});
|
||||
|
||||
test('does not let later draft cache updates downgrade an existing published bark battle', () => {
|
||||
const published = buildBarkBattleWork({
|
||||
status: 'published',
|
||||
playCount: 4,
|
||||
updatedAt: '2026-05-21T10:03:00.000Z',
|
||||
publishedAt: '2026-05-21T10:03:00.000Z',
|
||||
});
|
||||
const staleDraft = buildBarkBattleWork({
|
||||
title: '旧草稿标题',
|
||||
status: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:01:00.000Z',
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
const merged = mergeBarkBattleWorkSummary(published, staleDraft);
|
||||
|
||||
expect(merged.status).toBe('published');
|
||||
expect(merged.title).toBe('汪汪测试杯');
|
||||
expect(merged.playCount).toBe(4);
|
||||
expect(merged.publishedAt).toBe('2026-05-21T10:03:00.000Z');
|
||||
});
|
||||
|
||||
test('preserves local ready bark battle draft when refresh has not returned it yet', () => {
|
||||
const readyDraft = buildBarkBattleWork({
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player-ready.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent-ready.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background-ready.png',
|
||||
});
|
||||
|
||||
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])).toBe(true);
|
||||
|
||||
const merged = mergeBarkBattleWorksByWorkId([
|
||||
...[],
|
||||
...(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])
|
||||
? [readyDraft]
|
||||
: []),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]?.workId).toBe('BB-cache-race-12345678');
|
||||
expect(merged[0]?.generationStatus).toBe('ready');
|
||||
});
|
||||
|
||||
112
src/components/platform-entry/barkBattleWorkCache.ts
Normal file
112
src/components/platform-entry/barkBattleWorkCache.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
BarkBattleDraftConfig,
|
||||
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
|
||||
BarkBattleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/barkBattle';
|
||||
|
||||
export type BarkBattleGenerationStatus = SharedBarkBattleGenerationStatus;
|
||||
|
||||
export function mergeBarkBattleWorkSummary(
|
||||
current: BarkBattleWorkSummary,
|
||||
updated: BarkBattleWorkSummary,
|
||||
): BarkBattleWorkSummary {
|
||||
if (current.workId !== updated.workId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current.status === 'published' && updated.status !== 'published') {
|
||||
return {
|
||||
...updated,
|
||||
...current,
|
||||
playCount: current.playCount ?? updated.playCount,
|
||||
recentPlayCount7d: current.recentPlayCount7d ?? updated.recentPlayCount7d,
|
||||
updatedAt: current.updatedAt || updated.updatedAt,
|
||||
publishedAt: current.publishedAt ?? updated.publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...current, ...updated };
|
||||
}
|
||||
|
||||
export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary) {
|
||||
return Boolean(
|
||||
item.playerCharacterImageSrc?.trim() &&
|
||||
item.opponentCharacterImageSrc?.trim() &&
|
||||
item.uiBackgroundImageSrc?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
|
||||
item: BarkBattleWorkSummary,
|
||||
refreshed: readonly BarkBattleWorkSummary[],
|
||||
) {
|
||||
if (item.status === 'published') {
|
||||
return !refreshed.some(
|
||||
(entry) => entry.workId === item.workId && entry.status === 'published',
|
||||
);
|
||||
}
|
||||
if (refreshed.some((entry) => entry.workId === item.workId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:Bark Battle 创建/生成完成/保存后会先把本地摘要塞进作品架,
|
||||
// 后端 /works 读模型可能短暂落后;只要刷新结果还没有同 workId,就保留本地草稿,
|
||||
// 避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿”里消失。
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildBarkBattleWorkSummaryFromDraft(
|
||||
draft: BarkBattleDraftConfig,
|
||||
user: PublicUserSummary | null | undefined,
|
||||
generationStatus: BarkBattleGenerationStatus = 'pending_assets',
|
||||
): BarkBattleWorkSummary {
|
||||
const workId = draft.workId?.trim() || draft.draftId;
|
||||
return {
|
||||
workId,
|
||||
draftId: draft.draftId,
|
||||
ownerUserId: user?.id ?? '',
|
||||
authorDisplayName: user?.displayName ?? '创作者',
|
||||
title: draft.title,
|
||||
summary: draft.description ?? '',
|
||||
themeDescription: draft.themeDescription,
|
||||
playerImageDescription: draft.playerImageDescription,
|
||||
opponentImageDescription: draft.opponentImageDescription,
|
||||
onomatopoeia: draft.onomatopoeia,
|
||||
playerCharacterImageSrc: draft.playerCharacterImageSrc ?? null,
|
||||
opponentCharacterImageSrc: draft.opponentCharacterImageSrc ?? null,
|
||||
uiBackgroundImageSrc: draft.uiBackgroundImageSrc ?? null,
|
||||
difficultyPreset: draft.difficultyPreset,
|
||||
status: 'draft',
|
||||
generationStatus,
|
||||
publishReady: Boolean(
|
||||
draft.playerCharacterImageSrc?.trim() &&
|
||||
draft.opponentCharacterImageSrc?.trim() &&
|
||||
draft.uiBackgroundImageSrc?.trim(),
|
||||
),
|
||||
playCount: 0,
|
||||
updatedAt: draft.updatedAt,
|
||||
publishedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeBarkBattleWorksByWorkId(
|
||||
items: readonly BarkBattleWorkSummary[],
|
||||
): BarkBattleWorkSummary[] {
|
||||
const byWorkId = new Map<string, BarkBattleWorkSummary>();
|
||||
for (const item of items) {
|
||||
const current = byWorkId.get(item.workId);
|
||||
if (!current) {
|
||||
byWorkId.set(item.workId, item);
|
||||
continue;
|
||||
}
|
||||
if (current.status !== 'published' && item.status === 'published') {
|
||||
byWorkId.set(item.workId, { ...current, ...item });
|
||||
continue;
|
||||
}
|
||||
if (current.status === item.status || current.status === 'published') {
|
||||
byWorkId.set(item.workId, mergeBarkBattleWorkSummary(current, item));
|
||||
}
|
||||
}
|
||||
return Array.from(byWorkId.values());
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export type SelectionStage =
|
||||
| 'square-hole-generating'
|
||||
| 'square-hole-result'
|
||||
| 'square-hole-runtime'
|
||||
| 'bark-battle-generating'
|
||||
| 'bark-battle-result'
|
||||
| 'bark-battle-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
|
||||
import type {
|
||||
@@ -43,7 +44,11 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
publishBarkBattleWork,
|
||||
updateBarkBattleDraftConfig,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import {
|
||||
createBigFishCreationSession,
|
||||
@@ -475,8 +480,12 @@ vi.mock('../../services/big-fish-runtime', () => ({
|
||||
|
||||
vi.mock('../../services/bark-battle-creation', () => ({
|
||||
createBarkBattleDraft: vi.fn(),
|
||||
generateAllBarkBattleImageAssets: vi.fn(),
|
||||
listBarkBattleGallery: vi.fn(),
|
||||
listBarkBattleWorks: vi.fn(),
|
||||
publishBarkBattleWork: vi.fn(),
|
||||
regenerateBarkBattleImageAsset: vi.fn(),
|
||||
updateBarkBattleDraftConfig: vi.fn(),
|
||||
uploadBarkBattleAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1000,11 +1009,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
|
||||
onPreview: (payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
themePreset: string;
|
||||
playerDogSkinPreset: string;
|
||||
opponentDogSkinPreset: string;
|
||||
themeDescription: string;
|
||||
playerImageDescription: string;
|
||||
opponentImageDescription: string;
|
||||
difficultyPreset: 'normal';
|
||||
leaderboardEnabled: boolean;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div className="bark-battle-config-editor-mock">
|
||||
@@ -1026,11 +1034,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
|
||||
onPreview({
|
||||
title: '汪汪测试杯',
|
||||
description: '',
|
||||
themePreset: 'sunny-yard',
|
||||
playerDogSkinPreset: 'corgi',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
difficultyPreset: 'normal',
|
||||
leaderboardEnabled: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -1122,14 +1129,27 @@ vi.mock('../../games/bark-battle/ui/BarkBattleRuntimeShell', () => ({
|
||||
BarkBattleRuntimeShell: ({
|
||||
title,
|
||||
workId,
|
||||
runtimeMode,
|
||||
publishedConfig,
|
||||
onExit,
|
||||
}: {
|
||||
title?: string;
|
||||
workId?: string;
|
||||
runtimeMode?: string;
|
||||
publishedConfig?: { workId?: string; playerCharacterImageSrc?: string | null } | null;
|
||||
onExit?: () => void;
|
||||
}) => (
|
||||
<div className="bark-battle-runtime-shell-mock">
|
||||
<div>汪汪声浪运行态:{title ?? '未命名'} / {workId ?? 'missing-work'}</div>
|
||||
<div data-testid="bark-battle-runtime-mode">
|
||||
{runtimeMode ?? 'missing-mode'}
|
||||
</div>
|
||||
<div data-testid="bark-battle-runtime-work-id">
|
||||
{publishedConfig?.workId ?? 'missing-config-work'}
|
||||
</div>
|
||||
<div data-testid="bark-battle-runtime-player-src">
|
||||
{publishedConfig?.playerCharacterImageSrc ?? 'missing-player-src'}
|
||||
</div>
|
||||
<button type="button" onClick={onExit}>
|
||||
返回配置
|
||||
</button>
|
||||
@@ -1311,6 +1331,34 @@ function buildMockBabyObjectMatchDraft(
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
return {
|
||||
workId: 'BB-C661A45F',
|
||||
draftId: 'bark-battle-draft-public-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
title: '汪汪公开杯',
|
||||
summary: '',
|
||||
themeDescription: '霓虹城市公园里的声浪擂台',
|
||||
playerImageDescription: '戴红围巾的柴犬主角',
|
||||
opponentImageDescription: '戴蓝色头带的哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
finishCount: 0,
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
publishedAt: '2026-05-14T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockSquareHoleAgentSession(
|
||||
overrides: Partial<
|
||||
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
|
||||
@@ -2837,15 +2885,61 @@ beforeEach(() => {
|
||||
workId: 'bark-battle-work-1',
|
||||
title: '汪汪测试杯',
|
||||
description: '',
|
||||
themePreset: 'sunny-yard',
|
||||
playerDogSkinPreset: 'corgi',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
difficultyPreset: 'normal',
|
||||
leaderboardEnabled: true,
|
||||
configVersion: 1,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
});
|
||||
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).mockImplementation(async (payload) => ({
|
||||
draftId: payload.draftId,
|
||||
workId: payload.workId ?? 'bark-battle-work-1',
|
||||
title: payload.title,
|
||||
description: payload.description,
|
||||
themeDescription: payload.themeDescription,
|
||||
playerImageDescription: payload.playerImageDescription,
|
||||
opponentImageDescription: payload.opponentImageDescription,
|
||||
playerCharacterImageSrc: payload.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: payload.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: payload.uiBackgroundImageSrc,
|
||||
difficultyPreset: payload.difficultyPreset,
|
||||
configVersion: (payload.configVersion ?? 1) + 1,
|
||||
rulesetVersion: payload.rulesetVersion ?? 'bark-battle-ruleset-v1',
|
||||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||||
}));
|
||||
vi.mocked(listBarkBattleWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listBarkBattleGallery).mockResolvedValue({ items: [] });
|
||||
vi.mocked(publishBarkBattleWork).mockResolvedValue({
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
@@ -2854,11 +2948,10 @@ beforeEach(() => {
|
||||
playTypeId: 'bark-battle',
|
||||
title: '汪汪测试杯',
|
||||
description: '',
|
||||
themePreset: 'sunny-yard',
|
||||
playerDogSkinPreset: 'corgi',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
difficultyPreset: 'normal',
|
||||
leaderboardEnabled: true,
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
publishedAt: '2026-05-14T10:00:00.000Z',
|
||||
});
|
||||
@@ -3233,6 +3326,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
await openCreateTemplateHub(user);
|
||||
|
||||
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain(
|
||||
'scroll-px-3',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
@@ -3309,7 +3405,7 @@ test('create tab switches bark battle into the embedded config form', async () =
|
||||
expect(publishBarkBattleWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bark battle draft result can test before publish and return to the embedded form', async () => {
|
||||
test('bark battle draft result can test before publish and publish to work detail', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
@@ -3321,11 +3417,21 @@ test('bark battle draft result can test before publish and return to the embedde
|
||||
expect(createBarkBattleDraft).toHaveBeenCalledWith({
|
||||
title: '汪汪测试杯',
|
||||
description: '',
|
||||
themePreset: 'sunny-yard',
|
||||
playerDogSkinPreset: 'corgi',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
difficultyPreset: 'normal',
|
||||
leaderboardEnabled: true,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
draftId: 'bark-battle-draft-1',
|
||||
workId: 'bark-battle-work-1',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
|
||||
expect(await screen.findByText('作品ID:bark-battle-work-1')).toBeTruthy();
|
||||
@@ -3345,17 +3451,154 @@ test('bark battle draft result can test before publish and return to the embedde
|
||||
workId: 'bark-battle-work-1',
|
||||
publishedSnapshot: expect.objectContaining({
|
||||
title: '汪汪测试杯',
|
||||
leaderboardEnabled: true,
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
}),
|
||||
});
|
||||
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/works/detail');
|
||||
expect(window.location.search).toBe('?work=BB-TLEWORK1');
|
||||
});
|
||||
expect(await screen.findByText('分享给朋友')).toBeTruthy();
|
||||
expect(screen.getByText(/作品号:BB-TLEWORK1/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回配置' }));
|
||||
test('direct bark battle runtime public code opens published runtime', async () => {
|
||||
const publicWork = buildMockBarkBattleWork();
|
||||
vi.mocked(listBarkBattleGallery).mockResolvedValueOnce({
|
||||
items: [publicWork],
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/runtime/bark-battle?work=BB-C661A45F',
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText(/汪汪声浪运行态:汪汪公开杯/u)).toBeTruthy();
|
||||
expect(screen.getByTestId('bark-battle-runtime-mode').textContent).toBe(
|
||||
'published',
|
||||
);
|
||||
expect(screen.getByTestId('bark-battle-runtime-work-id').textContent).toBe(
|
||||
'BB-C661A45F',
|
||||
);
|
||||
expect(screen.getByTestId('bark-battle-runtime-player-src').textContent).toBe(
|
||||
'/generated-bark-battle/player.png',
|
||||
);
|
||||
expect(screen.queryByText('分享给朋友')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('bark battle form checks mud points before creating image assets', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 2,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
|
||||
).toBeTruthy();
|
||||
expect(createBarkBattleDraft).not.toHaveBeenCalled();
|
||||
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('bark battle draft is visible in draft shelf while image assets are generating', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(generateAllBarkBattleImageAssets).mockImplementation(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof generateAllBarkBattleImageAssets>>>(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('自动生成素材')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回编辑' }));
|
||||
await openDraftHub(user);
|
||||
|
||||
const panel = getPlatformTabPanel('saves');
|
||||
expect(
|
||||
await within(panel).findByRole('button', {
|
||||
name: /继续创作《汪汪测试杯》/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await expectDraftHubGeneratingBadgeCountAtLeast(1);
|
||||
expect(listBarkBattleWorks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('published bark battle stays visible when refresh temporarily returns only the duplicate draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(listBarkBattleWorks).mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
workId: 'bark-battle-work-1',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试玩家',
|
||||
title: '汪汪测试杯',
|
||||
summary: '',
|
||||
themeDescription: '阳光草坪声浪竞技场',
|
||||
playerImageDescription: '戴红色围巾的柯基选手',
|
||||
opponentImageDescription: '蓝色护目镜哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'normal',
|
||||
status: 'draft',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-14T10:01:00.000Z',
|
||||
publishedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await waitFor(() => {
|
||||
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
draftId: 'bark-battle-draft-1',
|
||||
workId: 'bark-battle-work-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/works/detail');
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '返回' }));
|
||||
|
||||
await openDraftHub(user);
|
||||
const panel = getPlatformTabPanel('saves');
|
||||
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
|
||||
|
||||
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: /查看详情《汪汪测试杯》/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('running match3d form generation can return to draft tab and reopen progress', async () => {
|
||||
@@ -4590,7 +4833,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy
|
||||
).toHaveProperty('textContent', '1');
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await user.click(await screen.findByRole('button', { name: '返回' }));
|
||||
|
||||
await openDraftHub(user);
|
||||
expect(screen.queryByLabelText('新生成完成')).toBeNull();
|
||||
@@ -4638,7 +4881,7 @@ test('completed baby object match draft viewed immediately does not keep unread
|
||||
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
|
||||
});
|
||||
expect(await screen.findByLabelText('物品 A')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await user.click(await screen.findByRole('button', { name: '返回' }));
|
||||
|
||||
await openDraftHub(user);
|
||||
expect(
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
findPublicWorkForHistoryEntry,
|
||||
isEdutainmentEntryEnabled,
|
||||
} from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
@@ -126,6 +127,7 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
@@ -263,6 +265,7 @@ type PlatformCategoryKindFilter =
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'visual-novel'
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'custom-world';
|
||||
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
|
||||
@@ -302,6 +305,7 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
|
||||
{ id: 'match3d', label: '抓鹅' },
|
||||
{ id: 'square-hole', label: '方洞' },
|
||||
{ id: 'visual-novel', label: '视觉' },
|
||||
{ id: 'bark-battle', label: '汪汪' },
|
||||
{ id: 'big-fish', label: '大鱼' },
|
||||
{ id: 'custom-world', label: 'RPG' },
|
||||
];
|
||||
@@ -415,6 +419,43 @@ function ResolvedAssetBackdrop({
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformWorkCoverArtwork({
|
||||
entry,
|
||||
imageSrc,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
imageSrc?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
alt: string;
|
||||
className: string;
|
||||
}) {
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return (
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={imageSrc}
|
||||
fallbackImageSrc={fallbackSrc}
|
||||
title={entry.worldName}
|
||||
fallbackLabel="封面"
|
||||
renderMode={entry.coverRenderMode}
|
||||
characterImageSrcs={entry.coverCharacterImageSrcs}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResolvedAssetBackdrop
|
||||
src={imageSrc}
|
||||
fallbackSrc={fallbackSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
@@ -609,8 +650,9 @@ function WorldCard({
|
||||
>
|
||||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackAssetCoverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
@@ -713,8 +755,9 @@ function RecommendCoverOnlyCard({
|
||||
className="platform-recommend-cover-only"
|
||||
>
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
@@ -873,8 +916,9 @@ function RecommendRuntimePreviewCard({
|
||||
data-preview-position={position}
|
||||
>
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
@@ -1258,8 +1302,9 @@ function DesktopTrendingItem({
|
||||
>
|
||||
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -1339,8 +1384,9 @@ function PlatformRankingItem({
|
||||
<div className="platform-ranking-item__rank">{rank}</div>
|
||||
<div className="platform-ranking-item__cover">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -1406,8 +1452,9 @@ function PlatformCategoryGameItem({
|
||||
>
|
||||
<div className="platform-category-game-item__cover">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -1732,9 +1779,11 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -1846,9 +1895,11 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? '汪汪'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
@@ -2016,6 +2067,10 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
@@ -5949,12 +6004,21 @@ export function RpgEntryHomeView({
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={desktopHeroCover}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
desktopHeroEntry ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={desktopHeroEntry}
|
||||
imageSrc={desktopHeroCover}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
) : (
|
||||
<ResolvedAssetBackdrop
|
||||
src={desktopHeroCover}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||||
@@ -5998,10 +6062,10 @@ export function RpgEntryHomeView({
|
||||
>
|
||||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isBarkBattleGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapBarkBattleWorkToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
@@ -235,3 +237,98 @@ test('maps baby object match draft to edutainment public card', () => {
|
||||
expect(card.coverImageSrc).toBe('/apple.png');
|
||||
expect(card.themeTags[0]).toBe('寓教于乐');
|
||||
});
|
||||
|
||||
test('maps bark battle work to BB public card with scene roles cover', () => {
|
||||
const card = mapBarkBattleWorkToPlatformGalleryCard({
|
||||
workId: 'bark-battle-work-abcdef12',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '公园声浪赛',
|
||||
summary: '柯基和哈士奇比拼声浪。',
|
||||
themeDescription: '傍晚公园擂台',
|
||||
playerImageDescription: '红围巾柯基',
|
||||
opponentImageDescription: '蓝头带哈士奇',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
|
||||
difficultyPreset: 'hard',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 9,
|
||||
recentPlayCount7d: 4,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(isBarkBattleGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('BB-ABCDEF12');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('BB-ABCDEF12');
|
||||
expect(card.coverImageSrc).toBe('/generated-bark-battle/background.png');
|
||||
expect(card.coverRenderMode).toBe('scene_with_roles');
|
||||
expect(card.coverCharacterImageSrcs).toEqual([
|
||||
'/generated-bark-battle/player.png',
|
||||
'/generated-bark-battle/opponent.png',
|
||||
]);
|
||||
expect(buildPlatformWorldDisplayTags(card, 3)).toEqual([
|
||||
'汪汪声浪',
|
||||
'高能',
|
||||
'傍晚公园',
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps bark battle public card cover from character or reference fallback', () => {
|
||||
const characterCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
|
||||
workId: 'BB-COVER001',
|
||||
draftId: 'bark-battle-draft-cover',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '角色封面赛',
|
||||
summary: '',
|
||||
themeDescription: '草地声浪挑战',
|
||||
playerImageDescription: '柯基选手',
|
||||
opponentImageDescription: '哈士奇对手',
|
||||
playerCharacterImageSrc: '/bark/player-cover.png',
|
||||
opponentCharacterImageSrc: '/bark/opponent-cover.png',
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'normal',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 1,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
});
|
||||
const fallbackCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
|
||||
workId: 'BB-COVER002',
|
||||
draftId: 'bark-battle-draft-cover-fallback',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
title: '默认封面赛',
|
||||
summary: '',
|
||||
themeDescription: '夜市声浪挑战',
|
||||
playerImageDescription: '柴犬选手',
|
||||
opponentImageDescription: '机器人对手',
|
||||
playerCharacterImageSrc: null,
|
||||
opponentCharacterImageSrc: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
difficultyPreset: 'easy',
|
||||
status: 'published',
|
||||
generationStatus: 'ready',
|
||||
publishReady: true,
|
||||
playCount: 1,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(characterCoverCard.coverImageSrc).toBe('/bark/player-cover.png');
|
||||
expect(characterCoverCard.coverCharacterImageSrcs).toEqual([
|
||||
'/bark/player-cover.png',
|
||||
'/bark/opponent-cover.png',
|
||||
]);
|
||||
expect(fallbackCoverCard.coverImageSrc).toBe(
|
||||
'/creation-type-references/bark-battle.webp',
|
||||
);
|
||||
expect(fallbackCoverCard.publicWorkCode).toBe('BB-COVER002');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
@@ -22,6 +23,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -43,6 +45,7 @@ export type PlatformWorldCardLike =
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
@@ -196,6 +199,34 @@ export type PlatformEdutainmentGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformBarkBattleGalleryCard = {
|
||||
sourceType: 'bark-battle';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorPublicUserCode: string | null;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
themeTags: string[];
|
||||
themeMode: CustomWorldGalleryCard['themeMode'];
|
||||
playableNpcCount: number;
|
||||
landmarkCount: number;
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
@@ -203,6 +234,7 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
@@ -247,6 +279,12 @@ export function isEdutainmentGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'edutainment';
|
||||
}
|
||||
|
||||
export function isBarkBattleGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformBarkBattleGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -422,6 +460,64 @@ export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBarkBattleWorkToPlatformGalleryCard(
|
||||
work: BarkBattleWorkSummary,
|
||||
): PlatformBarkBattleGalleryCard {
|
||||
const playerCharacterImageSrc = normalizePlatformOptionalImageSrc(
|
||||
work.playerCharacterImageSrc,
|
||||
);
|
||||
const opponentCharacterImageSrc = normalizePlatformOptionalImageSrc(
|
||||
work.opponentCharacterImageSrc,
|
||||
);
|
||||
const backgroundImageSrc = normalizePlatformOptionalImageSrc(
|
||||
work.uiBackgroundImageSrc,
|
||||
);
|
||||
const coverImageSrc =
|
||||
backgroundImageSrc ??
|
||||
playerCharacterImageSrc ??
|
||||
opponentCharacterImageSrc ??
|
||||
'/creation-type-references/bark-battle.webp';
|
||||
const coverCharacterImageSrcs = [
|
||||
playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc,
|
||||
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
|
||||
const canRenderSceneWithRoles =
|
||||
Boolean(backgroundImageSrc) && coverCharacterImageSrcs.length >= 2;
|
||||
|
||||
return {
|
||||
sourceType: 'bark-battle',
|
||||
workId: work.workId,
|
||||
profileId: work.workId,
|
||||
sourceSessionId: work.draftId ?? null,
|
||||
publicWorkCode: buildBarkBattlePublicWorkCode(work.workId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorPublicUserCode: null,
|
||||
authorDisplayName: work.authorDisplayName,
|
||||
worldName: work.title.trim() || '汪汪声浪大作战',
|
||||
subtitle: `汪汪声浪 · ${describeBarkBattleDifficultyLabel(
|
||||
work.difficultyPreset,
|
||||
)}`,
|
||||
summaryText:
|
||||
work.summary.trim() ||
|
||||
work.themeDescription.trim() ||
|
||||
'用声音能量挑战对手。',
|
||||
coverImageSrc,
|
||||
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
|
||||
coverCharacterImageSrcs,
|
||||
themeTags: buildBarkBattleThemeTags(work),
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: work.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? null,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
return {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
@@ -473,6 +569,10 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/creative-agent.webp';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return '/creation-type-references/bark-battle.webp';
|
||||
}
|
||||
|
||||
return '/creation-type-references/rpg.webp';
|
||||
}
|
||||
|
||||
@@ -634,6 +734,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: [entry.templateName];
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['汪汪声浪'];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -724,6 +830,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
@@ -745,3 +855,31 @@ export function describePlatformThemeLabel(
|
||||
return '回响';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlatformOptionalImageSrc(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function describeBarkBattleDifficultyLabel(
|
||||
difficulty: BarkBattleWorkSummary['difficultyPreset'],
|
||||
) {
|
||||
switch (difficulty) {
|
||||
case 'easy':
|
||||
return '轻松';
|
||||
case 'hard':
|
||||
return '高能';
|
||||
default:
|
||||
return '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
|
||||
return [
|
||||
'汪汪声浪',
|
||||
describeBarkBattleDifficultyLabel(work.difficultyPreset),
|
||||
work.themeDescription,
|
||||
]
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user