接入反馈凭证原生图片导入

反馈页上传凭证在原生壳内优先调用 HostBridge 图片导入

宿主图片结果转换为 File 后复用现有凭证预览和提交校验

补充反馈凭证原生导入测试和宿主壳文档边界
This commit is contained in:
2026-06-18 06:24:44 +08:00
parent a7d713c806
commit 01349e7882
5 changed files with 88 additions and 3 deletions

View File

@@ -2312,3 +2312,10 @@
- 决策:创作 Agent 工作台在 `native_app` 且宿主声明 `file.importText` 时优先调用 `importHostTextFile()`,将宿主返回的 UTF-8 文本副本转换成浏览器 `File` 后继续调用现有 `/api/runtime/creation-agent/document-inputs/parse`。原生壳只负责受控选择和返回文本内容,不暴露设备 URI、本机路径或通用文件系统也不在前端绕过后端文档解析、256KB 解析限制、docx 支持或错误口径。普通浏览器、小程序和未声明该能力的裁剪壳继续使用原 `<input type="file">` 路径。
- 影响范围:`src/components/creation-agent/CreationAgentWorkspace.tsx``src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。
- 验证方式:`npm run check:native-shells``npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding``git diff --check`
## 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">`
- 影响范围:`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`

File diff suppressed because one or more lines are too long

View File

@@ -60,7 +60,7 @@ AI H5 sandbox
- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB成功只返回文件名和字节数不把本机绝对路径暴露给 H5系统分享不可用或用户取消时返回明确错误由 H5 fallback 承接。
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIMEExpo 移动壳写入缓存图片后交给系统分享 / 保存面板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` 回调;在桌面壳同时声明 `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` 回调;反馈页上传凭证在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入并继续复用原有数量、大小、data URL 和提交 payload 校验;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIMEExpo 移动壳写入缓存音频后交给系统分享 / 保存面板Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB成功只返回文件名和字节数不回传本机绝对路径也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。

View File

@@ -3,6 +3,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import { PlatformFeedbackView } from './PlatformFeedbackView';
class MockFileReader {
@@ -138,6 +139,56 @@ test('PlatformFeedbackView previews image data urls and submits evidence items',
});
});
test('PlatformFeedbackView imports evidence image through native HostBridge', async () => {
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.spyOn(hostBridgeServices, 'importHostImageFile').mockResolvedValue({
action: 'selected',
fileName: 'feedback.png',
base64Data: 'ZmVlZGJhY2s=',
mimeType: 'image/png',
bytes: 8,
});
const onSubmit = vi.fn();
try {
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
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.png',
contentType: 'image/png',
sizeBytes: 8,
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} />);

View File

@@ -2,6 +2,11 @@ import { ArrowLeft, CheckCircle2, Send } from 'lucide-react';
import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -55,6 +60,16 @@ function readFileAsDataUrl(file: File) {
});
}
function hostEvidenceImageResultToFile(result: HostFileImportImageResult) {
const binary = atob(result.base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], result.fileName, { type: result.mimeType });
}
export function PlatformFeedbackView({
onBack,
onSubmit,
@@ -90,10 +105,22 @@ export function PlatformFeedbackView({
};
const openEvidencePicker = () => {
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
addEvidenceFiles([hostEvidenceImageResultToFile(importedImageFile)]);
})();
return;
}
evidenceInputRef.current?.click();
};
const addEvidenceFiles = (files: FileList | null) => {
const addEvidenceFiles = (files: FileList | File[] | null) => {
const selectedFiles = files ? Array.from(files) : [];
if (evidenceInputRef.current) {
evidenceInputRef.current.value = '';