From caac418e0e772ba1b32a3fb14f38e347327071ca Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 22:00:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=BC=B9=E7=AA=97=E7=8A=B6=E6=80=81=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + docs/README.md | 2 + ...PlatformDialogStateModel收口计划-2026-06-03.md | 46 ++ .../PlatformEntryFlowShellImpl.tsx | 558 ++++++++---------- .../platformDialogStateModel.test.ts | 113 ++++ .../platformDialogStateModel.ts | 85 +++ 6 files changed, 501 insertions(+), 311 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformDialogStateModel.test.ts create mode 100644 src/components/platform-entry/platformDialogStateModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c3aaa42e..d1663fbf 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-03 平台入口弹窗状态规则收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。 +- 决策:新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module,统一导出 `normalizePlatformDialogMessage`、`formatPlatformDialogSource`、`resolvePlatformErrorDialog`、dismiss key builder、`resolveActivePlatformDialog`、`isBackgroundGenerationStillRunningMessage` 和 `PLATFORM_TASK_COMPLETION_MESSAGE`。平台壳只汇总候选、持有 React state,并在关闭弹窗时作为 Adapter 清理对应副作用 setter。 +- 影响范围:平台入口错误弹窗、任务完成弹窗、后台生成仍在处理识别、草稿生成完成 / 失败通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts`、`npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、相关壳层交互测试、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md`。 + ## 2026-06-03 前端 SSE 客户端传输层统一收口 - 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 diff --git a/docs/README.md b/docs/README.md index ab1166ee..adec95d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作恢复 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)。 + 小游戏 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)。 抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..525d4669 --- /dev/null +++ b/docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md @@ -0,0 +1,46 @@ +# PlatformDialogStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载平台级错误 / 完成弹窗的纯状态规则:错误文案 trim、来源 label 与 id 拼接、后台生成仍在处理的识别、错误候选优先级、dismiss key 与生成完成文案都散在壳层 Implementation 内。壳层因此既要管理 React state 与副作用清理,又要记住弹窗判定细则;新增玩法错误或调整弹窗展示时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module。 +- Module Interface 收口: + - `normalizePlatformDialogMessage` + - `formatPlatformDialogSource` + - `isBackgroundGenerationStillRunningMessage` + - `resolvePlatformErrorDialog` + - `buildPlatformErrorDialogDismissKey` + - `buildPlatformTaskCompletionDialogDismissKey` + - `resolveActivePlatformDialog` + - `PLATFORM_TASK_COMPLETION_MESSAGE` + - `PlatformErrorDialogState`、`PlatformTaskFailureDialogState` 与 `PlatformTaskCompletionDialogState` +- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:汇总各玩法候选、持有 React state、关闭弹窗时清理对应 setter。副作用清理不下沉到 Module,避免把大量壳层 setter 变成浅 Interface。 + +## Interface 约束 + +- 错误与完成弹窗文案先 trim;空字符串或全空白字符串统一视为 `null`。 +- 来源格式固定为 `label + 空格 + trimmed id`;缺 id 时只返回 label。 +- 平台错误候选按数组顺序取第一个有效文案;候选本身只描述 `key/source/message`。 +- 错误 dismiss key 固定为 `key:source:message`;完成 dismiss key 固定为 `key:source:message:completedAtMs`,缺完成时间时补 `0`。 +- `resolveActivePlatformDialog` 只根据当前弹窗 dismiss key 与已记录 dismiss key 决定是否隐藏,不修改底层错误或完成状态。 +- 任务完成弹窗文案统一使用 `PLATFORM_TASK_COMPLETION_MESSAGE`,不得在壳层重复写同一中文 literal。 +- `closePlatformErrorDialog` 保持在壳层 Adapter;它负责按错误来源清理 `creationEntryConfigError`、玩法 error、作品详情 error 等副作用状态,不属于纯状态 Module。 + +## Depth / Leverage / Locality + +- **Depth**:壳层传入候选和 dismiss 记录,即可得到当前平台弹窗状态;文案归一、来源格式和 dismiss 规则藏在 Module Implementation 内。 +- **Leverage**:新增玩法错误来源时只需补候选;调整弹窗纯规则时优先改 Module 与单测。 +- **Locality**:平台错误弹窗、任务完成弹窗和后台生成 still-running 识别集中在一个小 Module,避免继续散落在大型平台壳 Implementation 内。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts` +- `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "background match3d draft failure notifies and reopens failed retry page|completed match3d draft notice first opens trial then reopens result|puzzle compile timeout shows failure dialog when reread session is still generating"` +- `npx eslint src/components/platform-entry/platformDialogStateModel.ts src/components/platform-entry/platformDialogStateModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 7d15d87d..1540a5f8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -421,6 +421,19 @@ import { hasPuzzleRuntimeUrlStateValue, normalizeCreationUrlValue, } from './platformCreationUrlStateModel'; +import { + buildPlatformErrorDialogDismissKey, + buildPlatformTaskCompletionDialogDismissKey, + formatPlatformDialogSource, + isBackgroundGenerationStillRunningMessage, + PLATFORM_TASK_COMPLETION_MESSAGE, + type PlatformDialogCandidate, + type PlatformErrorDialogState, + type PlatformTaskCompletionDialogState, + type PlatformTaskFailureDialogState, + resolveActivePlatformDialog, + resolvePlatformErrorDialog, +} from './platformDialogStateModel'; import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, @@ -480,10 +493,7 @@ import type { SelectionStage, } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; -import { - PlatformErrorDialog, - type PlatformErrorDialogPayload, -} from './PlatformErrorDialog'; +import { PlatformErrorDialog } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; import { buildMatch3DProfileFromSession, @@ -509,10 +519,7 @@ import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, } from './platformPuzzleIdentityModel'; -import { - PlatformTaskCompletionDialog, - type PlatformTaskCompletionDialogPayload, -} from './PlatformTaskCompletionDialog'; +import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; @@ -1507,39 +1514,6 @@ function buildWoodenFishPendingSession( }; } -function normalizePlatformErrorMessage(message: string | null | undefined) { - const normalized = message?.trim(); - return normalized ? normalized : null; -} - -function formatPlatformErrorSource(label: string, id?: string | null) { - const normalizedId = id?.trim(); - return normalizedId ? `${label} ${normalizedId}` : label; -} - -function isBackgroundGenerationStillRunningMessage(message: string) { - return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); -} - -function buildPlatformErrorDialogDismissKey( - error: (PlatformErrorDialogPayload & { key: string }) | null, -) { - return error ? `${error.key}:${error.source}:${error.message}` : null; -} - -function buildPlatformTaskCompletionDialogDismissKey( - completion: - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null, -) { - return completion - ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` - : null; -} - /** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, @@ -2733,23 +2707,11 @@ export function PlatformEntryFlowShellImpl({ const [ pendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog, - ] = useState< - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null - >(null); + ] = useState(null); const [ pendingPlatformTaskFailureDialog, setPendingPlatformTaskFailureDialog, - ] = useState< - | (PlatformErrorDialogPayload & { - key: string; - failedAtMs: number; - }) - | null - >(null); + ] = useState(null); const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); @@ -2916,7 +2878,7 @@ export function PlatformEntryFlowShellImpl({ setPendingPlatformTaskCompletionDialog({ key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, source: buildDraftCompletionDialogSource(kind, ids), - message: '生成任务已完成,可以继续查看草稿。', + message: PLATFORM_TASK_COMPLETION_MESSAGE, completedAtMs, }); }, @@ -5462,263 +5424,237 @@ export function PlatformEntryFlowShellImpl({ dismissedPlatformTaskCompletionDialogKey, setDismissedPlatformTaskCompletionDialogKey, ] = useState(null); - const currentPlatformErrorDialog = useMemo< - (PlatformErrorDialogPayload & { key: string }) | null - >(() => { - const candidates: Array<{ - key: string; - source: string; - message: string | null | undefined; - }> = [ - { - key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', - source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', - message: pendingPlatformTaskFailureDialog?.message, - }, - { - key: 'creation-entry-config', - source: '创作入口配置', - message: creationEntryConfigError, - }, - { - key: 'platform-bootstrap', - source: '平台首页', - message: platformBootstrap.platformError, - }, - { - key: 'rpg-creation-type', - source: '创作入口', - message: sessionController.creationTypeError, - }, - { - key: 'rpg-restore', - source: '创作作品架', - message: sessionController.agentWorkspaceRestoreError, - }, - { - key: 'rpg-result', - source: formatPlatformErrorSource( - 'RPG 草稿', - sessionController.agentSession?.sessionId ?? - sessionController.generatedCustomWorldProfile?.id, - ), - message: resultViewError, - }, - { - key: 'public-work-detail', - source: formatPlatformErrorSource( - '作品详情', - selectedPublicWorkDetail - ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) - : selectedDetailEntry?.profileId, - ), - message: publicWorkDetailError ?? detailNavigation.detailError, - }, - { - key: 'big-fish', - source: formatPlatformErrorSource( - selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿', - bigFishRun?.runId ?? bigFishSession?.sessionId, - ), - message: bigFishError, - }, - { - key: 'match3d', - source: formatPlatformErrorSource( - selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', - match3dRun?.runId ?? - match3dGenerationViewSession?.sessionId ?? - match3dSession?.sessionId, - ), - message: match3dGenerationViewError ?? match3dError, - }, - { - key: 'square-hole', - source: formatPlatformErrorSource( - selectionStage === 'square-hole-runtime' - ? '方洞挑战游玩' - : '方洞挑战草稿', - squareHoleRun?.runId ?? squareHoleSession?.sessionId, - ), - message: squareHoleError, - }, - { - key: 'jump-hop', - source: formatPlatformErrorSource( - selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', - jumpHopRun?.runId ?? jumpHopSession?.sessionId, - ), - message: jumpHopError, - }, - { - key: 'wooden-fish', - source: formatPlatformErrorSource( - selectionStage === 'wooden-fish-runtime' - ? '敲木鱼游玩' - : '敲木鱼草稿', - woodenFishRun?.runId ?? woodenFishSession?.sessionId, - ), - message: woodenFishError, - }, - { - key: 'puzzle', - source: formatPlatformErrorSource( - selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', - puzzleRun?.runId ?? - puzzleGenerationViewSession?.sessionId ?? - puzzleSession?.sessionId, - ), - message: - puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, - }, - { - key: 'puzzle-onboarding', - source: '拼图首次创作', - message: puzzleOnboardingError, - }, - { - key: 'puzzle-shelf', - source: '拼图作品架', - message: puzzleShelfError, - }, - { - key: 'visual-novel', - source: formatPlatformErrorSource( - selectionStage === 'visual-novel-runtime' - ? '视觉小说游玩' - : '视觉小说草稿', - visualNovelRun?.runId ?? visualNovelSession?.sessionId, - ), - message: visualNovelError, - }, - { - key: 'baby-object-match', - source: formatPlatformErrorSource( - selectionStage === 'baby-object-match-runtime' - ? '宝贝识物游玩' - : '宝贝识物草稿', - babyObjectMatchDraft?.profileId, - ), - message: babyObjectMatchError, - }, - { - key: 'bark-battle', - source: formatPlatformErrorSource( - selectionStage === 'bark-battle-runtime' - ? '汪汪声浪游玩' - : '汪汪声浪草稿', - barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, - ), - message: barkBattleError, - }, - { - key: 'creative-agent', - source: formatPlatformErrorSource( - '智能创作 Agent', - creativeAgentSession?.sessionId, - ), - message: creativeAgentError, - }, - { - key: 'rpg-generation', - source: formatPlatformErrorSource( - 'RPG 草稿生成', - sessionController.agentSession?.sessionId, - ), - message: sessionController.activeGenerationError, - }, - ]; + const currentPlatformErrorDialog = + useMemo(() => { + const candidates: PlatformDialogCandidate[] = [ + { + key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', + source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', + message: pendingPlatformTaskFailureDialog?.message, + }, + { + key: 'creation-entry-config', + source: '创作入口配置', + message: creationEntryConfigError, + }, + { + key: 'platform-bootstrap', + source: '平台首页', + message: platformBootstrap.platformError, + }, + { + key: 'rpg-creation-type', + source: '创作入口', + message: sessionController.creationTypeError, + }, + { + key: 'rpg-restore', + source: '创作作品架', + message: sessionController.agentWorkspaceRestoreError, + }, + { + key: 'rpg-result', + source: formatPlatformDialogSource( + 'RPG 草稿', + sessionController.agentSession?.sessionId ?? + sessionController.generatedCustomWorldProfile?.id, + ), + message: resultViewError, + }, + { + key: 'public-work-detail', + source: formatPlatformDialogSource( + '作品详情', + selectedPublicWorkDetail + ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) + : selectedDetailEntry?.profileId, + ), + message: publicWorkDetailError ?? detailNavigation.detailError, + }, + { + key: 'big-fish', + source: formatPlatformDialogSource( + selectionStage === 'big-fish-runtime' + ? '大鱼吃小鱼游玩' + : '大鱼草稿', + bigFishRun?.runId ?? bigFishSession?.sessionId, + ), + message: bigFishError, + }, + { + key: 'match3d', + source: formatPlatformDialogSource( + selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', + match3dRun?.runId ?? + match3dGenerationViewSession?.sessionId ?? + match3dSession?.sessionId, + ), + message: match3dGenerationViewError ?? match3dError, + }, + { + key: 'square-hole', + source: formatPlatformDialogSource( + selectionStage === 'square-hole-runtime' + ? '方洞挑战游玩' + : '方洞挑战草稿', + squareHoleRun?.runId ?? squareHoleSession?.sessionId, + ), + message: squareHoleError, + }, + { + key: 'jump-hop', + source: formatPlatformDialogSource( + selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', + jumpHopRun?.runId ?? jumpHopSession?.sessionId, + ), + message: jumpHopError, + }, + { + key: 'wooden-fish', + source: formatPlatformDialogSource( + selectionStage === 'wooden-fish-runtime' + ? '敲木鱼游玩' + : '敲木鱼草稿', + woodenFishRun?.runId ?? woodenFishSession?.sessionId, + ), + message: woodenFishError, + }, + { + key: 'puzzle', + source: formatPlatformDialogSource( + selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', + puzzleRun?.runId ?? + puzzleGenerationViewSession?.sessionId ?? + puzzleSession?.sessionId, + ), + message: + puzzleGenerationViewError ?? puzzleCreationError ?? puzzleError, + }, + { + key: 'puzzle-onboarding', + source: '拼图首次创作', + message: puzzleOnboardingError, + }, + { + key: 'puzzle-shelf', + source: '拼图作品架', + message: puzzleShelfError, + }, + { + key: 'visual-novel', + source: formatPlatformDialogSource( + selectionStage === 'visual-novel-runtime' + ? '视觉小说游玩' + : '视觉小说草稿', + visualNovelRun?.runId ?? visualNovelSession?.sessionId, + ), + message: visualNovelError, + }, + { + key: 'baby-object-match', + source: formatPlatformDialogSource( + selectionStage === 'baby-object-match-runtime' + ? '宝贝识物游玩' + : '宝贝识物草稿', + babyObjectMatchDraft?.profileId, + ), + message: babyObjectMatchError, + }, + { + key: 'bark-battle', + source: formatPlatformDialogSource( + selectionStage === 'bark-battle-runtime' + ? '汪汪声浪游玩' + : '汪汪声浪草稿', + barkBattlePublishedConfig?.workId ?? barkBattleDraftConfig?.workId, + ), + message: barkBattleError, + }, + { + key: 'creative-agent', + source: formatPlatformDialogSource( + '智能创作 Agent', + creativeAgentSession?.sessionId, + ), + message: creativeAgentError, + }, + { + key: 'rpg-generation', + source: formatPlatformDialogSource( + 'RPG 草稿生成', + sessionController.agentSession?.sessionId, + ), + message: sessionController.activeGenerationError, + }, + ]; - for (const candidate of candidates) { - const message = normalizePlatformErrorMessage(candidate.message); - if (message) { - return { - key: candidate.key, - source: candidate.source, - message, - }; - } - } - - return null; - }, [ - babyObjectMatchDraft?.profileId, - babyObjectMatchError, - barkBattleDraftConfig?.workId, - barkBattleError, - barkBattlePublishedConfig?.workId, - bigFishError, - bigFishRun?.runId, - bigFishSession?.sessionId, - creationEntryConfigError, - creativeAgentError, - creativeAgentSession?.sessionId, - detailNavigation.detailError, - jumpHopError, - jumpHopRun?.runId, - jumpHopSession?.sessionId, - match3dError, - match3dGenerationViewError, - match3dGenerationViewSession?.sessionId, - match3dRun?.runId, - match3dSession?.sessionId, - pendingPlatformTaskFailureDialog, - platformBootstrap.platformError, - publicWorkDetailError, - puzzleCreationError, - puzzleError, - puzzleGenerationViewError, - puzzleGenerationViewSession?.sessionId, - puzzleOnboardingError, - puzzleRun?.runId, - puzzleSession?.sessionId, - puzzleShelfError, - resultViewError, - selectedDetailEntry?.profileId, - selectedPublicWorkDetail, - selectionStage, - sessionController.activeGenerationError, - sessionController.agentSession?.sessionId, - sessionController.agentWorkspaceRestoreError, - sessionController.creationTypeError, - sessionController.generatedCustomWorldProfile?.id, - squareHoleError, - squareHoleRun?.runId, - squareHoleSession?.sessionId, - visualNovelError, - visualNovelRun?.runId, - visualNovelSession?.sessionId, - woodenFishError, - woodenFishRun?.runId, - woodenFishSession?.sessionId, - ]); - const currentPlatformTaskCompletionDialog = useMemo< - | (PlatformTaskCompletionDialogPayload & { - key: string; - completedAtMs: number | null; - }) - | null - >( - () => pendingPlatformTaskCompletionDialog, - [pendingPlatformTaskCompletionDialog], - ); - const activePlatformTaskCompletionDialogDismissKey = - buildPlatformTaskCompletionDialogDismissKey( - currentPlatformTaskCompletionDialog, + return resolvePlatformErrorDialog(candidates); + }, [ + babyObjectMatchDraft?.profileId, + babyObjectMatchError, + barkBattleDraftConfig?.workId, + barkBattleError, + barkBattlePublishedConfig?.workId, + bigFishError, + bigFishRun?.runId, + bigFishSession?.sessionId, + creationEntryConfigError, + creativeAgentError, + creativeAgentSession?.sessionId, + detailNavigation.detailError, + jumpHopError, + jumpHopRun?.runId, + jumpHopSession?.sessionId, + match3dError, + match3dGenerationViewError, + match3dGenerationViewSession?.sessionId, + match3dRun?.runId, + match3dSession?.sessionId, + pendingPlatformTaskFailureDialog, + platformBootstrap.platformError, + publicWorkDetailError, + puzzleCreationError, + puzzleError, + puzzleGenerationViewError, + puzzleGenerationViewSession?.sessionId, + puzzleOnboardingError, + puzzleRun?.runId, + puzzleSession?.sessionId, + puzzleShelfError, + resultViewError, + selectedDetailEntry?.profileId, + selectedPublicWorkDetail, + selectionStage, + sessionController.activeGenerationError, + sessionController.agentSession?.sessionId, + sessionController.agentWorkspaceRestoreError, + sessionController.creationTypeError, + sessionController.generatedCustomWorldProfile?.id, + squareHoleError, + squareHoleRun?.runId, + squareHoleSession?.sessionId, + visualNovelError, + visualNovelRun?.runId, + visualNovelSession?.sessionId, + woodenFishError, + woodenFishRun?.runId, + woodenFishSession?.sessionId, + ]); + const currentPlatformTaskCompletionDialog = + useMemo( + () => pendingPlatformTaskCompletionDialog, + [pendingPlatformTaskCompletionDialog], ); - const activePlatformTaskCompletionDialog = - activePlatformTaskCompletionDialogDismissKey && - activePlatformTaskCompletionDialogDismissKey === - dismissedPlatformTaskCompletionDialogKey - ? null - : currentPlatformTaskCompletionDialog; - const activePlatformErrorDialogDismissKey = - buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog); - const activePlatformErrorDialog = - activePlatformErrorDialogDismissKey && - activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey - ? null - : currentPlatformErrorDialog; + const activePlatformTaskCompletionDialog = resolveActivePlatformDialog( + currentPlatformTaskCompletionDialog, + dismissedPlatformTaskCompletionDialogKey, + buildPlatformTaskCompletionDialogDismissKey, + ); + const activePlatformErrorDialog = resolveActivePlatformDialog( + currentPlatformErrorDialog, + dismissedPlatformErrorDialogKey, + buildPlatformErrorDialogDismissKey, + ); const closePlatformErrorDialog = useCallback(() => { if (!currentPlatformErrorDialog) { return; diff --git a/src/components/platform-entry/platformDialogStateModel.test.ts b/src/components/platform-entry/platformDialogStateModel.test.ts new file mode 100644 index 00000000..cbd15ff0 --- /dev/null +++ b/src/components/platform-entry/platformDialogStateModel.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildPlatformErrorDialogDismissKey, + buildPlatformTaskCompletionDialogDismissKey, + formatPlatformDialogSource, + isBackgroundGenerationStillRunningMessage, + normalizePlatformDialogMessage, + PLATFORM_TASK_COMPLETION_MESSAGE, + resolveActivePlatformDialog, + resolvePlatformErrorDialog, +} from './platformDialogStateModel'; + +describe('platformDialogStateModel', () => { + test('normalizes platform dialog messages', () => { + expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败'); + expect(normalizePlatformDialogMessage(' ')).toBeNull(); + expect(normalizePlatformDialogMessage(null)).toBeNull(); + }); + + test('formats dialog source with optional identity', () => { + expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe( + '拼图草稿 puzzle-session-1', + ); + expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿'); + }); + + test('detects background generation still running messages', () => { + expect( + isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'), + ).toBe(true); + expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe( + false, + ); + }); + + test('resolves the first non-empty error candidate', () => { + expect( + resolvePlatformErrorDialog([ + { + key: 'empty', + source: '空来源', + message: ' ', + }, + { + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: ' 素材生成失败。 ', + }, + ]), + ).toEqual({ + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }); + + expect( + resolvePlatformErrorDialog([ + { + key: 'empty', + source: '空来源', + message: null, + }, + ]), + ).toBeNull(); + }); + + test('builds stable dismiss keys for error and completion dialogs', () => { + expect( + buildPlatformErrorDialogDismissKey({ + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }), + ).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。'); + expect(buildPlatformErrorDialogDismissKey(null)).toBeNull(); + + expect( + buildPlatformTaskCompletionDialogDismissKey({ + key: 'match3d', + source: '抓大鹅草稿 match3d-session-1', + message: PLATFORM_TASK_COMPLETION_MESSAGE, + completedAtMs: null, + }), + ).toBe( + `match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`, + ); + }); + + test('hides active dialog when the dismiss key has already been recorded', () => { + const dialog = { + key: 'puzzle', + source: '拼图草稿 puzzle-session-1', + message: '素材生成失败。', + }; + const dismissKey = buildPlatformErrorDialogDismissKey(dialog); + + expect( + resolveActivePlatformDialog( + dialog, + dismissKey, + buildPlatformErrorDialogDismissKey, + ), + ).toBeNull(); + expect( + resolveActivePlatformDialog( + dialog, + 'other-dismiss-key', + buildPlatformErrorDialogDismissKey, + ), + ).toBe(dialog); + }); +}); diff --git a/src/components/platform-entry/platformDialogStateModel.ts b/src/components/platform-entry/platformDialogStateModel.ts new file mode 100644 index 00000000..796cc1fd --- /dev/null +++ b/src/components/platform-entry/platformDialogStateModel.ts @@ -0,0 +1,85 @@ +import type { PlatformErrorDialogPayload } from './PlatformErrorDialog'; +import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog'; + +export type PlatformErrorDialogState = PlatformErrorDialogPayload & { + key: string; +}; + +export type PlatformTaskFailureDialogState = PlatformErrorDialogState & { + failedAtMs: number; +}; + +export type PlatformTaskCompletionDialogState = + PlatformTaskCompletionDialogPayload & { + key: string; + completedAtMs: number | null; + }; + +export type PlatformDialogCandidate = { + key: string; + source: string; + message: string | null | undefined; +}; + +export const PLATFORM_TASK_COMPLETION_MESSAGE = + '生成任务已完成,可以继续查看草稿。'; + +/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */ +export function normalizePlatformDialogMessage( + message: string | null | undefined, +) { + const normalized = message?.trim(); + return normalized ? normalized : null; +} + +export function formatPlatformDialogSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +} + +export function isBackgroundGenerationStillRunningMessage(message: string) { + return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); +} + +export function resolvePlatformErrorDialog( + candidates: readonly PlatformDialogCandidate[], +): PlatformErrorDialogState | null { + for (const candidate of candidates) { + const message = normalizePlatformDialogMessage(candidate.message); + if (message) { + return { + key: candidate.key, + source: candidate.source, + message, + }; + } + } + + return null; +} + +export function buildPlatformErrorDialogDismissKey( + error: PlatformErrorDialogState | null, +) { + return error ? `${error.key}:${error.source}:${error.message}` : null; +} + +export function buildPlatformTaskCompletionDialogDismissKey( + completion: PlatformTaskCompletionDialogState | null, +) { + return completion + ? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}` + : null; +} + +export function resolveActivePlatformDialog( + currentDialog: TDialog | null, + dismissedDialogKey: string | null, + buildDismissKey: (dialog: TDialog | null) => string | null, +): TDialog | null { + const currentDialogDismissKey = buildDismissKey(currentDialog); + return currentDialogDismissKey && + currentDialogDismissKey === dismissedDialogKey + ? null + : currentDialog; +}