fix: polish platform creation flow interactions

This commit is contained in:
2026-06-06 21:36:38 +08:00
parent 7e6ed91149
commit 50e335ba47
12 changed files with 434 additions and 102 deletions

View File

@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
fireEvent.click(
screen.getByRole('button', { name: '关闭关卡图片预览' }),
);
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
},
});
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel can hide upload and history controls independently', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadMainImage={false}
canUseImageHistory={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
expect(
screen.queryByLabelText('上传参考图', { selector: 'input' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render(
<CreativeImageInputPanel

View File

@@ -6,7 +6,7 @@ import {
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
promptReferenceUpload: string;
promptReferencePreviewAlt: string;
closePromptReferencePreview: string;
previewMainImage?: string;
closeMainImagePreview?: string;
history?: string;
};
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
disabled?: boolean;
isSubmitting?: boolean;
mainImageMode?: 'edit' | 'preview';
mainImageClickMode?: 'upload' | 'preview';
canUploadMainImage?: boolean;
canUseImageHistory?: boolean;
canRemoveMainImage?: boolean;
canToggleAiRedraw?: boolean;
canUploadPromptReferences?: boolean;
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
disabled = false,
isSubmitting = false,
mainImageMode = 'edit',
mainImageClickMode = 'preview',
canUploadMainImage = true,
canUseImageHistory = true,
canRemoveMainImage = true,
canToggleAiRedraw = true,
canUploadPromptReferences,
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
onHistoryClick,
onSubmit,
}: CreativeImageInputPanelProps) {
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const [previewReferenceImage, setPreviewReferenceImage] =
useState<CreativeImageInputReferenceImage | null>(null);
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
const canEditMainImage = mainImageMode === 'edit';
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
const shouldShowHistoryButton =
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
const shouldPreviewMainImage =
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
const shouldShowMainImageUploadButton =
isMainImageUploadEnabled && shouldPreviewMainImage;
useEffect(() => {
if (uploadedImageSrc) {
setPreviewReferenceImage(null);
} else {
setIsMainImagePreviewOpen(false);
}
}, [uploadedImageSrc]);
@@ -187,35 +206,48 @@ export function CreativeImageInputPanel({
</div>
<div className={imageFrameClassName}>
<div className={imageCardClassName}>
{canEditMainImage ? (
<>
<input
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
}}
className="sr-only"
/>
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
{isMainImageUploadEnabled ? (
<input
ref={mainImageInputRef}
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
>
<span className="sr-only">
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
</span>
</label>
</>
}}
className="sr-only"
/>
) : null}
{shouldPreviewMainImage ? (
<button
type="button"
className="absolute inset-0 z-[2] cursor-zoom-in"
aria-label={labels.previewMainImage ?? uploadedImageAlt}
title={labels.previewMainImage ?? uploadedImageAlt}
onClick={() => setIsMainImagePreviewOpen(true)}
/>
) : isMainImageUploadEnabled ? (
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={
uploadedImageSrc
? labels.replaceImage
: labels.uploadImage
}
>
<span className="sr-only">
{uploadedImageSrc
? labels.replaceImage
: labels.uploadImage}
</span>
</label>
) : null}
{uploadedImageSrc ? (
<ResolvedAssetImage
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{canEditMainImage && onHistoryClick ? (
{shouldShowMainImageUploadButton ? (
<button
type="button"
disabled={disabled}
onClick={() => mainImageInputRef.current?.click()}
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.replaceImage}
title={labels.replaceImage}
>
<ImagePlus className="h-4 w-4" />
</button>
) : null}
{shouldShowHistoryButton ? (
<button
type="button"
disabled={disabled}
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
>
<Trash2 className="h-4 w-4" />
</button>
) : canEditMainImage && !uploadedImageSrc ? (
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
<label
htmlFor={mainImageInputId}
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)] sm:bottom-10 ${
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
</div>
) : null}
{isMainImagePreviewOpen && uploadedImageSrc ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
onClick={() => setIsMainImagePreviewOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-main-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-main-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{labels.previewMainImage ?? uploadedImageAlt}
</div>
<button
type="button"
aria-label={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
onClick={() => setIsMainImagePreviewOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
</div>
</div>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div