ai-chatbot/components/scene-fusion-view.tsx
Your Name 004d1de1ce
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: update scene fusion grid layout to 4 cols and adjust aspect ratio
2026-01-18 16:07:16 +08:00

588 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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-2 md:grid-cols-3 xl:grid-cols-4 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-[3/4] 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-0 relative overflow-hidden">
{scene.status === 'processing' ? (
<>
{/* 背景模糊图 */}
<img
src={scene.image}
alt="生成中"
className="w-full h-full object-cover absolute inset-0 blur-md opacity-80"
/>
{/* 动态遮罩层 */}
<div className="absolute inset-0 bg-black/40" />
{/* 动态光效 - 模拟扫描效果 */}
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.3),transparent)] bg-[length:200%_100%] animate-[shimmer_2s_infinite]" />
{/* 中心 Loading */}
<div className="relative z-10 flex flex-col items-center gap-3">
<div className="relative">
{/* 外圈脉冲 */}
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
<div className="bg-background/80 backdrop-blur-sm rounded-full p-3 shadow-lg border border-primary/20">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
</div>
<div className="bg-background/60 backdrop-blur-md px-3 py-1 rounded-full border border-white/10">
<span className="text-xs font-medium text-white/90">正在融合...</span>
</div>
</div>
</>
) : scene.status === 'failed' ? (
<div className="text-center p-4 relative z-10">
<p className="text-sm text-destructive mb-2 font-medium">{scene.error || "生成失败"}</p>
<Button size="sm" variant="outline" onClick={() => regenerateScene(scene.id)} className="bg-background/80 hover:bg-background">
<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>
);
}