fix: 稳定推荐页拼图下一关体验
This commit is contained in:
@@ -207,7 +207,7 @@
|
|||||||
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
|
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
|
||||||
|
|
||||||
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
|
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
|
||||||
- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。
|
- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置。
|
||||||
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
|
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
|
||||||
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
|
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||||
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
||||||
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。
|
||||||
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
|
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
|
||||||
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
|
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
|
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
|
||||||
|
|
||||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
|
|||||||
|
|
||||||
type RecommendRuntimeState = {
|
type RecommendRuntimeState = {
|
||||||
activeKind: RecommendRuntimeKind | null;
|
activeKind: RecommendRuntimeKind | null;
|
||||||
|
barkBattlePublishedConfig: BarkBattlePublishedConfig | null;
|
||||||
babyObjectMatchDraft: BabyObjectMatchDraft | null;
|
babyObjectMatchDraft: BabyObjectMatchDraft | null;
|
||||||
bigFishRun: BigFishRuntimeSnapshotResponse | null;
|
bigFishRun: BigFishRuntimeSnapshotResponse | null;
|
||||||
jumpHopRun: JumpHopRunResponse['run'] | null;
|
jumpHopRun: JumpHopRunResponse['run'] | null;
|
||||||
@@ -730,7 +731,7 @@ function isRecommendRuntimeReadyForEntry(
|
|||||||
return Boolean(state.visualNovelRun);
|
return Boolean(state.visualNovelRun);
|
||||||
}
|
}
|
||||||
if (expectedKind === 'bark-battle') {
|
if (expectedKind === 'bark-battle') {
|
||||||
return true;
|
return Boolean(state.barkBattlePublishedConfig);
|
||||||
}
|
}
|
||||||
if (expectedKind === 'edutainment') {
|
if (expectedKind === 'edutainment') {
|
||||||
return Boolean(state.babyObjectMatchDraft);
|
return Boolean(state.babyObjectMatchDraft);
|
||||||
@@ -15003,6 +15004,29 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isDesktopLayout,
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
isDesktopLayout ||
|
isDesktopLayout ||
|
||||||
@@ -15020,25 +15044,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
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 (
|
if (
|
||||||
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
|
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
|
||||||
isStartingRecommendEntry
|
isStartingRecommendEntry
|
||||||
@@ -15054,9 +15059,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [
|
}, [
|
||||||
activeRecommendEntryKey,
|
activeRecommendEntryKey,
|
||||||
activeRecommendRuntimeKind,
|
activeRecommendRuntimeKind,
|
||||||
|
activeRecommendEntry,
|
||||||
|
barkBattlePublishedConfig,
|
||||||
babyObjectMatchDraft,
|
babyObjectMatchDraft,
|
||||||
bigFishRun,
|
bigFishRun,
|
||||||
jumpHopRun,
|
jumpHopRun,
|
||||||
|
isActiveRecommendRuntimeReady,
|
||||||
isStartingRecommendEntry,
|
isStartingRecommendEntry,
|
||||||
match3dRun,
|
match3dRun,
|
||||||
platformBootstrap.isLoadingPlatform,
|
platformBootstrap.isLoadingPlatform,
|
||||||
@@ -16399,6 +16407,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||||
recommendRuntimeContent={recommendRuntimeContent}
|
recommendRuntimeContent={recommendRuntimeContent}
|
||||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||||
|
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||||
isStartingRecommendEntry={
|
isStartingRecommendEntry={
|
||||||
isStartingRecommendEntry ||
|
isStartingRecommendEntry ||
|
||||||
isBigFishBusy ||
|
isBigFishBusy ||
|
||||||
|
|||||||
@@ -823,6 +823,7 @@ function renderLoggedOutHomeView(
|
|||||||
| 'recommendRuntimeContent'
|
| 'recommendRuntimeContent'
|
||||||
| 'activeRecommendEntryKey'
|
| 'activeRecommendEntryKey'
|
||||||
| 'isStartingRecommendEntry'
|
| 'isStartingRecommendEntry'
|
||||||
|
| 'isRecommendRuntimeReady'
|
||||||
| 'recommendRuntimeError'
|
| 'recommendRuntimeError'
|
||||||
| 'onSelectNextRecommendEntry'
|
| 'onSelectNextRecommendEntry'
|
||||||
| 'onSelectPreviousRecommendEntry'
|
| 'onSelectPreviousRecommendEntry'
|
||||||
@@ -883,6 +884,7 @@ function renderLoggedOutHomeView(
|
|||||||
}
|
}
|
||||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||||
|
isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
|
||||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||||
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||||
onSelectPreviousRecommendEntry={
|
onSelectPreviousRecommendEntry={
|
||||||
@@ -3703,7 +3705,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
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(
|
expect(
|
||||||
document.querySelector('.platform-public-work-card__cover'),
|
document.querySelector('.platform-public-work-card__cover'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
@@ -3712,7 +3717,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
|
|||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
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(), {
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
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,
|
recommendRuntimeContent: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
|
expect(
|
||||||
expect(screen.getByText('加载中...')).toBeTruthy();
|
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', () => {
|
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -195,6 +196,7 @@ export interface RpgEntryHomeViewProps {
|
|||||||
recommendRuntimeContent?: ReactNode;
|
recommendRuntimeContent?: ReactNode;
|
||||||
activeRecommendEntryKey?: string | null;
|
activeRecommendEntryKey?: string | null;
|
||||||
isStartingRecommendEntry?: boolean;
|
isStartingRecommendEntry?: boolean;
|
||||||
|
isRecommendRuntimeReady?: boolean;
|
||||||
recommendRuntimeError?: string | null;
|
recommendRuntimeError?: string | null;
|
||||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||||
onSelectPreviousRecommendEntry?: (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({
|
function RecommendSwipeCard({
|
||||||
entry,
|
entry,
|
||||||
authorAvatarUrl,
|
authorAvatarUrl,
|
||||||
@@ -4023,6 +4134,7 @@ export function RpgEntryHomeView({
|
|||||||
recommendRuntimeContent,
|
recommendRuntimeContent,
|
||||||
activeRecommendEntryKey = null,
|
activeRecommendEntryKey = null,
|
||||||
isStartingRecommendEntry = false,
|
isStartingRecommendEntry = false,
|
||||||
|
isRecommendRuntimeReady = false,
|
||||||
recommendRuntimeError = null,
|
recommendRuntimeError = null,
|
||||||
onSelectNextRecommendEntry,
|
onSelectNextRecommendEntry,
|
||||||
onSelectPreviousRecommendEntry,
|
onSelectPreviousRecommendEntry,
|
||||||
@@ -5687,10 +5799,6 @@ export function RpgEntryHomeView({
|
|||||||
{recommendRuntimeError}
|
{recommendRuntimeError}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
) : isStartingRecommendEntry ? (
|
|
||||||
<section className="platform-recommend-runtime-panel">
|
|
||||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
|
||||||
</section>
|
|
||||||
) : activeRecommendEntry ? (
|
) : activeRecommendEntry ? (
|
||||||
<div
|
<div
|
||||||
ref={recommendCardStageRef}
|
ref={recommendCardStageRef}
|
||||||
@@ -5732,9 +5840,12 @@ export function RpgEntryHomeView({
|
|||||||
)}
|
)}
|
||||||
isActive
|
isActive
|
||||||
visual={
|
visual={
|
||||||
<div className="platform-recommend-runtime-viewport">
|
<RecommendRuntimeVisual
|
||||||
{recommendRuntimeContent}
|
entry={activeRecommendEntry}
|
||||||
</div>
|
runtimeContent={recommendRuntimeContent}
|
||||||
|
isStarting={isStartingRecommendEntry}
|
||||||
|
isRuntimeReady={isRecommendRuntimeReady}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
onDragPointerDown={beginRecommendDrag}
|
onDragPointerDown={beginRecommendDrag}
|
||||||
onDragPointerMove={moveRecommendDrag}
|
onDragPointerMove={moveRecommendDrag}
|
||||||
|
|||||||
@@ -4831,6 +4831,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
pointer-events: auto;
|
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 {
|
.platform-recommend-swipe-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user