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

个人头像上传在原生壳声明 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
@@ -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) {