接入草稿生成原生通知
平台壳生成完成和失败收口消费 notification.showLocal 新增 HostBridge 草稿通知 payload 与去重模型测试 迁移生成状态纯函数以通过组件 Fast Refresh 约束 更新原生壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
@@ -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 capability,H5 只能传必填 `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 capability,H5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。
|
||||
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `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 边界。
|
||||
|
||||
@@ -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 3:Tauri 桌面壳 MVP
|
||||
|
||||
- 新增 `apps/desktop-shell/`。
|
||||
@@ -277,6 +279,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。
|
||||
|
||||
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Tauri 壳仍只通过 Rust 侧 notification 插件发送即时系统通知,不开放插件 JS guest API、远程推送、定时提醒或通知 token;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。
|
||||
|
||||
### Phase 4:宿主能力扩展
|
||||
|
||||
- 移动端接入系统分享、推送、原生登录和渠道支付。
|
||||
|
||||
@@ -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 Haptics;H5 运行时点击反馈在 `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 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview` 的 `reload()`,Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user