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:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View 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;