Commit ea37c382 authored by yaoke.yk's avatar yaoke.yk

角色和场景图片预览功能

parent 2d68dceb
import { useEffect } from "react";
import { X } from "lucide-react";
interface ImagePreviewModalProps {
src: string | null;
alt?: string;
title?: string;
onClose: () => void;
}
export function ImagePreviewModal({ src, alt, title, onClose }: ImagePreviewModalProps) {
useEffect(() => {
if (!src) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, src]);
if (!src) return null;
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/75 p-5 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label={title ?? alt ?? "Image preview"}
onClick={onClose}
>
<div className="relative flex max-h-[92vh] max-w-[94vw] flex-col items-center" onClick={(event) => event.stopPropagation()}>
<button
type="button"
onClick={onClose}
className="absolute -right-3 -top-3 z-10 rounded-full bg-white p-2 text-slate-900 shadow-lg transition-colors hover:bg-slate-100"
aria-label="Close preview"
>
<X className="h-5 w-5" />
</button>
{title && (
<div className="mb-3 max-w-[84vw] rounded-full bg-black/45 px-4 py-1.5 text-sm font-medium text-white shadow-sm">
{title}
</div>
)}
<img
src={src}
alt={alt ?? title ?? "Image preview"}
className="max-h-[84vh] max-w-[94vw] rounded-xl object-contain shadow-2xl"
/>
</div>
</div>
);
}
......@@ -29,6 +29,7 @@ import {
useUploadCharacterImage,
} from "../../hooks/useAi";
import type { Character, CharacterState } from "../../lib/api/ai";
import { ImagePreviewModal } from "../components/ImagePreviewModal";
type SettingsTab = "characters" | "scenes" | "props";
type CharacterViewType = "front" | "side" | "back";
......@@ -171,6 +172,7 @@ export function CharacterGeneration() {
const [stateDraft, setStateDraft] = useState<CharacterStateDraft>(emptyStateDraft());
const [saving, setSaving] = useState(false);
const [savingState, setSavingState] = useState(false);
const [previewImage, setPreviewImage] = useState<{ src: string; title: string } | null>(null);
const editingCharacterId = editingChar.id ? String(editingChar.id) : "";
const { data: characterStates = [] } = useCharacterStates(pid, editingCharacterId);
......@@ -477,7 +479,13 @@ export function CharacterGeneration() {
<div className="flex items-start gap-3">
<div className="w-14 h-14 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{mainImage ? (
<button
type="button"
onClick={() => setPreviewImage({ src: mainImage, title: character.name })}
className="block h-full w-full cursor-zoom-in"
>
<img src={mainImage} alt={character.name} className="w-full h-full object-cover" />
</button>
) : (
<ImageIcon className="w-6 h-6 text-muted-foreground" />
)}
......@@ -552,25 +560,26 @@ export function CharacterGeneration() {
if (sheetUrl) {
return (
<div className="rounded-lg overflow-hidden bg-white border border-border">
<button
type="button"
onClick={() => setPreviewImage({ src: sheetUrl, title: `${character.name} - 角色三视图` })}
className="block w-full cursor-zoom-in"
>
<img
src={sheetUrl}
alt={`${character.name}-角色三视图`}
className="w-full h-auto object-contain"
/>
</button>
</div>
);
}
const viewImages = CHARACTER_VIEWS
.map((view) => ({ ...view, imageUrl: getCharacterViewUrl(character, view.type) }))
.filter((view) => view.imageUrl);
if (viewImages.length === 0) {
return (
<div className="grid grid-cols-3 gap-3">
{CHARACTER_VIEWS.map((view) => {
const imageUrl = getCharacterViewUrl(character, view.type);
return (
<div key={view.type}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border">
{imageUrl ? (
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" />
) : (
<div className="aspect-[16/9] rounded-lg overflow-hidden bg-white border border-border">
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
{isGenerating ? (
<>
......@@ -584,7 +593,24 @@ export function CharacterGeneration() {
</>
)}
</div>
)}
</div>
);
}
return (
<div className={`grid gap-3 ${viewImages.length === 1 ? "grid-cols-1" : viewImages.length === 2 ? "grid-cols-2" : "grid-cols-3"}`}>
{viewImages.map((view) => {
const imageUrl = view.imageUrl!;
return (
<div key={view.type}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border">
<button
type="button"
onClick={() => setPreviewImage({ src: imageUrl, title: `${character.name} - ${view.label}` })}
className="block h-full w-full cursor-zoom-in"
>
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" />
</button>
</div>
</div>
);
......@@ -927,6 +953,12 @@ export function CharacterGeneration() {
</div>
</div>
)}
<ImagePreviewModal
src={previewImage?.src ?? null}
title={previewImage?.title}
alt={previewImage?.title}
onClose={() => setPreviewImage(null)}
/>
</div>
);
}
......@@ -13,6 +13,7 @@ import {
X,
Check,
RefreshCw,
ImageIcon,
Loader2,
} from "lucide-react";
import {
......@@ -24,6 +25,7 @@ import {
useDeleteAsset,
} from "../../hooks/useAssets";
import type { GlobalAsset } from "../../lib/api/assets";
import { ImagePreviewModal } from "../components/ImagePreviewModal";
type SettingsTab = "characters" | "scenes" | "props";
......@@ -69,6 +71,7 @@ export function PropsGeneration() {
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const [previewImage, setPreviewImage] = useState<{ src: string; title: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const settingsTabs = [
......@@ -295,15 +298,44 @@ export function PropsGeneration() {
<div key={prop.id} className="rounded-xl border border-border bg-card overflow-hidden group">
<div className="aspect-square relative bg-muted">
{prop.imageUrl ? (
<button
type="button"
onClick={() => setPreviewImage({ src: prop.imageUrl!, title: prop.name })}
className="block h-full w-full cursor-zoom-in"
>
<img src={prop.imageUrl} alt={prop.name} className="w-full h-full object-cover" />
</button>
) : (
<div className="w-full h-full flex items-center justify-center">
<Box className="w-10 h-10 text-muted-foreground/30" />
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<div
className={`pointer-events-none absolute inset-0 bg-black/40 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 ${
prop.imageUrl ? "cursor-zoom-in" : ""
}`}
onClick={() => {
if (prop.imageUrl) setPreviewImage({ src: prop.imageUrl, title: prop.name });
}}
>
{prop.imageUrl && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setPreviewImage({ src: prop.imageUrl!, title: prop.name });
}}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
title="查看大图"
>
<ImageIcon className="w-4 h-4 text-white" />
</button>
)}
<button
onClick={() => handleGenerateImage(prop)}
onClick={(event) => {
event.stopPropagation();
handleGenerateImage(prop);
}}
disabled={generatingImageId === prop.id || generateImage.isPending}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title={prop.imageUrl ? "AI重新生成道具图" : "AI生成道具图"}
......@@ -317,7 +349,10 @@ export function PropsGeneration() {
)}
</button>
<button
onClick={() => openEdit(prop)}
onClick={(event) => {
event.stopPropagation();
openEdit(prop);
}}
className="p-1.5 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
>
<Edit2 className="w-4 h-4 text-white" />
......@@ -501,6 +536,12 @@ export function PropsGeneration() {
</div>
</div>
)}
<ImagePreviewModal
src={previewImage?.src ?? null}
title={previewImage?.title}
alt={previewImage?.title}
onClose={() => setPreviewImage(null)}
/>
</div>
);
}
......@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { ArrowRight, Sparkles, Plus, Edit2, Users, MapPin, Box, Trash2, X, Check, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react";
import { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi";
import type { Scene } from "../../lib/api/ai";
import { ImagePreviewModal } from "../components/ImagePreviewModal";
type SettingsTab = "characters" | "scenes" | "props";
......@@ -37,6 +38,7 @@ export function SceneGeneration() {
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [previewImage, setPreviewImage] = useState<{ src: string; title: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const settingsTabs = [
......@@ -249,7 +251,13 @@ export function SceneGeneration() {
{/* Image */}
<div className="aspect-video relative overflow-hidden bg-muted">
{scene.imageUrl ? (
<button
type="button"
onClick={() => setPreviewImage({ src: scene.imageUrl!, title: scene.name })}
className="block h-full w-full cursor-zoom-in"
>
<img src={scene.imageUrl} alt={scene.name} className="w-full h-full object-cover" />
</button>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
{scene.status === "generating" ? (
......@@ -259,9 +267,32 @@ export function SceneGeneration() {
)}
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<div
className={`pointer-events-none absolute inset-0 bg-black/40 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2 ${
scene.imageUrl ? "cursor-zoom-in" : ""
}`}
onClick={() => {
if (scene.imageUrl) setPreviewImage({ src: scene.imageUrl, title: scene.name });
}}
>
{scene.imageUrl && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setPreviewImage({ src: scene.imageUrl!, title: scene.name });
}}
className="p-2 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors"
title="查看大图"
>
<ImageIcon className="w-5 h-5 text-white" />
</button>
)}
<button
onClick={() => handleGenerateImage(scene)}
onClick={(event) => {
event.stopPropagation();
handleGenerateImage(scene);
}}
disabled={generatingImageId === scene.id || scene.status === "generating"}
className="p-2 rounded-lg bg-white/20 backdrop-blur-sm hover:bg-white/30 transition-colors disabled:opacity-50"
title="AI重新生成"
......@@ -458,6 +489,12 @@ export function SceneGeneration() {
</div>
</div>
)}
<ImagePreviewModal
src={previewImage?.src ?? null}
title={previewImage?.title}
alt={previewImage?.title}
onClose={() => setPreviewImage(null)}
/>
</div>
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment