接入个人头像原生图片导入
个人头像上传在原生壳声明 file.importImage 时调用 HostBridge 图片导入 头像导入结果转换为 File 后复用现有类型校验、5 MiB 限制、方形裁剪和资料更新链路 补充头像原生导入回归测试,确认不会触发隐藏文件输入 更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录
This commit is contained in:
@@ -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: [] });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user