From 5ba5ca6bf8cfc62a57dd86da342fa98e69059026 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 02:12:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E5=88=9B=E4=BD=9C=E7=8A=B6=E6=80=81=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 1 + docs/README.md | 2 +- ...latformSelectionStageModel收口计划-2026-06-04.md | 8 +- .../PlatformEntryFlowShellImpl.tsx | 120 ++++------ .../platformSelectionStageModel.test.ts | 205 +++++++++++++++++- .../platformSelectionStageModel.ts | 94 ++++++++ 6 files changed, 350 insertions(+), 80 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4e03c97c..b3a0f51b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 - 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`。 +- 追加决策:缺失草稿 / 作品 / run 时的阶段回退也归入 `platformSelectionStageModel.ts`,由 `resolveSelectionStageAfterMissingCreationState(params)` 统一判断 big-fish、match3d、square-hole、visual-novel 和 baby-object-match 的 result / runtime / gallery-detail 是否还能被当前状态支撑。壳层只汇总布尔事实并按输出 stage 跳转;big-fish、match3d、square-hole 的草稿事实固定来自 `Boolean(session?.draft)`,visual-novel 的 session draft 与 work draft 可独立支撑结果页,baby-object-match runtime 缺 draft 时直接回首页。 - 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`。 - 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`。 diff --git a/docs/README.md b/docs/README.md index 036b6d89..b9287db1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,7 +53,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。 -平台入口受保护数据失效后的 stage 去留判定收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 +平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md index 4662012a..bb235147 100644 --- a/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md @@ -4,20 +4,24 @@ `PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。 -此前该规则以内联长否定串维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单,缺少独立测试面。 +此外,平台壳还曾在多个 `useEffect` 中分别判断 big-fish、match3d、square-hole、visual-novel、baby-object-match 缺少草稿、作品或 run 时应回工作台、结果页还是首页。这类“当前 stage 已不能被现有状态支撑”的规则同样属于 stage 纯判定,不应散在壳层。 + +此前这些规则以内联长否定串或多段相似 effect 维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单和状态缺失回退,缺少独立测试面。 ## 决策 新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为: - `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage;可保留则原样返回,否则返回 `platform`。 +- `resolveSelectionStageAfterMissingCreationState(params)`:输入当前 `SelectionStage` 与各玩法“是否有 session / draft / run / work / formPayload”等可渲染事实,输出状态缺失后应停留的 stage;仍可展示则原样返回。 -`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,或把当前 React state 汇总为布尔事实,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`。 ## 约定 - 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record` 与测试中列明。 - 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`。 +- 缺失状态回退只读取壳层传入的布尔事实,不直接读取玩法 session / work / run 对象。big-fish、match3d、square-hole 的草稿事实必须来自 `Boolean(session?.draft)`;visual-novel 的 session draft 与 work draft 可独立支撑结果页;baby-object-match runtime 缺 draft 时不看 formPayload,直接回 `platform`。 - 此 **Module** 不清理 state、不调用路由、不触发登录弹窗,只表达纯 stage 决策。 ## 验收 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 04e18324..f4a7ee9c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -540,7 +540,10 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; -import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; +import { + resolveSelectionStageAfterMissingCreationState, + resolveSelectionStageAfterProtectedDataLoss, +} from './platformSelectionStageModel'; import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; @@ -8861,88 +8864,53 @@ export function PlatformEntryFlowShellImpl({ ); useEffect(() => { - if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) { - setSelectionStage( - bigFishSession ? 'big-fish-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'big-fish-runtime' && !bigFishRun) { - setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform'); - } - }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]); + const nextSelectionStage = resolveSelectionStageAfterMissingCreationState({ + stage: selectionStage, + bigFish: { + hasSession: Boolean(bigFishSession), + hasSessionDraft: Boolean(bigFishSession?.draft), + hasRun: Boolean(bigFishRun), + }, + match3d: { + hasSession: Boolean(match3dSession), + hasSessionDraft: Boolean(match3dSession?.draft), + hasRun: Boolean(match3dRun), + }, + squareHole: { + hasSession: Boolean(squareHoleSession), + hasSessionDraft: Boolean(squareHoleSession?.draft), + hasRun: Boolean(squareHoleRun), + }, + visualNovel: { + hasSession: Boolean(visualNovelSession), + hasSessionDraft: Boolean(visualNovelSession?.draft), + hasWork: Boolean(visualNovelWork), + hasWorkDraft: Boolean(visualNovelWork?.draft), + hasRun: Boolean(visualNovelRun), + }, + babyObjectMatch: { + hasDraft: Boolean(babyObjectMatchDraft), + hasFormPayload: Boolean(babyObjectMatchFormPayload), + }, + }); - useEffect(() => { - if (selectionStage === 'match3d-result' && !match3dSession?.draft) { - setSelectionStage( - match3dSession ? 'match3d-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'match3d-runtime' && !match3dRun) { - setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform'); - } - }, [match3dRun, match3dSession, selectionStage, setSelectionStage]); - - useEffect(() => { - if (selectionStage === 'square-hole-result' && !squareHoleSession?.draft) { - setSelectionStage( - squareHoleSession ? 'square-hole-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'square-hole-runtime' && !squareHoleRun) { - setSelectionStage( - squareHoleSession?.draft ? 'square-hole-result' : 'platform', - ); - } - }, [selectionStage, setSelectionStage, squareHoleRun, squareHoleSession]); - - useEffect(() => { - if ( - selectionStage === 'visual-novel-result' && - !visualNovelSession?.draft && - !visualNovelWork?.draft - ) { - setSelectionStage( - visualNovelSession ? 'visual-novel-agent-workspace' : 'platform', - ); - } - if (selectionStage === 'visual-novel-runtime' && !visualNovelRun) { - setSelectionStage( - visualNovelSession?.draft || visualNovelWork?.draft - ? 'visual-novel-result' - : 'platform', - ); - } - if (selectionStage === 'visual-novel-gallery-detail' && !visualNovelWork) { - setSelectionStage('platform'); - } - }, [ - selectionStage, - setSelectionStage, - visualNovelRun, - visualNovelSession, - visualNovelWork, - ]); - - useEffect(() => { - if ( - selectionStage === 'baby-object-match-result' && - !babyObjectMatchDraft - ) { - setSelectionStage( - babyObjectMatchFormPayload ? 'baby-object-match-workspace' : 'platform', - ); - } - if ( - selectionStage === 'baby-object-match-runtime' && - !babyObjectMatchDraft - ) { - setSelectionStage('platform'); + if (nextSelectionStage !== selectionStage) { + setSelectionStage(nextSelectionStage); } }, [ babyObjectMatchDraft, babyObjectMatchFormPayload, + bigFishRun, + bigFishSession, + match3dRun, + match3dSession, selectionStage, setSelectionStage, + squareHoleRun, + squareHoleSession, + visualNovelRun, + visualNovelSession, + visualNovelWork, ]); const startBigFishRun = useCallback(async () => { diff --git a/src/components/platform-entry/platformSelectionStageModel.test.ts b/src/components/platform-entry/platformSelectionStageModel.test.ts index 56a7acde..6b53e87b 100644 --- a/src/components/platform-entry/platformSelectionStageModel.test.ts +++ b/src/components/platform-entry/platformSelectionStageModel.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'vitest'; import type { SelectionStage } from './platformEntryTypes'; -import { resolveSelectionStageAfterProtectedDataLoss } from './platformSelectionStageModel'; +import { + type MissingCreationStateParams, + resolveSelectionStageAfterMissingCreationState, + resolveSelectionStageAfterProtectedDataLoss, +} from './platformSelectionStageModel'; describe('platformSelectionStageModel', () => { test('keeps public and workspace stages after protected data loss', () => { @@ -72,4 +76,203 @@ describe('platformSelectionStageModel', () => { ); }); }); + + test('resolves missing session draft result stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('big-fish-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'match3d-result', + match3d: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('match3d-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'square-hole-result', + squareHole: { + hasSession: true, + hasSessionDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('square-hole-agent-workspace'); + }); + + test('resolves missing session run stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('big-fish-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'match3d-runtime', + match3d: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('match3d-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'square-hole-runtime', + squareHole: { + hasSession: true, + hasSessionDraft: true, + hasRun: false, + }, + }), + ), + ).toBe('square-hole-result'); + }); + + test('resolves visual novel and baby object missing state stages', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-result', + visualNovel: { + hasSession: true, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-agent-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-runtime', + visualNovel: { + hasSession: true, + hasSessionDraft: false, + hasWork: true, + hasWorkDraft: true, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-gallery-detail', + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('platform'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'baby-object-match-result', + babyObjectMatch: { hasDraft: false, hasFormPayload: true }, + }), + ), + ).toBe('baby-object-match-workspace'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'baby-object-match-runtime', + babyObjectMatch: { hasDraft: false, hasFormPayload: true }, + }), + ), + ).toBe('platform'); + }); + + test('keeps stages when required creation state exists', () => { + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-result', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false }, + }), + ), + ).toBe('big-fish-result'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'big-fish-runtime', + bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true }, + }), + ), + ).toBe('big-fish-runtime'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'visual-novel-gallery-detail', + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: true, + hasWorkDraft: false, + hasRun: false, + }, + }), + ), + ).toBe('visual-novel-gallery-detail'); + expect( + resolveSelectionStageAfterMissingCreationState( + buildMissingCreationStateParams({ + stage: 'platform', + }), + ), + ).toBe('platform'); + }); }); + +function buildMissingCreationStateParams( + overrides: Partial = {}, +): MissingCreationStateParams { + return { + stage: 'platform', + bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false }, + match3d: { hasSession: false, hasSessionDraft: false, hasRun: false }, + squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false }, + visualNovel: { + hasSession: false, + hasSessionDraft: false, + hasWork: false, + hasWorkDraft: false, + hasRun: false, + }, + babyObjectMatch: { hasDraft: false, hasFormPayload: false }, + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformSelectionStageModel.ts b/src/components/platform-entry/platformSelectionStageModel.ts index 33dca39c..86bd0bea 100644 --- a/src/components/platform-entry/platformSelectionStageModel.ts +++ b/src/components/platform-entry/platformSelectionStageModel.ts @@ -57,3 +57,97 @@ export function resolveSelectionStageAfterProtectedDataLoss( ): SelectionStage { return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform'; } + +type SessionDraftRunState = { + hasSession: boolean; + hasSessionDraft: boolean; + hasRun: boolean; +}; + +type VisualNovelCreationState = { + hasSession: boolean; + hasSessionDraft: boolean; + hasWork: boolean; + hasWorkDraft: boolean; + hasRun: boolean; +}; + +type BabyObjectMatchCreationState = { + hasDraft: boolean; + hasFormPayload: boolean; +}; + +export type MissingCreationStateParams = { + stage: SelectionStage; + bigFish: SessionDraftRunState; + match3d: SessionDraftRunState; + squareHole: SessionDraftRunState; + visualNovel: VisualNovelCreationState; + babyObjectMatch: BabyObjectMatchCreationState; +}; + +export function resolveSelectionStageAfterMissingCreationState( + params: MissingCreationStateParams, +): SelectionStage { + const { stage } = params; + + if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) { + return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform'; + } + if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) { + return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform'; + } + + if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) { + return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform'; + } + if (stage === 'match3d-runtime' && !params.match3d.hasRun) { + return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform'; + } + + if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) { + return params.squareHole.hasSession + ? 'square-hole-agent-workspace' + : 'platform'; + } + if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) { + return params.squareHole.hasSessionDraft + ? 'square-hole-result' + : 'platform'; + } + + if ( + stage === 'visual-novel-result' && + !params.visualNovel.hasSessionDraft && + !params.visualNovel.hasWorkDraft + ) { + return params.visualNovel.hasSession + ? 'visual-novel-agent-workspace' + : 'platform'; + } + if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) { + return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft + ? 'visual-novel-result' + : 'platform'; + } + if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) { + return 'platform'; + } + + if ( + stage === 'baby-object-match-result' && + !params.babyObjectMatch.hasDraft + ) { + return params.babyObjectMatch.hasFormPayload + ? 'baby-object-match-workspace' + : 'platform'; + } + if ( + stage === 'baby-object-match-runtime' && + !params.babyObjectMatch.hasDraft + ) { + return 'platform'; + } + + return stage; +}