接入反馈凭证原生拍摄

反馈页在移动壳声明拍摄能力时展示拍摄凭证入口

拍摄图片复用现有凭证校验、预览和提交链路

同步原生壳测试、文档和共享记忆
This commit is contained in:
2026-06-19 11:20:32 +08:00
parent 48cd76918b
commit 7c50fa489d
6 changed files with 96 additions and 10 deletions
@@ -2391,10 +2391,10 @@
## 2026-06-18 反馈凭证上传接入原生壳图片导入
- 背景:帮助与反馈页的上传凭证入口在原生壳内仍只能触发浏览器隐藏文件输入;Expo / Tauri 壳已具备受控 `file.importImage` 图片选择能力。
- 决策:`PlatformFeedbackView``native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有凭证预览和提交逻辑。反馈页仍保留最多 4 张、单张 1MB、总 4MB、图片 MIME 和 data URL payload 校验;宿主不暴露设备 URI、本机绝对路径或通用文件系统。普通浏览器、小程序和未声明能力的裁剪壳继续使用原 `<input type="file">`
- 背景:帮助与反馈页的上传凭证入口在原生壳内仍只能触发浏览器隐藏文件输入;Expo / Tauri 壳已具备受控 `file.importImage` 图片选择能力Expo 移动壳还具备真实 `file.captureImage` 相机拍摄能力
- 决策:`PlatformFeedbackView``native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有凭证预览和提交逻辑;移动壳声明 `file.captureImage` 时额外展示“拍摄凭证”入口,调用 `captureHostImageFile()` 后复用同一转换、校验和提交链路。反馈页仍保留最多 4 张、单张 1MB、总 4MB、图片 MIME 和 data URL payload 校验;宿主不暴露设备 URI、本机绝对路径或通用文件系统。普通浏览器、小程序、Tauri 桌面壳和未声明拍摄能力的裁剪壳不显示拍摄入口
- 影响范围:`src/components/platform-entry/PlatformFeedbackView.tsx``src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。
- 验证方式:`npm run check:native-shells``npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx`针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding``git diff --check`
- 验证方式:`npm run check:native-shells``npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 个人头像上传接入原生壳图片导入
@@ -219,6 +219,7 @@ npm run check:native-shells
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、H5 `navigation.canGoBack` 消费 hook 与直达二级页返回锚点测试、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口、H5 页面内导航保留完整原生宿主上下文和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。
创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
反馈页上传凭证在原生壳声明 `file.importImage` 时必须优先走宿主图片导入;移动壳声明 `file.captureImage` 时才显示拍摄凭证入口,并把拍摄图片同样转为 `File` 后复用反馈页原有数量、大小、MIME、data URL 预览和提交 payload 校验。
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `file.importImage` / `file.importAudio` 时必须优先走宿主受控导入,并把 H5 base64 转 `File` 后继续交给 `uploadVisualNovelAsset` 与当前素材字段写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,历史素材选择和 AI 图片生成保持原链路。
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
@@ -316,6 +316,8 @@ GameBridge 禁止:
2026-06-19 追加:视觉小说结果页素材选择弹窗在移动壳声明 `file.importImage` / `file.importAudio` 时优先打开宿主系统图片或音频选择器,把 HostBridge 返回的 base64 副本转换成浏览器 `File` 后继续调用 `uploadVisualNovelAsset`,不绕过平台素材上传、历史素材、AI 图片生成或草稿写回链路;用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入。普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
2026-06-19 追加:帮助与反馈页在移动壳声明 `file.captureImage` 时展示“拍摄凭证”入口,调用 Expo 相机拍摄后把 HostBridge 返回的图片副本转换成浏览器 `File`,继续复用反馈页最多 4 张、单张 1 MiB、总 4 MiB、图片 MIME、data URL 预览和提交 payload 校验。拍摄取消时停留在壳流程内,不触发浏览器文件输入;Tauri 桌面壳不声明拍摄能力,普通浏览器、小程序和未声明能力的裁剪壳不显示该入口。
2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType``base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还会按 MIME 补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免系统分享 / 保存面板拿到无扩展或错扩展的缓存图片。
2026-06-18 追加:移动壳音频导入的 DocumentPicker `type` 参数包含 `audio/*`,用于兼容 iOS / Android 文件提供方只按通用音频类型暴露候选文件的情况;这不放宽 HostBridge 返回契约。系统返回 `application/octet-stream`、空 MIME 或其它宽泛 MIME 时,移动壳只会按 `.mp3` / `.m4a` / `.mp4` / `.wav` / `.ogg` / `.webm` 扩展名归一到允许 MIME,并继续执行 base64 bytes 匹配,扩展名或 bytes 任一不匹配都返回 `invalid_request`
@@ -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 DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `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 工作台的参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;在桌面壳同时声明 `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 工作台的参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页在移动壳声明 `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(...)`,防止后续事件订阅绕过双能力门控。
@@ -37,6 +37,7 @@ test('PlatformFeedbackView renders reference feedback fields', () => {
expect(screen.getByText('0/200')).toBeTruthy();
expect(screen.getByText('上传凭证(提供问题截图)')).toBeTruthy();
expect(screen.getByText('上传凭证')).toBeTruthy();
expect(screen.queryByText('拍摄凭证')).toBeNull();
const contactPhoneField = screen.getByLabelText('联系电话');
expect(contactPhoneField).toBeTruthy();
expect(contactPhoneField.tagName).toBe('INPUT');
@@ -189,6 +190,57 @@ test('PlatformFeedbackView imports evidence image through native HostBridge', as
}
});
test('PlatformFeedbackView captures evidence image through native HostBridge', async () => {
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.captureImage',
);
vi.spyOn(hostBridgeServices, 'captureHostImageFile').mockResolvedValue({
action: 'captured',
fileName: 'feedback-camera.png',
base64Data: 'Y2FtZXJhLWZlZWRiYWNr',
mimeType: 'image/png',
bytes: 15,
});
const onSubmit = vi.fn();
try {
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
expect(screen.getByText('拍摄凭证')).toBeTruthy();
fireEvent.click(screen.getByText('拍摄凭证'));
const preview = await screen.findByAltText('反馈凭证预览');
expect(preview.getAttribute('src')).toBe(
'data:image/png;base64,ZmVlZGJhY2s=',
);
expect(inputClickSpy).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('问题描述'), {
target: { value: '拍摄凭证后应该可以直接提交' },
});
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith({
description: '拍摄凭证后应该可以直接提交',
contactPhone: null,
evidenceItems: [
{
fileName: 'feedback-camera.png',
contentType: 'image/png',
sizeBytes: 15,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
} finally {
inputClickSpy.mockRestore();
}
});
test('PlatformFeedbackView calls back from header home button', () => {
const onBack = vi.fn();
render(<PlatformFeedbackView onBack={onBack} />);
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
import {
canUseNativeHostCapability,
captureHostImageFile,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
@@ -86,6 +87,8 @@ export function PlatformFeedbackView({
const [submitted, setSubmitted] = useState(false);
const descriptionLength = description.length;
const canCaptureHostEvidenceImage =
canUseNativeHostCapability('file.captureImage');
const showTemporaryNotice = (message: string) => {
setNotice(message);
@@ -104,6 +107,10 @@ export function PlatformFeedbackView({
setSubmitted(false);
};
const addHostEvidenceImage = (imageFile: HostFileImportImageResult) => {
addEvidenceFiles([hostEvidenceImageResultToFile(imageFile)]);
};
const openEvidencePicker = () => {
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
@@ -112,7 +119,7 @@ export function PlatformFeedbackView({
return;
}
addEvidenceFiles([hostEvidenceImageResultToFile(importedImageFile)]);
addHostEvidenceImage(importedImageFile);
})();
return;
}
@@ -120,6 +127,21 @@ export function PlatformFeedbackView({
evidenceInputRef.current?.click();
};
const captureEvidenceImage = () => {
if (!canCaptureHostEvidenceImage) {
return;
}
void (async () => {
const capturedImageFile = await captureHostImageFile();
if (!capturedImageFile) {
return;
}
addHostEvidenceImage(capturedImageFile);
})();
};
const addEvidenceFiles = (files: FileList | File[] | null) => {
const selectedFiles = files ? Array.from(files) : [];
if (evidenceInputRef.current) {
@@ -312,11 +334,20 @@ export function PlatformFeedbackView({
/>
))}
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
<PlatformUploadTile
onClick={openEvidencePicker}
label="上传凭证"
hint="(最多四张)"
/>
<>
<PlatformUploadTile
onClick={openEvidencePicker}
label="上传凭证"
hint="(最多四张)"
/>
{canCaptureHostEvidenceImage ? (
<PlatformUploadTile
onClick={captureEvidenceImage}
label="拍摄凭证"
hint="(最多四张)"
/>
) : null}
</>
) : null}
</div>
<input