fix: 稳定推荐页运行态封面遮罩

This commit is contained in:
2026-06-07 16:00:29 +08:00
parent c810e255a5
commit 8dca8a6443
6 changed files with 672 additions and 34 deletions
+8
View File
@@ -16,6 +16,14 @@
---
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。推荐 rail 切换完成后归零不能走反向过渡动画。
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
File diff suppressed because one or more lines are too long
@@ -41,6 +41,7 @@ import {
const {
mockQrCodeToDataUrl,
mockRedirectToPaymentUrl,
mockRequestJson,
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
@@ -128,6 +129,7 @@ const {
return {
mockQrCodeToDataUrl: qrCodeToDataUrl,
mockRedirectToPaymentUrl: redirectToPaymentUrl,
mockRequestJson: vi.fn(),
mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
@@ -349,6 +351,10 @@ const {
}));
vi.mock('../../services/apiClient', () => ({
BACKGROUND_AUTH_REQUEST_OPTIONS: {
authImpact: 'background',
},
requestJson: mockRequestJson,
refreshStoredAccessToken: mockRefreshStoredAccessToken,
}));
@@ -3906,6 +3912,234 @@ test('mobile recommend startup keeps cover visible without loading copy', () =>
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('mobile recommend preloads every fetched work cover', () => {
const preloadedSrcs: string[] = [];
mockRequestJson.mockResolvedValue({
read: {
objectKey: 'generated-recommend/current.png',
signedUrl: 'https://signed.example.com/current-cover.png',
expiresAt: '2099-01-01T00:10:00Z',
},
});
class MockPreloadImage {
decoding = 'auto';
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(value: string) {
preloadedSrcs.push(value);
}
}
vi.stubGlobal('Image', MockPreloadImage);
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
profileId: 'puzzle-profile-feed-1',
ownerUserId: 'user-feed-1',
publicWorkCode: 'PZ-FEED1',
worldName: '当前拼图',
coverImageSrc: '/generated-recommend/current.png',
} satisfies PlatformPublicGalleryCard;
const secondEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-2',
profileId: 'puzzle-profile-feed-2',
ownerUserId: 'user-feed-2',
publicWorkCode: 'PZ-FEED2',
worldName: '下一拼图',
coverImageSrc: 'next-cover.png',
} satisfies PlatformPublicGalleryCard;
const thirdEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-3',
profileId: 'puzzle-profile-feed-3',
ownerUserId: 'user-feed-3',
publicWorkCode: 'PZ-FEED3',
worldName: '上一拼图',
coverImageSrc: 'previous-cover.png',
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [firstEntry, secondEntry, thirdEntry],
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
});
expect(preloadedSrcs).toEqual(
expect.arrayContaining([
'next-cover.png',
'previous-cover.png',
'/creation-type-references/puzzle.webp',
]),
);
expect(preloadedSrcs).not.toContain('/generated-recommend/current.png');
return waitFor(() => {
expect(preloadedSrcs).toContain('https://signed.example.com/current-cover.png');
});
});
test('mobile recommend runtime cover keeps the labeled work card visual', () => {
const carouselEntry = buildCarouselPuzzleEntry(
'recommend-card-cover',
'轮播拼图',
'card-cover',
);
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [carouselEntry],
activeRecommendEntryKey:
'puzzle:user-2:puzzle-profile-recommend-card-cover',
isStartingRecommendEntry: true,
recommendRuntimeContent: null,
});
const cover = document.querySelector('.platform-recommend-runtime-cover');
const coverImage = cover?.querySelector('img');
expect(coverImage?.getAttribute('src')).toBe('card-cover-1.png');
expect(coverImage?.getAttribute('src')).not.toBe('card-cover-fallback.png');
expect(cover?.querySelector('.platform-recommend-runtime-preview')).toBeTruthy();
expect(cover?.textContent).toContain('拼图');
expect(cover?.textContent).toContain('轮播拼图');
});
test('mobile recommend runtime cover does not swap to a late signed cover', async () => {
const preloadImages: Array<{
srcValue: string;
onload: (() => void) | null;
}> = [];
mockRequestJson.mockResolvedValue({
read: {
objectKey: 'generated-recommend/late-current.png',
signedUrl: 'https://signed.example.com/late-current-cover.png',
expiresAt: '2099-01-01T00:10:00Z',
},
});
class MockPreloadImage {
decoding = 'auto';
complete = false;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
srcValue = '';
set src(value: string) {
this.srcValue = value;
preloadImages.push(this);
}
}
vi.stubGlobal('Image', MockPreloadImage);
const generatedCoverEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-late-cover',
profileId: 'puzzle-profile-late-cover',
ownerUserId: 'user-late-cover',
publicWorkCode: 'PZ-LATE1',
worldName: '迟到封面拼图',
coverImageSrc: '/generated-recommend/late-current.png',
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [generatedCoverEntry],
activeRecommendEntryKey: 'puzzle:user-late-cover:puzzle-profile-late-cover',
isStartingRecommendEntry: true,
recommendRuntimeContent: null,
});
const cover = document.querySelector('.platform-recommend-runtime-cover');
const coverImage = cover?.querySelector('img');
expect(coverImage?.getAttribute('src')).toBe(
'/creation-type-references/puzzle.webp',
);
await waitFor(() => {
expect(
preloadImages.some(
(image) =>
image.srcValue ===
'https://signed.example.com/late-current-cover.png',
),
).toBe(true);
});
act(() => {
preloadImages
.filter(
(image) =>
image.srcValue ===
'https://signed.example.com/late-current-cover.png',
)
.forEach((image) => image.onload?.());
});
await waitFor(() => expect(mockRequestJson).toHaveBeenCalled());
expect(
document
.querySelector('.platform-recommend-runtime-cover img')
?.getAttribute('src'),
).toBe('/creation-type-references/puzzle.webp');
expect(
Array.from(
document.querySelectorAll('.platform-recommend-runtime-cover img'),
).some(
(image) =>
image.getAttribute('src') ===
'https://signed.example.com/late-current-cover.png',
),
).toBe(false);
});
test('mobile recommend cover waits until runtime images are ready', async () => {
vi.useFakeTimers();
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(),
});
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
isRecommendRuntimeReady: true,
recommendRuntimeContent: (
<img src="/runtime-sprite.png" alt="runtime sprite" />
),
});
act(() => {
animationCallbacks.splice(0).forEach((callback) => callback(16));
});
expect(
document.querySelector('.platform-recommend-runtime-cover')?.className,
).not.toContain('platform-recommend-runtime-cover--hidden');
fireEvent.load(screen.getByAltText('runtime sprite'));
act(() => {
animationCallbacks.splice(0).forEach((callback) => callback(32));
});
expect(
document.querySelector('.platform-recommend-runtime-cover')?.className,
).not.toContain('platform-recommend-runtime-cover--hidden');
await act(async () => {
vi.advanceTimersByTime(520);
});
expect(
document.querySelector('.platform-recommend-runtime-cover')?.className,
).toContain('platform-recommend-runtime-cover--hidden');
});
test('mobile recommend keeps runtime visual stable when active entry changes', async () => {
const animationCallbacks: FrameRequestCallback[] = [];
Object.defineProperty(window, 'requestAnimationFrame', {
@@ -4119,7 +4353,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(
document.querySelectorAll('.platform-recommend-runtime-preview'),
document.querySelectorAll(
'.platform-recommend-runtime-preview:not(.platform-recommend-runtime-preview--cover)',
),
).toHaveLength(2);
expect(
document.querySelectorAll('.platform-recommend-swipe-card'),
@@ -4170,6 +4406,10 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
expect(rail?.className).toContain(
'platform-recommend-swipe-rail--resetting',
);
vi.useRealTimers();
});
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -5303,13 +5303,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
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;
transition: opacity 420ms ease;
}
.platform-recommend-swipe-stage {
@@ -5335,6 +5335,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transition: none;
}
.platform-recommend-swipe-rail--resetting {
transition: none;
}
.platform-recommend-swipe-page {
position: absolute;
inset: 0;
+15
View File
@@ -129,3 +129,18 @@ describe('index stylesheet creation agent hero contrast', () => {
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
});
});
describe('index stylesheet recommend runtime cover', () => {
it('only fades the card cover out after runtime is ready', () => {
const css = readIndexCss();
const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover');
expect(coverBlock).not.toContain('transition: opacity');
const hiddenCoverBlock = getCssBlock(
css,
'.platform-recommend-runtime-cover--hidden',
);
expect(hiddenCoverBlock).toContain('transition: opacity 420ms ease;');
});
});