Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03

# Conflicts:
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-14 19:17:17 +08:00
495 changed files with 40663 additions and 5654 deletions

View File

@@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps {
onSelectPuzzle: () => void;
onSelectCreativeAgent: () => void;
onSelectVisualNovel: () => void;
onSelectBabyObjectMatch: () => void;
}
function CreationTypeCard(props: {
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
onSelectPuzzle,
onSelectCreativeAgent,
onSelectVisualNovel,
onSelectBabyObjectMatch,
}: PlatformEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'visual-novel') {
onSelectVisualNovel();
}
if (item.id === 'baby-object-match') {
onSelectBabyObjectMatch();
}
}}
/>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
vi.mock('../ResolvedAssetImage', () => ({
@@ -52,6 +57,31 @@ function createPuzzleEntry(): PlatformPuzzleGalleryCard {
};
}
function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
return {
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',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '陶泥儿主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags: ['寓教于乐'],
playCount: 12,
remixCount: 0,
likeCount: 4,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T12:00:00.000Z',
};
}
afterEach(() => {
vi.useRealTimers();
});
@@ -140,6 +170,23 @@ test('PlatformWorkDetailView switches remix action label for owned work edit', (
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
});
test('PlatformWorkDetailView labels baby object match works', () => {
render(
<PlatformWorkDetailView
entry={createBabyObjectMatchEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('宝贝识物')).toBeTruthy();
expect(screen.getByText('EDU-BABY01')).toBeTruthy();
});
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
vi.useFakeTimers();
const { container } = render(

View File

@@ -22,6 +22,7 @@ import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides,
@@ -66,6 +67,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
return '视觉小说';
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
return 'RPG';
}

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
canExposePublicWork,
filterEdutainmentPublicWorks,
@@ -28,6 +32,27 @@ function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
};
}
function buildBabyObjectMatchCard(themeTags: string[]): PlatformPublicGalleryCard {
return {
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',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-education',
authorDisplayName: '动作 Demo 作者',
worldName: '宝贝识物水果篮',
subtitle: '宝贝识物',
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
};
}
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -56,4 +81,14 @@ describe('platformEdutainmentVisibility', () => {
expect(canExposePublicWork(exact)).toBe(false);
expect(canExposePublicWork(general)).toBe(true);
});
test('applies the same exact tag rule to baby object match cards', () => {
const exact = buildBabyObjectMatchCard(['寓教于乐', '宝贝识物']);
const fuzzy = buildBabyObjectMatchCard(['寓教于乐 ', '宝贝识物']);
expect(isEdutainmentPublicWork(exact)).toBe(true);
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
});
});

View File

@@ -1,11 +1,16 @@
import { expect, test } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
afterEach(() => {
vi.unstubAllEnvs();
});
test('database entry config controls visibility open state and display order', () => {
const cards = derivePlatformCreationTypes([
{
@@ -100,12 +105,14 @@ test('visible platform creation types hide invisible cards and put locked cards
},
]);
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([
'open',
'locked',
]);
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
['open', 'locked'],
);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -113,3 +120,65 @@ test('visible platform creation types hide invisible cards and put locked cards
).toBe(true);
});
test('edutainment switch hides baby object match creation entry from database config', () => {
const cards = derivePlatformCreationTypes([
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 2,
updatedAtMicros: 1,
},
]);
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
const hiddenCards = derivePlatformCreationTypes([
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/child-motion-demo/picture-book-grass-stage.png',
visible: true,
open: true,
sortOrder: 1,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 2,
updatedAtMicros: 1,
},
]);
expect(isPlatformCreationTypeVisible(hiddenCards, 'baby-object-match')).toBe(
false,
);
expect(
getVisiblePlatformCreationTypes(hiddenCards).map((item) => item.id),
).toEqual(['puzzle']);
});

View File

@@ -1,4 +1,5 @@
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
export type PlatformCreationTypeId = string;
@@ -31,6 +32,15 @@ export function isPlatformCreationTypeVisible(
return creationTypes.some((item) => item.id === id && !item.hidden);
}
export function isPlatformCreationTypeOpen(
creationTypes: readonly PlatformCreationTypeCard[],
id: PlatformCreationTypeId,
) {
return creationTypes.some(
(item) => item.id === id && !item.hidden && !item.locked,
);
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置。
*/
@@ -46,7 +56,9 @@ export function derivePlatformCreationTypes(
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
hidden: !item.visible,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
}));
return [

View File

@@ -39,6 +39,11 @@ export type SelectionStage =
| 'visual-novel-result'
| 'visual-novel-gallery-detail'
| 'visual-novel-runtime'
| 'baby-object-match-workspace'
| 'baby-object-match-generating'
| 'baby-object-match-result'
| 'baby-object-match-runtime'
| 'baby-love-drawing-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'