ai-chatbot/components/scene-fusion-view.tsx
Your Name 378900e0da
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
优化详情替换自动带入产品图
2026-02-05 18:46:07 +08:00

908 lines
37 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; // 该场景的修改要求
retryCount?: number; // 重试次数
targetName: string; // 背景中需要被替换的物体名称
status: 'pending' | 'processing' | 'completed' | 'failed';
result?: string; // 生成结果缩略图 URL
originalUrl?: string; // 生成结果原图 URL(用于下载)
error?: string; // 错误信息
}
interface SceneFusionViewProps {
onBack: () => void;
/**
* 可选:从外部(例如图片编辑器当前图片)传入的初始产品图。
* 这样在「详情替换」中就可以自动带入当前正在编辑的图片,
* 避免用户重复上传。
*/
initialProductImage?: string | null;
/**
* 可选:初始产品名称,便于在进入详情替换时预填文案。
*/
initialProductName?: string;
}
export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) {
// 最大自动重试次数
const MAX_AUTO_RETRY = 10;
// 获取详情替换配置(使用公开 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 aspectRatioOptions = [
{ label: "自动", value: "auto" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "3:4", value: "3:4" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
// 分辨率选项
const imageSizeOptions = [
{ label: "标准 (1K)", value: "1K" },
{ label: "高清 (2K)", value: "2K" },
{ label: "超清 (4K)", value: "4K" },
];
// 状态管理
const [productImage, setProductImage] = useState<string | null>(null);
const [productName, setProductName] = useState<string>(initialProductName || ""); // 产品名称(如"沙发")
const [scenes, setScenes] = useState<SceneItem[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [aspectRatio, setAspectRatio] = useState<string>("auto"); // 图片比例
const [imageSize, setImageSize] = useState<string>("2K"); // 图片分辨率
// 文件上传 Input 引用
const productInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
// 如果外部传入了初始产品图,则在首次挂载或传入值变化时自动带入
useEffect(() => {
if (initialProductImage) {
setProductImage(initialProductImage);
}
}, [initialProductImage]);
// 处理产品图上传
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: "",
targetName: "", // 背景中需要被替换的物体名称
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 updateSceneTargetName = (id: string, targetName: string) => {
setScenes(prev => prev.map(scene =>
scene.id === id ? { ...scene, targetName } : 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 {
console.log('[SceneFusion Client] Starting streaming API call with', scenesToProcess.length, 'scenes');
// 创建 AbortController 用于超时控制(10分钟)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10 * 60 * 1000);
// 调用后端 API(流式响应)
const response = await fetch('/api/scene-fusion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
body: JSON.stringify({
productImage,
productName: productName || undefined, // 产品名称
aspectRatio, // 图片比例
imageSize, // 图片分辨率
scenes: scenesToProcess.map(s => ({
id: s.id,
image: s.image,
targetName: s.targetName || undefined, // 背景中需要被替换的物体名称
customPrompt: s.customPrompt || undefined,
})),
}),
});
clearTimeout(timeoutId);
console.log('[SceneFusion Client] Response received, status:', response.status);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '处理失败');
}
// 流式读取响应
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('无法读取响应流');
}
let buffer = '';
let totalScenes = 0;
let completedCount = 0;
let failedCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('[SceneFusion Client] Stream completed');
break;
}
// 解码数据并添加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 处理完整的行(以\n分隔)
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 Client] Received message:', message.type);
if (message.type === 'start') {
totalScenes = message.total;
console.log('[SceneFusion Client] Total scenes:', totalScenes);
} else if (message.type === 'result') {
const result = message.data;
console.log('[SceneFusion Client] Scene result:', result.id, result.status);
// 立即更新对应场景的状态
setScenes(prev => prev.map(s => {
if (s.id === result.id) {
if (result.status === 'completed') {
completedCount++;
toast.success(`场景 ${completedCount} 生成完成!`);
return {
...s,
status: 'completed',
result: result.result,
originalUrl: result.originalUrl,
retryCount: 0, // 重置重试计数
};
} else {
// 检查是否需要自动重试
const currentRetryCount = s.retryCount || 0;
if (currentRetryCount < MAX_AUTO_RETRY) {
console.log(`[SceneFusion] Scene ${result.id} failed, will auto-retry (${currentRetryCount + 1}/${MAX_AUTO_RETRY})`);
// 标记为需要重试,稍后会自动重试
return {
...s,
status: 'pending', // 改为 pending 以便重新处理
error: undefined,
retryCount: currentRetryCount + 1,
};
} else {
failedCount++;
console.log(`[SceneFusion] Scene ${result.id} failed after ${MAX_AUTO_RETRY} retries`);
return {
...s,
status: 'failed',
error: `重试${MAX_AUTO_RETRY}次后仍然失败: ${result.error || '生成失败'}`
};
}
}
}
return s;
}));
} else if (message.type === 'done') {
const { summary } = message;
console.log('[SceneFusion Client] All done:', summary);
// 检查是否有需要自动重试的场景
// 注意:这里不直接调用,而是在 finally 中检查
if (summary.failed === 0) {
toast.success(`批量处理完成:${summary.success} 张图片已生成`);
}
} else if (message.type === 'error') {
console.error('[SceneFusion Client] Stream error:', message.error);
toast.error(`处理失败:${message.error || '未知错误'}`);
// 将所有处理中的场景标记为失败
setScenes(prev => prev.map(s =>
s.status === 'processing' ? { ...s, status: 'failed', error: message.error || '处理失败' } : s
));
}
} catch (parseError) {
console.error('[SceneFusion Client] Failed to parse message:', parseError, 'Line:', line);
}
}
}
} catch (error) {
console.error('[SceneFusion] Error:', error);
toast.error(error instanceof Error ? error.message : '处理失败');
// 将 processing 状态改回 pending(以便自动重试)或 failed
setScenes(prev => prev.map(s => {
if (s.status === 'processing') {
const currentRetryCount = s.retryCount || 0;
if (currentRetryCount < MAX_AUTO_RETRY) {
return { ...s, status: 'pending', retryCount: currentRetryCount + 1 };
}
return { ...s, status: 'failed', error: '处理失败' };
}
return s;
}));
} finally {
setIsProcessing(false);
// 检查是否有需要自动重试的场景(状态为 pending 且 retryCount > 0)
// 使用 setTimeout 延迟执行,避免立即重试
setTimeout(() => {
setScenes(currentScenes => {
const pendingRetries = currentScenes.filter(s => s.status === 'pending' && (s.retryCount || 0) > 0);
if (pendingRetries.length > 0) {
console.log(`[SceneFusion] Auto-retrying ${pendingRetries.length} failed scenes...`);
// 触发重新处理 - 由于 startFusion 会处理 pending 状态的场景
// 我们需要在下一个事件循环中调用
setTimeout(() => startFusion(), 100);
} else {
// 所有场景都处理完成,检查最终结果
const failed = currentScenes.filter(s => s.status === 'failed').length;
const completed = currentScenes.filter(s => s.status === 'completed').length;
if (failed > 0 && completed > 0) {
toast.warning(`批量处理完成:${completed} 成功,${failed} 失败`);
}
}
return currentScenes;
});
}, 500);
}
};
// 重新生成单个场景(带自动重试)
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,
productName: productName || undefined,
aspectRatio,
imageSize,
scenes: [{
id: scene.id,
image: scene.image,
targetName: scene.targetName || undefined,
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 (
<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>
{/* 产品名称输入 */}
{productImage && (
<div className="mt-2">
<Input
placeholder="产品名称(如:沙发、椅子、花瓶)"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="h-9 text-sm"
disabled={isProcessing}
/>
<p className="text-xs text-muted-foreground mt-1">输入产品名称可以让 AI 更精准理解</p>
</div>
)}
</div>
{/* 1.5 图片尺寸设置 */}
<div className="space-y-3">
<Label className="text-base font-medium">输出设置</Label>
{/* 比例选择 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">图片比例</label>
<div className="flex flex-wrap gap-1.5">
{aspectRatioOptions.map((option) => (
<button
key={option.value}
onClick={() => setAspectRatio(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
aspectRatio === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</div>
</div>
{/* 分辨率选择 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<div className="flex flex-wrap gap-1.5">
{imageSizeOptions.map((option) => (
<button
key={option.value}
onClick={() => setImageSize(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
imageSize === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</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.targetName}
onChange={(e) => updateSceneTargetName(scene.id, e.target.value)}
className="h-7 text-xs"
disabled={isProcessing}
/>
<Input
placeholder="修改要求(可选)"
value={scene.customPrompt}
onChange={(e) => updateScenePrompt(scene.id, e.target.value)}
className="h-7 text-xs"
disabled={isProcessing}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<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="absolute -inset-8 w-[calc(100%+4rem)] h-[calc(100%+4rem)] object-cover blur-xl scale-110"
/>
{/* 动态遮罩层 */}
<div className="absolute inset-0 bg-black/25" />
{/* 动态光效 - 模拟扫描效果 */}
<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>
);
}