Merge remote-tracking branch 'origin/master' into codex/pr57-p0-p2
# Conflicts: # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md # docs/【项目基线】当前产品与工程约束-2026-05-15.md
This commit is contained in:
@@ -547,6 +547,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
|
||||
type RecommendRuntimeState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
barkBattlePublishedConfig: BarkBattlePublishedConfig | null;
|
||||
babyObjectMatchDraft: BabyObjectMatchDraft | null;
|
||||
bigFishRun: BigFishRuntimeSnapshotResponse | null;
|
||||
jumpHopRun: JumpHopRunResponse['run'] | null;
|
||||
@@ -713,7 +714,7 @@ function isRecommendRuntimeReadyForEntry(
|
||||
return Boolean(state.visualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'bark-battle') {
|
||||
return true;
|
||||
return Boolean(state.barkBattlePublishedConfig);
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.babyObjectMatchDraft);
|
||||
@@ -9431,20 +9432,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
const executeSquareHoleAction = squareHoleFlow.executeAction;
|
||||
|
||||
const retryMatch3DDraftGeneration = useCallback(() => {
|
||||
if (match3dFormDraftPayload && !match3dSession?.draft?.profileId) {
|
||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||
if (match3dSession?.sessionId) {
|
||||
const retryPayload =
|
||||
match3dFormDraftPayload ??
|
||||
buildMatch3DFormPayloadFromSession(match3dSession);
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: retryPayload.generateClickSound,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: match3dFormDraftPayload?.generateClickSound,
|
||||
});
|
||||
if (match3dFormDraftPayload) {
|
||||
void createMatch3DDraftFromForm(match3dFormDraftPayload);
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
createMatch3DDraftFromForm,
|
||||
executeMatch3DAction,
|
||||
match3dFormDraftPayload,
|
||||
match3dSession?.draft?.profileId,
|
||||
match3dSession,
|
||||
]);
|
||||
|
||||
const retrySquareHoleAssetGeneration = useCallback(() => {
|
||||
@@ -10318,15 +10325,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
if (puzzleSession?.sessionId) {
|
||||
const retryPayload =
|
||||
puzzleFormDraftPayload ??
|
||||
buildPuzzleFormPayloadFromSession(puzzleSession);
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(retryPayload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload),
|
||||
);
|
||||
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
}
|
||||
}, [
|
||||
createPuzzleDraftFromForm,
|
||||
executePuzzleAction,
|
||||
puzzleFormDraftPayload,
|
||||
puzzleSession,
|
||||
]);
|
||||
|
||||
const retryVisualNovelDraftGeneration = useCallback(() => {
|
||||
if (!visualNovelFormDraftPayload) {
|
||||
@@ -15134,6 +15151,29 @@ export function PlatformEntryFlowShellImpl({
|
||||
isDesktopLayout,
|
||||
]);
|
||||
|
||||
const activeRecommendEntry =
|
||||
activeRecommendEntryKey && !isDesktopLayout
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) ===
|
||||
activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
barkBattlePublishedConfig,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
match3dRun,
|
||||
puzzleRun,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
woodenFishRun,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isDesktopLayout ||
|
||||
@@ -15151,25 +15191,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRecommendEntry = activeRecommendEntryKey
|
||||
? (recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null)
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
match3dRun,
|
||||
puzzleRun,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
woodenFishRun,
|
||||
});
|
||||
if (
|
||||
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
|
||||
isStartingRecommendEntry
|
||||
@@ -15185,9 +15206,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
activeRecommendEntry,
|
||||
barkBattlePublishedConfig,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
jumpHopRun,
|
||||
isActiveRecommendRuntimeReady,
|
||||
isStartingRecommendEntry,
|
||||
match3dRun,
|
||||
platformBootstrap.isLoadingPlatform,
|
||||
@@ -16541,6 +16565,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
|
||||
@@ -4239,6 +4239,115 @@ test('background match3d draft failure notifies and reopens failed retry page',
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('failed match3d draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_config',
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-retry-failed-work',
|
||||
profileId: 'match3d-retry-failed-profile',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
gameName: '重试抓鹅',
|
||||
themeText: '霓虹水果摊',
|
||||
summary: '抓大鹅素材生成失败,可重新打开处理。',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-18T12:05:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
generatedItemAssets: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.executeAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('抓大鹅'));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
await screen.findByRole('progressbar', { name: '抓大鹅草稿生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('抓大鹅素材服务失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《(?:重试抓鹅|抓大鹅草稿)》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: persistedFailedWork.profileId,
|
||||
gameName: persistedFailedWork.gameName,
|
||||
themeText: persistedFailedWork.themeText,
|
||||
summary: persistedFailedWork.summary,
|
||||
tags: persistedFailedWork.tags,
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: persistedFailedWork.clearCount,
|
||||
difficulty: persistedFailedWork.difficulty,
|
||||
generatedItemAssets: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'match3d_compile_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running match3d persisted draft reopens progress instead of unfinished result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockMatch3DAgentSession({
|
||||
@@ -4932,6 +5041,113 @@ test('failed parallel puzzle generations stay as separate non-generating drafts'
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('failed puzzle draft retry reuses current session instead of creating another draft', async () => {
|
||||
const user = userEvent.setup();
|
||||
const failedSession = buildMockPuzzleAgentSession({
|
||||
sessionId: 'puzzle-retry-failed-session',
|
||||
draft: null,
|
||||
stage: 'collecting_anchors',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
});
|
||||
const persistedFailedWork: PuzzleWorkSummary = {
|
||||
workId: `puzzle-work-${failedSession.sessionId}`,
|
||||
profileId: `puzzle-profile-${failedSession.sessionId}`,
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: failedSession.sessionId,
|
||||
authorDisplayName: '测试玩家',
|
||||
workTitle: '',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '第1关',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-18T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
generationStatus: 'failed',
|
||||
levels: [],
|
||||
};
|
||||
let rejectCompile!: (reason?: unknown) => void;
|
||||
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
|
||||
session: failedSession,
|
||||
});
|
||||
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectCompile = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(await findCreationTypeButton('拼图'));
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
await screen.findByRole('progressbar', { name: '拼图图片生成进度' });
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
await openDraftHub(user);
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [persistedFailedWork],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rejectCompile(new Error('拼图图片生成失败'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(within(failureDialog).getByRole('button', { name: '关闭' }));
|
||||
|
||||
const draftPanel = getPlatformTabPanel('saves');
|
||||
await user.click(
|
||||
await within(draftPanel).findByRole('button', {
|
||||
name: /继续创作《[^》]+》/u,
|
||||
}),
|
||||
);
|
||||
const reopenedFailureDialog = await screen.findByRole('dialog', {
|
||||
name: '发生错误',
|
||||
});
|
||||
await user.click(
|
||||
within(reopenedFailureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-retry',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '草稿已生成',
|
||||
progress: 1,
|
||||
},
|
||||
session: buildMockPuzzleAgentSession({
|
||||
sessionId: failedSession.sessionId,
|
||||
stage: 'ready_to_publish',
|
||||
progressPercent: 100,
|
||||
draft: buildReadyPuzzleDraft(),
|
||||
}),
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '重新生成图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
failedSession.sessionId,
|
||||
expect.objectContaining({ action: 'compile_puzzle_draft' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('running puzzle draft opens generation progress from draft tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningSession = buildMockPuzzleAgentSession({
|
||||
|
||||
@@ -873,6 +873,7 @@ function renderLoggedOutHomeView(
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
| 'isStartingRecommendEntry'
|
||||
| 'isRecommendRuntimeReady'
|
||||
| 'recommendRuntimeError'
|
||||
| 'onSelectNextRecommendEntry'
|
||||
| 'onSelectPreviousRecommendEntry'
|
||||
@@ -933,6 +934,7 @@ function renderLoggedOutHomeView(
|
||||
}
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={
|
||||
@@ -3891,7 +3893,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('.platform-public-work-card__cover'),
|
||||
).toBeNull();
|
||||
@@ -3900,7 +3905,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile recommend loading state is themed instead of hardcoded black', () => {
|
||||
test('mobile recommend startup keeps cover visible without loading copy', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
@@ -3908,8 +3913,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
||||
recommendRuntimeContent: null,
|
||||
});
|
||||
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
||||
expect(screen.getByText('加载中...')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile recommend next level keeps runtime visual stable when active work changes', async () => {
|
||||
const animationCallbacks: FrameRequestCallback[] = [];
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn((callback: FrameRequestCallback) => {
|
||||
animationCallbacks.push(callback);
|
||||
return animationCallbacks.length;
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-feed-1',
|
||||
profileId: 'puzzle-profile-feed-1',
|
||||
ownerUserId: 'user-feed-1',
|
||||
publicWorkCode: 'PZ-FEED1',
|
||||
worldName: '当前拼图',
|
||||
coverImageSrc: 'current-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const similarEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-similar-1',
|
||||
profileId: 'puzzle-profile-similar-1',
|
||||
ownerUserId: 'user-feed-2',
|
||||
publicWorkCode: 'PZ-SIMILAR1',
|
||||
worldName: '相似拼图',
|
||||
coverImageSrc: 'similar-cover.png',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { rerender } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [firstEntry, similarEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
|
||||
isRecommendRuntimeReady: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
isDesktopLayout={false}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[firstEntry, similarEntry]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
||||
activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1"
|
||||
isRecommendRuntimeReady
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const rail = document.querySelector(
|
||||
'.platform-recommend-swipe-rail',
|
||||
) as HTMLElement | null;
|
||||
expect(rail?.className).toContain('platform-recommend-swipe-rail--settled');
|
||||
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
|
||||
expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||
).toContain('platform-recommend-runtime-cover--hidden');
|
||||
});
|
||||
|
||||
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -196,6 +197,7 @@ export interface RpgEntryHomeViewProps {
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
isRecommendRuntimeReady?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
@@ -952,6 +954,115 @@ function RecommendRuntimePreviewCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeCover({
|
||||
entry,
|
||||
className = '',
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
className?: string;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-recommend-runtime-cover ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverImage || fallbackCoverImage ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeMountedProbe({
|
||||
onMounted,
|
||||
}: {
|
||||
onMounted: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const animationFrameId = window.requestAnimationFrame(onMounted);
|
||||
return () => window.cancelAnimationFrame(animationFrameId);
|
||||
}, [onMounted]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RecommendRuntimeVisual({
|
||||
entry,
|
||||
runtimeContent,
|
||||
isStarting,
|
||||
isRuntimeReady,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
runtimeContent?: ReactNode;
|
||||
isStarting: boolean;
|
||||
isRuntimeReady: boolean;
|
||||
}) {
|
||||
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
||||
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
||||
const previousEntryKeyRef = useRef(activeEntryKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousEntryKeyRef.current === activeEntryKey) {
|
||||
return;
|
||||
}
|
||||
previousEntryKeyRef.current = activeEntryKey;
|
||||
setIsRuntimeMounted((currentValue) => {
|
||||
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
|
||||
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
|
||||
if (currentValue && !isStarting && isRuntimeReady) {
|
||||
return currentValue;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
||||
|
||||
const handleRuntimeMounted = useCallback(() => {
|
||||
if (!isStarting && isRuntimeReady) {
|
||||
setIsRuntimeMounted(true);
|
||||
}
|
||||
}, [isRuntimeReady, isStarting]);
|
||||
|
||||
const shouldShowCover =
|
||||
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
|
||||
|
||||
return (
|
||||
<div className="platform-recommend-runtime-visual">
|
||||
{runtimeContent ? (
|
||||
<Suspense fallback={null}>
|
||||
<div
|
||||
className="platform-recommend-runtime-viewport"
|
||||
aria-hidden={shouldShowCover}
|
||||
>
|
||||
{runtimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeMountedProbe
|
||||
key={activeEntryKey}
|
||||
onMounted={handleRuntimeMounted}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<RecommendRuntimeCover
|
||||
entry={entry}
|
||||
className={
|
||||
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendSwipeCard({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
@@ -4014,6 +4125,7 @@ export function RpgEntryHomeView({
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
isRecommendRuntimeReady = false,
|
||||
recommendRuntimeError = null,
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
@@ -5712,10 +5824,6 @@ export function RpgEntryHomeView({
|
||||
{recommendRuntimeError}
|
||||
</button>
|
||||
</section>
|
||||
) : isStartingRecommendEntry ? (
|
||||
<section className="platform-recommend-runtime-panel">
|
||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
||||
</section>
|
||||
) : activeRecommendEntry ? (
|
||||
<div
|
||||
ref={recommendCardStageRef}
|
||||
@@ -5757,9 +5865,12 @@ export function RpgEntryHomeView({
|
||||
)}
|
||||
isActive
|
||||
visual={
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
{recommendRuntimeContent}
|
||||
</div>
|
||||
<RecommendRuntimeVisual
|
||||
entry={activeRecommendEntry}
|
||||
runtimeContent={recommendRuntimeContent}
|
||||
isStarting={isStartingRecommendEntry}
|
||||
isRuntimeReady={isRecommendRuntimeReady}
|
||||
/>
|
||||
}
|
||||
onDragPointerDown={beginRecommendDrag}
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
|
||||
@@ -237,7 +237,7 @@ test('resolves public work author from display name and public user code before
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('敲木鱼玩家');
|
||||
});
|
||||
|
||||
test('public work author display hides phone masks and public user codes on cards', () => {
|
||||
test('public work author display keeps phone masks and hides bare public user codes on cards', () => {
|
||||
const card = mapWoodenFishWorkToPlatformGalleryCard({
|
||||
publicWorkCode: 'WF-AUTHOR2',
|
||||
workId: 'wooden-fish-work-author-mask',
|
||||
@@ -263,8 +263,18 @@ test('public work author display hides phone masks and public user codes on card
|
||||
displayName: '158****3533',
|
||||
avatarUrl: null,
|
||||
}),
|
||||
).toBe('玩家');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家');
|
||||
).toBe('158****3533');
|
||||
expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe(
|
||||
'158****3533 · SY-00000003',
|
||||
);
|
||||
|
||||
const publicCodeOnlyCard = {
|
||||
...card,
|
||||
authorDisplayName: 'SY-00000003',
|
||||
};
|
||||
expect(resolvePlatformWorkAuthorDisplayName(publicCodeOnlyCard, null)).toBe(
|
||||
'玩家',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
|
||||
@@ -909,9 +909,6 @@ function normalizePlatformPublicAuthorName(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
const compact = normalized.replace(/\s+/gu, '');
|
||||
if (/^\d+\*+\d+(?:[·.-]?SY-\d+)?$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
if (/^SY-\d+$/iu.test(compact)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user