refactor: 将场景裂变功能从弹窗改为全屏视图切换
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

- 新建 SceneFusionView 组件,替代 SceneFusionDialog
- 修改 ImageAnnotator,支持在编辑器模式和场景裂变模式之间切换
- 场景裂变模式下隐藏原有编辑器工具栏,提供独立的顶部导航和返回按钮
- 优化用户体验,无需离开编辑器即可使用场景裂变功能
This commit is contained in:
Your Name 2026-01-18 15:26:37 +08:00
parent 24af251fd3
commit 4e2869bf2d
2 changed files with 574 additions and 8 deletions

View File

@ -9,7 +9,7 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { toast } from "sonner";
import { AngleEditDialog } from "./angle-edit-dialog";
import { SceneFusionDialog } from "./scene-fusion-dialog";
import { SceneFusionView } from "./scene-fusion-view";
type Tool = "pen" | "arrow" | "circle" | "text";
@ -1216,7 +1216,15 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
className="fixed inset-0 z-[9999] flex items-center justify-center bg-background"
onClick={onCancel}
>
{/* 弹窗容器 - 全屏显示 */}
{showSceneFusion ? (
<div
className="w-full h-full bg-background"
onClick={(e) => e.stopPropagation()}
>
<SceneFusionView onBack={() => setShowSceneFusion(false)} />
</div>
) : (
/* 弹窗容器 - 全屏显示 */
<div
className="flex flex-col overflow-hidden w-full h-full"
onClick={(e) => e.stopPropagation()}
@ -1701,6 +1709,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
)}
</div>
</div>
)}
{/* 文字输入弹窗 */}
{textPosition && (
@ -1739,12 +1748,6 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel, initialResizeMod
onComplete={handleAngleEditComplete}
/>
)}
{/* 场景裂变弹窗 */}
<SceneFusionDialog
open={showSceneFusion}
onOpenChange={setShowSceneFusion}
/>
</div>
);

View File

@ -0,0 +1,563 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Upload,
Plus,
Trash2,
Download,
RefreshCw,
Image as ImageIcon,
Loader2,
Layers,
Sparkles,
ArrowLeft
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
// 场景项数据结构
export interface SceneItem {
id: string;
image: string; // 场景图 URL 或 Base64
customPrompt: string; // 该场景的补充关键词
status: 'pending' | 'processing' | 'completed' | 'failed';
result?: string; // 生成结果缩略图 URL
originalUrl?: string; // 生成结果原图 URL(用于下载)
error?: string; // 错误信息
}
interface SceneFusionViewProps {
onBack: () => void;
}
export function SceneFusionView({ onBack }: SceneFusionViewProps) {
// 获取场景裂变配置(使用公开 API 端点)
const { data: configData } = useSWR(
"/api/scene-fusion/config",
fetcher
);
const config = configData as {
enabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
} | undefined;
const defaultPrompt = config?.defaultPrompt || "将图1的产品替换到图2的场景中,保持图1产品的外观、颜色和细节不变,自然融合到图2的环境中。";
const maxScenes = config?.maxScenes || 10;
// 状态管理
const [productImage, setProductImage] = useState<string | null>(null);
const [scenes, setScenes] = useState<SceneItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
// 文件上传 Input 引用
const productInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
// 处理产品图上传
const handleProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
toast.error("图片大小不能超过 10MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
setProductImage(e.target?.result as string);
};
reader.readAsDataURL(file);
// 重置 input,允许重复选择同一文件
e.target.value = "";
};
// 处理场景图上传
const handleSceneUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (scenes.length + files.length > maxScenes) {
toast.error(`最多只能添加 ${maxScenes} 张场景参考图`);
return;
}
files.forEach(file => {
if (file.size > 10 * 1024 * 1024) {
toast.error(`图片 ${file.name} 大小不能超过 10MB`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const newScene: SceneItem = {
id: Math.random().toString(36).substring(7),
image: e.target?.result as string,
customPrompt: "",
status: 'pending'
};
setScenes(prev => [...prev, newScene]);
};
reader.readAsDataURL(file);
});
// 重置 input
e.target.value = "";
};
// 移除场景
const removeScene = (id: string) => {
setScenes(prev => prev.filter(scene => scene.id !== id));
};
// 更新场景关键词
const updateScenePrompt = (id: string, prompt: string) => {
setScenes(prev => prev.map(scene =>
scene.id === id ? { ...scene, customPrompt: prompt } : scene
));
};
// 开始裂变
const startFusion = async () => {
if (config?.enabled === false) {
toast.error("场景裂变功能已被管理员禁用");
return;
}
if (!productImage) {
toast.error("请先上传产品图");
return;
}
if (scenes.length === 0) {
toast.error("请至少添加一张场景参考图");
return;
}
setIsProcessing(true);
// 将所有 pending 或 failed 的状态改为 processing
const scenesToProcess = scenes.filter(s => s.status === 'pending' || s.status === 'failed');
setScenes(prev => prev.map(s =>
(s.status === 'pending' || s.status === 'failed')
? { ...s, status: 'processing' }
: s
));
try {
// 调用后端 API
const response = await fetch('/api/scene-fusion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
productImage,
scenes: scenesToProcess.map(s => ({
id: s.id,
image: s.image,
customPrompt: s.customPrompt || undefined,
})),
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '处理失败');
}
// 更新场景状态
setScenes(prev => prev.map(s => {
const result = data.results?.find((r: any) => r.id === s.id);
if (result) {
if (result.status === 'completed') {
return {
...s,
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
};
} else {
return {
...s,
status: 'failed',
error: result.error || '生成失败'
};
}
}
return s;
}));
const { summary } = data;
if (summary.failed > 0) {
toast.warning(`批量处理完成:${summary.success} 成功,${summary.failed} 失败`);
} else {
toast.success(`批量处理完成:${summary.success} 张图片已生成`);
}
} catch (error) {
console.error('[SceneFusion] Error:', error);
toast.error(error instanceof Error ? error.message : '处理失败');
// 将 processing 状态改回 failed
setScenes(prev => prev.map(s =>
s.status === 'processing' ? { ...s, status: 'failed', error: '处理失败' } : s
));
} finally {
setIsProcessing(false);
}
};
// 重新生成单个场景
const regenerateScene = async (id: string) => {
if (!productImage) return;
const scene = scenes.find(s => s.id === id);
if (!scene) return;
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'processing', error: undefined } : s
));
try {
const response = await fetch('/api/scene-fusion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
productImage,
scenes: [{
id: scene.id,
image: scene.image,
customPrompt: scene.customPrompt || undefined,
}],
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '处理失败');
}
const result = data.results?.[0];
if (result?.status === 'completed') {
setScenes(prev => prev.map(s =>
s.id === id ? {
...s,
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
} : s
));
toast.success('重新生成成功');
} else {
throw new Error(result?.error || '生成失败');
}
} catch (error) {
console.error('[SceneFusion] Regenerate error:', error);
setScenes(prev => prev.map(s =>
s.id === id ? { ...s, status: 'failed', error: error instanceof Error ? error.message : '处理失败' } : s
));
toast.error(error instanceof Error ? error.message : '重新生成失败');
}
};
// 下载图片
const downloadImage = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 全部下载
const downloadAll = async () => {
const completedScenes = scenes.filter(s => s.status === 'completed' && (s.originalUrl || s.result));
if (completedScenes.length === 0) {
toast.error("没有可下载的图片");
return;
}
toast.info(`正在下载 ${completedScenes.length} 张图片...`);
for (let i = 0; i < completedScenes.length; i++) {
const scene = completedScenes[i];
const url = scene.originalUrl || scene.result!;
// 延迟下载避免浏览器阻止
setTimeout(() => {
downloadImage(url, `scene-fusion-${i + 1}.png`);
}, i * 300);
}
};
return (
<div className="flex flex-col w-full h-full bg-background">
{/* 顶部标题栏 */}
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack} title="返回图片编辑">
<ArrowLeft className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2 font-medium">
<Layers className="w-5 h-5 text-primary" />
场景裂变
</div>
</div>
<div className="text-sm text-muted-foreground hidden md:block">
上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中
</div>
<div className="w-10"></div>{/* 占位,保持标题居中 */}
</div>
<div className="flex-1 overflow-hidden flex flex-col md:flex-row">
{/* 左侧:输入区域 */}
<div className="w-full md:w-[400px] border-r flex flex-col bg-muted/10 shrink-0">
<ScrollArea className="flex-1 p-4">
<div className="space-y-6">
{/* 1. 产品图上传 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">产品图 A (必须)</Label>
{productImage && (
<Button variant="ghost" size="sm" onClick={() => setProductImage(null)} className="h-6 text-xs text-muted-foreground hover:text-destructive">
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
productImage ? "border-primary/50 bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !productImage && productInputRef.current?.click()}
>
<input
type="file"
ref={productInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleProductUpload}
/>
{productImage ? (
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
<img src={productImage} alt="产品图" className="w-full h-full object-contain rounded-md" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-md">
<Button variant="secondary" size="sm" onClick={(e) => { e.stopPropagation(); productInputRef.current?.click(); }}>
更换图片
</Button>
</div>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传产品图</span>
</div>
)}
</div>
</div>
{/* 2. 场景图列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
场景参考图 ({scenes.length}/{maxScenes})
</Label>
<Button
variant="outline"
size="sm"
onClick={() => sceneInputRef.current?.click()}
disabled={scenes.length >= maxScenes || isProcessing}
className="h-7 text-xs gap-1"
>
<Plus className="w-3 h-3" /> 添加场景
</Button>
<input
type="file"
ref={sceneInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleSceneUpload}
/>
</div>
<div className="space-y-3">
{scenes.length === 0 && (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => sceneInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击添加场景参考图</p>
<p className="text-xs text-muted-foreground/60 mt-1">支持批量上传,最多{maxScenes}张</p>
</div>
)}
{scenes.map((scene, index) => (
<div key={scene.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3">
{/* 缩略图 */}
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={scene.image} alt={`场景 ${index + 1}`} className="w-full h-full object-cover" />
</div>
{/* 关键词输入 */}
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">场景 {index + 1}</span>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeScene(scene.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
<Input
placeholder="补充关键词:如'放在木桌上'"
value={scene.customPrompt}
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
{/* 默认提示词提示 */}
<div className="px-4 py-2 border-t bg-muted/30">
<p className="text-xs text-muted-foreground">
<span className="font-medium">默认提示词:</span>
{defaultPrompt.length > 60 ? `${defaultPrompt.substring(0, 60)}...` : defaultPrompt}
</p>
</div>
<div className="p-4 border-t bg-background">
<Button
className="w-full"
size="lg"
onClick={startFusion}
disabled={isProcessing || !productImage || scenes.length === 0}
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
处理中...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
开始裂变 ({scenes.length} 张)
</>
)}
</Button>
</div>
</div>
{/* 右侧:结果展示区域 */}
<div className="flex-1 flex flex-col bg-background/50 h-full">
<div className="p-4 border-b flex items-center justify-between bg-background">
<h3 className="font-medium">生成结果</h3>
{scenes.some(s => s.status === 'completed') && (
<Button variant="outline" size="sm" onClick={downloadAll}>
<Download className="w-4 h-4 mr-2" />
全部下载
</Button>
)}
</div>
<ScrollArea className="flex-1 p-4">
{scenes.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 min-h-[300px]">
<Layers className="w-16 h-16 mb-4" />
<p>添加场景图并开始裂变,结果将显示在这里</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{scenes.map((scene, index) => (
<div key={scene.id} className="border rounded-lg overflow-hidden bg-background shadow-sm flex flex-col">
<div className="p-2 border-b text-xs text-muted-foreground bg-muted/30 flex justify-between items-center">
<span>场景 {index + 1}</span>
<span className={cn(
"capitalize px-1.5 py-0.5 rounded text-[10px]",
scene.status === 'completed' && "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
scene.status === 'failed' && "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
scene.status === 'processing' && "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
scene.status === 'pending' && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
)}>
{scene.status === 'completed' ? '完成' :
scene.status === 'failed' ? '失败' :
scene.status === 'processing' ? '生成中' : '等待'}
</span>
</div>
<div className="aspect-[4/3] bg-muted/20 relative group">
{scene.result ? (
<img src={scene.result} alt="生成结果" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-4">
{scene.status === 'processing' ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">正在生成...</span>
</div>
) : scene.status === 'failed' ? (
<div className="text-center">
<p className="text-sm text-destructive mb-2">{scene.error || "生成失败"}</p>
<Button size="sm" variant="outline" onClick={() => regenerateScene(scene.id)}>
<RefreshCw className="w-3 h-3 mr-1" /> 重试
</Button>
</div>
) : (
<div className="flex items-center justify-center w-full h-full opacity-30">
<img src={scene.image} className="w-full h-full object-cover blur-sm" />
<span className="absolute text-xs font-medium bg-black/50 text-white px-2 py-1 rounded">等待处理</span>
</div>
)}
</div>
)}
{/* 悬停操作层 */}
{scene.status === 'completed' && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="icon" variant="secondary" onClick={() => downloadImage(scene.originalUrl || scene.result!, `scene-${index+1}.png`)}>
<Download className="w-4 h-4" />
</Button>
<Button size="icon" variant="secondary" onClick={() => regenerateScene(scene.id)}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</div>
</div>
</div>
);
}