"use client"; import React, { useState, useRef } from "react"; import useSWR from "swr"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription } from "@/components/ui/dialog"; 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, X, Layers, Sparkles } 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; // 该场景的修改要求 retryCount?: number; // 重试次数 status: 'pending' | 'processing' | 'completed' | 'failed'; result?: string; // 生成结果缩略图 URL originalUrl?: string; // 生成结果原图 URL(用于下载) error?: string; // 错误信息 } interface SceneFusionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } export function SceneFusionDialog({ open, onOpenChange }: SceneFusionDialogProps) { // 最大自动重试次数 const MAX_AUTO_RETRY = 10; // 获取详情替换配置(使用公开 API 端点) const { data: configData } = useSWR( open ? "/api/scene-fusion/config" : null, 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(null); const [scenes, setScenes] = useState([]); const [isProcessing, setIsProcessing] = useState(false); // 文件上传 Input 引用 const productInputRef = useRef(null); const sceneInputRef = useRef(null); // 处理产品图上传 const handleProductUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (file.size > 50 * 1024 * 1024) { toast.error("图片大小不能超过 50MB"); 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) => { 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 > 50 * 1024 * 1024) { toast.error(`图片 ${file.name} 大小不能超过 50MB`); 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, retryCount: number = 0) => { if (!productImage) return; const scene = scenes.find(s => s.id === id); if (!scene) return; // 只在首次调用时显示处理状态 if (retryCount === 0) { setScenes(prev => prev.map(s => s.id === id ? { ...s, status: 'processing', error: undefined, retryCount: 0 } : 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, }], }), }); if (!response.ok) { // 尝试解析错误信息 try { const errorData = await response.json(); throw new Error(errorData.error || '处理失败'); } catch { throw new Error(`请求失败: ${response.status}`); } } // 处理流式响应 const reader = response.body?.getReader(); if (!reader) { throw new Error('无法读取响应流'); } const decoder = new TextDecoder(); let buffer = ''; let success = false; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; try { const message = JSON.parse(line); console.log('[SceneFusion] Regenerate message:', message.type); if (message.type === 'result') { const result = message.data; if (result.id === id) { if (result.status === 'completed') { setScenes(prev => prev.map(s => s.id === id ? { ...s, status: 'completed', result: result.result, originalUrl: result.originalUrl, retryCount: 0, } : s )); toast.success('生成成功'); success = true; } else { throw new Error(result.error || '生成失败'); } } } else if (message.type === 'error') { throw new Error(message.error || '处理失败'); } } catch (parseError) { if (parseError instanceof SyntaxError) { console.error('[SceneFusion] Failed to parse message:', line); } else { throw parseError; } } } } if (!success) { throw new Error('未收到有效的生成结果'); } } catch (error) { console.error(`[SceneFusion] Regenerate error (retry ${retryCount}/${MAX_AUTO_RETRY}):`, error); // 检查是否需要自动重试 if (retryCount < MAX_AUTO_RETRY) { console.log(`[SceneFusion] Auto-retrying scene ${id} (${retryCount + 1}/${MAX_AUTO_RETRY})...`); // 更新重试计数,保持 processing 状态(用户无感) setScenes(prev => prev.map(s => s.id === id ? { ...s, status: 'processing', retryCount: retryCount + 1, error: undefined } : s )); // 延迟2秒后自动重试 await new Promise(resolve => setTimeout(resolve, 2000)); return regenerateScene(id, retryCount + 1); } // 达到最大重试次数,显示失败 setScenes(prev => prev.map(s => s.id === id ? { ...s, status: 'failed', error: `重试${MAX_AUTO_RETRY}次后失败: ${error instanceof Error ? error.message : '处理失败'}` } : s )); toast.error(`重试${MAX_AUTO_RETRY}次后仍然失败`); } }; // 下载图片 - 使用 fetch 获取图片数据后下载,解决跨域问题 const downloadImage = async (url: string, filename: string) => { try { const response = await fetch(url); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 释放 Blob URL setTimeout(() => URL.revokeObjectURL(blobUrl), 100); } catch (error) { console.error('下载图片失败:', error); // 降级方案:直接打开链接 window.open(url, '_blank'); } }; // 全部下载 - 顺序下载所有图片 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!; try { await downloadImage(url, `scene-fusion-${i + 1}.png`); // 添加小延迟避免浏览器阻止 await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { console.error(`下载第 ${i + 1} 张图片失败:`, error); } } toast.success(`已下载 ${completedScenes.length} 张图片`); }; return ( 详情替换 上传1张产品图和多张场景图,AI 自动将产品融合到所有场景中。
{/* 左侧:输入区域 */}
{/* 1. 产品图上传 */}
{productImage && ( )}
!productImage && productInputRef.current?.click()} > {productImage ? (
产品图
) : (
点击上传产品图
)}
{/* 2. 场景图列表 */}
{scenes.length === 0 && (
sceneInputRef.current?.click()} >

点击添加场景参考图

支持批量上传,最多{maxScenes}张

)} {scenes.map((scene, index) => (
{/* 缩略图 */}
{`场景
{/* 关键词输入 */}
场景 {index + 1} {!isProcessing && ( )}
updateScenePrompt(scene.id, e.target.value)} className="h-8 text-xs" disabled={isProcessing} />
))}
{/* 默认提示词提示 */}

默认提示词: {defaultPrompt.length > 60 ? `${defaultPrompt.substring(0, 60)}...` : defaultPrompt}

{/* 右侧:结果展示区域 */}

生成结果

{scenes.some(s => s.status === 'completed') && ( )}
{scenes.length === 0 ? (

添加场景图并开始裂变,结果将显示在这里

) : (
{scenes.map((scene, index) => (
场景 {index + 1} {scene.status === 'completed' ? '完成' : scene.status === 'failed' ? '失败' : scene.status === 'processing' ? '生成中' : '等待'}
{scene.result ? ( 生成结果 ) : (
{scene.status === 'processing' ? (
正在生成...
) : scene.status === 'failed' ? (

{scene.error || "生成失败"}

) : (
等待处理
)}
)} {/* 悬停操作层 */} {scene.status === 'completed' && (
)}
))}
)}
); }