接入RPG封面原生导入

RPG作品封面上传在原生壳内优先走宿主图片导入

封面原生选择取消时不触发浏览器文件输入

同步封面上传测试、宿主壳协议和共享记忆
This commit is contained in:
2026-06-19 12:01:09 +08:00
parent bf50c6fab6
commit 72b032ff9d
5 changed files with 140 additions and 4 deletions
@@ -13,6 +13,7 @@ import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
import * as hostBridgeServices from '../services/host-bridge/hostBridge';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import type {
CustomWorldNpc,
@@ -29,6 +30,7 @@ import {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
vi.mock('../data/characterPresets', async () => {
@@ -159,6 +161,11 @@ vi.mock('../services/customWorldCoverAssetService', () => ({
uploadCustomWorldCoverImage: vi.fn(),
}));
vi.mock('../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
}));
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
@@ -2194,3 +2201,82 @@ test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
);
});
test('作品封面上传在原生壳内优先走 HostBridge 图片导入', async () => {
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: 'native-cover.png',
base64Data:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
mimeType: 'image/png',
bytes: 68,
});
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = 1920;
naturalHeight = 1080;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const user = userEvent.setup();
render(<CoverEditorFlowHarness />);
await user.click(screen.getByText('上传封面'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
});
expect(inputClickSpy).not.toHaveBeenCalled();
expect(
screen.getByRole('img', { name: '上传封面裁剪预览' }),
).toBeTruthy();
});
test('作品封面取消原生导入时不触发浏览器文件输入', async () => {
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const user = userEvent.setup();
render(<CoverEditorFlowHarness />);
await user.click(screen.getByText('上传封面'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
expect(screen.queryByText('裁剪上传封面')).toBeNull();
});
@@ -61,6 +61,11 @@ import {
type RpgCreationHistoryAsset,
type RpgCreationHistoryAssetKind,
} from '../../services/rpg-creation/rpgCreationAssetClient';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
AnimationState,
@@ -4069,6 +4074,7 @@ export function WorldCoverEditor({
height: number;
} | null>(null);
const coverUploadInputId = useId();
const coverUploadInputRef = useRef<HTMLInputElement | null>(null);
const previewProfile = useMemo(
() => ({
...profile,
@@ -4081,9 +4087,7 @@ export function WorldCoverEditor({
[previewProfile],
);
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
const prepareCoverUploadFile = async (file: File) => {
if (!file) {
return;
}
@@ -4108,6 +4112,37 @@ export function WorldCoverEditor({
}
};
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
await prepareCoverUploadFile(file);
};
const handleUploadCoverClick = () => {
if (!canUseNativeHostCapability('file.importImage')) {
coverUploadInputRef.current?.click();
return;
}
void (async () => {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
await prepareCoverUploadFile(
puzzleReferenceImageDataUrlToFile(
`data:${importedImage.mimeType};base64,${importedImage.base64Data}`,
importedImage.fileName,
),
);
})();
};
const handleConfirmUploadCrop = async (
cropRect: CustomWorldCoverCropRect,
) => {
@@ -4177,8 +4212,15 @@ export function WorldCoverEditor({
size="panel"
surface="editorDark"
disabled={isUploading}
onClick={(event) => {
event.preventDefault();
if (!isUploading) {
handleUploadCoverClick();
}
}}
/>
<input
ref={coverUploadInputRef}
id={coverUploadInputId}
type="file"
accept="image/png,image/jpeg,image/webp"