From 98daf41790b0297e169a3375245362bd55fe1c33 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 11:46:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=8A=93=E5=A4=A7=E9=B9=85?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E5=8E=9F=E7=94=9F=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抓大鹅发布封面图在原生壳内优先走宿主图片导入 封面参考图取消原生选择时不触发浏览器文件输入 同步 Match3D 测试、宿主壳协议和共享记忆 --- .../shared-memory/decision-log.md | 7 + .../shared-memory/development-workflow.md | 1 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- .../match3d-result/Match3DResultView.test.tsx | 93 ++++++++++++ .../match3d-result/Match3DResultView.tsx | 142 +++++++++++++++++- 5 files changed, 240 insertions(+), 5 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0317c7b12..3dbef9e45 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2661,3 +2661,10 @@ - 决策:轻输入 composer 在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路;移动壳声明 `file.captureImage` 时额外展示拍摄参考图入口,调用 `captureHostImageFile()` 后复用同一链路。用户取消宿主选择或拍摄时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 - 影响范围:`src/components/creative-agent/CreativeAgentInputComposer.tsx`、`src/components/creative-agent/CreativeAgentInputComposer.test.tsx`、宿主壳能力统一协议文档。 - 验证方式:`npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 抓大鹅发布封面接入原生壳图片导入 + +- 背景:抓大鹅结果页发布弹窗的封面图和封面参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。 +- 决策:`Match3DResultView` 发布封面图和封面参考图在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readPuzzleReferenceImageAsDataUrl`、AI 重绘开关、参考图集合和 `generateMatch3DCoverImage` payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-result/Match3DResultView.test.tsx`、宿主壳能力统一协议文档。 +- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 4ac652bd4..3b12f6ecc 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -223,6 +223,7 @@ npm run check:native-shells 反馈页上传凭证在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄凭证入口,并把拍摄图片同样转为 `File` 后复用反馈页原有数量、大小、MIME、data URL 预览和提交 payload 校验。 固定内置 H5 体验入口在原生壳声明 `navigation.openNativePage` 时必须优先走 `navigateHostNativePage()`;例如儿童动作热身 Demo 从平台首页进入 `/child-motion-demo` 时应由 HostBridge 发出 `navigation.openNativePage`,宿主不可用时才回退浏览器跳转。 汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 +抓大鹅结果页发布封面图和封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。 视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `file.importImage` / `file.importAudio` 时必须优先走宿主受控导入,并把 H5 base64 转 `File` 后继续交给 `uploadVisualNovelAsset` 与当前素材字段写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,历史素材选择和 AI 图片生成保持原链路。 该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 29bafeb2d..497a2901a 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -72,7 +72,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。 - `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。 -- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png`、`image/jpeg`、`image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel` 在 `native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传、抓大鹅结果页发布封面 / 封面参考图上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,抓大鹅结果页继续复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。 HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 和 `file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle`、`network.statusChanged`、`navigation.canGoBack` 或 `file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。 diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 3beee0f2e..356a640c2 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -10,6 +10,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks'; +import * as hostBridgeServices from '../../services/host-bridge/hostBridge'; import * as match3dWorksService from '../../services/match3d-works'; import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache'; import { Match3DResultView } from './Match3DResultView'; @@ -49,6 +50,11 @@ vi.mock('../../services/match3d-works', () => ({ updateMatch3DWork: vi.fn(), })); +vi.mock('../../services/host-bridge/hostBridge', () => ({ + canUseNativeHostCapability: vi.fn(() => false), + importHostImageFile: vi.fn(), +})); + vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => { const actual = await importOriginal< @@ -536,6 +542,93 @@ describe('Match3DResultView', () => { }); }); + test('发布封面图在原生壳内优先走 HostBridge 图片导入', async () => { + const uploadedDataUrl = 'data:image/png;base64,host-match3d-cover'; + stubMatch3DCoverUpload(uploadedDataUrl); + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({ + action: 'selected', + fileName: '抓大鹅封面.png', + base64Data: 'Y292ZXI=', + mimeType: 'image/png', + bytes: 5, + }); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + try { + render( + {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '发布' })); + const publishDialog = screen.getByRole('dialog', { + name: '发布抓大鹅作品', + }); + fireEvent.click( + within(publishDialog).getByRole('button', { name: '上传封面图' }), + ); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + expect( + within(publishDialog).getByRole('switch', { name: 'AI重绘' }), + ).toBeTruthy(); + }); + expect( + within(publishDialog) + .getByRole('img', { name: '封面图预览' }) + .getAttribute('src'), + ).toBe(uploadedDataUrl); + expect(inputClickSpy).not.toHaveBeenCalled(); + } finally { + inputClickSpy.mockRestore(); + } + }); + + test('发布封面参考图取消原生导入时不触发浏览器文件输入', async () => { + vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation( + (capability) => capability === 'file.importImage', + ); + vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false); + const inputClickSpy = vi + .spyOn(HTMLInputElement.prototype, 'click') + .mockImplementation(() => undefined); + + try { + render( + {}} + onStartTestRun={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '发布' })); + const publishDialog = screen.getByRole('dialog', { + name: '发布抓大鹅作品', + }); + fireEvent.click( + within(publishDialog).getByRole('button', { name: '上传参考图' }), + ); + + await waitFor(() => { + expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1); + }); + expect(inputClickSpy).not.toHaveBeenCalled(); + expect(within(publishDialog).queryByText('自定义参考图')).toBeNull(); + } finally { + inputClickSpy.mockRestore(); + } + }); + test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => { const profile = createProfile(); const onStartTestRun = vi.fn(); diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 3dfe769d7..2a8d90403 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -17,6 +17,7 @@ import { type ReactNode, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -29,6 +30,11 @@ import type { PutMatch3DWorkRequest, } from '../../../packages/shared/src/contracts/match3dWorks'; import { isGeneratedLegacyPath } from '../../services/assetReadUrlService'; +import { + canUseNativeHostCapability, + type HostFileImportImageResult, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; import { generateMatch3DCoverImage, generateMatch3DItemAssets, @@ -49,7 +55,10 @@ import { loadMatch3DSpritesheetAssetRegions, type Match3DDecodedSpritesheetRegion, } from '../../services/match3dSpritesheetParser'; -import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; +import { + puzzleReferenceImageDataUrlToFile, + readPuzzleReferenceImageAsDataUrl, +} from '../../services/puzzleReferenceImage'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBackActionButton } from '../common/PlatformBackActionButton'; import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard'; @@ -1152,6 +1161,13 @@ async function readCoverReferenceImageAsDataUrl(file: File) { return readPuzzleReferenceImageAsDataUrl(file); } +function hostMatch3DImageResultToFile(result: HostFileImportImageResult) { + return puzzleReferenceImageDataUrlToFile( + `data:${result.mimeType};base64,${result.base64Data}`, + result.fileName, + ); +} + function resolveMatch3DCoverSourceAssets( assetDrafts: Match3DItemAssetDraft[], backgroundPreviewSrc: string, @@ -1483,9 +1499,11 @@ type Match3DCoverImageEditorProps = { error: string | null; onAiRedrawChange: (enabled: boolean) => void; onFileChange: (event: ChangeEvent) => void; + onFileUploadClick: (fallback: () => void) => void; onPromptChange: (value: string) => void; onReferenceSelect: (source: string) => void; onReferenceFileChange: (event: ChangeEvent) => void; + onReferenceFileUploadClick: (fallback: () => void) => void; onReferenceRemove: (referenceId: string) => void; onUploadedImageRemove: () => void; onSubmit: () => void; @@ -1502,13 +1520,17 @@ function Match3DCoverImageEditor({ error, onAiRedrawChange, onFileChange, + onFileUploadClick, onPromptChange, onReferenceSelect, onReferenceFileChange, + onReferenceFileUploadClick, onReferenceRemove, onUploadedImageRemove, onSubmit, }: Match3DCoverImageEditorProps) { + const coverInputRef = useRef(null); + const referenceInputRef = useRef(null); const previewSrc = uploadedImageSrc || editState.coverImageSrc; const promptLabel = uploadedImageSrc ? 'AI重绘要求' : '封面描述'; const canSubmit = Boolean(uploadedImageSrc.trim() || prompt.trim()); @@ -1518,15 +1540,31 @@ function Match3DCoverImageEditor({