feat: add wooden fish play template
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import { derivePlatformCreationTypes } from './platformEntryCreationTypes';
|
||||
|
||||
const entryConfig = {
|
||||
startCard: {
|
||||
title: '新建作品',
|
||||
description: '',
|
||||
idleBadge: '模板',
|
||||
busyBadge: '开启中',
|
||||
},
|
||||
typeModal: {
|
||||
title: '选择创作类型',
|
||||
description: '',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'wooden-fish',
|
||||
title: '敲木鱼',
|
||||
subtitle: '轻点积累功德',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
} satisfies CreationEntryConfig;
|
||||
|
||||
test('dispatches wooden fish creation type selection', () => {
|
||||
const onSelectWoodenFish = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformEntryCreationTypeModal
|
||||
isOpen
|
||||
isBusy={false}
|
||||
error={null}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={derivePlatformCreationTypes(entryConfig.creationTypes)}
|
||||
onClose={() => {}}
|
||||
onSelectRpg={() => {}}
|
||||
onSelectBigFish={() => {}}
|
||||
onSelectMatch3D={() => {}}
|
||||
onSelectSquareHole={() => {}}
|
||||
onSelectJumpHop={() => {}}
|
||||
onSelectWoodenFish={onSelectWoodenFish}
|
||||
onSelectPuzzle={() => {}}
|
||||
onSelectCreativeAgent={() => {}}
|
||||
onSelectBarkBattle={() => {}}
|
||||
onSelectVisualNovel={() => {}}
|
||||
onSelectBabyObjectMatch={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /敲木鱼/u }));
|
||||
|
||||
expect(onSelectWoodenFish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectMatch3D: () => void;
|
||||
onSelectSquareHole: () => void;
|
||||
onSelectJumpHop: () => void;
|
||||
onSelectWoodenFish: () => void;
|
||||
onSelectPuzzle: () => void;
|
||||
onSelectCreativeAgent: () => void;
|
||||
onSelectBarkBattle: () => void;
|
||||
@@ -102,6 +103,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onSelectMatch3D,
|
||||
onSelectSquareHole,
|
||||
onSelectJumpHop,
|
||||
onSelectWoodenFish,
|
||||
onSelectPuzzle,
|
||||
onSelectCreativeAgent,
|
||||
onSelectBarkBattle,
|
||||
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'jump-hop') {
|
||||
onSelectJumpHop();
|
||||
}
|
||||
if (item.id === 'wooden-fish') {
|
||||
onSelectWoodenFish();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,10 @@ export type SelectionStage =
|
||||
| 'jump-hop-result'
|
||||
| 'jump-hop-runtime'
|
||||
| 'jump-hop-gallery-detail'
|
||||
| 'wooden-fish-workspace'
|
||||
| 'wooden-fish-generating'
|
||||
| 'wooden-fish-result'
|
||||
| 'wooden-fish-runtime'
|
||||
| 'bark-battle-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
| 'visual-novel-agent-workspace'
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -1843,22 +1844,31 @@ async function getPublicWorkAuthorSummary(
|
||||
}
|
||||
|
||||
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? '抓鹅'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? '方洞'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? '跳一跳'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('大鱼');
|
||||
}
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('拼图');
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('抓鹅');
|
||||
}
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('方洞');
|
||||
}
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('跳一跳');
|
||||
}
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('敲木鱼');
|
||||
}
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag('视觉');
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
}
|
||||
|
||||
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
mapWoodenFishWorkToPlatformGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
@@ -165,6 +167,34 @@ test('maps visual novel work to platform gallery card with VN public code', () =
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
|
||||
});
|
||||
|
||||
test('maps wooden fish work to platform gallery card with WF public code', () => {
|
||||
const card = mapWoodenFishWorkToPlatformGalleryCard({
|
||||
publicWorkCode: '',
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
workTitle: '每日一敲',
|
||||
workDescription: '敲一下,好事发生。',
|
||||
coverImageSrc: '/generated-wooden-fish-assets/profile/hit-object.png',
|
||||
themeTags: [],
|
||||
publicationStatus: 'published',
|
||||
playCount: 12,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
generationStatus: 'ready',
|
||||
});
|
||||
|
||||
expect(isWoodenFishGalleryEntry(card)).toBe(true);
|
||||
expect(card.sourceType).toBe('wooden-fish');
|
||||
expect(card.publicWorkCode).toBe('WF-12345678');
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('WF-12345678');
|
||||
expect(resolvePlatformWorldFallbackCoverImage(card)).toBe(
|
||||
'/wooden-fish/default-hit-object.png',
|
||||
);
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']);
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
const card: PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment',
|
||||
|
||||
@@ -22,6 +22,10 @@ import type {
|
||||
SquareHoleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
WoodenFishGalleryCardResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
@@ -32,7 +36,9 @@ import {
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC } from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
|
||||
@@ -48,6 +54,7 @@ export type PlatformWorldCardLike =
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformWoodenFishGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
@@ -202,6 +209,28 @@ export type PlatformJumpHopGalleryCard = {
|
||||
stylePreset?: string;
|
||||
};
|
||||
|
||||
export type PlatformWoodenFishGalleryCard = {
|
||||
sourceType: 'wooden-fish';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment';
|
||||
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
@@ -233,6 +262,7 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformWoodenFishGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
@@ -278,6 +308,12 @@ export function isJumpHopGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
|
||||
}
|
||||
|
||||
export function isWoodenFishGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformWoodenFishGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'wooden-fish';
|
||||
}
|
||||
|
||||
export function isEdutainmentGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformEdutainmentGalleryCard {
|
||||
@@ -472,6 +508,39 @@ export function mapJumpHopWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWoodenFishWorkToPlatformGalleryCard(
|
||||
work: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse,
|
||||
): PlatformWoodenFishGalleryCard {
|
||||
const summary = 'summary' in work ? work.summary : work;
|
||||
|
||||
return {
|
||||
sourceType: 'wooden-fish',
|
||||
workId: summary.workId,
|
||||
profileId: summary.profileId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
|
||||
publicWorkCode:
|
||||
'publicWorkCode' in summary && summary.publicWorkCode.trim()
|
||||
? summary.publicWorkCode
|
||||
: buildWoodenFishPublicWorkCode(summary.profileId),
|
||||
ownerUserId: summary.ownerUserId,
|
||||
authorDisplayName:
|
||||
'authorDisplayName' in summary ? summary.authorDisplayName : '玩家',
|
||||
worldName: summary.workTitle,
|
||||
subtitle: '敲木鱼',
|
||||
summaryText: summary.workDescription,
|
||||
coverImageSrc: summary.coverImageSrc ?? null,
|
||||
themeTags: summary.themeTags.length > 0 ? summary.themeTags : ['敲木鱼'],
|
||||
playCount: summary.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: summary.publishedAt ?? null,
|
||||
updatedAt: summary.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): PlatformEdutainmentGalleryCard {
|
||||
@@ -553,6 +622,10 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/jump-hop.webp';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return '/creation-type-references/big-fish.webp';
|
||||
}
|
||||
@@ -722,6 +795,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['跳一跳'];
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['敲木鱼'];
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
@@ -818,6 +897,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { WoodenFishWorkspace } from './WoodenFishWorkspace';
|
||||
|
||||
test('功德有什么默认只显示基础词条,不显示运行态 +1 后缀', () => {
|
||||
render(
|
||||
<WoodenFishWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sectionTitle = screen.getByText('功德有什么');
|
||||
const section = sectionTitle.closest('section');
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('幸运')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('健康')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByDisplayValue('财富')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('幸运+1')).toBeNull();
|
||||
expect(within(section as HTMLElement).queryByDisplayValue('功德+1')).toBeNull();
|
||||
});
|
||||
534
src/components/wooden-fish-creation/WoodenFishWorkspace.tsx
Normal file
534
src/components/wooden-fish-creation/WoodenFishWorkspace.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Send,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} 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 { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: WoodenFishSessionResponse,
|
||||
payload: WoodenFishWorkspaceCreateRequest,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundPrompt: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_FLOATING_WORDS = [
|
||||
'幸运',
|
||||
'健康',
|
||||
'财富',
|
||||
'姻缘',
|
||||
'幸福',
|
||||
'事业',
|
||||
'成功',
|
||||
'功德',
|
||||
];
|
||||
|
||||
const DEFAULT_FORM_STATE: WoodenFishWorkspaceFormState = {
|
||||
workTitle: '今日敲木鱼',
|
||||
workDescription: '',
|
||||
themeTags: '敲木鱼 解压',
|
||||
hitObjectPrompt: WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
hitObjectReferenceImageSrc: '',
|
||||
hitSoundPrompt: '清脆短促的木鱼敲击声',
|
||||
hitSoundAsset: null,
|
||||
floatingWords: DEFAULT_FLOATING_WORDS,
|
||||
};
|
||||
|
||||
function splitTags(value: string) {
|
||||
return value
|
||||
.split(/[,,、\s]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function normalizeFloatingWords(words: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const word of words) {
|
||||
const trimmed = word.trim().replace(/[++]\s*1$/u, '').trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
|
||||
}
|
||||
|
||||
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
|
||||
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持录音。');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || 'audio/webm',
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
|
||||
type: blob.type,
|
||||
});
|
||||
void readAudioFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '录音保存失败。',
|
||||
);
|
||||
});
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
onError(null);
|
||||
} catch (caughtError) {
|
||||
onError(
|
||||
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
recorderRef.current?.stop();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
敲击音效
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
|
||||
>
|
||||
重置
|
||||
</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 ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void readAudioFileAsAsset(file, 'uploaded')
|
||||
.then((nextAsset) => {
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
{isRecording ? '停止' : '录音'}
|
||||
</button>
|
||||
{asset?.audioSrc ? (
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : '可生成、上传或录制'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoodenFishWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: WoodenFishWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
|
||||
const normalizedFloatingWords = useMemo(
|
||||
() => normalizeFloatingWords(formState.floatingWords),
|
||||
[formState.floatingWords],
|
||||
);
|
||||
const canSubmit = Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.hitObjectPrompt.trim() &&
|
||||
normalizedFloatingWords.length > 0,
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: WoodenFishWorkspaceCreateRequest = {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: splitTags(formState.themeTags),
|
||||
hitObjectPrompt: formState.hitObjectPrompt.trim(),
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: formState.hitSoundAsset
|
||||
? null
|
||||
: formState.hitSoundPrompt.trim() || null,
|
||||
hitSoundAsset: formState.hitSoundAsset,
|
||||
floatingWords: normalizedFloatingWords,
|
||||
};
|
||||
const response = await woodenFishClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
|
||||
<div className="flex min-h-[26rem] min-w-0 flex-col">
|
||||
<CreativeImageInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
isSubmitting={isSubmitting}
|
||||
uploadedImageSrc={formState.hitObjectReferenceImageSrc}
|
||||
uploadedImageAlt="敲击物参考图"
|
||||
mainImageInputId="wooden-fish-hit-object-reference"
|
||||
promptTextareaId="wooden-fish-hit-object-prompt"
|
||||
prompt={formState.hitObjectPrompt}
|
||||
promptLabel="敲什么"
|
||||
promptRows={4}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
submitLabel="生成"
|
||||
submitDisabled={!canSubmit || isSubmitting || isBusy}
|
||||
labels={{
|
||||
imageField: '参考图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '替换参考图',
|
||||
emptyImageHint: '上传图像',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图',
|
||||
removeImageConfirmBody: '移除后仍可用文字描述生成敲击物图案。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '敲击物参考图',
|
||||
closePromptReferencePreview: '关闭预览',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
void readPuzzleReferenceImageAsDataUrl(file)
|
||||
.then((dataUrl) => {
|
||||
setLocalError(null);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectReferenceImageSrc: dataUrl,
|
||||
}));
|
||||
setAiRedraw(true);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
setLocalError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '参考图读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
onMainImageRemove={() => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectReferenceImageSrc: '',
|
||||
}));
|
||||
}}
|
||||
onAiRedrawChange={setAiRedraw}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitObjectPrompt: value,
|
||||
}))
|
||||
}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: 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>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<WoodenFishAudioInputPanel
|
||||
disabled={isBusy || isSubmitting}
|
||||
prompt={formState.hitSoundPrompt}
|
||||
asset={formState.hitSoundAsset}
|
||||
onPromptChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundPrompt: value,
|
||||
}))
|
||||
}
|
||||
onAssetChange={(asset) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundAsset: asset,
|
||||
}))
|
||||
}
|
||||
onError={setLocalError}
|
||||
/>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
功德有什么
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{formState.floatingWords.map((word, index) => (
|
||||
<input
|
||||
key={index}
|
||||
value={word}
|
||||
maxLength={16}
|
||||
disabled={isBusy || isSubmitting}
|
||||
onChange={(event) => {
|
||||
const nextWords = [...formState.floatingWords];
|
||||
nextWords[index] = event.target.value;
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
floatingWords: nextWords.slice(0, 8),
|
||||
}));
|
||||
}}
|
||||
className="w-full rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2.5 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishWorkspace;
|
||||
206
src/components/wooden-fish-result/WoodenFishResultView.tsx
Normal file
206
src/components/wooden-fish-result/WoodenFishResultView.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishDraftResponse,
|
||||
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 { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type WoodenFishResultViewProps = {
|
||||
profile: WoodenFishDraftResponse | WoodenFishWorkProfileResponse;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateHitObject: () => void;
|
||||
onGenerateHitSound: () => void;
|
||||
};
|
||||
|
||||
function isWoodenFishWorkProfile(
|
||||
profile: WoodenFishResultViewProps['profile'],
|
||||
): profile is WoodenFishWorkProfileResponse {
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
export function WoodenFishResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateHitObject,
|
||||
onGenerateHitSound,
|
||||
}: WoodenFishResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isWoodenFishWorkProfile(profile);
|
||||
const draft = isWorkProfile ? profile.draft : profile;
|
||||
const summary = isWorkProfile ? profile.summary : null;
|
||||
const hitObjectAsset = isWorkProfile
|
||||
? profile.hitObjectAsset
|
||||
: draft.hitObjectAsset;
|
||||
const hitObjectSrc =
|
||||
hitObjectAsset?.imageSrc?.trim() || WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
|
||||
const hitSoundAsset = isWorkProfile
|
||||
? profile.hitSoundAsset
|
||||
: draft.hitSoundAsset;
|
||||
const floatingWords = isWorkProfile ? profile.floatingWords : draft.floatingWords;
|
||||
const title =
|
||||
summary?.workTitle?.trim() || draft.workTitle.trim() || '敲木鱼';
|
||||
const description =
|
||||
summary?.workDescription?.trim() || draft.workDescription.trim();
|
||||
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(
|
||||
hitSoundAsset?.audioSrc,
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(19rem,0.75fr)]">
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid min-h-0 flex-1 place-items-center rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-5">
|
||||
<ResolvedAssetImage
|
||||
src={hitObjectSrc}
|
||||
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
|
||||
alt="敲击物图案"
|
||||
className="max-h-[min(46vh,22rem)] w-full object-contain drop-shadow-[0_18px_28px_rgba(15,23,42,0.18)]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
飘字
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{floatingWords.map((word) => (
|
||||
<span
|
||||
key={word}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] bg-white/88 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
音效
|
||||
</div>
|
||||
{resolvedAudioUrl ? (
|
||||
<audio controls src={resolvedAudioUrl} className="mt-3 w-full" />
|
||||
) : (
|
||||
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
|
||||
音效尚未准备完成。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto grid gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handlePublish();
|
||||
}}
|
||||
disabled={isBusy || isPublishing}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishResultView;
|
||||
384
src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx
Normal file
384
src/components/wooden-fish-runtime/WoodenFishRuntimeShell.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { ArrowLeft, Loader2, RotateCcw, X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishRuntimeRunSnapshotResponse,
|
||||
WoodenFishWordCounter,
|
||||
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 { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
applyWoodenFishTap,
|
||||
chooseWoodenFishFloatingWord,
|
||||
formatWoodenFishFloatingText,
|
||||
isWoodenFishFunctionalTarget,
|
||||
normalizeWoodenFishFloatingWords,
|
||||
} from './woodenFishRuntimeModel';
|
||||
|
||||
type WoodenFishRuntimeShellProps = {
|
||||
profile?: WoodenFishWorkProfileResponse | null;
|
||||
run?: WoodenFishRuntimeRunSnapshotResponse | null;
|
||||
snapshot?: WoodenFishRuntimeRunSnapshotResponse | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onCheckpoint?: (payload: {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishWordCounter[];
|
||||
}) => Promise<unknown>;
|
||||
onFinish?: (payload: {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishWordCounter[];
|
||||
}) => Promise<unknown>;
|
||||
onRestart?: () => void;
|
||||
onExit?: () => void;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
type FloatingText = {
|
||||
id: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const AUDIO_POOL_SIZE = 5;
|
||||
const MIN_AUDIO_INTERVAL_MS = 48;
|
||||
|
||||
function getRun(
|
||||
run: WoodenFishRuntimeRunSnapshotResponse | null | undefined,
|
||||
snapshot: WoodenFishRuntimeRunSnapshotResponse | null | undefined,
|
||||
) {
|
||||
return run ?? snapshot ?? null;
|
||||
}
|
||||
|
||||
export function WoodenFishRuntimeShell({
|
||||
profile = null,
|
||||
run,
|
||||
snapshot,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onCheckpoint,
|
||||
onFinish,
|
||||
onRestart,
|
||||
onExit,
|
||||
onBack,
|
||||
}: WoodenFishRuntimeShellProps) {
|
||||
const activeRun = getRun(run, snapshot);
|
||||
const exitHandler = onExit ?? onBack;
|
||||
const [totalTapCount, setTotalTapCount] = useState(
|
||||
activeRun?.totalTapCount ?? 0,
|
||||
);
|
||||
const [wordCounters, setWordCounters] = useState<WoodenFishWordCounter[]>(
|
||||
activeRun?.wordCounters ?? [],
|
||||
);
|
||||
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 currentSnapshotRef = useRef({ totalTapCount, wordCounters });
|
||||
const words = useMemo(
|
||||
() => normalizeWoodenFishFloatingWords(profile?.floatingWords ?? []),
|
||||
[profile?.floatingWords],
|
||||
);
|
||||
const hitObjectSrc =
|
||||
profile?.hitObjectAsset?.imageSrc?.trim() ||
|
||||
profile?.draft.hitObjectAsset?.imageSrc?.trim() ||
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC;
|
||||
const hitSoundSrc =
|
||||
profile?.hitSoundAsset?.audioSrc ?? profile?.draft.hitSoundAsset?.audioSrc;
|
||||
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc);
|
||||
|
||||
useEffect(() => {
|
||||
currentSnapshotRef.current = { totalTapCount, wordCounters };
|
||||
}, [totalTapCount, wordCounters]);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalTapCount(activeRun?.totalTapCount ?? 0);
|
||||
setWordCounters(activeRun?.wordCounters ?? []);
|
||||
}, [activeRun?.runId, activeRun?.totalTapCount, activeRun?.wordCounters]);
|
||||
|
||||
useEffect(() => {
|
||||
audioPoolRef.current.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
});
|
||||
audioPoolRef.current = [];
|
||||
audioIndexRef.current = 0;
|
||||
|
||||
if (!resolvedAudioUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
audioPoolRef.current = Array.from({ length: AUDIO_POOL_SIZE }, () => {
|
||||
const audio = new Audio(resolvedAudioUrl);
|
||||
audio.preload = 'auto';
|
||||
return audio;
|
||||
});
|
||||
|
||||
return () => {
|
||||
audioPoolRef.current.forEach((audio) => {
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
});
|
||||
audioPoolRef.current = [];
|
||||
};
|
||||
}, [resolvedAudioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onCheckpoint || !activeRun?.runId || activeRun.status !== 'playing') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const snapshotPayload = currentSnapshotRef.current;
|
||||
if (
|
||||
snapshotPayload.totalTapCount <= 0 ||
|
||||
Date.now() - lastCheckpointAtRef.current < 2500
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastCheckpointAtRef.current = Date.now();
|
||||
void onCheckpoint(snapshotPayload).catch(() => undefined);
|
||||
}, 3000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeRun?.runId, activeRun?.status, onCheckpoint]);
|
||||
|
||||
const playHitSound = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastAudioAtRef.current < MIN_AUDIO_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastAudioAtRef.current = now;
|
||||
const pool = audioPoolRef.current;
|
||||
if (pool.length === 0) {
|
||||
return;
|
||||
}
|
||||
const audio = pool[audioIndexRef.current % pool.length] ?? null;
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
audioIndexRef.current += 1;
|
||||
audio.currentTime = 0;
|
||||
void audio.play().catch(() => undefined);
|
||||
};
|
||||
|
||||
const registerTap = (event: PointerEvent<HTMLElement>) => {
|
||||
if (
|
||||
isBusy ||
|
||||
activeRun?.status === 'finished' ||
|
||||
isWoodenFishFunctionalTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 100;
|
||||
const y = ((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 100;
|
||||
const word = chooseWoodenFishFloatingWord(words);
|
||||
const nextSnapshot = applyWoodenFishTap(
|
||||
currentSnapshotRef.current,
|
||||
word,
|
||||
);
|
||||
setTotalTapCount(nextSnapshot.totalTapCount);
|
||||
setWordCounters(nextSnapshot.wordCounters);
|
||||
setHitPulse((value) => value + 1);
|
||||
setFloatingTexts((current) => [
|
||||
...current.slice(-9),
|
||||
{
|
||||
id: `${Date.now()}-${nextSnapshot.totalTapCount}`,
|
||||
text: formatWoodenFishFloatingText(word),
|
||||
x,
|
||||
y: Math.max(18, y - 10),
|
||||
},
|
||||
]);
|
||||
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"
|
||||
onPointerDown={registerTap}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
|
||||
|
||||
<header
|
||||
data-wooden-fish-functional="true"
|
||||
className="relative z-30 flex items-start justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
重开
|
||||
</button>
|
||||
</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">
|
||||
<div className="pointer-events-none absolute left-1/2 top-[54%] h-[22rem] w-[22rem] max-w-[82vw] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/36 blur-2xl" />
|
||||
<div
|
||||
key={hitPulse}
|
||||
className="wooden-fish-runtime__object relative z-10 grid aspect-square w-[min(68vw,22rem)] place-items-center"
|
||||
style={
|
||||
{
|
||||
'--wooden-fish-hit': hitPulse,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={hitObjectSrc}
|
||||
fallbackSrc={WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC}
|
||||
alt="敲击物图案"
|
||||
draggable={false}
|
||||
className="h-full w-full object-contain drop-shadow-[0_28px_30px_rgba(91,64,32,0.22)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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)]"
|
||||
style={{
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
setFloatingTexts((current) =>
|
||||
current.filter((floating) => floating.id !== item.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
data-wooden-fish-functional="true"
|
||||
className="absolute bottom-20 left-3 right-3 z-40 rounded-2xl bg-rose-600 px-4 py-3 text-sm font-bold text-white shadow-lg"
|
||||
>
|
||||
{error}
|
||||
</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__object {
|
||||
animation: wooden-fish-hit 220ms ease both;
|
||||
}
|
||||
|
||||
.wooden-fish-runtime__floating-text {
|
||||
transform: translate(-50%, -50%);
|
||||
animation: wooden-fish-float 920ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes wooden-fish-hit {
|
||||
0% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
42% {
|
||||
transform: scale(0.92, 0.86) rotate(-1deg);
|
||||
}
|
||||
72% {
|
||||
transform: scale(1.04, 1.02) rotate(1deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wooden-fish-float {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.3rem) scale(0.88);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -3rem) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wooden-fish-runtime__object,
|
||||
.wooden-fish-runtime__floating-text {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WoodenFishRuntimeShell;
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
applyWoodenFishTap,
|
||||
chooseWoodenFishFloatingWord,
|
||||
formatWoodenFishFloatingText,
|
||||
isWoodenFishFunctionalTarget,
|
||||
normalizeWoodenFishFloatingWords,
|
||||
} from './woodenFishRuntimeModel';
|
||||
|
||||
test('applyWoodenFishTap creates word counter on first appearance', () => {
|
||||
const snapshot = applyWoodenFishTap(
|
||||
{
|
||||
totalTapCount: 0,
|
||||
wordCounters: [],
|
||||
},
|
||||
'幸运',
|
||||
);
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
totalTapCount: 1,
|
||||
wordCounters: [{ text: '幸运', count: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('applyWoodenFishTap keeps counting repeated and rapid taps', () => {
|
||||
const first = applyWoodenFishTap(
|
||||
{
|
||||
totalTapCount: 0,
|
||||
wordCounters: [],
|
||||
},
|
||||
'功德',
|
||||
);
|
||||
const second = applyWoodenFishTap(first, '功德');
|
||||
const third = applyWoodenFishTap(second, '健康');
|
||||
|
||||
expect(third.totalTapCount).toBe(3);
|
||||
expect(third.wordCounters).toEqual([
|
||||
{ text: '功德', count: 2 },
|
||||
{ text: '健康', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('chooseWoodenFishFloatingWord samples normalized words by random index', () => {
|
||||
expect(chooseWoodenFishFloatingWord(['幸运', '功德'], () => 0.72)).toBe(
|
||||
'功德',
|
||||
);
|
||||
expect(chooseWoodenFishFloatingWord([], () => 0)).toBe('幸运');
|
||||
});
|
||||
|
||||
test('floating word model stores base terms and formats runtime reward text', () => {
|
||||
expect(normalizeWoodenFishFloatingWords([' 幸运+1 ', '幸运', '健康+1'])).toEqual(
|
||||
['幸运', '健康'],
|
||||
);
|
||||
expect(formatWoodenFishFloatingText('幸运')).toBe('幸运+1');
|
||||
expect(formatWoodenFishFloatingText('功德+1')).toBe('功德+1');
|
||||
});
|
||||
|
||||
test('isWoodenFishFunctionalTarget detects functional controls', () => {
|
||||
const root = document.createElement('div');
|
||||
const button = document.createElement('button');
|
||||
button.dataset.woodenFishFunctional = 'true';
|
||||
const icon = document.createElement('span');
|
||||
button.appendChild(icon);
|
||||
root.appendChild(button);
|
||||
|
||||
expect(isWoodenFishFunctionalTarget(icon)).toBe(true);
|
||||
expect(isWoodenFishFunctionalTarget(root)).toBe(false);
|
||||
});
|
||||
97
src/components/wooden-fish-runtime/woodenFishRuntimeModel.ts
Normal file
97
src/components/wooden-fish-runtime/woodenFishRuntimeModel.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { WoodenFishWordCounter } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
|
||||
export type WoodenFishTapSnapshot = {
|
||||
totalTapCount: number;
|
||||
wordCounters: WoodenFishWordCounter[];
|
||||
};
|
||||
|
||||
const DEFAULT_FLOATING_WORDS = [
|
||||
'幸运',
|
||||
'健康',
|
||||
'财富',
|
||||
'姻缘',
|
||||
'幸福',
|
||||
'事业',
|
||||
'成功',
|
||||
'功德',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_FLOATING_WORD = DEFAULT_FLOATING_WORDS[0];
|
||||
|
||||
function normalizeWoodenFishFloatingWord(word: string) {
|
||||
return word.trim().replace(/[++]\s*1$/u, '').trim();
|
||||
}
|
||||
|
||||
export function normalizeWoodenFishFloatingWords(words: readonly string[]) {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const word of words) {
|
||||
const trimmed = normalizeWoodenFishFloatingWord(word);
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= 8) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalized.length > 0 ? normalized : DEFAULT_FLOATING_WORDS;
|
||||
}
|
||||
|
||||
export function formatWoodenFishFloatingText(word: string) {
|
||||
const normalizedWord = normalizeWoodenFishFloatingWord(word) || DEFAULT_FLOATING_WORD;
|
||||
return `${normalizedWord}+1`;
|
||||
}
|
||||
|
||||
export function chooseWoodenFishFloatingWord(
|
||||
words: readonly string[],
|
||||
random: () => number = Math.random,
|
||||
) {
|
||||
const normalizedWords = normalizeWoodenFishFloatingWords(words);
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
normalizedWords.length - 1,
|
||||
Math.floor(random() * normalizedWords.length),
|
||||
),
|
||||
);
|
||||
return normalizedWords[index] ?? DEFAULT_FLOATING_WORD;
|
||||
}
|
||||
|
||||
export function applyWoodenFishTap(
|
||||
snapshot: WoodenFishTapSnapshot,
|
||||
word: string,
|
||||
): WoodenFishTapSnapshot {
|
||||
const normalizedWord = normalizeWoodenFishFloatingWord(word) || DEFAULT_FLOATING_WORD;
|
||||
let hasCounter = false;
|
||||
const wordCounters = snapshot.wordCounters.map((counter) => {
|
||||
if (counter.text !== normalizedWord) {
|
||||
return counter;
|
||||
}
|
||||
hasCounter = true;
|
||||
return {
|
||||
...counter,
|
||||
count: counter.count + 1,
|
||||
};
|
||||
});
|
||||
|
||||
if (!hasCounter) {
|
||||
wordCounters.push({
|
||||
text: normalizedWord,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalTapCount: snapshot.totalTapCount + 1,
|
||||
wordCounters,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWoodenFishFunctionalTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
Boolean(target.closest('[data-wooden-fish-functional="true"]'))
|
||||
);
|
||||
}
|
||||
@@ -17,4 +17,22 @@ describe('appPageRoutes', () => {
|
||||
'/profile/feedback',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves wooden fish creation and runtime routes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish')).toBe(
|
||||
'wooden-fish-workspace',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish/generating')).toBe(
|
||||
'wooden-fish-generating',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/wooden-fish/result')).toBe(
|
||||
'wooden-fish-result',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/runtime/wooden-fish')).toBe(
|
||||
'wooden-fish-runtime',
|
||||
);
|
||||
expect(resolvePathForSelectionStage('wooden-fish-workspace')).toBe(
|
||||
'/creation/wooden-fish',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['square-hole-agent-workspace', '/creation/square-hole/agent'],
|
||||
['square-hole-result', '/creation/square-hole/result'],
|
||||
['square-hole-runtime', '/runtime/square-hole'],
|
||||
['wooden-fish-workspace', '/creation/wooden-fish'],
|
||||
['wooden-fish-generating', '/creation/wooden-fish/generating'],
|
||||
['wooden-fish-result', '/creation/wooden-fish/result'],
|
||||
['wooden-fish-runtime', '/runtime/wooden-fish'],
|
||||
['creative-agent-workspace', '/creation/creative-agent'],
|
||||
['visual-novel-agent-workspace', '/creation/visual-novel/agent'],
|
||||
['visual-novel-result', '/creation/visual-novel/result'],
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
buildWoodenFishGenerationAnchorEntries,
|
||||
createMiniGameDraftGenerationState,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
@@ -355,6 +356,55 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('wooden fish draft generation exposes hit object and sound pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 28_000,
|
||||
);
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'wooden-fish-draft',
|
||||
'wooden-fish-hit-object',
|
||||
'wooden-fish-hit-sound',
|
||||
'wooden-fish-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('wooden-fish-hit-object');
|
||||
expect(progress?.phaseLabel).toBe('生成敲击物图案');
|
||||
expect(progress?.estimatedRemainingMs).toBe(272_000);
|
||||
});
|
||||
|
||||
test('wooden fish generation anchors expose hit object, sound and words', () => {
|
||||
const entries = buildWoodenFishGenerationAnchorEntries(null, {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '每日一敲',
|
||||
workDescription: '敲一下,好事发生。',
|
||||
themeTags: ['解压'],
|
||||
hitObjectPrompt: '金色小木鱼',
|
||||
hitSoundPrompt: '清脆木鱼声',
|
||||
floatingWords: ['幸运+1', '功德+1'],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '敲击物',
|
||||
value: '金色小木鱼',
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '音效',
|
||||
value: '清脆木鱼声',
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-words',
|
||||
label: '飘字',
|
||||
value: '幸运+1、功德+1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
|
||||
import type {
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||||
import type {
|
||||
CreateJumpHopSessionRequest,
|
||||
@@ -28,7 +32,8 @@ export type MiniGameDraftGenerationKind =
|
||||
| 'square-hole'
|
||||
| 'match3d'
|
||||
| 'baby-object-match'
|
||||
| 'jump-hop';
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -59,6 +64,10 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'jump-hop-tile-atlas'
|
||||
| 'jump-hop-slice-tiles'
|
||||
| 'jump-hop-write-draft'
|
||||
| 'wooden-fish-draft'
|
||||
| 'wooden-fish-hit-object'
|
||||
| 'wooden-fish-hit-sound'
|
||||
| 'wooden-fish-write-draft'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
@@ -313,6 +322,35 @@ const JUMP_HOP_STEPS = [
|
||||
|
||||
const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
|
||||
const WOODEN_FISH_STEPS = [
|
||||
{
|
||||
id: 'wooden-fish-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '保存作品信息、敲击物、音效和飘字配置。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-object',
|
||||
label: '生成敲击物图案',
|
||||
detail: '使用 image2 生成最终运行态敲击物图案。',
|
||||
weight: 48,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-hit-sound',
|
||||
label: '准备敲击音效',
|
||||
detail: '生成或写回短促敲击音效资产。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存图案、音效、飘字和封面摘要。',
|
||||
weight: 12,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const WOODEN_FISH_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -333,6 +371,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'jump-hop') {
|
||||
return JUMP_HOP_STEPS;
|
||||
}
|
||||
if (kind === 'wooden-fish') {
|
||||
return WOODEN_FISH_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -390,8 +431,10 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'match3d-work-title'
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: kind === 'jump-hop'
|
||||
? 'jump-hop-draft'
|
||||
: kind === 'wooden-fish'
|
||||
? 'wooden-fish-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
@@ -481,6 +524,21 @@ function resolveJumpHopPhaseByElapsedMs(
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
|
||||
function resolveWoodenFishPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 270_000) {
|
||||
return 'wooden-fish-write-draft';
|
||||
}
|
||||
if (elapsedMs >= 185_000) {
|
||||
return 'wooden-fish-hit-sound';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'wooden-fish-hit-object';
|
||||
}
|
||||
return 'wooden-fish-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -566,6 +624,13 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
...state,
|
||||
phase: resolveJumpHopPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'wooden-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveWoodenFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
@@ -597,6 +662,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0.52
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? 0.5
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
@@ -630,6 +697,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? '跳一跳草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
@@ -655,6 +724,8 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
)
|
||||
: normalizedState.kind === 'jump-hop'
|
||||
? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'wooden-fish'
|
||||
? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
@@ -725,6 +796,57 @@ export function buildJumpHopGenerationAnchorEntries(
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildWoodenFishGenerationAnchorEntries(
|
||||
session: WoodenFishSessionSnapshotResponse | null | undefined,
|
||||
formPayload: WoodenFishWorkspaceCreateRequest | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const draft = session?.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'wooden-fish-hit-object',
|
||||
label: '敲击物',
|
||||
value:
|
||||
formPayload?.hitObjectPrompt?.trim() ||
|
||||
draft?.hitObjectPrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'wooden-fish-hit-sound',
|
||||
label: '音效',
|
||||
value:
|
||||
formPayload?.hitSoundPrompt?.trim() ||
|
||||
draft?.hitSoundPrompt?.trim() ||
|
||||
draft?.hitSoundAsset?.prompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'wooden-fish-words',
|
||||
label: '飘字',
|
||||
value:
|
||||
formPayload?.floatingWords
|
||||
?.map((word) => word.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('、') ||
|
||||
draft?.floatingWords
|
||||
?.map((word) => word.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
.join('、') ||
|
||||
'',
|
||||
},
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null,
|
||||
|
||||
24
src/services/publicWorkCode.test.ts
Normal file
24
src/services/publicWorkCode.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWoodenFishPublicWorkCode,
|
||||
isSameWoodenFishPublicWorkCode,
|
||||
} from './publicWorkCode';
|
||||
|
||||
test('builds wooden fish public work codes with WF prefix', () => {
|
||||
expect(buildWoodenFishPublicWorkCode('wooden-fish-profile-1234abcd')).toBe(
|
||||
'WF-1234ABCD',
|
||||
);
|
||||
});
|
||||
|
||||
test('matches wooden fish public work codes and raw profile ids', () => {
|
||||
expect(
|
||||
isSameWoodenFishPublicWorkCode('wf-1234abcd', 'wooden-fish-profile-1234abcd'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSameWoodenFishPublicWorkCode(
|
||||
'wooden-fish-profile-1234abcd',
|
||||
'wooden-fish-profile-1234abcd',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -61,6 +61,14 @@ export function buildJumpHopPublicWorkCode(profileId: string) {
|
||||
return `JH-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildWoodenFishPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `WF-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -142,3 +150,16 @@ export function isSameJumpHopPublicWorkCode(keyword: string, profileId: string)
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameWoodenFishPublicWorkCode(
|
||||
keyword: string,
|
||||
profileId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildWoodenFishPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
274
src/services/wooden-fish/woodenFishClient.ts
Normal file
274
src/services/wooden-fish/woodenFishClient.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishActionResponse,
|
||||
WoodenFishCheckpointRunRequest,
|
||||
WoodenFishFinishRunRequest,
|
||||
WoodenFishGalleryCardResponse,
|
||||
WoodenFishGalleryDetailResponse,
|
||||
WoodenFishGalleryResponse,
|
||||
WoodenFishRunResponse,
|
||||
WoodenFishRuntimeRunSnapshotResponse,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
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';
|
||||
const WOODEN_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
export type {
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishActionResponse,
|
||||
WoodenFishCheckpointRunRequest,
|
||||
WoodenFishFinishRunRequest,
|
||||
WoodenFishGalleryCardResponse,
|
||||
WoodenFishGalleryDetailResponse,
|
||||
WoodenFishGalleryResponse,
|
||||
WoodenFishRunResponse,
|
||||
WoodenFishRuntimeRunSnapshotResponse,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
};
|
||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||
export type WoodenFishSessionSnapshot = WoodenFishSessionSnapshotResponse;
|
||||
|
||||
const woodenFishCreationClient = createCreationAgentClient<
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishSessionResponse,
|
||||
WoodenFishSessionSnapshotResponse,
|
||||
never,
|
||||
never,
|
||||
WoodenFishActionRequest,
|
||||
WoodenFishActionResponse
|
||||
>({
|
||||
apiBase: WOODEN_FISH_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建敲木鱼共创会话失败',
|
||||
getSession: '读取敲木鱼共创会话失败',
|
||||
sendMessage: '发送敲木鱼共创消息失败',
|
||||
streamIncomplete: '敲木鱼共创消息流式结果不完整',
|
||||
executeAction: '执行敲木鱼共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
type FlattenedWoodenFishWorkProfileResponse = Omit<
|
||||
WoodenFishWorkProfileResponse,
|
||||
'summary'
|
||||
> &
|
||||
WoodenFishWorkSummaryResponse;
|
||||
|
||||
function normalizeWoodenFishWorkProfile(
|
||||
work:
|
||||
| WoodenFishWorkProfileResponse
|
||||
| FlattenedWoodenFishWorkProfileResponse,
|
||||
): WoodenFishWorkProfileResponse {
|
||||
if ('summary' in work && work.summary) {
|
||||
return work;
|
||||
}
|
||||
|
||||
const flattened = work as FlattenedWoodenFishWorkProfileResponse;
|
||||
const summary: WoodenFishWorkProfileResponse['summary'] = {
|
||||
runtimeKind: flattened.runtimeKind,
|
||||
workId: flattened.workId,
|
||||
profileId: flattened.profileId,
|
||||
ownerUserId: flattened.ownerUserId,
|
||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||
workTitle: flattened.workTitle,
|
||||
workDescription: flattened.workDescription,
|
||||
themeTags: flattened.themeTags,
|
||||
coverImageSrc: flattened.coverImageSrc ?? null,
|
||||
publicationStatus: flattened.publicationStatus,
|
||||
playCount: flattened.playCount,
|
||||
updatedAt: flattened.updatedAt,
|
||||
publishedAt: flattened.publishedAt ?? null,
|
||||
publishReady: flattened.publishReady,
|
||||
generationStatus: flattened.generationStatus,
|
||||
};
|
||||
|
||||
return {
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
hitObjectAsset: flattened.hitObjectAsset,
|
||||
hitSoundAsset: flattened.hitSoundAsset,
|
||||
floatingWords: flattened.floatingWords,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWoodenFishActionResponse(
|
||||
response: WoodenFishActionResponse,
|
||||
): WoodenFishActionResponse {
|
||||
return {
|
||||
...response,
|
||||
work: response.work ? normalizeWoodenFishWorkProfile(response.work) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWoodenFishWorkDetailResponse(
|
||||
response: WoodenFishWorkDetailResponse,
|
||||
): WoodenFishWorkDetailResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeWoodenFishWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWoodenFishWorkMutationResponse(
|
||||
response: WoodenFishWorkMutationResponse,
|
||||
): WoodenFishWorkMutationResponse {
|
||||
return {
|
||||
...response,
|
||||
item: normalizeWoodenFishWorkProfile(response.item),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWoodenFishCreationSession(
|
||||
payload: WoodenFishWorkspaceCreateRequest,
|
||||
) {
|
||||
return woodenFishCreationClient.createSession(payload);
|
||||
}
|
||||
|
||||
export function getWoodenFishCreationSession(sessionId: string) {
|
||||
return woodenFishCreationClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
export function executeWoodenFishCreationAction(
|
||||
sessionId: string,
|
||||
payload: WoodenFishActionRequest,
|
||||
) {
|
||||
return woodenFishCreationClient
|
||||
.executeAction(sessionId, payload)
|
||||
.then(normalizeWoodenFishActionResponse);
|
||||
}
|
||||
|
||||
export async function getWoodenFishWorkDetail(profileId: string) {
|
||||
const response = await requestJson<WoodenFishWorkDetailResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品详情失败',
|
||||
);
|
||||
return normalizeWoodenFishWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function listWoodenFishGallery() {
|
||||
return requestJson<WoodenFishGalleryResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼广场失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWoodenFishGalleryDetail(publicWorkCode: string) {
|
||||
const response = await requestJson<WoodenFishGalleryDetailResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery/${encodeURIComponent(publicWorkCode)}`,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼广场详情失败',
|
||||
{
|
||||
retry: WOODEN_FISH_RUNTIME_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
return normalizeWoodenFishWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function publishWoodenFishWork(profileId: string) {
|
||||
const response = await requestJson<WoodenFishWorkMutationResponse>(
|
||||
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布敲木鱼作品失败',
|
||||
);
|
||||
return normalizeWoodenFishWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function startWoodenFishRuntimeRun(profileId: string) {
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
},
|
||||
'启动敲木鱼运行态失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkpointWoodenFishRun(
|
||||
runId: string,
|
||||
payload: Omit<WoodenFishCheckpointRunRequest, 'clientEventId'>,
|
||||
) {
|
||||
const requestPayload: WoodenFishCheckpointRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `checkpoint-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/checkpoint`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'保存敲木鱼进度失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function finishWoodenFishRun(
|
||||
runId: string,
|
||||
payload: Omit<WoodenFishFinishRunRequest, 'clientEventId'>,
|
||||
) {
|
||||
const requestPayload: WoodenFishFinishRunRequest = {
|
||||
...payload,
|
||||
clientEventId: `finish-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
return requestJson<WoodenFishRunResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/finish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestPayload),
|
||||
},
|
||||
'结束敲木鱼运行失败',
|
||||
);
|
||||
}
|
||||
|
||||
export const woodenFishClient = {
|
||||
checkpointRun: checkpointWoodenFishRun,
|
||||
createSession: createWoodenFishCreationSession,
|
||||
executeAction: executeWoodenFishCreationAction,
|
||||
finishRun: finishWoodenFishRun,
|
||||
getGalleryDetail: getWoodenFishGalleryDetail,
|
||||
getSession: getWoodenFishCreationSession,
|
||||
getWorkDetail: getWoodenFishWorkDetail,
|
||||
listGallery: listWoodenFishGallery,
|
||||
publishWork: publishWoodenFishWork,
|
||||
startRun: startWoodenFishRuntimeRun,
|
||||
};
|
||||
5
src/services/wooden-fish/woodenFishDefaults.ts
Normal file
5
src/services/wooden-fish/woodenFishDefaults.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC =
|
||||
'/wooden-fish/default-hit-object.png';
|
||||
|
||||
export const WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT =
|
||||
'默认敲击物图案,圆润木质质感,透明背景';
|
||||
Reference in New Issue
Block a user