fix: 稳定推荐页拼图下一关体验

This commit is contained in:
2026-06-05 16:19:35 +08:00
parent 524ad430ab
commit d489488ca2
6 changed files with 299 additions and 34 deletions

View File

@@ -545,6 +545,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
type RecommendRuntimeState = {
activeKind: RecommendRuntimeKind | null;
barkBattlePublishedConfig: BarkBattlePublishedConfig | null;
babyObjectMatchDraft: BabyObjectMatchDraft | null;
bigFishRun: BigFishRuntimeSnapshotResponse | null;
jumpHopRun: JumpHopRunResponse['run'] | null;
@@ -730,7 +731,7 @@ function isRecommendRuntimeReadyForEntry(
return Boolean(state.visualNovelRun);
}
if (expectedKind === 'bark-battle') {
return true;
return Boolean(state.barkBattlePublishedConfig);
}
if (expectedKind === 'edutainment') {
return Boolean(state.babyObjectMatchDraft);
@@ -15003,6 +15004,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 ||
@@ -15020,25 +15044,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
@@ -15054,9 +15059,12 @@ export function PlatformEntryFlowShellImpl({
}, [
activeRecommendEntryKey,
activeRecommendRuntimeKind,
activeRecommendEntry,
barkBattlePublishedConfig,
babyObjectMatchDraft,
bigFishRun,
jumpHopRun,
isActiveRecommendRuntimeReady,
isStartingRecommendEntry,
match3dRun,
platformBootstrap.isLoadingPlatform,
@@ -16399,6 +16407,7 @@ export function PlatformEntryFlowShellImpl({
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
recommendRuntimeContent={recommendRuntimeContent}
activeRecommendEntryKey={activeRecommendEntryKey}
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
isStartingRecommendEntry={
isStartingRecommendEntry ||
isBigFishBusy ||

View File

@@ -823,6 +823,7 @@ function renderLoggedOutHomeView(
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'isStartingRecommendEntry'
| 'isRecommendRuntimeReady'
| 'recommendRuntimeError'
| 'onSelectNextRecommendEntry'
| 'onSelectPreviousRecommendEntry'
@@ -883,6 +884,7 @@ function renderLoggedOutHomeView(
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
recommendRuntimeError={overrides.recommendRuntimeError}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={
@@ -3703,7 +3705,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();
@@ -3712,7 +3717,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',
@@ -3720,8 +3725,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', () => {

View File

@@ -39,6 +39,7 @@ import {
type CSSProperties,
type PointerEvent,
type ReactNode,
Suspense,
useCallback,
useEffect,
useMemo,
@@ -195,6 +196,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;
@@ -946,6 +948,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,
@@ -4023,6 +4134,7 @@ export function RpgEntryHomeView({
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
isRecommendRuntimeReady = false,
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
@@ -5687,10 +5799,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}
@@ -5732,9 +5840,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}

View File

@@ -4831,6 +4831,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
pointer-events: auto;
}
.platform-recommend-runtime-visual {
position: absolute;
inset: 0;
min-width: 0;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
}
.platform-recommend-runtime-cover {
position: absolute;
inset: 0;
z-index: 3;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
opacity: 1;
pointer-events: auto;
transition: opacity 420ms ease;
will-change: opacity;
}
.platform-recommend-runtime-cover--hidden {
opacity: 0;
pointer-events: none;
}
.platform-recommend-swipe-stage {
position: relative;
flex: 1 1 auto;