fix: 稳定推荐页运行态封面遮罩
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user