接入草稿生成原生通知

平台壳生成完成和失败收口消费 notification.showLocal

新增 HostBridge 草稿通知 payload 与去重模型测试

迁移生成状态纯函数以通过组件 Fast Refresh 约束

更新原生壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
2026-06-18 07:10:01 +08:00
parent e8c2e8d532
commit cdcbaf4cee
8 changed files with 258 additions and 70 deletions

View File

@@ -40,6 +40,7 @@
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()``useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
- 2026-06-18 本地通知能力:新增 `notification.showLocal` HostBridge capabilityH5 只能传必填 `title` 和可选 `body`共享契约负责修剪、折叠普通空白、限制长度并拒绝控制字符Expo 壳通过 `expo-notifications` 请求系统通知权限、创建 Android 本地 channel 并发送即时本地通知Tauri 壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知且不开放插件 JS guest API。该能力不包含远程推送、token 注册、定时提醒或后台远程通知,权限拒绝、系统失败或宿主未声明时由 H5 视作失败并继续主流程。
- 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capabilityH5 只能读取纯文本结果,契约限制返回文本最多 100000 字符Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件宿主未声明或读取失败时由 H5 视作失败并保留原流程。
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capabilityH5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容Expo 壳通过 `expo-document-picker` 打开系统文档选择器Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。

View File

@@ -262,6 +262,8 @@ GameBridge 禁止:
2026-06-18 追加H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。
2026-06-18 追加H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
### Phase 3Tauri 桌面壳 MVP
- 新增 `apps/desktop-shell/`
@@ -277,6 +279,8 @@ GameBridge 禁止:
2026-06-18 追加H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest APIH5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。
2026-06-18 追加H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,不开放插件 JS guest API、远程推送、定时提醒或通知 tokenH5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
### Phase 4宿主能力扩展
- 移动端接入系统分享、推送、原生登录和渠道支付。

View File

@@ -51,7 +51,7 @@ AI H5 sandbox
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
- `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo HapticsH5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。当前 H5 只在现有草稿生成任务收口为完成或失败时请求即时本地通知;通知按草稿来源去重,同一草稿重新进入生成中后才允许再次通知,不改变队列状态、弹窗、作品架或后端裁决。
- `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步Tauri 桌面壳支持该能力Expo 移动壳不声明时静默忽略。
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0-99999` 的整数,`0` 表示清除角标Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标Android 不声明该能力Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。
- `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebViewExpo 移动壳调用当前 `react-native-webview``reload()`Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref成功只表示宿主已发起刷新刷新后当前 H5 上下文会卸载。

View File

@@ -1,15 +1,15 @@
import { describe, expect, test } from 'vitest';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './PlatformEntryFlowShellImpl';
import {
buildExternalGenerationQueuePresentation,
buildExternalGenerationQueueStatus,
} from './platformExternalGenerationQueueStatusModel';
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
import {
resolveFinishedMiniGameDraftGenerationState,
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => {
test('returns jump hop and wooden fish generation states for progress ticking', () => {

View File

@@ -176,7 +176,6 @@ import {
streamCreativeAgentMessage,
streamCreativeDraftEdit,
} from '../../services/creative-agent';
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
import {
readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState,
@@ -190,6 +189,8 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
import { showHostLocalNotification } from '../../services/host-bridge/hostBridge';
import {
jumpHopClient,
type JumpHopGalleryCardResponse,
@@ -224,7 +225,6 @@ import {
buildSquareHoleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationKind,
type MiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
} from '../../services/miniGameDraftGenerationProgress';
@@ -240,7 +240,6 @@ import {
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
buildWoodenFishPublicWorkCode,
isSamePuzzleClearPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import {
@@ -356,8 +355,8 @@ import {
publishVisualNovelWork,
updateVisualNovelWork,
} from '../../services/visual-novel-works';
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
import { postWechatMiniProgramShareTarget } from '../../services/wechatMiniProgramShareTarget';
import { requestGenerationResultSubscribePermission } from '../../services/wechatMiniProgramSubscribe';
import {
woodenFishClient,
type WoodenFishGalleryCardResponse,
@@ -373,7 +372,6 @@ import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeSt
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PublishShareModal } from '../common/PublishShareModal';
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
@@ -393,7 +391,6 @@ import {
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isPuzzleClearGalleryEntry,
isPuzzleGalleryEntry,
mapPuzzleClearWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
@@ -459,7 +456,6 @@ import {
resolveWoodenFishCreationUrlRestoreStage,
} from './platformCreationUrlStateModel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import {
buildPlatformErrorDialogDismissKey,
buildPlatformTaskCompletionDialogDismissKey,
@@ -474,6 +470,10 @@ import {
resolveActivePlatformDialog,
resolvePlatformErrorDialog,
} from './platformDialogStateModel';
import {
type DraftGenerationPointNotice,
PlatformDraftGenerationPointNoticeDialog,
} from './PlatformDraftGenerationPointNoticeDialog';
import {
buildCreationWorkShelfRuntimeState,
buildDraftCompletionDialogSource,
@@ -511,10 +511,6 @@ import {
canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE,
} from './platformEdutainmentVisibility';
import {
PlatformDraftGenerationPointNoticeDialog,
type DraftGenerationPointNotice,
} from './PlatformDraftGenerationPointNoticeDialog';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -543,8 +539,16 @@ import type {
} from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformErrorDialog } from './PlatformErrorDialog';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
import {
buildPlatformHostDraftFailureNotification,
buildPlatformHostDraftNotificationResetKeys,
buildPlatformHostDraftReadyNotification,
type PlatformHostDraftNotification,
sendPlatformHostDraftNotificationOnce,
} from './platformHostNotificationModel';
import {
buildMatch3DProfileFromSession,
hasMatch3DRuntimeAsset,
@@ -570,6 +574,7 @@ import {
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
rebaseMiniGameDraftGenerationStateForDisplay,
resolveFinishedMiniGameDraftGenerationState,
resolveMiniGameGenerationViewBusy,
} from './platformMiniGameDraftGenerationStateModel';
import {
buildJumpHopDraftActionPayload,
@@ -655,7 +660,6 @@ import {
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import {
type PlatformPuzzleRuntimeAuthMode,
resolvePlatformRecommendRuntimeAuthPlan,
@@ -691,38 +695,6 @@ type PuzzleBackgroundCompileTask = {
error: string | null;
};
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'puzzle-clear-generating': 'puzzle-clear',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}
export function resolveMiniGameGenerationViewBusy(
isBusy: boolean,
state: MiniGameDraftGenerationState | null | undefined,
) {
return isBusy || isMiniGameDraftGenerating(state ?? null);
}
function isExternalGenerationQueueStage(selectionStage: SelectionStage) {
return (
selectionStage === 'puzzle-generating' ||
@@ -913,7 +885,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_ACTION_POLL_INTERVAL_MS = 3000;
const PUZZLE_BACKGROUND_ACTION_MAX_POLL_ATTEMPTS = 160;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
function isPuzzleBackgroundAction(payload: PuzzleAgentActionRequest) {
return (
@@ -2027,6 +1998,9 @@ export function PlatformEntryFlowShellImpl({
const profileWalletLocalDeltaRef = useRef(0);
const lastProfileDashboardSnapshotRef =
useRef<ProfileDashboardSummary | null>(null);
const sentPlatformHostDraftNotificationKeysRef = useRef<Set<string>>(
new Set(),
);
const [
pendingPlatformTaskCompletionDialog,
setPendingPlatformTaskCompletionDialog,
@@ -2043,6 +2017,34 @@ export function PlatformEntryFlowShellImpl({
);
const handledPuzzleRuntimeUrlStateKeyRef = useRef<string | null>(null);
const sendPlatformHostDraftNotification = useCallback(
(notification: PlatformHostDraftNotification | null) => {
if (!notification) {
return;
}
sendPlatformHostDraftNotificationOnce(
sentPlatformHostDraftNotificationKeysRef.current,
notification,
showHostLocalNotification,
);
},
[],
);
const resetPlatformHostDraftNotifications = useCallback(
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
const sentKeys = sentPlatformHostDraftNotificationKeysRef.current;
for (const key of buildPlatformHostDraftNotificationResetKeys(
kind,
ids,
)) {
sentKeys.delete(key);
}
},
[],
);
useEffect(() => {
selectionStageRef.current = selectionStage;
}, [selectionStage]);
@@ -2173,12 +2175,14 @@ export function PlatformEntryFlowShellImpl({
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) => {
setPendingPlatformTaskCompletionDialog(null);
setPendingPlatformTaskFailureDialog(null);
resetPlatformHostDraftNotifications(kind, ids);
updateDraftGenerationNotices(
collectDraftNoticeKeys(kind, ids),
'generating',
);
},
[
resetPlatformHostDraftNotifications,
setPendingPlatformTaskCompletionDialog,
setPendingPlatformTaskFailureDialog,
updateDraftGenerationNotices,
@@ -2198,6 +2202,9 @@ export function PlatformEntryFlowShellImpl({
);
setProfileTaskRefreshKey((current) => current + 1);
const completedAtMs = Date.now();
sendPlatformHostDraftNotification(
buildPlatformHostDraftReadyNotification(kind, ids),
);
setPendingPlatformTaskCompletionDialog({
key: `${kind}:${collectDraftNoticeKeys(kind, ids).join('|')}:${completedAtMs}`,
source: buildDraftCompletionDialogSource(kind, ids),
@@ -2206,6 +2213,7 @@ export function PlatformEntryFlowShellImpl({
});
},
[
sendPlatformHostDraftNotification,
setPendingPlatformTaskCompletionDialog,
setPendingPlatformTaskFailureDialog,
updateDraftGenerationNotices,
@@ -2224,6 +2232,13 @@ export function PlatformEntryFlowShellImpl({
const normalizedErrorMessage = errorMessage?.trim();
if (normalizedErrorMessage && showFailureDialog) {
const failedAtMs = Date.now();
sendPlatformHostDraftNotification(
buildPlatformHostDraftFailureNotification(
kind,
ids,
normalizedErrorMessage,
),
);
setPendingPlatformTaskFailureDialog({
key: `draft-failure:${kind}:${noticeKeys.join('|')}:${failedAtMs}`,
source: buildDraftCompletionDialogSource(kind, ids),
@@ -2233,6 +2248,7 @@ export function PlatformEntryFlowShellImpl({
}
},
[
sendPlatformHostDraftNotification,
setPendingPlatformTaskCompletionDialog,
setPendingPlatformTaskFailureDialog,
updateDraftGenerationNotices,
@@ -14356,25 +14372,6 @@ export function PlatformEntryFlowShellImpl({
openPublicWorkDetail(matchedEntry.detail);
};
const tryOpenPuzzleClearGalleryEntry = async () => {
const entries =
puzzleClearGalleryEntries.length > 0
? puzzleClearGalleryEntries
: await refreshPuzzleClearGallery();
const matchedEntry = entries.find((entry) => {
const detailEntry = mapPuzzleClearWorkToPlatformGalleryCard(entry);
return (
canExposePublicWork(detailEntry) &&
isSamePuzzleClearPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到拼消消作品。');
}
await openPuzzleClearPublicWorkDetail(matchedEntry.profileId);
};
const tryOpenMatch3DGalleryEntry = async () => {
const entries =
match3dGalleryEntries.length > 0

View File

@@ -0,0 +1,68 @@
import { describe, expect, test, vi } from 'vitest';
import {
buildPlatformHostDraftFailureNotification,
buildPlatformHostDraftNotificationResetKeys,
buildPlatformHostDraftReadyNotification,
sendPlatformHostDraftNotificationOnce,
} from './platformHostNotificationModel';
describe('platformHostNotificationModel', () => {
test('builds draft ready notification from stable draft identity', () => {
expect(
buildPlatformHostDraftReadyNotification('match3d', [
'match3d-work-1',
'match3d-session-1',
]),
).toEqual({
key: 'draft-ready:match3d:抓大鹅草稿 match3d-session-1',
title: '生成完成',
body: '抓大鹅草稿 match3d-session-1 生成任务已完成,可以继续查看草稿。',
});
});
test('builds draft failure notification only when error text is useful', () => {
expect(
buildPlatformHostDraftFailureNotification('puzzle', [
'puzzle-session-1',
], ' 图片生成失败。 '),
).toEqual({
key: 'draft-failed:puzzle:拼图草稿 puzzle-session-1',
title: '生成失败',
body: '拼图草稿 puzzle-session-1 图片生成失败。',
});
expect(
buildPlatformHostDraftFailureNotification('puzzle', [
'puzzle-session-1',
], ' '),
).toBeNull();
expect(buildPlatformHostDraftReadyNotification('puzzle', [])).toBeNull();
});
test('sends host notification once and reset keys allow a later regeneration', () => {
const sentKeys = new Set<string>();
const send = vi.fn();
const notification = buildPlatformHostDraftReadyNotification('jump-hop', [
'jump-hop-session-1',
]);
expect(
sendPlatformHostDraftNotificationOnce(sentKeys, notification, send),
).toBe(true);
expect(
sendPlatformHostDraftNotificationOnce(sentKeys, notification, send),
).toBe(false);
expect(send).toHaveBeenCalledTimes(1);
for (const key of buildPlatformHostDraftNotificationResetKeys('jump-hop', [
'jump-hop-session-1',
])) {
sentKeys.delete(key);
}
expect(
sendPlatformHostDraftNotificationOnce(sentKeys, notification, send),
).toBe(true);
expect(send).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,85 @@
import type { CreationWorkShelfKind } from '../custom-world-home/creationWorkShelf';
import { PLATFORM_TASK_COMPLETION_MESSAGE } from './platformDialogStateModel';
import {
buildDraftCompletionDialogSource,
collectDraftNoticeKeys,
} from './platformDraftGenerationShelfModel';
export type PlatformHostDraftNotification = {
key: string;
title: string;
body: string;
};
export type PlatformHostDraftNotificationSender = (
notification: Pick<PlatformHostDraftNotification, 'title' | 'body'>,
) => void | Promise<unknown>;
export function buildPlatformHostDraftReadyNotification(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
): PlatformHostDraftNotification | null {
const noticeKeys = collectDraftNoticeKeys(kind, ids);
if (noticeKeys.length === 0) {
return null;
}
const source = buildDraftCompletionDialogSource(kind, ids);
return {
key: `draft-ready:${kind}:${source}`,
title: '生成完成',
body: `${source} ${PLATFORM_TASK_COMPLETION_MESSAGE}`,
};
}
export function buildPlatformHostDraftFailureNotification(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
errorMessage?: string | null,
): PlatformHostDraftNotification | null {
const noticeKeys = collectDraftNoticeKeys(kind, ids);
const normalizedErrorMessage = errorMessage?.trim();
if (noticeKeys.length === 0 || !normalizedErrorMessage) {
return null;
}
const source = buildDraftCompletionDialogSource(kind, ids);
return {
key: `draft-failed:${kind}:${source}`,
title: '生成失败',
body: `${source} ${normalizedErrorMessage}`,
};
}
export function buildPlatformHostDraftNotificationResetKeys(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
const noticeKeys = collectDraftNoticeKeys(kind, ids);
if (noticeKeys.length === 0) {
return [];
}
const keySuffix = buildDraftCompletionDialogSource(kind, ids);
return [
`draft-ready:${kind}:${keySuffix}`,
`draft-failed:${kind}:${keySuffix}`,
];
}
export function sendPlatformHostDraftNotificationOnce(
sentKeys: Set<string>,
notification: PlatformHostDraftNotification | null,
send: PlatformHostDraftNotificationSender,
) {
if (!notification || sentKeys.has(notification.key)) {
return false;
}
sentKeys.add(notification.key);
void send({
title: notification.title,
body: notification.body,
});
return true;
}

View File

@@ -10,6 +10,39 @@ import {
type MiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
export type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'puzzle-clear-generating': 'puzzle-clear',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}
export function resolveMiniGameGenerationViewBusy(
isBusy: boolean,
state: MiniGameDraftGenerationState | null | undefined,
) {
return isBusy || isMiniGameDraftGenerating(state ?? null);
}
export function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,