feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

View File

@@ -25,9 +25,12 @@ import {
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
import type {
PlatformPublicGalleryCard,
PlatformPuzzleGalleryCard,
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformPublicGalleryCard,
type PlatformPuzzleGalleryCard,
} from './rpgEntryWorldPresentation';
const {
@@ -445,6 +448,37 @@ function buildTaggedPuzzleEntry(
} satisfies PlatformPuzzleGalleryCard;
}
function buildBabyObjectMatchEntry(
id: string,
worldName: string,
themeTags: string[] = ['寓教于乐'],
overrides: Partial<PlatformEdutainmentGalleryCard> = {},
) {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: `baby-object-match-work-${id}`,
profileId: `baby-object-match-profile-${id}`,
publicWorkCode: `EDU-${id.toUpperCase()}`,
ownerUserId: 'user-edutainment',
authorDisplayName: '动作 Demo 作者',
worldName,
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags,
playCount: 8,
remixCount: 0,
likeCount: 4,
recentPlayCount7d: 5,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
...overrides,
} satisfies PlatformEdutainmentGalleryCard;
}
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
@@ -1312,6 +1346,49 @@ test('mobile discover hides edutainment channel and work when switch is disabled
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('mobile discover keeps baby object match works in edutainment channel only', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
const onOpenGalleryDetail = vi.fn();
const babyObjectMatchEntry = buildBabyObjectMatchEntry(
'baby01',
'宝贝识物水果篮',
);
const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [
'儿童教育',
]);
renderStatefulLoggedOutHomeView({
latestEntries: [babyObjectMatchEntry, generalEntry],
onOpenGalleryDetail,
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
name: //u,
});
expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy();
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
await user.click(babyObjectMatchButton);
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, '宝贝识物水果篮{enter}');
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('discover search keeps public code fallback when local works do not match', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();

View File

@@ -92,6 +92,7 @@ import {
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
@@ -1193,7 +1194,9 @@ function DesktopTrendingItem({
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePublicGalleryCardKind(entry)}
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePublicGalleryCardKind(entry)}
</span>
)}
</div>
@@ -1510,7 +1513,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: 'rpg';
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1622,7 +1627,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '方洞'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: describePlatformThemeLabel(entry.themeMode);
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}

View File

@@ -1,13 +1,18 @@
import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
buildPlatformWorldDisplayTags,
buildPuzzleWorkCoverSlides,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
resolvePlatformPublicWorkCode,
} from './rpgEntryWorldPresentation';
@@ -132,3 +137,73 @@ test('maps visual novel work to platform gallery card with VN public code', () =
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
});
test('keeps baby object match public card code and template label intact', () => {
const card: PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: 'baby-object-match-work-1',
profileId: 'baby-object-match-profile-1',
sourceSessionId: 'baby-object-match-session-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '百梦主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags: ['寓教于乐'],
playCount: 3,
remixCount: 0,
likeCount: 1,
recentPlayCount7d: 3,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
};
expect(isEdutainmentGalleryEntry(card)).toBe(true);
expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']);
});
test('maps baby object match draft to edutainment public card', () => {
const card = mapBabyObjectMatchDraftToPlatformGalleryCard({
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物水果篮',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T12:00:00.000Z',
publishedAt: '2026-05-11T12:00:00.000Z',
});
expect(isEdutainmentGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('BO-12345678');
expect(card.coverImageSrc).toBe('/apple.png');
expect(card.themeTags[0]).toBe('寓教于乐');
});

View File

@@ -1,4 +1,6 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
Match3DGeneratedItemAsset,
Match3DWorkSummary,
@@ -18,6 +20,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
buildBabyObjectMatchPublicWorkCode,
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
@@ -28,6 +31,8 @@ import type { CustomWorldProfile } from '../../types';
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
@@ -36,7 +41,8 @@ export type PlatformWorldCardLike =
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
| PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
@@ -161,13 +167,38 @@ export type PlatformVisualNovelGalleryCard = {
updatedAt: string;
};
export type PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment';
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
templateName: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME;
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 PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard;
| PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
@@ -205,6 +236,12 @@ export function isVisualNovelGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
}
export function isEdutainmentGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformEdutainmentGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'edutainment';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -280,8 +317,7 @@ export function mapSquareHoleWorkToPlatformGalleryCard(
holeOptions: work.holeOptions,
shapeCount: work.shapeCount,
difficulty: work.difficulty,
themeTags:
work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
@@ -343,6 +379,40 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
};
}
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
draft: BabyObjectMatchDraft,
): PlatformEdutainmentGalleryCard {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: draft.profileId,
profileId: draft.profileId,
sourceSessionId: draft.draftId,
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
ownerUserId: 'current-user',
authorDisplayName: '百梦主',
worldName: draft.workTitle.trim() || draft.templateName,
subtitle: draft.templateName,
summaryText:
draft.workDescription.trim() ||
`${draft.itemNames[0]}${draft.itemNames[1]}识物分类`,
coverImageSrc:
draft.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null,
themeTags:
draft.themeTags.length > 0
? draft.themeTags
: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG],
playCount: 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: draft.publishedAt,
updatedAt: draft.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -482,9 +552,7 @@ export function formatPlatformWorkDisplayTags(
) {
return [
...new Set(
tags
.map((tag) => formatPlatformWorkDisplayTag(tag))
.filter(Boolean),
tags.map((tag) => formatPlatformWorkDisplayTag(tag)).filter(Boolean),
),
].slice(0, limit);
}
@@ -506,13 +574,13 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
}
if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['抓大鹅'];
}
if (isSquareHoleGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['方洞'];
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['方洞'];
}
if (isVisualNovelGalleryEntry(entry)) {
@@ -521,6 +589,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: ['视觉小说'];
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: [entry.templateName];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -607,6 +681,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}