refactor: 收口平台弹窗状态模型

This commit is contained in:
2026-06-03 22:00:36 +08:00
parent 3efbb6882c
commit caac418e0e
6 changed files with 501 additions and 311 deletions

View File

@@ -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 客户端传输层统一收口 ## 2026-06-03 前端 SSE 客户端传输层统一收口
- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。 - 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。

View File

@@ -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)。 平台入口创作恢复 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 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)。 抓大鹅 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)。

View File

@@ -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`

View File

@@ -421,6 +421,19 @@ import {
hasPuzzleRuntimeUrlStateValue, hasPuzzleRuntimeUrlStateValue,
normalizeCreationUrlValue, normalizeCreationUrlValue,
} from './platformCreationUrlStateModel'; } from './platformCreationUrlStateModel';
import {
buildPlatformErrorDialogDismissKey,
buildPlatformTaskCompletionDialogDismissKey,
formatPlatformDialogSource,
isBackgroundGenerationStillRunningMessage,
PLATFORM_TASK_COMPLETION_MESSAGE,
type PlatformDialogCandidate,
type PlatformErrorDialogState,
type PlatformTaskCompletionDialogState,
type PlatformTaskFailureDialogState,
resolveActivePlatformDialog,
resolvePlatformErrorDialog,
} from './platformDialogStateModel';
import { import {
buildCreationWorkShelfRuntimeState, buildCreationWorkShelfRuntimeState,
buildDraftCompletionDialogSource, buildDraftCompletionDialogSource,
@@ -480,10 +493,7 @@ import type {
SelectionStage, SelectionStage,
} from './platformEntryTypes'; } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { import { PlatformErrorDialog } from './PlatformErrorDialog';
PlatformErrorDialog,
type PlatformErrorDialogPayload,
} from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformFeedbackView } from './PlatformFeedbackView';
import { import {
buildMatch3DProfileFromSession, buildMatch3DProfileFromSession,
@@ -509,10 +519,7 @@ import {
buildPuzzleResultProfileId, buildPuzzleResultProfileId,
buildPuzzleResultWorkId, buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel'; } from './platformPuzzleIdentityModel';
import { import { PlatformTaskCompletionDialog } from './PlatformTaskCompletionDialog';
PlatformTaskCompletionDialog,
type PlatformTaskCompletionDialogPayload,
} from './PlatformTaskCompletionDialog';
import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; 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( function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind, kind: MiniGameDraftGenerationKind,
@@ -2733,23 +2707,11 @@ export function PlatformEntryFlowShellImpl({
const [ const [
pendingPlatformTaskCompletionDialog, pendingPlatformTaskCompletionDialog,
setPendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog,
] = useState< ] = useState<PlatformTaskCompletionDialogState | null>(null);
| (PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
})
| null
>(null);
const [ const [
pendingPlatformTaskFailureDialog, pendingPlatformTaskFailureDialog,
setPendingPlatformTaskFailureDialog, setPendingPlatformTaskFailureDialog,
] = useState< ] = useState<PlatformTaskFailureDialogState | null>(null);
| (PlatformErrorDialogPayload & {
key: string;
failedAtMs: number;
})
| null
>(null);
const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0);
const [initialCreationUrlState] = useState(() => readCreationUrlState()); const [initialCreationUrlState] = useState(() => readCreationUrlState());
const handledInitialCreationUrlStateRef = useRef(false); const handledInitialCreationUrlStateRef = useRef(false);
@@ -2916,7 +2878,7 @@ export function PlatformEntryFlowShellImpl({
setPendingPlatformTaskCompletionDialog({ setPendingPlatformTaskCompletionDialog({
key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`, key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`,
source: buildDraftCompletionDialogSource(kind, ids), source: buildDraftCompletionDialogSource(kind, ids),
message: '生成任务已完成,可以继续查看草稿。', message: PLATFORM_TASK_COMPLETION_MESSAGE,
completedAtMs, completedAtMs,
}); });
}, },
@@ -5462,14 +5424,9 @@ export function PlatformEntryFlowShellImpl({
dismissedPlatformTaskCompletionDialogKey, dismissedPlatformTaskCompletionDialogKey,
setDismissedPlatformTaskCompletionDialogKey, setDismissedPlatformTaskCompletionDialogKey,
] = useState<string | null>(null); ] = useState<string | null>(null);
const currentPlatformErrorDialog = useMemo< const currentPlatformErrorDialog =
(PlatformErrorDialogPayload & { key: string }) | null useMemo<PlatformErrorDialogState | null>(() => {
>(() => { const candidates: PlatformDialogCandidate[] = [
const candidates: Array<{
key: string;
source: string;
message: string | null | undefined;
}> = [
{ {
key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure',
source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿', source: pendingPlatformTaskFailureDialog?.source ?? '创作草稿',
@@ -5497,7 +5454,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'rpg-result', key: 'rpg-result',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
'RPG 草稿', 'RPG 草稿',
sessionController.agentSession?.sessionId ?? sessionController.agentSession?.sessionId ??
sessionController.generatedCustomWorldProfile?.id, sessionController.generatedCustomWorldProfile?.id,
@@ -5506,7 +5463,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'public-work-detail', key: 'public-work-detail',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
'作品详情', '作品详情',
selectedPublicWorkDetail selectedPublicWorkDetail
? resolvePlatformPublicWorkCode(selectedPublicWorkDetail) ? resolvePlatformPublicWorkCode(selectedPublicWorkDetail)
@@ -5516,15 +5473,17 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'big-fish', key: 'big-fish',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'big-fish-runtime' ? '大鱼吃小鱼游玩' : '大鱼草稿', selectionStage === 'big-fish-runtime'
? '大鱼吃小鱼游玩'
: '大鱼草稿',
bigFishRun?.runId ?? bigFishSession?.sessionId, bigFishRun?.runId ?? bigFishSession?.sessionId,
), ),
message: bigFishError, message: bigFishError,
}, },
{ {
key: 'match3d', key: 'match3d',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿', selectionStage === 'match3d-runtime' ? '抓大鹅游玩' : '抓大鹅草稿',
match3dRun?.runId ?? match3dRun?.runId ??
match3dGenerationViewSession?.sessionId ?? match3dGenerationViewSession?.sessionId ??
@@ -5534,7 +5493,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'square-hole', key: 'square-hole',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'square-hole-runtime' selectionStage === 'square-hole-runtime'
? '方洞挑战游玩' ? '方洞挑战游玩'
: '方洞挑战草稿', : '方洞挑战草稿',
@@ -5544,7 +5503,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'jump-hop', key: 'jump-hop',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿', selectionStage === 'jump-hop-runtime' ? '跳一跳游玩' : '跳一跳草稿',
jumpHopRun?.runId ?? jumpHopSession?.sessionId, jumpHopRun?.runId ?? jumpHopSession?.sessionId,
), ),
@@ -5552,7 +5511,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'wooden-fish', key: 'wooden-fish',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'wooden-fish-runtime' selectionStage === 'wooden-fish-runtime'
? '敲木鱼游玩' ? '敲木鱼游玩'
: '敲木鱼草稿', : '敲木鱼草稿',
@@ -5562,7 +5521,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'puzzle', key: 'puzzle',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿', selectionStage === 'puzzle-runtime' ? '拼图游玩' : '拼图草稿',
puzzleRun?.runId ?? puzzleRun?.runId ??
puzzleGenerationViewSession?.sessionId ?? puzzleGenerationViewSession?.sessionId ??
@@ -5583,7 +5542,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'visual-novel', key: 'visual-novel',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'visual-novel-runtime' selectionStage === 'visual-novel-runtime'
? '视觉小说游玩' ? '视觉小说游玩'
: '视觉小说草稿', : '视觉小说草稿',
@@ -5593,7 +5552,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'baby-object-match', key: 'baby-object-match',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'baby-object-match-runtime' selectionStage === 'baby-object-match-runtime'
? '宝贝识物游玩' ? '宝贝识物游玩'
: '宝贝识物草稿', : '宝贝识物草稿',
@@ -5603,7 +5562,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'bark-battle', key: 'bark-battle',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
selectionStage === 'bark-battle-runtime' selectionStage === 'bark-battle-runtime'
? '汪汪声浪游玩' ? '汪汪声浪游玩'
: '汪汪声浪草稿', : '汪汪声浪草稿',
@@ -5613,7 +5572,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'creative-agent', key: 'creative-agent',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
'智能创作 Agent', '智能创作 Agent',
creativeAgentSession?.sessionId, creativeAgentSession?.sessionId,
), ),
@@ -5621,7 +5580,7 @@ export function PlatformEntryFlowShellImpl({
}, },
{ {
key: 'rpg-generation', key: 'rpg-generation',
source: formatPlatformErrorSource( source: formatPlatformDialogSource(
'RPG 草稿生成', 'RPG 草稿生成',
sessionController.agentSession?.sessionId, sessionController.agentSession?.sessionId,
), ),
@@ -5629,18 +5588,7 @@ export function PlatformEntryFlowShellImpl({
}, },
]; ];
for (const candidate of candidates) { return resolvePlatformErrorDialog(candidates);
const message = normalizePlatformErrorMessage(candidate.message);
if (message) {
return {
key: candidate.key,
source: candidate.source,
message,
};
}
}
return null;
}, [ }, [
babyObjectMatchDraft?.profileId, babyObjectMatchDraft?.profileId,
babyObjectMatchError, babyObjectMatchError,
@@ -5692,33 +5640,21 @@ export function PlatformEntryFlowShellImpl({
woodenFishRun?.runId, woodenFishRun?.runId,
woodenFishSession?.sessionId, woodenFishSession?.sessionId,
]); ]);
const currentPlatformTaskCompletionDialog = useMemo< const currentPlatformTaskCompletionDialog =
| (PlatformTaskCompletionDialogPayload & { useMemo<PlatformTaskCompletionDialogState | null>(
key: string;
completedAtMs: number | null;
})
| null
>(
() => pendingPlatformTaskCompletionDialog, () => pendingPlatformTaskCompletionDialog,
[pendingPlatformTaskCompletionDialog], [pendingPlatformTaskCompletionDialog],
); );
const activePlatformTaskCompletionDialogDismissKey = const activePlatformTaskCompletionDialog = resolveActivePlatformDialog(
buildPlatformTaskCompletionDialogDismissKey(
currentPlatformTaskCompletionDialog, currentPlatformTaskCompletionDialog,
dismissedPlatformTaskCompletionDialogKey,
buildPlatformTaskCompletionDialogDismissKey,
);
const activePlatformErrorDialog = resolveActivePlatformDialog(
currentPlatformErrorDialog,
dismissedPlatformErrorDialogKey,
buildPlatformErrorDialogDismissKey,
); );
const activePlatformTaskCompletionDialog =
activePlatformTaskCompletionDialogDismissKey &&
activePlatformTaskCompletionDialogDismissKey ===
dismissedPlatformTaskCompletionDialogKey
? null
: currentPlatformTaskCompletionDialog;
const activePlatformErrorDialogDismissKey =
buildPlatformErrorDialogDismissKey(currentPlatformErrorDialog);
const activePlatformErrorDialog =
activePlatformErrorDialogDismissKey &&
activePlatformErrorDialogDismissKey === dismissedPlatformErrorDialogKey
? null
: currentPlatformErrorDialog;
const closePlatformErrorDialog = useCallback(() => { const closePlatformErrorDialog = useCallback(() => {
if (!currentPlatformErrorDialog) { if (!currentPlatformErrorDialog) {
return; return;

View File

@@ -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);
});
});

View File

@@ -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<TDialog>(
currentDialog: TDialog | null,
dismissedDialogKey: string | null,
buildDismissKey: (dialog: TDialog | null) => string | null,
): TDialog | null {
const currentDialogDismissKey = buildDismissKey(currentDialog);
return currentDialogDismissKey &&
currentDialogDismissKey === dismissedDialogKey
? null
: currentDialog;
}