接入个人头像原生图片导入

个人头像上传在原生壳声明 file.importImage 时调用 HostBridge 图片导入

头像导入结果转换为 File 后复用现有类型校验、5 MiB 限制、方形裁剪和资料更新链路

补充头像原生导入回归测试,确认不会触发隐藏文件输入

更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
2026-06-18 06:39:19 +08:00
parent 01349e7882
commit a0497cb39d
5 changed files with 96 additions and 23 deletions
@@ -2319,3 +2319,10 @@
- 决策:`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`
## 2026-06-18 个人头像上传接入原生壳图片导入
- 背景:个人资料头像上传在 Expo / Tauri 壳内仍只触发浏览器隐藏文件输入,移动端相册选择和桌面系统选择框能力没有被头像流程复用。
- 决策:`RpgEntryHomeView` 的头像上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换成浏览器 `File` 后继续走现有头像读取、类型校验、5 MiB 大小限制、方形裁剪和 `updateAuthProfile({ avatarDataUrl })` 链路。宿主只负责受控图片选择,不暴露设备 URI、本机绝对路径或通用文件系统,也不新增 React Native / Tauri 专属头像编辑页;普通浏览器、小程序和未声明能力的裁剪壳继续使用原 `<input type="file">`
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/services/host-bridge/hostBridge.ts`、Expo / Tauri HostBridge 文档。
- 验证方式:`npm run check:native-shells``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile avatar upload"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding``git diff --check`
File diff suppressed because one or more lines are too long
@@ -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` 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` 回调;反馈页上传凭证在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,继续复用原有数量、大小、data URL 和提交 payload 校验;在桌面壳同时声明 `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 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路;在桌面壳同时声明 `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` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
@@ -24,6 +24,7 @@ import type {
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
import {
@@ -3252,6 +3253,39 @@ test('profile avatar upload uses the shared square crop tool', async () => {
expect(screen.queryByText('纵向')).toBeNull();
});
test('profile avatar upload imports image through native HostBridge', async () => {
stubFileReader('data:image/png;base64,native-avatar-source');
stubImage(720, 720);
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: 'native-avatar.webp',
base64Data: 'YXZhdGFy',
mimeType: 'image/webp',
bytes: 6,
});
try {
renderProfileView();
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy();
});
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(inputClickSpy).not.toHaveBeenCalled();
expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy();
} finally {
vi.restoreAllMocks();
}
});
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
+52 -20
View File
@@ -69,24 +69,27 @@ import {
getPublicAuthUserById,
updateAuthProfile,
} from '../../services/authService';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformAcknowledgeStatusDialog } from '../common/PlatformAcknowledgeStatusDialog';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import {
@@ -99,12 +102,12 @@ import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
import { SquareImageCropModal } from '../common/SquareImageCropModal';
import { UnifiedModal } from '../common/UnifiedModal';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import { UnifiedModal } from '../common/UnifiedModal';
import {
useCopyFeedback,
} from '../common/useCopyFeedback';
@@ -117,6 +120,11 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import {
ProfileLegalSection,
ProfileSettingsRow,
@@ -124,17 +132,12 @@ import {
ProfileStatCard,
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import {
type RechargePaymentResult,
usePlatformProfileCenterController,
@@ -143,7 +146,6 @@ import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildProfileDashboardPresentation,
formatSnapshotTime,
} from './rpgEntryProfileDashboardPresentation';
import { buildProfileTaskCardSummary } from './rpgEntryProfileTaskViewModel';
import {
@@ -2374,6 +2376,16 @@ function loadAvatarFile(file: File) {
});
}
function hostAvatarImageResultToFile(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 });
}
function cropAvatarImage(params: {
source: string;
cropX: number;
@@ -3042,15 +3054,6 @@ export function RpgEntryHomeView({
})
.finally(() => setIsSavingNickname(false));
};
const openAvatarPicker = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setAvatarError(null);
avatarFileInputRef.current?.click();
};
const handleAvatarFileChange = (file: File | null) => {
if (avatarFileInputRef.current) {
avatarFileInputRef.current.value = '';
@@ -3081,6 +3084,35 @@ export function RpgEntryHomeView({
);
});
};
const openAvatarPicker = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setAvatarError(null);
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
try {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
handleAvatarFileChange(
hostAvatarImageResultToFile(importedImageFile),
);
} catch (error: unknown) {
setAvatarError(
error instanceof Error ? error.message : '头像图片导入失败',
);
}
})();
return;
}
avatarFileInputRef.current?.click();
};
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {