diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f3a582f1..a7d72aa6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -460,3 +460,11 @@ - 处理:compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`;`update_match3d_work` / `publish_match3d_work` 保留该字段;API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。 - 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`。 - 关联:`server-rs/crates/spacetime-module/src/match3d/*`、`server-rs/crates/spacetime-client/src/mapper.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 公开作品详情深链找不到作品不能停在空详情页 + +- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。 +- 原因:旧恢复逻辑只覆盖 `/runtime/...`,没有覆盖 `/works/detail`。同时 `selectionStage === 'work-detail'` 且 `selectedPublicWorkDetail === null` 时没有兜底渲染,详情数据为空就只剩空页面。 +- 处理:公开详情失效统一走 `resolveWorkNotFoundRecoveryAction(...)`,覆盖 `/works/detail`、`/gallery/puzzle/detail` 和 `/gallery/visual-novel/detail`;搜索失败和拼图详情 404 分支清理详情/运行态临时状态并回首页;`work-detail` 空数据阶段显示轻量读取态,避免异步间隙白屏。 +- 验证:`npm run test -- src/routing/runtimeNotFoundRecovery.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`。 +- 关联:`docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md`、`src/routing/runtimeNotFoundRecovery.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 diff --git a/docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md b/docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md new file mode 100644 index 00000000..b134bb4d --- /dev/null +++ b/docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md @@ -0,0 +1,27 @@ +# 公开作品详情失效回首页修复 + +日期:`2026-05-11` + +## 背景 + +直接访问 `/works/detail?work=<公开作品号>` 时,如果作品已经删除、下架或当前公开列表无法命中该作品,统一作品详情会先进入 `work-detail` 阶段。此前该阶段在没有 `selectedPublicWorkDetail` 时不会渲染任何内容;用户关闭“作品不存在或已下架”的提示后,页面可能只剩空白区域。 + +## 修复 + +1. `resolveWorkNotFoundRecoveryAction(...)` 覆盖 `/works/detail`、拼图公开详情和视觉小说公开详情,并复用运行态深链失效的回首页策略。 +2. 拼图公开详情、拼图运行态启动和拼图详情页读取的 `404/NOT_FOUND` 分支改为统一走公开作品失效恢复逻辑。 +3. 直接打开 `/works/detail?work=...` 的搜索失败分支会清理详情态、运行态临时数据,切回首页并清掉 URL query。 +4. `work-detail` 阶段在详情数据为空时渲染轻量读取态,避免异步间隙或异常分支出现纯白屏。 + +## 验证 + +- `npm run test -- src/routing/runtimeNotFoundRecovery.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"` +- `npm run typecheck` +- `npm run check:encoding -- src/routing/runtimeNotFoundRecovery.ts src/routing/runtimeNotFoundRecovery.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md` + +## 关联文件 + +1. `src/routing/runtimeNotFoundRecovery.ts` +2. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` diff --git a/docs/technical/README.md b/docs/technical/README.md index 913b62c3..8bb751a1 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 66881629..7adbb468 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -99,7 +99,10 @@ import { buildPublicWorkStagePath, pushAppHistoryPath, } from '../../routing/appPageRoutes'; -import { resolveRuntimeNotFoundRecoveryAction } from '../../routing/runtimeNotFoundRecovery'; +import { + resolveRuntimeNotFoundRecoveryAction, + resolveWorkNotFoundRecoveryAction, +} from '../../routing/runtimeNotFoundRecovery'; import { ApiClientError, BACKGROUND_AUTH_REQUEST_OPTIONS, @@ -907,6 +910,24 @@ function maybeAlertRuntimeNotFoundAndReturnHome() { return true; } +function maybeAlertWorkNotFoundAndReturnHome() { + if (typeof window === 'undefined') { + return false; + } + + const recoveryAction = resolveWorkNotFoundRecoveryAction( + window.location.pathname, + ); + if (!recoveryAction) { + return false; + } + + // 中文注释:直接打开公开详情或运行态深链失效时,确认提示后必须离开空详情页。 + window.alert('作品不存在或已下架,将返回首页。'); + pushAppHistoryPath(recoveryAction.nextPath); + return true; +} + function hasSeenPuzzleOnboarding() { if (typeof window === 'undefined') { return true; @@ -4334,7 +4355,7 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + if (!maybeAlertWorkNotFoundAndReturnHome()) { pushAppHistoryPath('/'); } return false; @@ -5836,7 +5857,7 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + if (!maybeAlertWorkNotFoundAndReturnHome()) { pushAppHistoryPath('/'); } return; @@ -6057,7 +6078,7 @@ export function PlatformEntryFlowShellImpl({ setPublicWorkDetailError(null); setPlatformTab('home'); setSelectionStage('platform'); - if (!maybeAlertRuntimeNotFoundAndReturnHome()) { + if (!maybeAlertWorkNotFoundAndReturnHome()) { pushAppHistoryPath('/'); } return; @@ -7387,6 +7408,23 @@ export function PlatformEntryFlowShellImpl({ const user = await getPublicAuthUserByCode(normalizedKeyword); setSearchedPublicUser(user); } catch (error) { + if (selectionStage === 'work-detail') { + setSelectedPublicWorkDetail(null); + setSelectedDetailEntry(null); + setSelectedPuzzleDetail(null); + setPuzzleDetailReturnTarget(null); + setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); + setPuzzleError(null); + setPublicWorkDetailError(null); + setPlatformTab('home'); + setSelectionStage('platform'); + if (!maybeAlertWorkNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } + return; + } + setPublicSearchError( resolveRpgCreationErrorMessage(error, '未找到对应的百梦号或作品号。'), ); @@ -7407,6 +7445,8 @@ export function PlatformEntryFlowShellImpl({ refreshSquareHoleGallery, refreshVisualNovelGallery, squareHoleGalleryEntries, + selectionStage, + setPlatformTab, visualNovelGalleryEntries, ], ); @@ -8091,6 +8131,20 @@ export function PlatformEntryFlowShellImpl({ )} + {selectionStage === 'work-detail' && !selectedPublicWorkDetail && ( + + + {publicWorkDetailError || '正在读取作品详情...'} + + + )} + {selectionStage === 'work-detail' && selectedPublicWorkDetail && ( (() => - window.location.pathname === '/creation/rpg/agent' - ? 'agent-workspace' - : 'platform', + resolveSelectionStageFromPath(window.location.pathname), + ); + const [initialPublicWorkCode] = useState(() => + readPublicWorkCodeFromLocationSearch(window.location.search), ); const content = ( {})} @@ -4392,6 +4398,39 @@ test('missing puzzle public detail returns to platform home', async () => { expect(startPuzzleRun).toHaveBeenCalledTimes(0); }); +test('direct missing public work detail alert returns to platform home', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + window.history.replaceState( + null, + '', + '/works/detail?work=PZ-7A7B18D9', + ); + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [], + }); + + render(); + + expect(await screen.findByText('正在读取作品详情...')).toBeTruthy(); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('作品不存在或已下架,将返回首页。'); + }); + await waitFor(() => { + expect(window.location.pathname).toBe('/'); + }); + expect(window.location.search).toBe(''); + await waitFor(() => { + expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe( + 'false', + ); + }); + expect(screen.queryByText('详情')).toBeNull(); + expect(screen.queryByText('未找到拼图作品。')).toBeNull(); + expect(startPuzzleRun).toHaveBeenCalledTimes(0); +}); + test('public code search opens a published big fish work by BF code', async () => { const user = userEvent.setup(); const bigFishWork: BigFishWorkSummary = { diff --git a/src/routing/runtimeNotFoundRecovery.test.ts b/src/routing/runtimeNotFoundRecovery.test.ts index 6cfb18b7..4e4b77dc 100644 --- a/src/routing/runtimeNotFoundRecovery.test.ts +++ b/src/routing/runtimeNotFoundRecovery.test.ts @@ -1,6 +1,9 @@ import { expect, test } from 'vitest'; -import { resolveRuntimeNotFoundRecoveryAction } from './runtimeNotFoundRecovery'; +import { + resolveRuntimeNotFoundRecoveryAction, + resolveWorkNotFoundRecoveryAction, +} from './runtimeNotFoundRecovery'; test('runtime not found recovery returns home after direct runtime route alert', () => { expect(resolveRuntimeNotFoundRecoveryAction('/runtime/puzzle')).toEqual({ @@ -19,3 +22,21 @@ test('runtime not found recovery only handles direct runtime routes', () => { expect(resolveRuntimeNotFoundRecoveryAction('/gallery/puzzle/detail')).toBeNull(); expect(resolveRuntimeNotFoundRecoveryAction('/creation/puzzle/result')).toBeNull(); }); + +test('work not found recovery returns home for direct public detail routes', () => { + expect(resolveWorkNotFoundRecoveryAction('/works/detail')).toEqual({ + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }); + expect(resolveWorkNotFoundRecoveryAction('/works/detail/')).toEqual({ + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }); + expect(resolveWorkNotFoundRecoveryAction('/gallery/puzzle/detail')).toEqual({ + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }); +}); diff --git a/src/routing/runtimeNotFoundRecovery.ts b/src/routing/runtimeNotFoundRecovery.ts index ebfc6767..18be139a 100644 --- a/src/routing/runtimeNotFoundRecovery.ts +++ b/src/routing/runtimeNotFoundRecovery.ts @@ -28,3 +28,27 @@ export function resolveRuntimeNotFoundRecoveryAction( return null; } + +/** + * 中文注释:公开作品详情页和运行态深链都可能在作品被删除或下架后失效。 + * 这类入口没有上一层可回退的详情数据,确认提示后统一回首页,避免空详情页白屏。 + */ +export function resolveWorkNotFoundRecoveryAction( + pathname: string, +): RuntimeNotFoundRecoveryAction | null { + const normalizedPath = pathname.trim().toLowerCase().replace(/\/+$/u, ''); + + if ( + normalizedPath === '/works/detail' || + normalizedPath === '/gallery/puzzle/detail' || + normalizedPath === '/gallery/visual-novel/detail' + ) { + return { + nextStage: 'platform', + nextPath: '/', + shouldAlert: true, + }; + } + + return resolveRuntimeNotFoundRecoveryAction(pathname); +}