refactor: 收口创作直达恢复目标

This commit is contained in:
2026-06-04 01:57:13 +08:00
parent dbc00be2cc
commit a504da1e32
7 changed files with 213 additions and 27 deletions

View File

@@ -1340,6 +1340,7 @@
- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。
- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State ModuleInterface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。
- 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip``mark-handled``wait``restore` 执行 ref 标记或进入原恢复副作用。
- 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。
- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts``npm run test -- src/services/creationUrlState.test.ts``npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`

View File

@@ -49,7 +49,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
平台入口创作生成通知、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)。
平台入口创作恢复 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)。
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。

View File

@@ -7,7 +7,7 @@
## 决策
- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。
- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState``normalizeCreationUrlValue``hasCreationUrlStateValue``hasPuzzleRuntimeUrlStateValue``buildPuzzleRuntimeUrlStateKey`初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`
- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState``normalizeCreationUrlValue``hasCreationUrlStateValue``hasPuzzleRuntimeUrlStateValue``buildPuzzleRuntimeUrlStateKey`初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`,以及创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`
- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module统一 `puzzle-session-*``puzzle-profile-*``puzzle-work-*` 的互推规则。
- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。
@@ -19,11 +19,13 @@
- 拼图 runtime query 独立使用 `mode``runtimeSessionId``runtimeProfileId``runtimeLevelId``publicWorkCode`,不与创作恢复 query 混写。
- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`
- 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。
- 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底不执行网络请求、草稿打开、stage 切换或 URL 写回。
- `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。
## Depth / Leverage / Locality
- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state各玩法字段优先级藏在 Module Implementation 内。
- **Leverage**:新增或调整玩法恢复规则、恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。
- **Leverage**:新增或调整玩法恢复规则、恢复目标或恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。
- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module避免散落在页面壳、作品架和 runtime 打开逻辑中。
## 验收

View File

@@ -8,7 +8,7 @@
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 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`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记和大鱼吃小鱼 workId 兜底统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg``big-fish``puzzle``match3d``jump-hop``wooden-fish``square-hole``bark-battle``visual-novel``baby-object-match``creative-agent``airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec字段类型只保留 `text``select``image``audio``UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。

View File

@@ -409,6 +409,7 @@ import {
buildWoodenFishCreationUrlState,
hasPuzzleRuntimeUrlStateValue,
normalizeCreationUrlValue,
resolveCreationUrlRestoreTarget,
resolveInitialCreationUrlRestoreDecision,
} from './platformCreationUrlStateModel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
@@ -12142,21 +12143,17 @@ export function PlatformEntryFlowShellImpl({
handledInitialCreationUrlStateRef.current = true;
const restoreCreationUrlState = async () => {
const path = window.location.pathname;
const sessionId = normalizeCreationUrlValue(
initialCreationUrlState.sessionId,
const target = resolveCreationUrlRestoreTarget(
window.location.pathname,
initialCreationUrlState,
);
const profileId = normalizeCreationUrlValue(
initialCreationUrlState.profileId,
);
const draftId = normalizeCreationUrlValue(
initialCreationUrlState.draftId,
);
const workId = normalizeCreationUrlValue(initialCreationUrlState.workId);
if (!target) {
return;
}
const { sessionId, profileId, draftId, workId } = target;
if (path.startsWith('/creation/big-fish')) {
const targetSessionId =
sessionId ?? workId?.replace(/^big-fish-work-/u, '');
if (target.kind === 'big-fish') {
const targetSessionId = target.bigFishSessionId;
if (targetSessionId) {
const matchedWork =
(bigFishWorks.length > 0
@@ -12176,7 +12173,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/match3d')) {
if (target.kind === 'match3d') {
const matchedWork =
(match3dWorks.length > 0
? match3dWorks
@@ -12199,7 +12196,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/square-hole')) {
if (target.kind === 'square-hole') {
const matchedWork =
(squareHoleWorks.length > 0
? squareHoleWorks
@@ -12220,7 +12217,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/puzzle')) {
if (target.kind === 'puzzle') {
const matchedWork =
(puzzleWorks.length > 0
? puzzleWorks
@@ -12241,7 +12238,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/visual-novel')) {
if (target.kind === 'visual-novel') {
const matchedWork =
(visualNovelWorks.length > 0
? visualNovelWorks
@@ -12257,7 +12254,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/bark-battle')) {
if (target.kind === 'bark-battle') {
const matchedWork =
(barkBattleWorks.length > 0
? barkBattleWorks
@@ -12271,7 +12268,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/baby-object-match')) {
if (target.kind === 'baby-object-match') {
const matchedDraft =
(babyObjectMatchDrafts.length > 0
? babyObjectMatchDrafts
@@ -12288,7 +12285,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/jump-hop')) {
if (target.kind === 'jump-hop') {
let session: JumpHopSessionSnapshotResponse | null = null;
let work: JumpHopWorkProfileResponse | null = null;
try {
@@ -12316,7 +12313,7 @@ export function PlatformEntryFlowShellImpl({
);
enterCreateTab();
setSelectionStage(
path.includes('/generating')
target.isGeneratingPath
? 'jump-hop-generating'
: session?.draft || work
? 'jump-hop-result'
@@ -12330,7 +12327,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (path.startsWith('/creation/wooden-fish')) {
if (target.kind === 'wooden-fish') {
if (!sessionId) {
return;
}
@@ -12347,7 +12344,7 @@ export function PlatformEntryFlowShellImpl({
);
enterCreateTab();
setSelectionStage(
path.includes('/generating')
target.isGeneratingPath
? 'wooden-fish-generating'
: session.draft
? 'wooden-fish-result'

View File

@@ -32,6 +32,7 @@ import {
hasCreationUrlStateValue,
hasPuzzleRuntimeUrlStateValue,
normalizeCreationUrlValue,
resolveCreationUrlRestoreTarget,
resolveInitialCreationUrlRestoreDecision,
} from './platformCreationUrlStateModel';
@@ -94,6 +95,104 @@ describe('platformCreationUrlStateModel', () => {
});
});
test('resolves supported creation url restore targets from paths', () => {
const state = {
sessionId: ' session-1 ',
profileId: ' profile-1 ',
draftId: ' draft-1 ',
workId: ' work-1 ',
};
const cases = [
['/creation/big-fish/result', 'big-fish'],
['/creation/match3d/result', 'match3d'],
['/creation/square-hole/result', 'square-hole'],
['/creation/puzzle/result', 'puzzle'],
['/creation/visual-novel/result', 'visual-novel'],
['/creation/bark-battle/result', 'bark-battle'],
['/creation/baby-object-match/result', 'baby-object-match'],
['/creation/jump-hop/result', 'jump-hop'],
['/creation/wooden-fish/result', 'wooden-fish'],
] as const;
cases.forEach(([pathname, kind]) => {
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
kind,
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
isGeneratingPath: false,
});
});
});
test('normalizes creation url restore target values and generating paths', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
sessionId: ' ',
profileId: ' jump-profile-1 ',
draftId: undefined,
workId: null,
}),
).toEqual({
kind: 'jump-hop',
sessionId: null,
profileId: 'jump-profile-1',
draftId: null,
workId: null,
isGeneratingPath: true,
});
});
test('derives big fish restore session from work id when needed', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
workId: 'big-fish-work-river',
}),
).toEqual({
kind: 'big-fish',
sessionId: null,
profileId: null,
draftId: null,
workId: 'big-fish-work-river',
isGeneratingPath: false,
bigFishSessionId: 'river',
});
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
sessionId: 'big-fish-session-carp',
workId: 'big-fish-work-river',
}),
).toMatchObject({
kind: 'big-fish',
bigFishSessionId: 'big-fish-session-carp',
});
});
test('keeps unsupported creation paths without a concrete restore target', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
sessionId: 'rpg-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
sessionId: 'unknown-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
sessionId: 'big-fish-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/works/detail', {
workId: 'work-1',
}),
).toBeNull();
});
test('builds creation restore state for core session based plays', () => {
expect(
buildBigFishCreationUrlState({

View File

@@ -60,6 +60,93 @@ export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
].join('|');
}
export type CreationUrlRestoreTargetKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'puzzle'
| 'visual-novel'
| 'bark-battle'
| 'baby-object-match'
| 'jump-hop'
| 'wooden-fish';
type CreationUrlRestoreTargetBase = {
kind: CreationUrlRestoreTargetKind;
sessionId: string | null;
profileId: string | null;
draftId: string | null;
workId: string | null;
isGeneratingPath: boolean;
};
export type CreationUrlRestoreTarget =
| (CreationUrlRestoreTargetBase & {
kind: 'big-fish';
bigFishSessionId: string | null;
})
| (CreationUrlRestoreTargetBase & {
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
});
type NonBigFishCreationUrlRestoreTarget = Extract<
CreationUrlRestoreTarget,
{ kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'> }
>;
const CREATION_URL_RESTORE_TARGET_ROUTES = [
['/creation/big-fish', 'big-fish'],
['/creation/match3d', 'match3d'],
['/creation/square-hole', 'square-hole'],
['/creation/puzzle', 'puzzle'],
['/creation/visual-novel', 'visual-novel'],
['/creation/bark-battle', 'bark-battle'],
['/creation/baby-object-match', 'baby-object-match'],
['/creation/jump-hop', 'jump-hop'],
['/creation/wooden-fish', 'wooden-fish'],
] as const satisfies readonly (readonly [
string,
CreationUrlRestoreTargetKind,
])[];
export function resolveCreationUrlRestoreTarget(
pathname: string | undefined,
state: CreationUrlState,
): CreationUrlRestoreTarget | null {
const path = pathname?.trim() ?? '';
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
path === prefix || path.startsWith(`${prefix}/`),
);
if (!route) {
return null;
}
const kind = route[1];
const sessionId = normalizeCreationUrlValue(state.sessionId);
const profileId = normalizeCreationUrlValue(state.profileId);
const draftId = normalizeCreationUrlValue(state.draftId);
const workId = normalizeCreationUrlValue(state.workId);
const base = {
kind,
sessionId,
profileId,
draftId,
workId,
isGeneratingPath: path.includes('/generating'),
};
if (kind === 'big-fish') {
return {
...base,
kind,
bigFishSessionId:
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
};
}
return base as NonBigFishCreationUrlRestoreTarget;
}
export type InitialCreationUrlRestoreDecision =
| { type: 'skip' }
| { type: 'mark-handled' }