Files
course-agent-od/web/src/app/(main)/spatial/page.tsx
T
pengxiao 50faf7ad4e feat: add forum seed categories, improve page layouts and navigation
- Seed default forum categories (announcements, learning, feedback) on startup
- Update forum pages with improved category/post/reply UI
- Refine navbar, mobile nav, and home page content layout
- Improve analytics, course-content, knowledge, profile, settings, spatial pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:02:30 +08:00

958 lines
38 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, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/store/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import {
Image,
ImageIcon,
Wand2,
Download,
Share2,
Eye,
Loader2,
Palette,
Settings,
Sparkles,
Type,
Edit3,
ArrowRight,
Upload,
X
} from "lucide-react";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { imageAPI } from "@/lib/api";
import { toast } from "sonner";
import { motion } from "framer-motion";
interface GeneratedImage {
id: string;
url: string;
prompt: string;
model: string;
metadata: any;
}
interface EditResult {
id: string;
url: string;
original_filename?: string;
edit_prompt: string;
mode: string;
metadata: any;
}
interface VariationResult {
id: string;
url: string;
type: string;
metadata: any;
}
interface ConfigOption {
id: string;
name: string;
description: string;
}
export default function SpatialPage() {
const router = useRouter();
const { isAuthenticated, isLoading: authLoading } = useAuthStore();
// Text-to-image states
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("kolors");
const [template, setTemplate] = useState("custom");
const [style, setStyle] = useState("realistic");
const [size, setSize] = useState("1024x1024");
const [numImages, setNumImages] = useState(1);
const [isGenerating, setIsGenerating] = useState(false);
const [generatedImages, setGeneratedImages] = useState<GeneratedImage[]>([]);
// Image-to-image states
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>("");
const [editMode, setEditMode] = useState("optimize");
const [editPrompt, setEditPrompt] = useState("");
const [strength, setStrength] = useState(0.8);
const [numVariations, setNumVariations] = useState(3);
const [isProcessing, setIsProcessing] = useState(false);
const [editResult, setEditResult] = useState<EditResult | null>(null);
const [variations, setVariations] = useState<VariationResult[]>([]);
// Canvas states for local editing
const [isDrawing, setIsDrawing] = useState(false);
const [brushSize, setBrushSize] = useState(20);
const [maskData, setMaskData] = useState<string>("");
// Configuration options
const [models, setModels] = useState<ConfigOption[]>([]);
const [templates, setTemplates] = useState<ConfigOption[]>([]);
const [styles, setStyles] = useState<ConfigOption[]>([]);
const [sizes, setSizes] = useState<ConfigOption[]>([]);
const [editModes, setEditModes] = useState<ConfigOption[]>([]);
const [stylePresets, setStylePresets] = useState<ConfigOption[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/login");
return;
}
if (isAuthenticated) {
loadConfigs();
}
}, [isAuthenticated, authLoading, router]);
const loadConfigs = async () => {
try {
const [modelsData, templatesData, stylesData, sizesData, modesData, presetsData] = await Promise.all([
imageAPI.getModels(),
imageAPI.getTemplates(),
imageAPI.getStyles(),
imageAPI.getSizes(),
imageAPI.getEditModes(),
imageAPI.getStylePresets(),
]);
setModels(modelsData);
setTemplates(templatesData);
setStyles(stylesData);
setSizes(sizesData);
setEditModes(modesData);
setStylePresets(presetsData);
} catch (error) {
console.error("加载配置失败:", error);
toast.error("加载配置失败");
}
};
// Text-to-image functions
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error("请输入图像描述");
return;
}
setIsGenerating(true);
try {
const response = await imageAPI.textToImage({
prompt,
model,
template,
style,
size,
num_images: numImages,
});
setGeneratedImages(response.images);
toast.success(`成功生成 ${response.total} 张图像`);
} catch (error) {
console.error("生成图像失败:", error);
toast.error("生成图像失败,请重试");
} finally {
setIsGenerating(false);
}
};
// Image-to-image functions
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedImage(file);
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file && file.type.startsWith("image/")) {
setSelectedImage(file);
const reader = new FileReader();
reader.onload = (e) => {
setImagePreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
};
const handleExecute = () => {
switch (editMode) {
case "optimize":
handleOptimizeImage();
break;
case "style_transfer":
handleStyleTransfer();
break;
case "local_edit":
handleImageEdit();
break;
case "outpaint":
handleOutpaintImage();
break;
default:
handleImageEdit();
}
};
const handleOptimizeImage = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
setIsProcessing(true);
try {
const response = await imageAPI.optimizeImage(selectedImage, editPrompt || "优化图像质量,增强细节,提高清晰度", strength);
const result = response.result || response; // 兼容包装和非包装的响应
console.log("图像优化结果:", result);
setEditResult(result);
toast.success("图像优化完成");
} catch (error) {
console.error("图像优化失败:", error);
toast.error("图像优化失败,请重试");
} finally {
setIsProcessing(false);
}
};
const handleStyleTransfer = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
if (!editPrompt.trim()) {
toast.error("请输入风格描述");
return;
}
setIsProcessing(true);
try {
const response = await imageAPI.styleTransfer(selectedImage, editPrompt, strength);
const result = response.result || response; // 兼容包装和非包装的响应
console.log("风格转换结果:", result);
setEditResult(result);
toast.success("风格转换完成");
} catch (error) {
console.error("风格转换失败:", error);
toast.error("风格转换失败,请重试");
} finally {
setIsProcessing(false);
}
};
const handleImageEdit = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
if (!editPrompt.trim()) {
toast.error("请输入编辑描述");
return;
}
setIsProcessing(true);
try {
let maskFile: File | undefined;
if (editMode === "local_edit" && maskData) {
const response = await fetch(maskData);
const blob = await response.blob();
maskFile = new File([blob], "mask.png", { type: "image/png" });
}
const response = await imageAPI.imageEdit(
selectedImage,
editPrompt,
editMode,
maskFile,
strength
);
const result = response.result || response; // 兼容包装和非包装的响应
console.log("图像编辑结果:", result);
setEditResult(result);
toast.success("图像编辑完成");
} catch (error) {
console.error("图像编辑失败:", error);
toast.error("图像编辑失败,请重试");
} finally {
setIsProcessing(false);
}
};
const handleOutpaintImage = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
if (!editPrompt.trim()) {
toast.error("请输入扩展描述");
return;
}
setIsProcessing(true);
try {
const response = await imageAPI.outpaintImage(selectedImage, editPrompt, strength);
const result = response.result || response; // 兼容包装和非包装的响应
console.log("图像扩展结果:", result);
setEditResult(result);
toast.success("图像扩展完成");
} catch (error) {
console.error("图像扩展失败:", error);
toast.error("图像扩展失败,请重试");
} finally {
setIsProcessing(false);
}
};
const handleGenerateVariations = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
setIsProcessing(true);
try {
const result = await imageAPI.imageVariations(selectedImage, numVariations);
setVariations(result.variations);
toast.success(`成功生成 ${result.total} 个变体`);
} catch (error) {
console.error("生成变体失败:", error);
toast.error("生成变体失败,请重试");
} finally {
setIsProcessing(false);
}
};
const handleDownload = (imageUrl: string, imageId: string) => {
const link = document.createElement("a");
link.href = imageUrl;
link.download = `image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
};
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Tab导航 */}
<Tabs defaultValue="text-to-image" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="text-to-image" className="flex items-center gap-2">
<Type className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="image-to-image" className="flex items-center gap-2">
<Edit3 className="w-4 h-4" />
</TabsTrigger>
</TabsList>
{/* 文生图 Tab */}
<TabsContent value="text-to-image" className="mt-6">
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:生成配置 */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="lg:col-span-1"
>
<Card className="sticky top-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 模型选择 */}
<div className="space-y-2">
<Label htmlFor="model">AI模型</Label>
<Select value={model} onValueChange={setModel}>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div>
<div className="font-medium">{option.name}</div>
<div className="text-sm text-muted-foreground">{option.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 提示词模板 */}
<div className="space-y-2">
<Label htmlFor="template"></Label>
<Select value={template} onValueChange={setTemplate}>
<SelectTrigger>
<SelectValue placeholder="选择模板" />
</SelectTrigger>
<SelectContent>
{templates.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div>
<div className="font-medium">{option.name}</div>
<div className="text-sm text-muted-foreground">{option.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 图像描述 */}
<div className="space-y-2">
<Label htmlFor="prompt"></Label>
<Textarea
id="prompt"
placeholder="描述您想要生成的图像,例如:城市中心区商业综合体规划,包含商业区、办公区、绿地..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
{/* 风格选择 */}
<div className="space-y-2">
<Label htmlFor="style"></Label>
<Select value={style} onValueChange={setStyle}>
<SelectTrigger>
<SelectValue placeholder="选择风格" />
</SelectTrigger>
<SelectContent>
{styles.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div>
<div className="font-medium">{option.name}</div>
<div className="text-sm text-muted-foreground">{option.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 尺寸选择 */}
<div className="space-y-2">
<Label htmlFor="size"></Label>
<Select value={size} onValueChange={setSize}>
<SelectTrigger>
<SelectValue placeholder="选择尺寸" />
</SelectTrigger>
<SelectContent>
{sizes.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div>
<div className="font-medium">{option.name}</div>
<div className="text-sm text-muted-foreground">{option.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 生成数量 */}
<div className="space-y-2">
<Label htmlFor="numImages"></Label>
<Select value={numImages.toString()} onValueChange={(value) => setNumImages(parseInt(value))}>
<SelectTrigger>
<SelectValue placeholder="选择数量" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* 生成按钮 */}
<Button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
className="w-full"
size="lg"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Image className="mr-2 h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
</motion.div>
{/* 右侧:生成结果 */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="lg:col-span-2"
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="h-5 w-5 text-green-600" />
</CardTitle>
<CardDescription>
{generatedImages.length > 0
? `已生成 ${generatedImages.length} 张图像`
: "生成图像将显示在这里"
}
</CardDescription>
</CardHeader>
<CardContent>
{generatedImages.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{generatedImages.map((image, index) => (
<motion.div
key={image.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
className="group relative"
>
<Card className="overflow-hidden">
<div className="aspect-square relative bg-muted">
<img
src={image.url}
alt={`Generated image ${index + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
</div>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline">{image.model}</Badge>
<Badge variant="secondary">{image.metadata?.style || style}</Badge>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{image.prompt}
</p>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleDownload(image.url, image.id)}
>
<Download className="h-4 w-4 mr-2" />
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
) : (
<div className="text-center py-12">
<Image className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground"></p>
<p className="text-sm text-muted-foreground/70">"生成图像"</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
</div>
</div>
</TabsContent>
{/* 图生图 Tab */}
<TabsContent value="image-to-image" className="mt-6">
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 左侧:图像上传和编辑配置 */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="space-y-6"
>
{/* 图像上传 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
{!selectedImage ? (
<div
className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-primary transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<Upload className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2"></p>
<p className="text-sm text-muted-foreground"> PNG, JPG, JPEG </p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
</div>
) : (
<div className="space-y-4">
<div className="relative">
{imagePreview ? (
<img
src={imagePreview}
alt="Uploaded image"
className="w-full h-64 object-cover rounded-lg"
/>
) : (
<div className="w-full h-64 bg-muted border-2 border-dashed border-border rounded-lg flex items-center justify-center">
<div className="text-center">
<ImageIcon className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
)}
<Button
size="sm"
variant="destructive"
className="absolute top-2 right-2"
onClick={() => {
setSelectedImage(null);
setImagePreview("");
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{selectedImage.name}</Badge>
<Badge variant="secondary">
{(selectedImage.size / 1024 / 1024).toFixed(2)} MB
</Badge>
</div>
</div>
)}
</CardContent>
</Card>
{/* 编辑配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5 text-green-600" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 编辑模式 */}
<div className="space-y-2">
<Label></Label>
<Select value={editMode} onValueChange={setEditMode}>
<SelectTrigger>
<SelectValue placeholder="选择编辑模式" />
</SelectTrigger>
<SelectContent>
{editModes.map((mode) => (
<SelectItem key={mode.id} value={mode.id}>
<div>
<div className="font-medium">{mode.name}</div>
<div className="text-sm text-gray-500">{mode.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 编辑描述 */}
<div className="space-y-2">
<Label></Label>
<Textarea
placeholder={
editMode === "optimize"
? "描述您希望如何优化图像..."
: editMode === "style_transfer"
? "描述您想要的风格,例如:水彩画风格、油画风格..."
: editMode === "local_edit"
? "描述您想要修改的内容..."
: "描述您希望如何扩展图像..."
}
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
rows={3}
/>
</div>
{/* 编辑强度 */}
<div className="space-y-2">
<Label>: {Math.round(strength * 100)}%</Label>
<Slider
value={[strength]}
onValueChange={([value]) => setStrength(value)}
max={1}
min={0}
step={0.1}
className="w-full"
/>
</div>
{/* 执行按钮 */}
<Button
onClick={handleExecute}
disabled={isProcessing || !selectedImage}
className="w-full"
size="lg"
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Wand2 className="mr-2 h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
{/* 生成变体 - 暂时禁用,因为API不支持 */}
{/* <Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5 text-purple-600" />
生成变体
</CardTitle>
<CardDescription>
基于原图生成多个变体
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>变体数量</Label>
<Select value={numVariations.toString()} onValueChange={(value) => setNumVariations(parseInt(value))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num} 个变体
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
onClick={handleGenerateVariations}
disabled={isProcessing || !selectedImage}
className="w-full"
variant="outline"
>
<Palette className="mr-2 h-4 w-4" />
生成变体
</Button>
</CardContent>
</Card> */}
</motion.div>
{/* 右侧:结果展示 */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="space-y-6"
>
{/* 编辑结果 */}
{editResult && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5 text-green-600" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="relative group bg-muted rounded-lg overflow-hidden">
<img
src={editResult.url}
alt="Edited image"
className="w-full h-64 object-contain"
loading="lazy"
/>
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline">{editResult.mode}</Badge>
<Badge variant="secondary">
: {Math.round((editResult.metadata?.strength || 0) * 100)}%
</Badge>
</div>
<p className="text-sm text-muted-foreground">{editResult.edit_prompt}</p>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleDownload(editResult.url, editResult.id)}
>
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 变体结果 - 暂时禁用 */}
{/* {variations.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5 text-purple-600" />
图像变体
</CardTitle>
<CardDescription>
生成了 {variations.length} 个变体
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{variations.map((variation, index) => (
<motion.div
key={variation.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
className="group relative"
>
<Card className="overflow-hidden">
<div className="relative bg-muted">
<img
src={variation.url}
alt={`Variation ${index + 1}`}
className="w-full h-48 object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-transparent group-hover:bg-black/20 transition-all duration-200" />
</div>
<CardContent className="p-4 space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline">变体 {index + 1}</Badge>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleDownload(variation.url, variation.id)}
>
<Download className="h-4 w-4 mr-2" />
下载图片
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
</CardContent>
</Card>
)} */}
{/* 空状态 */}
{!editResult && (
<Card>
<CardContent className="text-center py-12">
<Image className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground"></p>
<p className="text-sm text-muted-foreground/70"></p>
</CardContent>
</Card>
)}
</motion.div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}