Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
145
src/components/common/CreativeImageInputPanel.test.tsx
Normal file
145
src/components/common/CreativeImageInputPanel.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
test('creative image input panel handles reference uploads and preview', () => {
|
||||
const onPromptReferenceFilesSelect = vi.fn();
|
||||
const onPromptReferenceRemove = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc=""
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[
|
||||
{
|
||||
id: 'ref-1',
|
||||
label: '参考图 1',
|
||||
imageSrc: 'data:image/png;base64,ref-1',
|
||||
},
|
||||
]}
|
||||
imageModelPicker={<div />}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
|
||||
onPromptReferenceRemove={onPromptReferenceRemove}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const promptReferenceInput = screen.getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
|
||||
|
||||
fireEvent.change(promptReferenceInput, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['a'], 'ref-1.png', { type: 'image/png' }),
|
||||
new File(['b'], 'ref-2.png', { type: 'image/png' }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.any(File),
|
||||
expect.any(File),
|
||||
]),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '预览参考图 参考图 1' }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '参考图 1' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByAltText('参考图预览')).toHaveProperty(
|
||||
'src',
|
||||
expect.stringContaining('ref-1'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
|
||||
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel confirms before removing uploaded image', () => {
|
||||
const onMainImageRemove = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="data:image/png;base64,main"
|
||||
uploadedImageAlt="拼图图片"
|
||||
mainImageInputId="image-upload-input"
|
||||
promptTextareaId="image-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面AI重绘要求(提示词)"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={<div />}
|
||||
submitLabel="生成"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '拼图画面',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={onMainImageRemove}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
|
||||
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
463
src/components/common/CreativeImageInputPanel.tsx
Normal file
463
src/components/common/CreativeImageInputPanel.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
History,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
export type CreativeImageInputPanelLabels = {
|
||||
imageField: string;
|
||||
uploadImage: string;
|
||||
replaceImage: string;
|
||||
emptyImageHint: string;
|
||||
removeImage: string;
|
||||
removeImageConfirmTitle: string;
|
||||
removeImageConfirmBody: string;
|
||||
promptReferenceUpload: string;
|
||||
promptReferencePreviewAlt: string;
|
||||
closePromptReferencePreview: string;
|
||||
history?: string;
|
||||
};
|
||||
|
||||
export type CreativeImageInputPanelProps = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
mainImageInputId: string;
|
||||
mainImageAccept?: string;
|
||||
promptTextareaId: string;
|
||||
prompt: string;
|
||||
promptLabel: string;
|
||||
promptRows?: number;
|
||||
aiRedraw: boolean;
|
||||
promptReferenceImages: CreativeImageInputReferenceImage[];
|
||||
promptReferenceLimit?: number;
|
||||
imageModelPicker?: ReactNode;
|
||||
error?: string | null;
|
||||
inputError?: string | null;
|
||||
submitLabel: string;
|
||||
submitCostLabel?: string | null;
|
||||
submitDisabled: boolean;
|
||||
labels: CreativeImageInputPanelLabels;
|
||||
onMainImageFileSelect: (file: File) => void;
|
||||
onMainImageRemove: () => void;
|
||||
onAiRedrawChange: (enabled: boolean) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
onPromptReferenceFilesSelect?: (files: File[]) => void;
|
||||
onPromptReferenceRemove?: (referenceId: string) => void;
|
||||
onHistoryClick?: () => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
|
||||
const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
mainImageInputId,
|
||||
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
||||
promptTextareaId,
|
||||
prompt,
|
||||
promptLabel,
|
||||
promptRows = 2,
|
||||
aiRedraw,
|
||||
promptReferenceImages,
|
||||
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
||||
imageModelPicker = null,
|
||||
error = null,
|
||||
inputError = null,
|
||||
submitLabel,
|
||||
submitCostLabel = null,
|
||||
submitDisabled,
|
||||
labels,
|
||||
onMainImageFileSelect,
|
||||
onMainImageRemove,
|
||||
onAiRedrawChange,
|
||||
onPromptChange,
|
||||
onPromptReferenceFilesSelect,
|
||||
onPromptReferenceRemove,
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = !uploadedImageSrc || aiRedraw;
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
setPreviewReferenceImage(null);
|
||||
}
|
||||
}, [uploadedImageSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
previewReferenceImage &&
|
||||
!promptReferenceImages.some(
|
||||
(reference) => reference.id === previewReferenceImage.id,
|
||||
)
|
||||
) {
|
||||
setPreviewReferenceImage(null);
|
||||
}
|
||||
}, [previewReferenceImage, promptReferenceImages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
|
||||
>
|
||||
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
|
||||
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
|
||||
<div
|
||||
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
||||
showPrompt
|
||||
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
||||
: 'flex flex-col lg:grid lg:grid-cols-1'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
|
||||
disabled ? 'opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{labels.imageField}
|
||||
</div>
|
||||
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
||||
<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}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
alt={uploadedImageAlt}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
|
||||
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
|
||||
</span>
|
||||
</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%)]" />
|
||||
{onHistoryClick ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onHistoryClick}
|
||||
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${
|
||||
disabled ? 'cursor-not-allowed opacity-55' : ''
|
||||
}`}
|
||||
aria-label={labels.history ?? '选择历史图片'}
|
||||
title={labels.history ?? '选择历史图片'}
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={aiRedraw}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onAiRedrawChange(event.target.checked)}
|
||||
className="sr-only"
|
||||
aria-label="AI重绘"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative h-5 w-9 rounded-full transition ${
|
||||
aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
||||
className="absolute left-3 top-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-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={labels.removeImage}
|
||||
title={labels.removeImage}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<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-[#ff4056] sm:bottom-10 ${
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{labels.emptyImageHint}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPrompt ? (
|
||||
<div className="block shrink-0 lg:min-h-0">
|
||||
<label
|
||||
htmlFor={promptTextareaId}
|
||||
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{promptLabel}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id={promptTextareaId}
|
||||
value={prompt}
|
||||
disabled={disabled}
|
||||
rows={promptRows}
|
||||
placeholder=""
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
|
||||
aria-label={promptLabel}
|
||||
/>
|
||||
{imageModelPicker}
|
||||
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
|
||||
<label
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[#ff4056] ${
|
||||
promptReferenceUploadDisabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
aria-label={labels.promptReferenceUpload}
|
||||
title={labels.promptReferenceUpload}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<input
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
multiple
|
||||
aria-label={labels.promptReferenceUpload}
|
||||
disabled={promptReferenceUploadDisabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = '';
|
||||
if (files.length > 0) {
|
||||
onPromptReferenceFilesSelect(files);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{promptReferenceImages.map((reference) => (
|
||||
<div
|
||||
key={reference.id}
|
||||
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setPreviewReferenceImage(reference)}
|
||||
className="block h-full w-full"
|
||||
aria-label={`预览参考图 ${reference.label}`}
|
||||
title={reference.label}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={reference.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{onPromptReferenceRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPromptReferenceRemove(reference.id)}
|
||||
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[#ff4056] disabled:opacity-55"
|
||||
aria-label={`移除参考图 ${reference.label}`}
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{inputError ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{inputError}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || submitDisabled}
|
||||
onClick={onSubmit}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
|
||||
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{submitLabel}</span>
|
||||
{submitCostLabel ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
{submitCostLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewReferenceImage ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setPreviewReferenceImage(null)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-reference-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-2xl 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-reference-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{previewReferenceImage.label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.closePromptReferencePreview}
|
||||
onClick={() => setPreviewReferenceImage(null)}
|
||||
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-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={previewReferenceImage.imageSrc}
|
||||
alt={labels.promptReferencePreviewAlt}
|
||||
className="h-full max-h-[72vh] 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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-remove-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="creative-image-remove-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.removeImageConfirmTitle}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{labels.removeImageConfirmBody}
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRemoveImageConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onMainImageRemove();
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}}
|
||||
className="platform-button platform-button--primary justify-center"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeImageInputPanel;
|
||||
Reference in New Issue
Block a user