merge: sync origin master into puzzle runtime restore

This commit is contained in:
2026-05-25 22:57:07 +08:00
70 changed files with 4096 additions and 2121 deletions
@@ -53,6 +53,7 @@ export type CreativeImageInputPanelProps = {
aiRedraw: boolean;
promptReferenceImages: CreativeImageInputReferenceImage[];
promptReferenceLimit?: number;
imageLimitHint?: string | null;
imageModelPicker?: ReactNode;
error?: string | null;
inputError?: string | null;
@@ -96,6 +97,7 @@ export function CreativeImageInputPanel({
aiRedraw,
promptReferenceImages,
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
imageLimitHint = null,
imageModelPicker = null,
error = null,
inputError = null,
@@ -276,6 +278,11 @@ export function CreativeImageInputPanel({
</div>
</div>
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
{imageLimitHint ? (
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
{imageLimitHint}
</div>
) : null}
</div>
{showPrompt ? (
@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
@@ -61,6 +62,9 @@ type CustomWorldCreationHubProps = {
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
jumpHopItems?: JumpHopWorkSummaryResponse[];
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
@@ -169,6 +173,9 @@ export function CustomWorldCreationHub({
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
jumpHopItems = [],
onOpenJumpHopDetail,
onDeleteJumpHop = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
@@ -201,6 +208,7 @@ export function CustomWorldCreationHub({
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
@@ -210,6 +218,7 @@ export function CustomWorldCreationHub({
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
@@ -223,6 +232,8 @@ export function CustomWorldCreationHub({
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
@@ -249,6 +260,7 @@ export function CustomWorldCreationHub({
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onDeleteJumpHop,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
@@ -262,7 +274,9 @@ export function CustomWorldCreationHub({
getWorkState,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
onOpenSquareHoleDetail,
onOpenJumpHopDetail,
jumpHopItems,
visualNovelItems,
],
);
@@ -310,6 +324,9 @@ export function CustomWorldCreationHub({
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'jump-hop':
onOpenJumpHopDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
@@ -59,6 +59,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
@@ -7,12 +7,14 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
@@ -31,6 +33,7 @@ export type CreationWorkShelfKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'jump-hop'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
@@ -83,6 +86,10 @@ export type CreationWorkShelfSource =
kind: 'square-hole';
item: SquareHoleWorkSummary;
}
| {
kind: 'jump-hop';
item: JumpHopWorkSummaryResponse;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -137,6 +144,7 @@ export function buildCreationWorkShelfItems(params: {
bigFishItems: BigFishWorkSummary[];
match3dItems?: Match3DWorkSummary[];
squareHoleItems?: SquareHoleWorkSummary[];
jumpHopItems?: JumpHopWorkSummaryResponse[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
@@ -145,6 +153,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeleteJumpHop?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
@@ -158,6 +167,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
@@ -177,6 +188,7 @@ export function buildCreationWorkShelfItems(params: {
bigFishItems,
match3dItems = [],
squareHoleItems = [],
jumpHopItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
@@ -185,6 +197,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeleteJumpHop = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
@@ -198,6 +211,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
@@ -236,6 +251,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteSquareHole,
}),
),
...jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
@@ -749,6 +770,51 @@ function mapSquareHoleWorkToShelfItem(
};
}
function mapJumpHopWorkToShelfItem(
item: JumpHopWorkSummaryResponse,
canDelete: boolean,
adapter: WorkShelfAdapter<JumpHopWorkSummaryResponse>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildJumpHopPublicWorkCode(item.profileId) : null;
const coverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
return {
id: item.workId,
kind: 'jump-hop',
status,
title: item.workTitle,
summary: item.workDescription,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '跳一跳', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'jump-hop', item },
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
File diff suppressed because it is too large Load Diff
@@ -315,3 +315,36 @@ test('groups visible platform creation types by backend category metadata', () =
]);
expect(groups[1]?.items.map((item) => item.id)).toEqual(['visual-novel']);
});
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: undefined as unknown as string,
categoryLabel: undefined as unknown as string,
categorySortOrder: 0,
updatedAtMicros: 1,
},
]);
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
categoryId: 'recent',
categoryLabel: '最近创作',
}),
);
expect(groupVisiblePlatformCreationTypes(cards)).toEqual([
expect.objectContaining({
id: 'recent',
label: '最近创作',
}),
]);
});
@@ -55,13 +55,13 @@ export function isPlatformCreationTypeOpen(
);
}
function normalizeCategoryId(value: string) {
const normalized = value.trim();
function normalizeCategoryId(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_ID;
}
function normalizeCategoryLabel(value: string) {
const normalized = value.trim();
function normalizeCategoryLabel(value: string | null | undefined) {
const normalized = typeof value === 'string' ? value.trim() : '';
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
export const PLATFORM_DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
export function getInitialPlatformDesktopLayout() {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY).matches;
}
export function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(
getInitialPlatformDesktopLayout,
);
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}
@@ -551,9 +551,9 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
@@ -616,22 +616,10 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
test('puzzle workspace submits uploaded reference image as data URL when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
@@ -651,9 +639,7 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
expect(puzzleAssetClient.uploadReferenceImage).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
@@ -663,9 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: null,
referenceImageSrc: 'data:image/png;base64,uploaded-square',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
@@ -754,12 +740,12 @@ test('puzzle workspace uploads prompt references as asset object ids', async ()
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -842,15 +828,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
referenceImageSrcs: [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
'data:image/png;base64,reference-3',
'data:image/png;base64,reference-4',
'data:image/png;base64,reference-5',
],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -16,11 +16,9 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
puzzleReferenceImageDataUrlToFile,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
@@ -409,11 +407,10 @@ export function PuzzleAgentWorkspace({
return;
}
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: uploadImage.dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
@@ -441,18 +438,12 @@ export function PuzzleAgentWorkspace({
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => {
const [imageSrc, asset] = await Promise.all([
readPuzzleReferenceImageAsDataUrl(file),
puzzleAssetClient.uploadReferenceImage({ file }),
]);
return {
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: asset.imageSrc || imageSrc,
assetObjectId: asset.assetObjectId,
};
}),
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
assetObjectId: null,
})),
);
setFormState((current) => ({
...current,
@@ -515,15 +506,10 @@ export function PuzzleAgentWorkspace({
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
const file = puzzleReferenceImageDataUrlToFile(
dataUrl,
currentCropState.fileName,
);
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc || dataUrl,
referenceImageAssetObjectId: asset.assetObjectId,
referenceImageSrc: dataUrl,
referenceImageAssetObjectId: '',
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
@@ -651,6 +637,7 @@ export function PuzzleAgentWorkspace({
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}
@@ -840,6 +840,7 @@ function PuzzleLevelDetailDialog({
aiRedraw={aiRedraw}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
@@ -175,6 +176,35 @@ import {
type SelectionStage,
} from './RpgEntryFlowShell';
const authServiceMocks = vi.hoisted(() => ({
ensureRuntimeGuestToken: vi.fn(async () => ({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
})),
getPublicAuthUserByCode: vi.fn(
async (publicUserCode: string): Promise<PublicUserSummary> => ({
id: `public-user-${publicUserCode}`,
publicUserCode,
displayName: '公开作者',
avatarUrl: null,
}),
),
getPublicAuthUserById: vi.fn(
async (userId: string): Promise<PublicUserSummary> => ({
id: userId,
publicUserCode: `code-${userId}`,
displayName: '公开作者',
avatarUrl: null,
}),
),
}));
vi.mock('../../services/authService', () => ({
ensureRuntimeGuestToken: authServiceMocks.ensureRuntimeGuestToken,
getPublicAuthUserByCode: authServiceMocks.getPublicAuthUserByCode,
getPublicAuthUserById: authServiceMocks.getPublicAuthUserById,
}));
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
@@ -279,6 +309,11 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const RECOMMEND_RUNTIME_AUTH_OPTIONS = {
...ISOLATED_RUNTIME_AUTH_OPTIONS,
runtimeGuestToken: 'runtime-guest-token',
};
const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS;
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
@@ -1089,6 +1124,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
}) => (
<div className="bark-battle-config-editor-mock">
<div>汪汪声浪配置表单</div>
<label>
作品标题
<input aria-label="汪汪作品标题" defaultValue="汪汪测试杯" />
</label>
<div data-testid="bark-battle-editor-back-state">
{showBackButton ? 'back-visible' : 'back-hidden'}
</div>
@@ -2245,6 +2284,10 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(authServiceMocks.ensureRuntimeGuestToken).mockResolvedValue({
token: 'runtime-guest-token',
expiresAt: '2099-01-01T00:00:00.000Z',
});
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
@@ -3587,11 +3630,20 @@ test('bark battle form checks mud points before creating image assets', async ()
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
const titleInput = await screen.findByLabelText('汪汪作品标题');
await user.clear(titleInput);
await user.type(titleInput, '自定义声浪杯');
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
@@ -4310,11 +4362,15 @@ test('puzzle form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 2 泥点,当前 1 泥点。'),
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
@@ -4331,14 +4387,17 @@ test('match3d form checks mud points before creating a draft', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
await screen.findByText('泥点不足,本次需要 10 泥点,当前 9 泥点。'),
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区:missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
@@ -6133,11 +6192,59 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
});
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-2',
profileId: 'puzzle-profile-public-2',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-public-2',
authorDisplayName: '拼图作者',
levelName: '星桥机关',
summary: '旋转碎片并接通星桥机关。',
themeTags: ['机关', '星桥'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [publishedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: publishedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-2',
levelId: null,
},
expect.objectContaining({
authImpact: 'local',
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
}),
);
});
expect(vi.mocked(startPuzzleRun).mock.calls[0]?.[1]).not.toHaveProperty(
'runtimeGuestToken',
);
});
test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-1',
@@ -6196,7 +6303,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-card-1',
ISOLATED_RUNTIME_AUTH_OPTIONS,
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
await waitFor(() => {
@@ -6514,7 +6621,13 @@ test('home recommendation surfaces start failure instead of staying in loading s
expect(
await screen.findByText('作品暂时无法进入,请稍后再试。'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
await waitFor(() => {
expect(
within(getPlatformTabPanel('home'))
.queryByText('加载中...')
?.closest('.platform-recommend-runtime-panel'),
).toBeFalsy();
});
});
test('published big fish works stay hidden from platform home and game category channel', async () => {
@@ -7208,7 +7321,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
vi.mocked(listProfileSaveArchives).mockClear();
vi.mocked(listProfileSaveArchives).mockRejectedValueOnce(
@@ -7232,7 +7344,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
@@ -7253,7 +7364,6 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -7416,7 +7526,6 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();
@@ -7600,7 +7709,6 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
@@ -804,6 +804,7 @@ function renderLoggedOutHomeView(
>
> = {},
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
isDesktopLayout = false,
) {
return render(
<AuthUiContext.Provider
@@ -827,6 +828,7 @@ function renderLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -954,6 +956,7 @@ function renderStatefulLoggedOutHomeView(
| 'onSelectPreviousRecommendEntry'
>
> = {},
isDesktopLayout = false,
) {
const authSpies = {
openLoginModal: vi.fn(),
@@ -985,6 +988,7 @@ function renderStatefulLoggedOutHomeView(
>
<RpgEntryHomeView
activeTab={activeTab}
isDesktopLayout={isDesktopLayout}
onTabChange={setActiveTab}
hasSavedGame={false}
savedSnapshot={null}
@@ -2697,7 +2701,7 @@ test('logged out mobile shell defaults to discover tab', () => {
).toBeNull();
});
test('logged out recommend tab opens login modal and shows cover only', async () => {
test('logged out recommend tab opens embedded runtime without login modal', async () => {
const user = userEvent.setup();
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
@@ -2712,20 +2716,18 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(
container.querySelector('.platform-recommend-cover-only'),
).toBeTruthy();
expect(openLoginModal).not.toHaveBeenCalled();
expect(container.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
expect(
container.querySelector('.platform-mobile-entry-shell--recommend'),
).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(screen.getByLabelText('奇幻拼图 作品信息')).toBeTruthy();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('logged out recommend cover opens login modal again', async () => {
test('logged out recommend runtime keeps detail callback idle', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
const { openLoginModal } = renderStatefulLoggedOutHomeView({
@@ -2741,12 +2743,9 @@ test('logged out recommend cover opens login modal again', async () => {
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
await user.click(
screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }),
);
expect(openLoginModal).toHaveBeenCalledTimes(2);
expect(openLoginModal).toHaveBeenLastCalledWith();
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -2755,16 +2754,15 @@ test('logged out desktop recommend page renders runtime directly', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
}, 'home', true);
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
expect(screen.queryByText('今日游戏')).toBeNull();
expect(screen.queryByText('作品分类')).toBeNull();
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
});
test('logged out recommend page can enter runtime without login gate', () => {
mockDesktopLayout();
const openLoginModal = vi.fn();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(openLoginModal, {
@@ -2780,6 +2778,35 @@ test('logged out recommend page can enter runtime without login gate', () => {
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out desktop recommend rail enters runtime without login modal', async () => {
mockDesktopLayout();
const user = userEvent.setup();
const openLoginModal = vi.fn();
const { container } = renderLoggedOutHomeView(
openLoginModal,
{
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
},
'category',
true,
);
const desktopRail = container.querySelector('.platform-desktop-rail');
if (!desktopRail) {
throw new Error('缺少桌面侧边栏');
}
await user.click(
within(desktopRail as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).not.toHaveBeenCalled();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(container.querySelector('.platform-desktop-shell')).toBeTruthy();
});
test('logged in recommend page uses gated recommend detail callback', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
@@ -3082,7 +3109,7 @@ test('mobile recommend meta loads real author avatar from public user summary',
await waitFor(() => {
expect(
document
.querySelector('.platform-recommend-cover-only__author img')
.querySelector('.platform-recommend-work-meta__avatar img')
?.getAttribute('src'),
).toBe('data:image/png;base64,AUTHOR');
});
+7 -59
View File
@@ -131,6 +131,7 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
@@ -166,6 +167,7 @@ export type PlatformHomeTab =
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
isDesktopLayout?: boolean;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
@@ -233,7 +235,6 @@ const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
@@ -381,46 +382,6 @@ const PLATFORM_RANKING_TABS: Array<{
emptyText: '公开广场暂时还没有点赞作品。',
},
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}
function ResolvedAssetBackdrop({
src,
fallbackSrc,
@@ -3852,6 +3813,7 @@ function ProfilePlayedWorksModal({
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
onTabChange,
saveEntries,
saveError,
@@ -4008,7 +3970,8 @@ export function RpgEntryHomeView({
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const isDesktopLayout = usePlatformDesktopLayout();
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const generalFeaturedEntries = useMemo(
@@ -5372,7 +5335,7 @@ export function RpgEntryHomeView({
{recommendRuntimeError}
</button>
</section>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
) : isStartingRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">加载中...</div>
</section>
@@ -6581,10 +6544,7 @@ export function RpgEntryHomeView({
);
const tabContentById = {
home:
!isAuthenticated || !isDesktopLayout
? mobileRecommendContent
: desktopHomeContent,
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
@@ -6761,12 +6721,6 @@ export function RpgEntryHomeView({
return;
}
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -6924,12 +6878,6 @@ export function RpgEntryHomeView({
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
onClick={() => {
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -351,7 +351,7 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口。
// 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。
setPlatformTabState(isAuthenticated ? 'home' : 'category');
}
} finally {