方洞结果页接入宿主图片导入

方洞结果页图片槽位在原生壳内优先调用 file.importImage

保留浏览器文件输入回退并覆盖原生导入、回退和取消选择测试

同步方洞玩法链路、HostBridge 方案和团队决策记录
This commit is contained in:
2026-06-18 19:02:21 +08:00
parent d71fde8b9a
commit ab27815810
6 changed files with 221 additions and 15 deletions
@@ -8,9 +8,13 @@ import {
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { SquareHoleResultView } from './SquareHoleResultView';
vi.mock('../../services/square-hole-works', () => ({
@@ -22,6 +26,19 @@ vi.mock('../../services/square-hole-works', () => ({
updateSquareHoleWork: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
}));
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostImageFileMock = vi.mocked(importHostImageFile);
beforeEach(() => {
canUseNativeHostCapabilityMock.mockReturnValue(false);
importHostImageFileMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -308,6 +325,121 @@ test('square hole image dialog reuses platform media frame', async () => {
expect(mediaFrame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
});
test('square hole image dialog imports slot images from native host capability', async () => {
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
vi.mocked(updateSquareHoleWork).mockResolvedValue({
item: createProfile(),
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importImage',
);
importHostImageFileMock.mockResolvedValue({
action: 'selected',
fileName: '方洞封面.png',
mimeType: 'image/png',
base64Data: 'bmF0aXZlLWNvdmVy',
bytes: 12,
});
try {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
await waitFor(() => {
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
await waitFor(() => {
expect(updateSquareHoleWork).toHaveBeenCalledWith(
'profile-1',
expect.objectContaining({
coverImageSrc: 'data:image/png;base64,bmF0aXZlLWNvdmVy',
}),
);
});
} finally {
inputClickSpy.mockRestore();
}
});
test('square hole image dialog keeps browser file input fallback when native import is unavailable', async () => {
const user = userEvent.setup();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const { squareHoleAssetClient } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
try {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '查看封面图' }));
await screen.findByRole('dialog', { name: '封面图查看' });
await user.click(screen.getByRole('button', { name: '上传图片' }));
expect(importHostImageFileMock).not.toHaveBeenCalled();
expect(inputClickSpy).toHaveBeenCalledTimes(1);
} finally {
inputClickSpy.mockRestore();
}
});
test('square hole image dialog leaves slot image unchanged when native import is cancelled', async () => {
const { squareHoleAssetClient, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
vi.mocked(updateSquareHoleWork).mockResolvedValue({
item: createProfile(),
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.importImage',
);
importHostImageFileMock.mockResolvedValue(false);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看封面图' }));
expect(screen.getByRole('dialog', { name: '封面图查看' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '上传图片' }));
await waitFor(() => {
expect(importHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(updateSquareHoleWork).not.toHaveBeenCalled();
});
test('square hole cover and background buttons use platform media frames', () => {
render(
<SquareHoleResultView
@@ -9,7 +9,13 @@ import {
Send,
Trash2,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import {
type ChangeEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
@@ -26,6 +32,11 @@ import {
type SquareHoleImageAssetKind,
updateSquareHoleWork,
} from '../../services/square-hole-works';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
@@ -305,6 +316,10 @@ function readImageAsDataUrl(file: File) {
});
}
function hostImageImportResultToDataUrl(result: HostFileImportImageResult) {
return `data:${result.mimeType};base64,${result.base64Data}`;
}
function createShapeOption(
index: number,
holeOptions: SquareHoleHoleOption[],
@@ -484,6 +499,7 @@ function SquareHoleImageSlotDialog({
onClose,
onRegenerateImages,
onSelectHistory,
onHostUpload,
onUpload,
}: {
currentImageSrc: string;
@@ -494,14 +510,17 @@ function SquareHoleImageSlotDialog({
onClose: () => void;
onRegenerateImages: () => void;
onSelectHistory: (asset: SquareHoleHistoryAsset) => void;
onHostUpload: (slot: SquareHoleImageSlot) => void;
onUpload: (
slot: SquareHoleImageSlot,
event: ChangeEvent<HTMLInputElement>,
) => void;
}) {
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [assets, setAssets] = useState<SquareHoleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canImportHostImage = canUseNativeHostCapability('file.importImage');
useEffect(() => {
let cancelled = false;
@@ -534,6 +553,17 @@ function SquareHoleImageSlotDialog({
};
}, [slot.assetKind]);
const handleUploadClick = () => {
if (isBusy) {
return;
}
if (canImportHostImage) {
onHostUpload(slot);
return;
}
uploadInputRef.current?.click();
};
return (
<PlatformToolModalShell
open
@@ -559,22 +589,23 @@ function SquareHoleImageSlotDialog({
/>
<div className="grid gap-2 sm:grid-cols-2">
<PlatformActionButton
asChild="label"
onClick={handleUploadClick}
disabled={isBusy}
tone="ghost"
size="md"
aria-disabled={isBusy}
className={`min-h-11 cursor-pointer gap-2 px-4 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className="min-h-11 gap-2 px-4"
>
<ImagePlus className="h-4 w-4" />
上传图片
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
</PlatformActionButton>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
className="sr-only"
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
<PlatformActionButton
onClick={onRegenerateImages}
disabled={!canRegenerateImages || isBusy}
@@ -767,6 +798,37 @@ export function SquareHoleResultView({
}
};
const handleImageSlotHostUpload = async (slot: SquareHoleImageSlot) => {
if (
isBusy ||
isApplyingHistoryImage ||
isPublishing ||
isRegeneratingImages ||
isStartingTestRun
) {
return;
}
try {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
setEditState((current) =>
applyImageSlotSrc(
current,
slot,
hostImageImportResultToDataUrl(importedImage),
),
);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '图片读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null);
@@ -1420,6 +1482,9 @@ export function SquareHoleResultView({
onSelectHistory={(asset) => {
void handleSelectHistoryImage(activeImageSlot, asset);
}}
onHostUpload={(slot) => {
void handleImageSlotHostUpload(slot);
}}
onUpload={(slot, event) => {
void handleImageSlotUpload(slot, event);
}}