diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 05e98325..27e7f6a8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口 + +- 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。 +- 决策:扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,以 `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)` 返回纯打开计划和 notice keys;壳层只按 intent 执行网络读取、生成态 rebase、试玩启动、错误写入、路由 / stage 和 notice seen 副作用。 +- 影响范围:创作中心作品架打开拼图 / 抓大鹅草稿、公开码搜索强制打开抓大鹅草稿、生成完成后 ready 未读试玩、失败草稿恢复和后续 pending / persisted generating 判定。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 + ## 2026-06-04 Bark Battle Work Cache 草稿状态收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。 diff --git a/docs/README.md b/docs/README.md index dcbfb1f5..612614c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复 RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-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)。 +平台入口创作生成通知、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/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index 942da2b3..12270520 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -15,9 +15,10 @@ - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 - `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。 +- `resolvePuzzleDraftOpenIntent(...)` 与 `resolveMatch3DDraftOpenIntent(...)`:统一拼图 / 抓大鹅草稿打开时的已发布详情、缺 session、ready 未读试玩、失败生成页、active / background 生成页、持久化 generating 恢复和普通草稿恢复优先级。 - `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 -`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、启动生成、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总和作品架 runtime state 规则。 +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和拼图 / 抓大鹅草稿打开优先级。 ## 约定 @@ -26,6 +27,7 @@ - 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。 - 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 - 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 +- 拼图 / 抓大鹅草稿打开 intent 只返回纯计划与 notice keys,不创建失败生成态、不请求详情、不写 stage;这些仍由壳层 Adapter 执行。 - 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 ## 验证 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4a3cf00c..f47ac8de 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -349,8 +349,6 @@ import { buildCreationWorkShelfItems, type CreationWorkShelfItem, isPersistedBarkBattleDraftGenerating, - isPersistedPuzzleDraftGenerating, - resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; import { buildPlatformRecommendFeedEntries, @@ -437,7 +435,6 @@ import { import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, - buildDraftFailedShelfSummary, buildPendingBarkBattleWorks, buildPendingBigFishWorks, buildPendingJumpHopWorks, @@ -451,19 +448,17 @@ import { createPendingDraftShelfState, type DraftGenerationNoticeMap, type DraftGenerationNoticeStatus, - getDraftGenerationNotice, getGenerationNoticeShelfKeys, hasDraftGenerationNoticeStatus, hasUnreadDraftGenerationUpdates, - hasUnreadReadyDraftGenerationNotice, - isPersistedDraftFailed, - isPersistedDraftGenerating, mergeBigFishWorkSummary, mergePuzzleWorkSummary, normalizeDraftNoticeId, type PendingDraftShelfKind, type PendingDraftShelfMap, type PendingDraftShelfMetadata, + resolveMatch3DDraftOpenIntent, + resolvePuzzleDraftOpenIntent, } from './platformDraftGenerationShelfModel'; import { canExposePublicWork, @@ -2012,26 +2007,11 @@ export function PlatformEntryFlowShellImpl({ activePuzzleGenerationSessionIdRef.current === sessionId ); }, []); - const isDraftNoticeGenerating = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasDraftGenerationNoticeStatus( - draftGenerationNotices, - kind, - ids, - 'generating', - ), - [draftGenerationNotices], - ); const isDraftNoticeFailed = useCallback( (kind: CreationWorkShelfKind, ids: Array) => hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), [draftGenerationNotices], ); - const isDraftNoticeReadyUnread = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasUnreadReadyDraftGenerationNotice(draftGenerationNotices, kind, ids), - [draftGenerationNotices], - ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { @@ -10221,83 +10201,67 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleDraft = useCallback( async (item: PuzzleWorkSummary) => { - const noticeKeys = collectDraftNoticeKeys('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const failedNotice = getDraftGenerationNotice( - draftGenerationNotices, - noticeKeys, - ); - const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); - const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const hasFailedNotice = isDraftNoticeFailed('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]); - const noticeErrorMessage = - failedNotice?.status === 'failed' - ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) - : buildDraftFailedShelfSummary('puzzle'); - const isMarkedGenerating = - !hasFailedNotice && - ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || - isPersistedPuzzleDraftGenerating(item)); + const sourceSessionId = item.sourceSessionId?.trim() ?? ''; + const backgroundTask = sourceSessionId + ? getPuzzleBackgroundCompileTask(sourceSessionId) + : null; + const activeGenerationState = + backgroundTask?.generationState ?? puzzleGenerationViewState; + const openIntent = resolvePuzzleDraftOpenIntent({ + item, + notices: draftGenerationNotices, + generation: { + activeSessionId: puzzleSession?.sessionId, + hasActiveGenerationFailure: + activeGenerationState?.phase === 'failed', + hasActiveGenerationRunning: isMiniGameDraftGenerating( + activeGenerationState ?? null, + ), + hasBackgroundGenerationFailure: + backgroundTask?.generationState.phase === 'failed', + hasBackgroundGenerationRunning: isMiniGameDraftGenerating( + backgroundTask?.generationState ?? null, + ), + }, + }); + const { noticeKeys } = openIntent; setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); setSelectedPuzzleDetail(null); - if (!item.sourceSessionId?.trim()) { - if (item.publicationStatus === 'published') { - await openPuzzleDetail(item.profileId, { tab: 'create' }); - return; - } - setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。'); + if (openIntent.type === 'open-published-detail') { + await openPuzzleDetail(item.profileId, { tab: 'create' }); return; } - const backgroundTask = getPuzzleBackgroundCompileTask( - item.sourceSessionId, - ); - const activeGenerationState = - backgroundTask?.generationState ?? puzzleGenerationViewState; - const failedGenerationState = - backgroundTask?.generationState.phase === 'failed' - ? backgroundTask.generationState - : item.sourceSessionId === puzzleSession?.sessionId && - activeGenerationState?.phase === 'failed' - ? activeGenerationState - : hasFailedNotice || isPersistedFailed - ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + if (openIntent.type === 'missing-session') { + setPuzzleError(openIntent.errorMessage); + return; + } + + if (openIntent.type === 'failed-generation') { + const failedGenerationState = + openIntent.source === 'background' + ? backgroundTask?.generationState + : openIntent.source === 'active' + ? activeGenerationState + : createFailedMiniGameDraftGenerationStateForRestoredDraft( 'puzzle', item.updatedAt, - noticeErrorMessage, + openIntent.errorMessage, { puzzleAiRedraw: true }, - ) - : null; - - if ((hasFailedNotice || isPersistedFailed) && failedGenerationState) { + ); + if (!failedGenerationState) { + return; + } let failedSession = backgroundTask?.session ?? null; let failedPayload = backgroundTask?.payload ?? null; - const failedError = - backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + const failedError = backgroundTask?.error ?? openIntent.errorMessage; if (!failedSession) { try { const { session: latestSession } = await getPuzzleAgentSession( - item.sourceSessionId, + sourceSessionId, ); failedSession = latestSession; failedPayload = buildPuzzleFormPayloadFromSession(latestSession); @@ -10330,16 +10294,13 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setPuzzleGenerationState(failedGenerationState); setSelectionStage('puzzle-generating'); return; } - if ( - item.sourceSessionId === puzzleSession?.sessionId && - isMiniGameDraftGenerating(activeGenerationState) - ) { + if (openIntent.type === 'active-generation') { if (!activeGenerationState) { return; } @@ -10347,7 +10308,7 @@ export function PlatformEntryFlowShellImpl({ rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setPuzzleGenerationState(rebasedGenerationState); if (backgroundTask) { setPuzzleBackgroundCompileTasks((current) => ({ @@ -10362,10 +10323,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if ( - backgroundTask && - isMiniGameDraftGenerating(backgroundTask.generationState) - ) { + if (openIntent.type === 'background-generation' && backgroundTask) { const rebasedTask = rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); puzzleFlow.setSession(rebasedTask.session); @@ -10380,15 +10338,15 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('puzzle-generating'); return; } - if (isMarkedGenerating) { + if (openIntent.type === 'restore-generating') { try { const { session: latestSession } = await getPuzzleAgentSession( - item.sourceSessionId, + sourceSessionId, ); const payload = buildPuzzleFormPayloadFromSession(latestSession); const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs( @@ -10424,7 +10382,7 @@ export function PlatformEntryFlowShellImpl({ })); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; - activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + activePuzzleGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('puzzle-generating'); return; } catch (error) { @@ -10439,7 +10397,7 @@ export function PlatformEntryFlowShellImpl({ markDraftNoticeSeen(noticeKeys); const restoredSession = await puzzleFlow.restoreDraft( - item.sourceSessionId, + sourceSessionId, ); if (!restoredSession) { await refreshPuzzleShelf().catch(() => undefined); @@ -10459,8 +10417,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, draftGenerationNotices, getPuzzleBackgroundCompileTask, - isDraftNoticeFailed, - isDraftNoticeGenerating, markDraftNoticeSeen, openPuzzleDetail, puzzleFlow, @@ -10478,78 +10434,52 @@ export function PlatformEntryFlowShellImpl({ item: Match3DWorkSummary, options: { forceDraft?: boolean } = {}, ) => { - const noticeKeys = collectDraftNoticeKeys('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); - const hasUnreadReadyNotice = isDraftNoticeReadyUnread('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); + const sourceSessionId = item.sourceSessionId?.trim() ?? ''; + const backgroundTask = sourceSessionId + ? getMatch3DBackgroundCompileTask(sourceSessionId) + : null; + const activeGenerationState = + backgroundTask?.generationState ?? match3dGenerationViewState; + const openIntent = resolveMatch3DDraftOpenIntent({ + item, + notices: draftGenerationNotices, + forceDraft: options.forceDraft, + generation: { + activeSessionId: match3dSession?.sessionId, + hasActiveGenerationFailure: + activeGenerationState?.phase === 'failed', + hasActiveGenerationRunning: isMiniGameDraftGenerating( + activeGenerationState ?? null, + ), + hasBackgroundGenerationFailure: + backgroundTask?.generationState.phase === 'failed', + hasBackgroundGenerationRunning: isMiniGameDraftGenerating( + backgroundTask?.generationState ?? null, + ), + }, + }); + const { noticeKeys } = openIntent; setMatch3DRun(null); setMatch3DError(null); setMatch3DProfile(null); setMatch3DRuntimeProfile(null); - if (item.publicationStatus === 'published' && !options.forceDraft) { + if (openIntent.type === 'open-published-detail') { markDraftNoticeSeen(noticeKeys); openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item)); return; } - if (!item.sourceSessionId?.trim()) { + if (openIntent.type === 'missing-session') { markDraftNoticeSeen(noticeKeys); - setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。'); + setMatch3DError(openIntent.errorMessage); return; } - const failedNotice = getDraftGenerationNotice( - draftGenerationNotices, - noticeKeys, - ); - const hasFailedNotice = isDraftNoticeFailed('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]); - const noticeErrorMessage = - failedNotice?.status === 'failed' - ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) - : buildDraftFailedShelfSummary('match3d'); - const isMarkedGenerating = - !hasFailedNotice && - (isDraftNoticeGenerating('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]) || - isPersistedDraftGenerating(item.generationStatus)); - - const backgroundTask = getMatch3DBackgroundCompileTask( - item.sourceSessionId, - ); - const activeGenerationState = - backgroundTask?.generationState ?? match3dGenerationViewState; - const failedGenerationState = - backgroundTask?.generationState.phase === 'failed' - ? backgroundTask.generationState - : item.sourceSessionId === match3dSession?.sessionId && - activeGenerationState?.phase === 'failed' - ? activeGenerationState - : hasFailedNotice - ? createFailedMiniGameDraftGenerationStateForRestoredDraft( - 'match3d', - item.updatedAt, - noticeErrorMessage, - ) - : null; - - if (hasUnreadReadyNotice) { + if (openIntent.type === 'ready-unread') { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); const profileId = latestSession.draft?.profileId ?? item.profileId; @@ -10577,15 +10507,27 @@ export function PlatformEntryFlowShellImpl({ } } - if (failedGenerationState) { + if (openIntent.type === 'failed-generation') { + const failedGenerationState = + openIntent.source === 'background' + ? backgroundTask?.generationState + : openIntent.source === 'active' + ? activeGenerationState + : createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + item.updatedAt, + openIntent.errorMessage, + ); + if (!failedGenerationState) { + return; + } let failedSession = backgroundTask?.session ?? null; let failedPayload = backgroundTask?.payload ?? null; - const failedError = - backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + const failedError = backgroundTask?.error ?? openIntent.errorMessage; if (!failedSession) { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); failedSession = latestSession; failedPayload = buildMatch3DFormPayloadFromSession(latestSession); } catch { @@ -10617,16 +10559,13 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setMatch3DGenerationState(failedGenerationState); setSelectionStage('match3d-generating'); return; } - if ( - item.sourceSessionId === match3dSession?.sessionId && - isMiniGameDraftGenerating(activeGenerationState) - ) { + if (openIntent.type === 'active-generation') { if (!activeGenerationState) { return; } @@ -10634,7 +10573,7 @@ export function PlatformEntryFlowShellImpl({ rebaseMiniGameDraftGenerationStateForDisplay(activeGenerationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setMatch3DGenerationState(rebasedGenerationState); if (backgroundTask) { setMatch3DBackgroundCompileTasks((current) => ({ @@ -10649,10 +10588,7 @@ export function PlatformEntryFlowShellImpl({ return; } - if ( - backgroundTask && - isMiniGameDraftGenerating(backgroundTask.generationState) - ) { + if (openIntent.type === 'background-generation' && backgroundTask) { const rebasedTask = rebaseMiniGameDraftBackgroundCompileTaskForDisplay(backgroundTask); setMatch3DSession(rebasedTask.session); @@ -10667,15 +10603,15 @@ export function PlatformEntryFlowShellImpl({ } enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('match3d-generating'); return; } - if (isMarkedGenerating) { + if (openIntent.type === 'restore-generating') { try { const { session: latestSession } = - await match3dCreationClient.getSession(item.sourceSessionId); + await match3dCreationClient.getSession(sourceSessionId); setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); @@ -10690,7 +10626,7 @@ export function PlatformEntryFlowShellImpl({ setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; - activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + activeMatch3DGenerationSessionIdRef.current = sourceSessionId; setSelectionStage('match3d-generating'); return; } catch (error) { @@ -10705,7 +10641,7 @@ export function PlatformEntryFlowShellImpl({ markDraftNoticeSeen(noticeKeys); const restoredSession = await match3dFlow.restoreDraft( - item.sourceSessionId, + sourceSessionId, ); if (!restoredSession) { await refreshMatch3DShelf().catch(() => undefined); @@ -10729,9 +10665,6 @@ export function PlatformEntryFlowShellImpl({ enterCreateTab, draftGenerationNotices, getMatch3DBackgroundCompileTask, - isDraftNoticeFailed, - isDraftNoticeGenerating, - isDraftNoticeReadyUnread, markDraftNoticeSeen, match3dFlow, match3dGenerationViewState, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 5863cb11..f01e1195 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { @@ -15,9 +16,140 @@ import { hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, + resolveMatch3DDraftOpenIntent, + resolvePuzzleDraftOpenIntent, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { + test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork({ + sourceSessionId: null, + publicationStatus: 'published', + }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + }); + + test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork(), + notices: { + 'puzzle:puzzle-session-base': { + status: 'failed', + seen: false, + message: '首图生成失败。', + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'failed-generation', + source: 'restored', + errorMessage: '首图生成失败。', + }); + }); + + test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork(), + notices: {}, + generation: emptyGenerationFacts({ + activeSessionId: 'puzzle-session-base', + hasActiveGenerationRunning: true, + }), + }), + ).toMatchObject({ + type: 'active-generation', + }); + }); + + test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => { + expect( + resolvePuzzleDraftOpenIntent({ + item: buildPuzzleWork({ + coverImageSrc: '/media/puzzle-cover.png', + }), + notices: { + 'puzzle:puzzle-session-base': { + status: 'generating', + seen: false, + }, + }, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-draft', + }); + }); + + test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => { + const item = buildMatch3DWork({ + publicationStatus: 'published', + }); + + expect( + resolveMatch3DDraftOpenIntent({ + item, + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'open-published-detail', + }); + + expect( + resolveMatch3DDraftOpenIntent({ + item, + notices: {}, + forceDraft: true, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-draft', + }); + }); + + test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => { + expect( + resolveMatch3DDraftOpenIntent({ + item: buildMatch3DWork(), + notices: { + 'match3d:match3d-session-base': { + status: 'ready', + seen: false, + }, + }, + generation: emptyGenerationFacts({ + hasBackgroundGenerationFailure: true, + }), + }), + ).toMatchObject({ + type: 'ready-unread', + }); + }); + + test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => { + expect( + resolveMatch3DDraftOpenIntent({ + item: buildMatch3DWork({ + generationStatus: 'generating', + }), + notices: {}, + generation: emptyGenerationFacts(), + }), + ).toMatchObject({ + type: 'restore-generating', + }); + }); + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { const pending = buildPendingPuzzleWorks( { @@ -199,6 +331,19 @@ describe('platformDraftGenerationShelfModel', () => { }); }); +function emptyGenerationFacts( + overrides: Partial[0]['generation']> = {}, +): Parameters[0]['generation'] { + return { + activeSessionId: null, + hasActiveGenerationFailure: false, + hasActiveGenerationRunning: false, + hasBackgroundGenerationFailure: false, + hasBackgroundGenerationRunning: false, + ...overrides, + }; +} + function buildPuzzleWork( overrides: Partial = {}, ): PuzzleWorkSummary { @@ -227,6 +372,33 @@ function buildPuzzleWork( }; } +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work-base', + profileId: 'match3d-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-base', + gameName: '潮雾抓大鹅', + themeText: '潮雾港口', + summary: '潮雾港口抓大鹅。', + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'ready', + generatedItemAssets: [], + ...overrides, + }; +} + function buildBigFishWork( overrides: Partial = {}, ): BigFishWorkSummary { diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index dbd0863c..62094b63 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -12,6 +12,7 @@ import { type CreationWorkShelfItem, type CreationWorkShelfKind, type CreationWorkShelfRuntimeState, + isPersistedPuzzleDraftGenerating, resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; import { @@ -67,6 +68,86 @@ export type PlatformDraftGenerationVisibleShelfSources = { babyObjectMatchItems: readonly BabyObjectMatchDraft[]; }; +type DraftOpenGenerationFacts = { + activeSessionId?: string | null; + hasActiveGenerationFailure: boolean; + hasActiveGenerationRunning: boolean; + hasBackgroundGenerationFailure: boolean; + hasBackgroundGenerationRunning: boolean; +}; + +type FailedDraftGenerationSource = 'background' | 'active' | 'restored'; + +export type PuzzleDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'missing-session'; + noticeKeys: string[]; + errorMessage: string; + } + | { + type: 'failed-generation'; + noticeKeys: string[]; + errorMessage: string; + source: FailedDraftGenerationSource; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + } + | { + type: 'background-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + }; + +export type Match3DDraftOpenIntent = + | { + type: 'open-published-detail'; + noticeKeys: string[]; + } + | { + type: 'missing-session'; + noticeKeys: string[]; + errorMessage: string; + } + | { + type: 'ready-unread'; + noticeKeys: string[]; + } + | { + type: 'failed-generation'; + noticeKeys: string[]; + errorMessage: string; + source: FailedDraftGenerationSource; + } + | { + type: 'active-generation'; + noticeKeys: string[]; + } + | { + type: 'background-generation'; + noticeKeys: string[]; + } + | { + type: 'restore-generating'; + noticeKeys: string[]; + } + | { + type: 'restore-draft'; + noticeKeys: string[]; + }; + export function buildDraftNoticeKey( kind: CreationWorkShelfKind, id: string, @@ -334,6 +415,219 @@ export function hasUnreadReadyDraftGenerationNotice( }); } +export function buildPuzzleDraftOpenNoticeKeys(item: PuzzleWorkSummary) { + return collectDraftNoticeKeys('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]); +} + +export function buildMatch3DDraftOpenNoticeKeys(item: Match3DWorkSummary) { + return collectDraftNoticeKeys('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); +} + +export function resolvePuzzleDraftOpenIntent(params: { + item: PuzzleWorkSummary; + notices: DraftGenerationNoticeMap; + generation: DraftOpenGenerationFacts; +}): PuzzleDraftOpenIntent { + const { item, notices, generation } = params; + const noticeKeys = buildPuzzleDraftOpenNoticeKeys(item); + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + + if (!sourceSessionId) { + if (item.publicationStatus === 'published') { + return { type: 'open-published-detail', noticeKeys }; + } + + return { + type: 'missing-session', + noticeKeys, + errorMessage: '这份拼图草稿缺少会话信息,请重新开始创作。', + }; + } + + const failedNotice = getDraftGenerationNotice(notices, noticeKeys); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'puzzle', + [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ], + 'failed', + ); + const hasGeneratingNotice = hasDraftGenerationNoticeStatus( + notices, + 'puzzle', + [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ], + 'generating', + ); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) + : buildDraftFailedShelfSummary('puzzle'); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if (generation.hasBackgroundGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'background', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'active', + }; + } + + if (hasFailedNotice || isPersistedDraftFailed(item.generationStatus)) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'restored', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationRunning) { + return { type: 'active-generation', noticeKeys }; + } + + if (generation.hasBackgroundGenerationRunning) { + return { type: 'background-generation', noticeKeys }; + } + + const isMarkedGenerating = + !hasFailedNotice && + ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || + isPersistedPuzzleDraftGenerating(item)); + if (isMarkedGenerating) { + return { type: 'restore-generating', noticeKeys }; + } + + return { type: 'restore-draft', noticeKeys }; +} + +export function resolveMatch3DDraftOpenIntent(params: { + item: Match3DWorkSummary; + notices: DraftGenerationNoticeMap; + forceDraft?: boolean; + generation: DraftOpenGenerationFacts; +}): Match3DDraftOpenIntent { + const { item, notices, forceDraft = false, generation } = params; + const noticeKeys = buildMatch3DDraftOpenNoticeKeys(item); + + if (item.publicationStatus === 'published' && !forceDraft) { + return { type: 'open-published-detail', noticeKeys }; + } + + const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId); + if (!sourceSessionId) { + return { + type: 'missing-session', + noticeKeys, + errorMessage: '这份抓大鹅草稿缺少会话信息,请重新开始创作。', + }; + } + + if ( + hasUnreadReadyDraftGenerationNotice(notices, 'match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]) + ) { + return { type: 'ready-unread', noticeKeys }; + } + + const failedNotice = getDraftGenerationNotice(notices, noticeKeys); + const hasFailedNotice = hasDraftGenerationNoticeStatus( + notices, + 'match3d', + [item.workId, item.profileId, item.sourceSessionId], + 'failed', + ); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) + : buildDraftFailedShelfSummary('match3d'); + const isCurrentSession = + sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId); + + if (generation.hasBackgroundGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'background', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationFailure) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'active', + }; + } + + if (hasFailedNotice) { + return { + type: 'failed-generation', + noticeKeys, + errorMessage: noticeErrorMessage, + source: 'restored', + }; + } + + if (isCurrentSession && generation.hasActiveGenerationRunning) { + return { type: 'active-generation', noticeKeys }; + } + + if (generation.hasBackgroundGenerationRunning) { + return { type: 'background-generation', noticeKeys }; + } + + if ( + hasDraftGenerationNoticeStatus( + notices, + 'match3d', + [item.workId, item.profileId, item.sourceSessionId], + 'generating', + ) || + isPersistedDraftGenerating(item.generationStatus) + ) { + return { type: 'restore-generating', noticeKeys }; + } + + return { type: 'restore-draft', noticeKeys }; +} + export function buildCreationWorkShelfRuntimeState(params: { item: CreationWorkShelfItem; notices: DraftGenerationNoticeMap;