接入抓大鹅封面原生导入

抓大鹅发布封面图在原生壳内优先走宿主图片导入

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

同步 Match3D 测试、宿主壳协议和共享记忆
This commit is contained in:
2026-06-19 11:46:32 +08:00
parent 0cb75f14e5
commit 98daf41790
5 changed files with 240 additions and 5 deletions
@@ -10,6 +10,7 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import * as match3dWorksService from '../../services/match3d-works';
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
import { Match3DResultView } from './Match3DResultView';
@@ -49,6 +50,11 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
}));
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
const actual =
await importOriginal<
@@ -536,6 +542,93 @@ describe('Match3DResultView', () => {
});
});
test('发布封面图在原生壳内优先走 HostBridge 图片导入', async () => {
const uploadedDataUrl = 'data:image/png;base64,host-match3d-cover';
stubMatch3DCoverUpload(uploadedDataUrl);
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: '抓大鹅封面.png',
base64Data: 'Y292ZXI=',
mimeType: 'image/png',
bytes: 5,
});
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<Match3DResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '上传封面图' }),
);
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(
within(publishDialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
});
expect(
within(publishDialog)
.getByRole('img', { name: '封面图预览' })
.getAttribute('src'),
).toBe(uploadedDataUrl);
expect(inputClickSpy).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
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);
try {
render(
<Match3DResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '上传参考图' }),
);
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
expect(within(publishDialog).queryByText('自定义参考图')).toBeNull();
} finally {
inputClickSpy.mockRestore();
}
});
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
@@ -17,6 +17,7 @@ import {
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -29,6 +30,11 @@ import type {
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import {
generateMatch3DCoverImage,
generateMatch3DItemAssets,
@@ -49,7 +55,10 @@ import {
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
puzzleReferenceImageDataUrlToFile,
readPuzzleReferenceImageAsDataUrl,
} from '../../services/puzzleReferenceImage';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
@@ -1152,6 +1161,13 @@ async function readCoverReferenceImageAsDataUrl(file: File) {
return readPuzzleReferenceImageAsDataUrl(file);
}
function hostMatch3DImageResultToFile(result: HostFileImportImageResult) {
return puzzleReferenceImageDataUrlToFile(
`data:${result.mimeType};base64,${result.base64Data}`,
result.fileName,
);
}
function resolveMatch3DCoverSourceAssets(
assetDrafts: Match3DItemAssetDraft[],
backgroundPreviewSrc: string,
@@ -1483,9 +1499,11 @@ type Match3DCoverImageEditorProps = {
error: string | null;
onAiRedrawChange: (enabled: boolean) => void;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onFileUploadClick: (fallback: () => void) => void;
onPromptChange: (value: string) => void;
onReferenceSelect: (source: string) => void;
onReferenceFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onReferenceFileUploadClick: (fallback: () => void) => void;
onReferenceRemove: (referenceId: string) => void;
onUploadedImageRemove: () => void;
onSubmit: () => void;
@@ -1502,13 +1520,17 @@ function Match3DCoverImageEditor({
error,
onAiRedrawChange,
onFileChange,
onFileUploadClick,
onPromptChange,
onReferenceSelect,
onReferenceFileChange,
onReferenceFileUploadClick,
onReferenceRemove,
onUploadedImageRemove,
onSubmit,
}: Match3DCoverImageEditorProps) {
const coverInputRef = useRef<HTMLInputElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
const previewSrc = uploadedImageSrc || editState.coverImageSrc;
const promptLabel = uploadedImageSrc ? 'AI重绘要求' : '封面描述';
const canSubmit = Boolean(uploadedImageSrc.trim() || prompt.trim());
@@ -1518,15 +1540,31 @@ function Match3DCoverImageEditor({
<div className="space-y-3">
<div className="relative aspect-square overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/70">
<input
ref={coverInputRef}
id="match3d-cover-upload-input"
type="file"
accept="image/*"
className="sr-only"
disabled={isGenerating}
aria-label={previewSrc ? '更换封面图' : '上传封面图'}
onChange={onFileChange}
/>
<label
htmlFor="match3d-cover-upload-input"
role="button"
tabIndex={isGenerating ? -1 : 0}
onClick={(event) => {
event.preventDefault();
if (!isGenerating) {
onFileUploadClick(() => coverInputRef.current?.click());
}
}}
onKeyDown={(event) => {
if (isGenerating || (event.key !== 'Enter' && event.key !== ' ')) {
return;
}
event.preventDefault();
onFileUploadClick(() => coverInputRef.current?.click());
}}
className={`absolute inset-0 z-0 ${isGenerating ? 'cursor-not-allowed' : 'cursor-pointer'}`}
title={previewSrc ? '更换封面图' : '上传封面图'}
>
@@ -1568,7 +1606,24 @@ function Match3DCoverImageEditor({
</>
) : (
<label
htmlFor="match3d-cover-upload-input"
role="button"
tabIndex={isGenerating ? -1 : 0}
onClick={(event) => {
event.preventDefault();
if (!isGenerating) {
onFileUploadClick(() => coverInputRef.current?.click());
}
}}
onKeyDown={(event) => {
if (
isGenerating ||
(event.key !== 'Enter' && event.key !== ' ')
) {
return;
}
event.preventDefault();
onFileUploadClick(() => coverInputRef.current?.click());
}}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] ${isGenerating ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
上传图片/填写封面描述
@@ -1599,18 +1654,25 @@ function Match3DCoverImageEditor({
<div className="mb-2 flex items-center justify-between gap-3">
<PlatformFieldLabel variant="section">参考图</PlatformFieldLabel>
<PlatformIconButton
asChild="label"
className="h-9 w-9 cursor-pointer"
label="上传参考图"
title="上传参考图"
disabled={isGenerating}
onClick={() =>
onReferenceFileUploadClick(() =>
referenceInputRef.current?.click(),
)
}
icon={
<>
<ImagePlus className="h-4 w-4" />
<input
ref={referenceInputRef}
type="file"
accept="image/*"
className="sr-only"
disabled={isGenerating}
aria-label="上传参考图"
onChange={onReferenceFileChange}
/>
</>
@@ -1708,10 +1770,12 @@ function Match3DPublishDialog({
onAiRedrawChange,
onClose,
onFileChange,
onFileUploadClick,
onPromptChange,
onPublish,
onReferenceSelect,
onReferenceFileChange,
onReferenceFileUploadClick,
onReferenceRemove,
onUploadedImageRemove,
onSubmitCover,
@@ -1731,10 +1795,12 @@ function Match3DPublishDialog({
onAiRedrawChange: (enabled: boolean) => void;
onClose: () => void;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onFileUploadClick: (fallback: () => void) => void;
onPromptChange: (value: string) => void;
onPublish: () => void;
onReferenceSelect: (source: string) => void;
onReferenceFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onReferenceFileUploadClick: (fallback: () => void) => void;
onReferenceRemove: (referenceId: string) => void;
onUploadedImageRemove: () => void;
onSubmitCover: () => void;
@@ -1815,9 +1881,11 @@ function Match3DPublishDialog({
error={coverError}
onAiRedrawChange={onAiRedrawChange}
onFileChange={onFileChange}
onFileUploadClick={onFileUploadClick}
onPromptChange={onPromptChange}
onReferenceSelect={onReferenceSelect}
onReferenceFileChange={onReferenceFileChange}
onReferenceFileUploadClick={onReferenceFileUploadClick}
onReferenceRemove={onReferenceRemove}
onUploadedImageRemove={onUploadedImageRemove}
onSubmit={onSubmitCover}
@@ -3029,6 +3097,34 @@ export function Match3DResultView({
}
};
const setCoverImageFromFile = async (file: File) => {
try {
const dataUrl = await readImageAsDataUrl(file);
setCoverUploadedImageSrc(dataUrl);
setCoverAiRedraw(true);
setCoverPanelError(null);
} catch (caughtError) {
setCoverPanelError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleCoverImageUploadClick = (fallback: () => void) => {
if (!canUseNativeHostCapability('file.importImage')) {
fallback();
return;
}
void (async () => {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
await setCoverImageFromFile(hostMatch3DImageResultToFile(importedImageFile));
})();
};
const handleCoverReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -3056,6 +3152,42 @@ export function Match3DResultView({
}
};
const addCoverReferenceImageFromFile = async (file: File) => {
try {
const dataUrl = await readCoverReferenceImageAsDataUrl(file);
setCoverReferenceImages((current) =>
addMatch3DCoverReferenceDraft(current, {
id: `upload:${Date.now()}:${file.name}`,
label: file.name.trim() || '自定义参考图',
imageSrc: dataUrl,
source: 'upload',
}),
);
setCoverPanelError(null);
} catch (caughtError) {
setCoverPanelError(
caughtError instanceof Error ? caughtError.message : '参考图读取失败。',
);
}
};
const handleCoverReferenceImageUploadClick = (fallback: () => void) => {
if (!canUseNativeHostCapability('file.importImage')) {
fallback();
return;
}
void (async () => {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
await addCoverReferenceImageFromFile(
hostMatch3DImageResultToFile(importedImageFile),
);
})();
};
const resetCoverEditor = () => {
setCoverUploadedImageSrc('');
setCoverReferenceImages([]);
@@ -3581,6 +3713,7 @@ export function Match3DResultView({
onAiRedrawChange={setCoverAiRedraw}
onClose={closePublishDialog}
onFileChange={handleCoverImageChange}
onFileUploadClick={handleCoverImageUploadClick}
onPromptChange={setCoverPrompt}
onPublish={() => {
void handlePublish();
@@ -3601,6 +3734,7 @@ export function Match3DResultView({
setCoverPanelError(null);
}}
onReferenceFileChange={handleCoverReferenceImageChange}
onReferenceFileUploadClick={handleCoverReferenceImageUploadClick}
onReferenceRemove={(referenceId) => {
setCoverReferenceImages((current) =>
current.filter((reference) => reference.id !== referenceId),