feat: 完善敲木鱼玩法模板链路
This commit is contained in:
@@ -7589,8 +7589,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
hitObjectReferenceImageSrc:
|
||||
payload?.hitObjectReferenceImageSrc ??
|
||||
created.session.draft?.hitObjectReferenceImageSrc,
|
||||
hitSoundPrompt:
|
||||
payload?.hitSoundPrompt ?? created.session.draft?.hitSoundPrompt,
|
||||
hitSoundAsset:
|
||||
payload?.hitSoundAsset ?? created.session.draft?.hitSoundAsset,
|
||||
floatingWords:
|
||||
@@ -7643,7 +7641,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [compileWoodenFishSession, setSelectionStage, woodenFishSession]);
|
||||
|
||||
const regenerateWoodenFishAsset = useCallback(
|
||||
async (actionType: 'regenerate-hit-object' | 'generate-hit-sound') => {
|
||||
async (actionType: 'regenerate-hit-object') => {
|
||||
if (!woodenFishSession?.sessionId) {
|
||||
setSelectionStage('wooden-fish-workspace');
|
||||
return;
|
||||
@@ -7665,7 +7663,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
hitObjectPrompt: woodenFishSession.draft?.hitObjectPrompt,
|
||||
hitObjectReferenceImageSrc:
|
||||
woodenFishSession.draft?.hitObjectReferenceImageSrc,
|
||||
hitSoundPrompt: woodenFishSession.draft?.hitSoundPrompt,
|
||||
hitSoundAsset: woodenFishSession.draft?.hitSoundAsset,
|
||||
floatingWords: woodenFishSession.draft?.floatingWords,
|
||||
},
|
||||
@@ -7679,9 +7676,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
} catch (error) {
|
||||
const errorMessage = resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
actionType === 'regenerate-hit-object'
|
||||
? '重新生成敲击物图案失败。'
|
||||
: '生成敲击音效失败。',
|
||||
'重新生成敲击物图案失败。',
|
||||
);
|
||||
setWoodenFishError(errorMessage);
|
||||
setWoodenFishGenerationState(
|
||||
@@ -7811,33 +7806,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
[setSelectionStage],
|
||||
);
|
||||
|
||||
const restartWoodenFishRuntimeRun = useCallback(async () => {
|
||||
const profileId =
|
||||
woodenFishRun?.profileId?.trim() ??
|
||||
woodenFishWork?.summary.profileId?.trim();
|
||||
if (!profileId) {
|
||||
await startWoodenFishTestRunFromProfile();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsWoodenFishBusy(true);
|
||||
setWoodenFishError(null);
|
||||
try {
|
||||
const response = await woodenFishClient.startRun(profileId);
|
||||
setWoodenFishRun(response.run);
|
||||
} catch (error) {
|
||||
setWoodenFishError(
|
||||
resolveRpgCreationErrorMessage(error, '重新开始敲木鱼失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsWoodenFishBusy(false);
|
||||
}
|
||||
}, [
|
||||
startWoodenFishTestRunFromProfile,
|
||||
woodenFishRun?.profileId,
|
||||
woodenFishWork?.summary.profileId,
|
||||
]);
|
||||
|
||||
const checkpointWoodenFishRuntimeRun = useCallback(
|
||||
async (payload: {
|
||||
totalTapCount: number;
|
||||
@@ -7853,31 +7821,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
[woodenFishRun?.runId],
|
||||
);
|
||||
|
||||
const finishWoodenFishRuntimeRun = useCallback(
|
||||
async (payload: {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishRunResponse['run']['wordCounters'];
|
||||
}) => {
|
||||
const runId = woodenFishRun?.runId;
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
setIsWoodenFishBusy(true);
|
||||
setWoodenFishError(null);
|
||||
try {
|
||||
const response = await woodenFishClient.finishRun(runId, payload);
|
||||
setWoodenFishRun(response.run);
|
||||
} catch (error) {
|
||||
setWoodenFishError(
|
||||
resolveRpgCreationErrorMessage(error, '结束敲木鱼失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsWoodenFishBusy(false);
|
||||
}
|
||||
},
|
||||
[woodenFishRun?.runId],
|
||||
);
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
const executePuzzleBackgroundAction = useCallback(
|
||||
@@ -11479,11 +11422,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={() => {
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
}}
|
||||
onRestart={() => {
|
||||
void restartWoodenFishRuntimeRun();
|
||||
}}
|
||||
onCheckpoint={checkpointWoodenFishRuntimeRun}
|
||||
onFinish={finishWoodenFishRuntimeRun}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11650,7 +11589,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolveSquareHoleErrorMessage,
|
||||
reportBigFishObservedPlayTime,
|
||||
restartBigFishRun,
|
||||
restartWoodenFishRuntimeRun,
|
||||
selectedPuzzleDetail,
|
||||
selectionStage,
|
||||
setMatch3DError,
|
||||
@@ -11674,7 +11612,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
visualNovelSession,
|
||||
visualNovelWork,
|
||||
checkpointWoodenFishRuntimeRun,
|
||||
finishWoodenFishRuntimeRun,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -14449,9 +14386,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
onRegenerateHitObject={() => {
|
||||
void regenerateWoodenFishAsset('regenerate-hit-object');
|
||||
}}
|
||||
onGenerateHitSound={() => {
|
||||
void regenerateWoodenFishAsset('generate-hit-sound');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -14476,11 +14410,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={() => {
|
||||
setSelectionStage(woodenFishRuntimeReturnStage);
|
||||
}}
|
||||
onRestart={() => {
|
||||
void restartWoodenFishRuntimeRun();
|
||||
}}
|
||||
onCheckpoint={checkpointWoodenFishRuntimeRun}
|
||||
onFinish={finishWoodenFishRuntimeRun}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
@@ -23,3 +23,20 @@ test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀'
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
|
||||
});
|
||||
|
||||
test('敲击音效临时关闭提示词生成入口,仅保留上传和录音', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('敲击音效');
|
||||
const section = sectionTitle.closest('section');
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
|
||||
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishWorkspaceProps = {
|
||||
@@ -34,7 +37,6 @@ type WoodenFishWorkspaceFormState = {
|
||||
themeTags: string;
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundPrompt: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
@@ -56,7 +58,6 @@ const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
|
||||
themeTags: '敲木鱼 解压',
|
||||
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectReferenceImageSrc: '',
|
||||
hitSoundPrompt: '清脆短促的木鱼敲击声',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: DEFAULT_FLOATING_WORDS,
|
||||
};
|
||||
@@ -111,16 +112,12 @@ function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
|
||||
|
||||
function WoodenFishAudioInputPanel({
|
||||
disabled,
|
||||
prompt,
|
||||
asset,
|
||||
onPromptChange,
|
||||
onAssetChange,
|
||||
onError,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
prompt: string;
|
||||
asset: WoodenFishAudioAsset | null;
|
||||
onPromptChange: (value: string) => void;
|
||||
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}) {
|
||||
@@ -201,18 +198,6 @@ function WoodenFishAudioInputPanel({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
音效描述
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={disabled || Boolean(asset)}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
rows={2}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
|
||||
@@ -270,7 +255,7 @@ function WoodenFishAudioInputPanel({
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : '可生成、上传或录制'}
|
||||
{asset ? '音效已选择' : '默认木鱼音'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -317,10 +302,9 @@ export function WoodenFishWorkspace({
|
||||
hitObjectPrompt: formState.hitObjectPrompt.trim(),
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: formState.hitSoundAsset
|
||||
? null
|
||||
: formState.hitSoundPrompt.trim() || null,
|
||||
hitSoundAsset: formState.hitSoundAsset,
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset:
|
||||
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
floatingWords: normalizedFloatingWords,
|
||||
};
|
||||
const response = await woodenFishClient.createSession(payload);
|
||||
@@ -462,14 +446,7 @@ export function WoodenFishWorkspace({
|
||||
|
||||
<WoodenFishAudioInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
prompt={formState.hitSoundPrompt}
|
||||
asset={formState.hitSoundAsset}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundPrompt: value,
|
||||
}))
|
||||
}
|
||||
onAssetChange={(asset) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { WoodenFishDraftResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { WoodenFishResultView } from './WoodenFishResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (src?: string | null) => ({
|
||||
resolvedUrl: src?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createDraft(): WoodenFishDraftResponse {
|
||||
return {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '今日敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
hitObjectPrompt: '金色木鱼',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['幸运', '功德'],
|
||||
hitObjectAsset: null,
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: null,
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: null,
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
}
|
||||
|
||||
test('结果页缺少音频资产时使用默认木鱼音且不展示生成音效入口', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishResultView
|
||||
profile={createDraft()}
|
||||
onBack={() => {}}
|
||||
onEdit={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
onRegenerateHitObject={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '音效' })).toBeNull();
|
||||
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
|
||||
'/wooden-fish/default-hit-sound.mp3',
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -13,7 +12,10 @@ import type {
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type WoodenFishResultViewProps = {
|
||||
@@ -25,7 +27,6 @@ type WoodenFishResultViewProps = {
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateHitObject: () => void;
|
||||
onGenerateHitSound: () => void;
|
||||
};
|
||||
|
||||
function isWoodenFishWorkProfile(
|
||||
@@ -43,7 +44,6 @@ export function WoodenFishResultView({
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateHitObject,
|
||||
onGenerateHitSound,
|
||||
}: WoodenFishResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isWoodenFishWorkProfile(profile);
|
||||
@@ -59,8 +59,8 @@ export function WoodenFishResultView({
|
||||
: draft.backgroundAsset;
|
||||
const backgroundSrc = backgroundAsset?.imageSrc?.trim() || '';
|
||||
const hitSoundAsset = isWorkProfile
|
||||
? profile.hitSoundAsset
|
||||
: draft.hitSoundAsset;
|
||||
? profile.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET
|
||||
: draft.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
|
||||
const floatingWords = isWorkProfile ? profile.floatingWords : draft.floatingWords;
|
||||
const title =
|
||||
summary?.workTitle?.trim() || draft.workTitle.trim() || '敲木鱼';
|
||||
@@ -90,26 +90,15 @@ export function WoodenFishResultView({
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateHitObject}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
图案
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerateHitSound}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
音效
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateHitObject}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
图案
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(19rem,0.75fr)]">
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { WoodenFishWorkProfileResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { WoodenFishRuntimeShell } from './WoodenFishRuntimeShell';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (src?: string | null) => ({
|
||||
resolvedUrl: src?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
let audioConstructor: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createProfile(): WoodenFishWorkProfileResponse {
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: null,
|
||||
workTitle: '今日敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-22T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'wooden-fish',
|
||||
templateName: '敲木鱼',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
workTitle: '今日敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: ['敲木鱼'],
|
||||
hitObjectPrompt: '金色木鱼',
|
||||
hitObjectReferenceImageSrc: null,
|
||||
hitSoundPrompt: null,
|
||||
floatingWords: ['幸运', '功德'],
|
||||
hitObjectAsset: {
|
||||
assetId: 'wooden-fish-hit-object-1',
|
||||
imageSrc: '/wooden-fish/default-hit-object.png',
|
||||
imageObjectKey: 'public/wooden-fish/default-hit-object.png',
|
||||
assetObjectId: 'wooden-fish-hit-object-asset',
|
||||
generationProvider: 'bundled-default',
|
||||
prompt: '默认敲击物图案,圆润木质质感,透明背景',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: {
|
||||
assetId: 'wooden-fish-back-button-1',
|
||||
imageSrc: '/generated/back-button.png',
|
||||
imageObjectKey: 'generated/back-button.png',
|
||||
assetObjectId: 'wooden-fish-back-button-asset',
|
||||
generationProvider: 'image2',
|
||||
prompt: '主题返回按钮',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
hitSoundAsset: null,
|
||||
coverImageSrc: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
hitObjectAsset: {
|
||||
assetId: 'wooden-fish-hit-object-1',
|
||||
imageSrc: '/wooden-fish/default-hit-object.png',
|
||||
imageObjectKey: 'public/wooden-fish/default-hit-object.png',
|
||||
assetObjectId: 'wooden-fish-hit-object-asset',
|
||||
generationProvider: 'bundled-default',
|
||||
prompt: '默认敲击物图案,圆润木质质感,透明背景',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
backgroundAsset: null,
|
||||
backButtonAsset: {
|
||||
assetId: 'wooden-fish-back-button-1',
|
||||
imageSrc: '/generated/back-button.png',
|
||||
imageObjectKey: 'generated/back-button.png',
|
||||
assetObjectId: 'wooden-fish-back-button-asset',
|
||||
generationProvider: 'image2',
|
||||
prompt: '主题返回按钮',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
hitSoundAsset: null as unknown as WoodenFishWorkProfileResponse['hitSoundAsset'],
|
||||
floatingWords: ['幸运', '功德'],
|
||||
};
|
||||
}
|
||||
|
||||
function createRun() {
|
||||
return {
|
||||
runId: 'wooden-fish-run-1',
|
||||
profileId: 'wooden-fish-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing' as const,
|
||||
totalTapCount: 284,
|
||||
wordCounters: [
|
||||
{ text: '幸运', count: 63 },
|
||||
{ text: '功德', count: 17 },
|
||||
],
|
||||
startedAtMs: 1_717_000_000_000,
|
||||
updatedAtMs: 1_717_000_006_000,
|
||||
finishedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
audioConstructor = vi.fn().mockImplementation((src: string) => ({
|
||||
src,
|
||||
preload: '',
|
||||
pause: vi.fn(),
|
||||
play: vi.fn().mockResolvedValue(undefined),
|
||||
currentTime: 0,
|
||||
}));
|
||||
vi.stubGlobal('Audio', audioConstructor);
|
||||
});
|
||||
|
||||
test('运行态缺少音频资产时使用默认木鱼音', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} />);
|
||||
|
||||
expect(audioConstructor).toHaveBeenCalledWith(
|
||||
'/wooden-fish/default-hit-sound.mp3',
|
||||
);
|
||||
});
|
||||
|
||||
test('顶部只展示总数,点击后展开子项计数器面板,点外部收起', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
const totalCounterButton = screen.getByRole('button', { name: /总数 284/ });
|
||||
expect(totalCounterButton).toBeTruthy();
|
||||
expect(screen.queryByTestId('wooden-fish-counter-panel')).toBeNull();
|
||||
expect(screen.queryByText('幸运')).toBeNull();
|
||||
expect(screen.queryByText('功德')).toBeNull();
|
||||
|
||||
fireEvent.click(totalCounterButton);
|
||||
|
||||
const panel = screen.getByTestId('wooden-fish-counter-panel');
|
||||
const panelRows = within(panel).getAllByText(/幸运|功德/);
|
||||
expect(panelRows).toHaveLength(2);
|
||||
expect(within(panel).getByText('63')).toBeTruthy();
|
||||
expect(within(panel).getByText('17')).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
|
||||
expect(screen.queryByTestId('wooden-fish-counter-panel')).toBeNull();
|
||||
});
|
||||
|
||||
test('木鱼运行态不渲染右上角重开按钮', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '重开' })).toBeNull();
|
||||
});
|
||||
|
||||
test('木鱼运行态使用生成的主题返回按钮图', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
const backButton = screen.getByRole('button', { name: '返回' });
|
||||
const image = backButton.querySelector('img');
|
||||
expect(image?.getAttribute('src')).toBe('/generated/back-button.png');
|
||||
expect(backButton.className).not.toContain('backdrop-blur');
|
||||
expect(backButton.className).not.toContain('bg-white');
|
||||
expect(backButton.className).toContain('h-10');
|
||||
expect(backButton.className).toContain('w-10');
|
||||
});
|
||||
|
||||
test('木鱼运行态飘字去掉底板并放大字号', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />,
|
||||
);
|
||||
|
||||
const playfield = container.querySelector('.wooden-fish-runtime');
|
||||
expect(playfield).not.toBeNull();
|
||||
if (!playfield) {
|
||||
throw new Error('缺少木鱼运行态根节点。');
|
||||
}
|
||||
fireEvent.pointerDown(playfield, { clientX: 260, clientY: 360 });
|
||||
|
||||
const floatingText = screen.getByText(/幸运|功德/);
|
||||
expect(floatingText.className).toContain('text-[1.75rem]');
|
||||
expect(floatingText.className).not.toContain('bg-slate-950/78');
|
||||
expect(floatingText.className).not.toContain('rounded-full');
|
||||
});
|
||||
|
||||
test('子项计数器面板预置全部词条,未出现词条初始值为0', () => {
|
||||
render(
|
||||
<WoodenFishRuntimeShell
|
||||
profile={createProfile()}
|
||||
run={{
|
||||
...createRun(),
|
||||
totalTapCount: 63,
|
||||
wordCounters: [{ text: '幸运', count: 63 }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /总数 63/ }));
|
||||
|
||||
const panel = screen.getByTestId('wooden-fish-counter-panel');
|
||||
expect(within(panel).getByText('幸运')).toBeTruthy();
|
||||
expect(within(panel).getByText('63')).toBeTruthy();
|
||||
expect(within(panel).getByText('功德')).toBeTruthy();
|
||||
expect(within(panel).getByText('0')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('顶部总数条的 logo 使用紧凑标题条布局', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
const totalCounterButton = screen.getByRole('button', { name: /总数 284/ });
|
||||
const logo = totalCounterButton.querySelector(
|
||||
'[data-testid="wooden-fish-runtime-logo"]',
|
||||
) as HTMLImageElement | null;
|
||||
|
||||
expect(logo).toBeTruthy();
|
||||
expect(logo?.className).toContain('wooden-fish-runtime__counter-logo__image');
|
||||
expect(logo?.tagName).toBe('IMG');
|
||||
});
|
||||
|
||||
test('木鱼运行态不渲染底部进行中和结束操作区', () => {
|
||||
render(<WoodenFishRuntimeShell profile={createProfile()} run={createRun()} />);
|
||||
|
||||
expect(screen.queryByText('进行中')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, Loader2, RotateCcw, X } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
@@ -13,8 +13,12 @@ import type {
|
||||
WoodenFishWordCounter,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import woodenFishRuntimeLogo from '../../../media/logo.png';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
applyWoodenFishTap,
|
||||
@@ -34,11 +38,6 @@ type WoodenFishRuntimeShellProps = {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishWordCounter[];
|
||||
}) => Promise<unknown>;
|
||||
onFinish?: (payload: {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishWordCounter[];
|
||||
}) => Promise<unknown>;
|
||||
onRestart?: () => void;
|
||||
onExit?: () => void;
|
||||
onBack?: () => void;
|
||||
};
|
||||
@@ -67,8 +66,6 @@ export function WoodenFishRuntimeShell({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onCheckpoint,
|
||||
onFinish,
|
||||
onRestart,
|
||||
onExit,
|
||||
onBack,
|
||||
}: WoodenFishRuntimeShellProps) {
|
||||
@@ -80,17 +77,27 @@ export function WoodenFishRuntimeShell({
|
||||
const [wordCounters, setWordCounters] = useState<WoodenFishWordCounter[]>(
|
||||
activeRun?.wordCounters ?? [],
|
||||
);
|
||||
const [isCounterPanelOpen, setIsCounterPanelOpen] = useState(false);
|
||||
const [floatingTexts, setFloatingTexts] = useState<FloatingText[]>([]);
|
||||
const [hitPulse, setHitPulse] = useState(0);
|
||||
const audioPoolRef = useRef<HTMLAudioElement[]>([]);
|
||||
const audioIndexRef = useRef(0);
|
||||
const lastAudioAtRef = useRef(0);
|
||||
const lastCheckpointAtRef = useRef(0);
|
||||
const counterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const currentSnapshotRef = useRef({ totalTapCount, wordCounters });
|
||||
const words = useMemo(
|
||||
() => normalizeWoodenFishFloatingWords(profile?.floatingWords ?? []),
|
||||
[profile?.floatingWords],
|
||||
);
|
||||
const counterEntries = useMemo(
|
||||
() =>
|
||||
words.map((word) => ({
|
||||
text: word,
|
||||
count: wordCounters.find((counter) => counter.text === word)?.count ?? 0,
|
||||
})),
|
||||
[words, wordCounters],
|
||||
);
|
||||
const hitObjectSrc =
|
||||
profile?.hitObjectAsset?.imageSrc?.trim() ||
|
||||
profile?.draft.hitObjectAsset?.imageSrc?.trim() ||
|
||||
@@ -99,8 +106,14 @@ export function WoodenFishRuntimeShell({
|
||||
profile?.backgroundAsset?.imageSrc?.trim() ||
|
||||
profile?.draft.backgroundAsset?.imageSrc?.trim() ||
|
||||
'';
|
||||
const backButtonSrc =
|
||||
profile?.backButtonAsset?.imageSrc?.trim() ||
|
||||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
|
||||
'';
|
||||
const hitSoundSrc =
|
||||
profile?.hitSoundAsset?.audioSrc ?? profile?.draft.hitSoundAsset?.audioSrc;
|
||||
profile?.hitSoundAsset?.audioSrc ??
|
||||
profile?.draft.hitSoundAsset?.audioSrc ??
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET.audioSrc;
|
||||
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,6 +125,32 @@ export function WoodenFishRuntimeShell({
|
||||
setWordCounters(activeRun?.wordCounters ?? []);
|
||||
}, [activeRun?.runId, activeRun?.totalTapCount, activeRun?.wordCounters]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsCounterPanelOpen(false);
|
||||
}, [activeRun?.runId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCounterPanelOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleDocumentPointerDown = (event: globalThis.PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (counterMenuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setIsCounterPanelOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown);
|
||||
};
|
||||
}, [isCounterPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
audioPoolRef.current.forEach((audio) => {
|
||||
audio.pause();
|
||||
@@ -210,11 +249,6 @@ export function WoodenFishRuntimeShell({
|
||||
playHitSound();
|
||||
};
|
||||
|
||||
const finishRun = async () => {
|
||||
const payload = currentSnapshotRef.current;
|
||||
await onFinish?.(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="wooden-fish-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#f7f4ec] text-slate-950"
|
||||
@@ -239,37 +273,84 @@ export function WoodenFishRuntimeShell({
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/84 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
aria-label="返回"
|
||||
className={`wooden-fish-runtime__back-button min-h-0 overflow-hidden ${
|
||||
backButtonSrc
|
||||
? 'grid h-10 w-10 place-items-center rounded-full bg-transparent p-0 shadow-none sm:h-11 sm:w-11'
|
||||
: 'platform-button platform-button--ghost bg-white/84 px-3 py-2 text-sm'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex max-w-[58vw] flex-wrap justify-center gap-1.5">
|
||||
<span className="rounded-full border border-white/70 bg-white/84 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
|
||||
{totalTapCount}
|
||||
</span>
|
||||
{wordCounters.map((counter) => (
|
||||
<span
|
||||
key={counter.text}
|
||||
className="rounded-full border border-white/70 bg-white/84 px-2.5 py-2 text-xs font-black shadow-sm backdrop-blur"
|
||||
>
|
||||
{counter.text} {counter.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy || !onRestart}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/84 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{backButtonSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backButtonSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="h-full w-full object-contain drop-shadow-[0_8px_12px_rgba(63,36,18,0.2)]"
|
||||
/>
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</>
|
||||
)}
|
||||
重开
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 justify-center px-1">
|
||||
<div
|
||||
ref={counterMenuRef}
|
||||
data-wooden-fish-functional="true"
|
||||
className="wooden-fish-runtime__counter-anchor relative inline-flex max-w-full flex-col items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="wooden-fish-counter-panel"
|
||||
aria-expanded={isCounterPanelOpen}
|
||||
aria-label={`总数 ${totalTapCount}${
|
||||
isCounterPanelOpen ? ',已展开子项计数器' : ',点击查看子项计数器'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setIsCounterPanelOpen((value) => !value);
|
||||
}}
|
||||
className="wooden-fish-runtime__counter-card flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:max-w-[22rem] sm:px-4 sm:pr-5"
|
||||
>
|
||||
<span aria-hidden="true" className="wooden-fish-runtime__counter-logo">
|
||||
<img
|
||||
data-testid="wooden-fish-runtime-logo"
|
||||
src={woodenFishRuntimeLogo}
|
||||
alt=""
|
||||
className="wooden-fish-runtime__counter-logo__image"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="wooden-fish-runtime__counter-badge shrink-0 text-[0.92rem] font-black sm:text-base">
|
||||
总数
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[0.92rem] font-black tabular-nums sm:text-base">
|
||||
{totalTapCount}
|
||||
</span>
|
||||
</button>
|
||||
{isCounterPanelOpen ? (
|
||||
<div
|
||||
id="wooden-fish-counter-panel"
|
||||
data-testid="wooden-fish-counter-panel"
|
||||
className="wooden-fish-runtime__counter-panel absolute left-1/2 top-full z-40 mt-2 w-[min(16rem,calc(100vw_-_1rem))] -translate-x-1/2 overflow-hidden rounded-[0.95rem] shadow-[0_14px_30px_rgba(45,24,12,0.22)] backdrop-blur"
|
||||
>
|
||||
<div className="max-h-[min(46vh,19rem)] overflow-auto py-2">
|
||||
{counterEntries.map((counter) => (
|
||||
<div
|
||||
key={counter.text}
|
||||
className="wooden-fish-runtime__counter-row flex items-center justify-between gap-3 px-3.5 py-2 text-sm font-black"
|
||||
>
|
||||
<span className="min-w-0 truncate">{counter.text}</span>
|
||||
<span className="shrink-0 tabular-nums">{counter.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-10 w-10 shrink-0 sm:h-11 sm:w-11" />
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 flex flex-1 items-center justify-center px-5 pb-[max(5rem,env(safe-area-inset-bottom))] pt-4">
|
||||
@@ -295,7 +376,7 @@ export function WoodenFishRuntimeShell({
|
||||
{floatingTexts.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="wooden-fish-runtime__floating-text pointer-events-none absolute z-20 rounded-full bg-slate-950/78 px-3 py-1.5 text-sm font-black text-white shadow-[0_10px_24px_rgba(15,23,42,0.2)]"
|
||||
className="wooden-fish-runtime__floating-text pointer-events-none absolute z-20 text-[1.75rem] font-black text-white drop-shadow-[0_3px_8px_rgba(38,20,8,0.52)]"
|
||||
style={{
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
@@ -320,32 +401,68 @@ export function WoodenFishRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer
|
||||
data-wooden-fish-functional="true"
|
||||
className="absolute bottom-0 left-0 right-0 z-30 flex items-center justify-between gap-3 bg-white/76 px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-14px_34px_rgba(15,23,42,0.08)] backdrop-blur sm:px-4"
|
||||
>
|
||||
<div className="min-w-0 text-sm font-black text-slate-700">
|
||||
{activeRun?.status === 'finished' ? '已完成' : '进行中'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void finishRun();
|
||||
}}
|
||||
disabled={isBusy || !onFinish}
|
||||
className="platform-button platform-button--primary min-h-11 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
结束
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
.wooden-fish-runtime {
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-card {
|
||||
position: relative;
|
||||
min-width: min(14rem, calc(100vw - 7.75rem));
|
||||
min-height: 2.9rem;
|
||||
border: 2px solid rgba(255, 222, 189, 0.6);
|
||||
border-radius: 0.62rem 1.35rem 1.35rem 0.62rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent 44%),
|
||||
linear-gradient(135deg, #7d5129 0%, #68401f 58%, #543014 100%);
|
||||
color: #fff8ea;
|
||||
box-shadow: 0 12px 28px rgba(55, 28, 13, 0.2);
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-card::before {
|
||||
position: absolute;
|
||||
inset: 0.2rem 0.58rem auto 3.05rem;
|
||||
height: 1px;
|
||||
content: '';
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 232, 199, 0.36);
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 2.95rem;
|
||||
height: 2.95rem;
|
||||
flex: 0 0 auto;
|
||||
margin: -0.62rem 0 -0.62rem -1.18rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-logo__image {
|
||||
position: absolute;
|
||||
left: -1.1rem;
|
||||
top: -1.24rem;
|
||||
display: block;
|
||||
width: 5.3rem;
|
||||
height: 5.3rem;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-panel {
|
||||
border: 1.5px solid rgba(255, 222, 189, 0.56);
|
||||
background: linear-gradient(180deg, rgba(92, 54, 26, 0.98), rgba(67, 36, 14, 0.98));
|
||||
color: #fff8ea;
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__counter-row + .wooden-fish-runtime__counter-row {
|
||||
border-top: 1px solid rgba(255, 224, 191, 0.14);
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__object {
|
||||
animation: wooden-fish-hit 220ms ease both;
|
||||
}
|
||||
|
||||
@@ -434,7 +434,15 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
workDescription: '敲一下,好事发生。',
|
||||
themeTags: ['解压'],
|
||||
hitObjectPrompt: '金色小木鱼',
|
||||
hitSoundPrompt: '清脆木鱼声',
|
||||
hitSoundAsset: {
|
||||
assetId: 'wooden-fish-default-hit-sound',
|
||||
audioSrc: '/wooden-fish/default-hit-sound.mp3',
|
||||
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
|
||||
assetObjectId: 'wooden-fish-default-hit-sound',
|
||||
source: 'bundled-default',
|
||||
prompt: '默认木鱼音',
|
||||
durationMs: 3000,
|
||||
},
|
||||
floatingWords: ['幸运+1', '功德+1'],
|
||||
});
|
||||
|
||||
@@ -447,7 +455,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '音效',
|
||||
value: '清脆木鱼声',
|
||||
value: '默认木鱼音',
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-words',
|
||||
|
||||
@@ -427,7 +427,7 @@ const WOODEN_FISH_STEPS = [
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '准备敲击音效',
|
||||
detail: '生成或写回短促敲击音效资产。',
|
||||
detail: '写回上传、录音或默认短促敲击音效资产。',
|
||||
weight: 16,
|
||||
},
|
||||
{
|
||||
@@ -917,8 +917,7 @@ export function buildWoodenFishGenerationAnchorEntries(
|
||||
key: 'wooden-fish-hit-sound',
|
||||
label: '音效',
|
||||
value:
|
||||
formPayload?.hitSoundPrompt?.trim() ||
|
||||
draft?.hitSoundPrompt?.trim() ||
|
||||
formPayload?.hitSoundAsset?.prompt?.trim() ||
|
||||
draft?.hitSoundAsset?.prompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
|
||||
36
src/services/wooden-fish/woodenFishClient.test.ts
Normal file
36
src/services/wooden-fish/woodenFishClient.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../creation-agent', () => ({
|
||||
createCreationAgentClient: createCreationAgentClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
createCreationAgentClientMock.mockReset();
|
||||
createCreationAgentClientMock.mockReturnValue({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
||||
await import('./woodenFishClient');
|
||||
|
||||
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createSessionTimeoutMs: 20 * 60 * 1000,
|
||||
executeActionTimeoutMs: 20 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import { createCreationAgentClient } from '../creation-agent';
|
||||
const WOODEN_FISH_API_BASE = '/api/creation/wooden-fish/sessions';
|
||||
const WOODEN_FISH_WORKS_API_BASE = '/api/creation/wooden-fish/works';
|
||||
const WOODEN_FISH_RUNTIME_API_BASE = '/api/runtime/wooden-fish';
|
||||
// 中文注释:敲木鱼创作会串行等待多次 image2 与 OSS 写入,前端请求窗口需要覆盖完整生成链路。
|
||||
const WOODEN_FISH_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
@@ -66,6 +68,8 @@ const woodenFishCreationClient = createCreationAgentClient<
|
||||
streamIncomplete: '敲木鱼共创消息流式结果不完整',
|
||||
executeAction: '执行敲木鱼共创操作失败',
|
||||
},
|
||||
createSessionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
|
||||
executeActionTimeoutMs: WOODEN_FISH_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
type FlattenedWoodenFishWorkProfileResponse = Omit<
|
||||
@@ -108,6 +112,8 @@ function normalizeWoodenFishWorkProfile(
|
||||
hitObjectAsset: flattened.hitObjectAsset,
|
||||
backgroundAsset:
|
||||
flattened.backgroundAsset ?? flattened.draft?.backgroundAsset ?? null,
|
||||
backButtonAsset:
|
||||
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
|
||||
hitSoundAsset: flattened.hitSoundAsset,
|
||||
floatingWords: flattened.floatingWords,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { WoodenFishAudioAsset } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
|
||||
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
|
||||
'/wooden-fish/default-hit-object.png';
|
||||
|
||||
export const WOODEN_FISH_DEFAULT_HIT_SOUND_SRC =
|
||||
'/wooden-fish/default-hit-sound.mp3';
|
||||
|
||||
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
|
||||
'默认敲击物图案,圆润木质质感,透明背景';
|
||||
|
||||
export const WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET: WoodenFishAudioAsset = {
|
||||
assetId: 'wooden-fish-default-hit-sound',
|
||||
audioSrc: WOODEN_FISH_DEFAULT_HIT_SOUND_SRC,
|
||||
audioObjectKey: 'public/wooden-fish/default-hit-sound.mp3',
|
||||
assetObjectId: 'wooden-fish-default-hit-sound',
|
||||
source: 'bundled-default',
|
||||
prompt: '默认木鱼音',
|
||||
durationMs: 3000,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user