diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b3a0f51b..5d3d571c 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Platform Creation Launch Model 收口 + +- 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。 +- 决策:新增 `src/components/platform-entry/platformCreationLaunchModel.ts`,以 `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })` 收口创作入口启动意图。`airp` 返回 `noop` 且不触发 `prepareCreationLaunch()`;隐藏 `baby-object-match` 返回 blocked intent 且仍在 prepare 后显示 `EDUTAINMENT_HIDDEN_MESSAGE`;未知入口保持旧语义,先 prepare 后 no-op;已知入口返回稳定 launch target。壳层只执行 prepare、错误提示和 `runProtectedAction(...)`。 +- 影响范围:底部加号创作入口模板卡点击、入口可见性拦截、后续新增可启动模板的 launch target 接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md`。 + ## 2026-06-04 Platform Selection Stage Model 收口 - 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`。 diff --git a/docs/README.md b/docs/README.md index b9287db1..4d22a415 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用,规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。 +创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。 + 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bbcd2525 --- /dev/null +++ b/docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md @@ -0,0 +1,29 @@ +# 【前端架构】Platform Creation Launch Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的创作入口点击回调曾直接以内联 `if` 链判断 `airp` 占位、隐藏的 `baby-object-match`、RPG 与各小游戏工作台启动目标。壳层因此同时理解入口 ID、是否需要执行启动前准备、隐藏模板错误文案和具体工作台分流。 + +这类规则属于创作入口启动意图。壳层应只执行准备、错误提示和受保护动作,不应持有入口 ID 到工作台目标的长链判定。 + +## 决策 + +新增 `src/components/platform-entry/platformCreationLaunchModel.ts` 作为 Platform Creation Launch **Module**。其公开 **Interface** 为: + +- `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })`:输入后端入口配置下发的模板 ID 与幼教入口可见性,输出 `noop`、`blocked` 或 `launch` 意图。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:根据 intent 决定是否调用 `prepareCreationLaunch()`,对 blocked intent 写入 `sessionController.setCreationTypeError(...)`,对 launch intent 进入 `runProtectedAction(...)` 并调用具体工作台打开函数。 + +## 约定 + +- `airp` 是占位入口,必须在 `prepareCreationLaunch()` 之前返回 `noop`,避免触发新游戏初始化、返回目标复位或错误清理。 +- 隐藏的 `baby-object-match` 必须在 `prepareCreationLaunch()` 之后返回 blocked intent,错误文案仍使用 `EDUTAINMENT_HIDDEN_MESSAGE`。 +- 未知入口 ID 保持旧语义:先允许壳层执行启动前准备,再作为 `noop` 结束,避免改变未来后端配置异常时的准备流程。 +- 新增可启动模板时,先在本 **Module** 的 launch target union、目标集合和测试中列明,再在壳层 Adapter 中补具体启动函数。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationLaunchModel.ts src/components/platform-entry/platformCreationLaunchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index b40e8bde..985aa6ae 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -6,7 +6,7 @@ 创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。 -当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 +当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f4a7ee9c..79642a0a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -394,6 +394,10 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + type PlatformCreationLaunchTarget, + resolvePlatformCreationLaunchIntent, +} from './platformCreationLaunchModel'; import { buildBabyObjectMatchCreationUrlState, buildBarkBattleCreationUrlState, @@ -6701,7 +6705,12 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if (type === 'airp') { + const intent = resolvePlatformCreationLaunchIntent({ + type, + isBabyObjectMatchVisible, + }); + + if (!intent.shouldPrepare) { return; } @@ -6709,79 +6718,49 @@ export function PlatformEntryFlowShellImpl({ return; } - if (type === 'baby-object-match' && !isBabyObjectMatchVisible) { - sessionController.setCreationTypeError(EDUTAINMENT_HIDDEN_MESSAGE); + if (intent.type === 'blocked') { + sessionController.setCreationTypeError(intent.message); return; } - if (type === 'rpg') { - runProtectedAction(() => { + if (intent.type !== 'launch') { + return; + } + + const launchers = { + rpg: () => { void sessionController.openRpgAgentWorkspace(); - }); - return; - } - - if (type === 'big-fish') { - runProtectedAction(() => { + }, + 'big-fish': () => { void openBigFishAgentWorkspace(); - }); - return; - } - - if (type === 'match3d') { - runProtectedAction(() => { + }, + match3d: () => { void openMatch3DWorkspace(); - }); - return; - } - - if (type === 'square-hole') { - runProtectedAction(() => { + }, + 'square-hole': () => { void openSquareHoleAgentWorkspace(); - }); - return; - } - - if (type === 'jump-hop') { - runProtectedAction(() => { + }, + 'jump-hop': () => { void openJumpHopWorkspace(); - }); - return; - } - - if (type === 'wooden-fish') { - runProtectedAction(() => { + }, + 'wooden-fish': () => { void openWoodenFishWorkspace(); - }); - return; - } - - if (type === 'puzzle') { - runProtectedAction(() => { + }, + puzzle: () => { void openPuzzleWorkspace(); - }); - return; - } - - if (type === 'bark-battle') { - runProtectedAction(() => { + }, + 'bark-battle': () => { void openBarkBattleWorkspace(); - }); - return; - } - - if (type === 'visual-novel') { - runProtectedAction(() => { + }, + 'visual-novel': () => { void openVisualNovelWorkspace(); - }); - return; - } - - if (type === 'baby-object-match') { - runProtectedAction(() => { + }, + 'baby-object-match': () => { void openBabyObjectMatchWorkspace(); - }); - } + }, + } satisfies Record void>; + + runProtectedAction(launchers[intent.target]); }, [ isBabyObjectMatchVisible, diff --git a/src/components/platform-entry/platformCreationLaunchModel.test.ts b/src/components/platform-entry/platformCreationLaunchModel.test.ts new file mode 100644 index 00000000..ccaa9977 --- /dev/null +++ b/src/components/platform-entry/platformCreationLaunchModel.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; + +import { + type PlatformCreationLaunchTarget, + resolvePlatformCreationLaunchIntent, +} from './platformCreationLaunchModel'; +import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility'; + +describe('platformCreationLaunchModel', () => { + test('keeps airp as a placeholder noop before prepare', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'airp', + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'noop', + shouldPrepare: false, + reason: 'placeholder', + }); + }); + + test('blocks hidden baby object match after prepare', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'baby-object-match', + isBabyObjectMatchVisible: false, + }), + ).toEqual({ + type: 'blocked', + shouldPrepare: true, + message: EDUTAINMENT_HIDDEN_MESSAGE, + }); + }); + + test('resolves known creation launch targets', () => { + const targets: PlatformCreationLaunchTarget[] = [ + 'rpg', + 'big-fish', + 'match3d', + 'square-hole', + 'jump-hop', + 'wooden-fish', + 'puzzle', + 'bark-battle', + 'visual-novel', + 'baby-object-match', + ]; + + targets.forEach((target) => { + expect( + resolvePlatformCreationLaunchIntent({ + type: target, + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'launch', + shouldPrepare: true, + target, + }); + }); + }); + + test('keeps unknown creation type as a prepared noop', () => { + expect( + resolvePlatformCreationLaunchIntent({ + type: 'unknown-template', + isBabyObjectMatchVisible: true, + }), + ).toEqual({ + type: 'noop', + shouldPrepare: true, + reason: 'unknown', + }); + }); +}); diff --git a/src/components/platform-entry/platformCreationLaunchModel.ts b/src/components/platform-entry/platformCreationLaunchModel.ts new file mode 100644 index 00000000..0b2b210c --- /dev/null +++ b/src/components/platform-entry/platformCreationLaunchModel.ts @@ -0,0 +1,87 @@ +import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility'; +import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; + +export type PlatformCreationLaunchTarget = + | 'rpg' + | 'big-fish' + | 'match3d' + | 'square-hole' + | 'jump-hop' + | 'wooden-fish' + | 'puzzle' + | 'bark-battle' + | 'visual-novel' + | 'baby-object-match'; + +export type PlatformCreationLaunchIntent = + | { + type: 'noop'; + shouldPrepare: false; + reason: 'placeholder'; + } + | { + type: 'noop'; + shouldPrepare: true; + reason: 'unknown'; + } + | { + type: 'blocked'; + shouldPrepare: true; + message: string; + } + | { + type: 'launch'; + shouldPrepare: true; + target: PlatformCreationLaunchTarget; + }; + +const PLATFORM_CREATION_LAUNCH_TARGETS = new Set([ + 'rpg', + 'big-fish', + 'match3d', + 'square-hole', + 'jump-hop', + 'wooden-fish', + 'puzzle', + 'bark-battle', + 'visual-novel', + 'baby-object-match', +]); + +export function resolvePlatformCreationLaunchIntent(params: { + type: PlatformCreationTypeId; + isBabyObjectMatchVisible: boolean; +}): PlatformCreationLaunchIntent { + if (params.type === 'airp') { + return { + type: 'noop', + shouldPrepare: false, + reason: 'placeholder', + }; + } + + if ( + params.type === 'baby-object-match' && + !params.isBabyObjectMatchVisible + ) { + return { + type: 'blocked', + shouldPrepare: true, + message: EDUTAINMENT_HIDDEN_MESSAGE, + }; + } + + if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) { + return { + type: 'noop', + shouldPrepare: true, + reason: 'unknown', + }; + } + + return { + type: 'launch', + shouldPrepare: true, + target: params.type as PlatformCreationLaunchTarget, + }; +}