接入RPG封面原生导入
RPG作品封面上传在原生壳内优先走宿主图片导入 封面原生选择取消时不触发浏览器文件输入 同步封面上传测试、宿主壳协议和共享记忆
This commit is contained in:
@@ -2675,3 +2675,10 @@
|
||||
- 决策:`RpgCreationRoleAssetStudioModal` 的角色参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readFileAsDataUrl`、参考图集合和角色形象生成 payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
|
||||
- 影响范围:`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`、`src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx`、`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`、宿主壳能力统一协议文档。
|
||||
- 验证方式:`npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-19 RPG 作品封面上传接入原生壳图片导入
|
||||
|
||||
- 背景:RPG 作品封面编辑器的上传封面仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器,也无法在用户取消原生选择时停留在壳流程内。
|
||||
- 决策:`WorldCoverEditor` 的作品封面上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 10 MiB 校验、data URL 读取、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
|
||||
- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`、`src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。
|
||||
- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面"`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -225,6 +225,7 @@ npm run check:native-shells
|
||||
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
|
||||
抓大鹅结果页发布封面图和封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
|
||||
RPG 角色资产工作室的角色参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
|
||||
RPG 作品封面上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 10 MiB 校验、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路;用户取消原生选择不应再连带弹出浏览器文件输入。
|
||||
视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `file.importImage` / `file.importAudio` 时必须优先走宿主受控导入,并把 H5 base64 转 `File` 后继续交给 `uploadVisualNovelAsset` 与当前素材字段写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,历史素材选择和 AI 图片生成保持原链路。
|
||||
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
|
||||
该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user