接入抓大鹅封面原生导入
抓大鹅发布封面图在原生壳内优先走宿主图片导入 封面参考图取消原生选择时不触发浏览器文件输入 同步 Match3D 测试、宿主壳协议和共享记忆
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user