Initial commit: 国土空间规划课程智能体 v1.0

单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:40:18 +08:00
commit ddbb79b9f6
167 changed files with 44147 additions and 0 deletions
@@ -0,0 +1,697 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { Loader2, Download, Copy, Upload, Edit3, Wand2, Expand, Palette, Image as ImageIcon, X } from "lucide-react";
import { imageAPI } from "@/lib/api";
import { toast } from "sonner";
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 ImageToImagePage() {
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 相关状态
const [isDrawing, setIsDrawing] = useState(false);
const [brushSize, setBrushSize] = useState(20);
const [maskData, setMaskData] = useState<string>("");
// 配置选项
const [editModes, setEditModes] = useState<ConfigOption[]>([]);
const [stylePresets, setStylePresets] = useState<ConfigOption[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 加载配置选项
useEffect(() => {
const loadConfigs = async () => {
try {
const [modesData, presetsData] = await Promise.all([
imageAPI.getEditModes(),
imageAPI.getStylePresets(),
]);
setEditModes(modesData);
setStylePresets(presetsData);
} catch (error) {
console.error("加载配置失败:", error);
toast.error("加载配置失败");
}
};
loadConfigs();
}, []);
// 处理图像上传
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();
};
// Canvas 绘制蒙版
const handleCanvasMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
setIsDrawing(true);
drawOnCanvas(event);
};
const handleCanvasMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
drawOnCanvas(event);
};
const handleCanvasMouseUp = () => {
setIsDrawing(false);
// 导出蒙版数据
const canvas = canvasRef.current;
if (canvas) {
const dataURL = canvas.toDataURL();
setMaskData(dataURL);
}
};
const drawOnCanvas = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
ctx.beginPath();
ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
};
// 清除蒙版
const clearMask = () => {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setMaskData("");
}
}
};
// 图像编辑
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 result = await imageAPI.imageEdit(
selectedImage,
editPrompt,
editMode,
maskFile,
strength
);
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 handleStyleTransfer = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
if (!editPrompt.trim()) {
toast.error("请输入风格描述");
return;
}
setIsProcessing(true);
try {
const result = await imageAPI.styleTransfer(selectedImage, editPrompt, strength);
setEditResult(result);
toast.success("风格转换完成");
} catch (error) {
console.error("风格转换失败:", error);
toast.error("风格转换失败,请重试");
} finally {
setIsProcessing(false);
}
};
// 图像优化
const handleOptimizeImage = async () => {
if (!selectedImage) {
toast.error("请先上传图像");
return;
}
setIsProcessing(true);
try {
const result = await imageAPI.optimizeImage(selectedImage, editPrompt || "优化图像质量,增强细节,提高清晰度", strength);
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 result = await imageAPI.outpaintImage(selectedImage, editPrompt, strength);
setEditResult(result);
toast.success("图像扩展完成");
} catch (error) {
console.error("图像扩展失败:", error);
toast.error("图像扩展失败,请重试");
} finally {
setIsProcessing(false);
}
};
// 执行操作
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 handleDownload = (imageUrl: string, imageId: string) => {
const link = document.createElement("a");
link.href = imageUrl;
link.download = `edited-image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-blue-50">
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-8"
>
<h1 className="text-4xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent mb-4">
- Image to Image
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
</p>
</motion.div>
<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-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-2"></p>
<p className="text-sm text-gray-400"> PNG, JPG, JPEG </p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
</div>
) : (
<div className="space-y-4">
<div className="relative">
<img
src={imagePreview}
alt="Uploaded image"
className="w-full h-64 object-cover rounded-lg"
/>
<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>
{/* 局部编辑蒙版 */}
{editMode === "local_edit" && selectedImage && (
<div className="space-y-2">
<Label></Label>
<div className="border rounded-lg p-4">
<div className="space-y-2 mb-4">
<Label>: {brushSize}px</Label>
<Slider
value={[brushSize]}
onValueChange={([value]) => setBrushSize(value)}
max={50}
min={5}
step={5}
className="w-full"
/>
</div>
<div className="relative">
<canvas
ref={canvasRef}
width={400}
height={300}
className="border rounded cursor-crosshair w-full h-64 object-cover"
style={{ backgroundImage: `url(${imagePreview})`, backgroundSize: "cover" }}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
/>
<div className="absolute top-2 right-2">
<Button size="sm" variant="outline" onClick={clearMask}>
</Button>
</div>
</div>
<p className="text-sm text-gray-500 mt-2">
</p>
</div>
</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>
{/* 生成变体 */}
<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">
<img
src={editResult.url}
alt="Edited image"
className="w-full h-64 object-cover rounded-lg"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded-lg" />
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button
size="sm"
variant="secondary"
onClick={() => handleDownload(editResult.url, editResult.id)}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-2">
<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-gray-600">{editResult.edit_prompt}</p>
</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"
>
<div className="relative">
<img
src={variation.url}
alt={`Variation ${index + 1}`}
className="w-full h-48 object-cover rounded-lg"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded-lg" />
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button
size="sm"
variant="secondary"
onClick={() => handleDownload(variation.url, variation.id)}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-2">
<Badge variant="outline"> {index + 1}</Badge>
</div>
</motion.div>
))}
</div>
</CardContent>
</Card>
)}
{/* 空状态 */}
{!editResult && variations.length === 0 && (
<Card>
<CardContent className="text-center py-12">
<ImageIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400"></p>
</CardContent>
</Card>
)}
</motion.div>
</div>
</div>
</div>
);
}
+964
View File
@@ -0,0 +1,964 @@
"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">
{/* 页面头部 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2"></h1>
<p className="text-muted-foreground">使AI生成国土空间规划相关的设计图和示意图</p>
</div>
{/* 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>
);
}
@@ -0,0 +1,378 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { Loader2, Download, Copy, RefreshCw, Sparkles, Image as ImageIcon } from "lucide-react";
import { imageAPI } from "@/lib/api";
import { toast } from "sonner";
interface GeneratedImage {
id: string;
url: string;
prompt: string;
model: string;
metadata: any;
}
interface ConfigOption {
id: string;
name: string;
description: string;
}
export default function TextToImagePage() {
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[]>([]);
// 配置选项
const [models, setModels] = useState<ConfigOption[]>([]);
const [templates, setTemplates] = useState<ConfigOption[]>([]);
const [styles, setStyles] = useState<ConfigOption[]>([]);
const [sizes, setSizes] = useState<ConfigOption[]>([]);
// 加载配置选项
useEffect(() => {
const loadConfigs = async () => {
try {
const [modelsData, templatesData, stylesData, sizesData] = await Promise.all([
imageAPI.getModels(),
imageAPI.getTemplates(),
imageAPI.getStyles(),
imageAPI.getSizes(),
]);
setModels(modelsData);
setTemplates(templatesData);
setStyles(stylesData);
setSizes(sizesData);
} catch (error) {
console.error("加载配置失败:", error);
toast.error("加载配置失败");
}
};
loadConfigs();
}, []);
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);
}
};
const handleDownload = (imageUrl: string, imageId: string) => {
const link = document.createElement("a");
link.href = imageUrl;
link.download = `generated-image-${imageId}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图像下载成功");
};
const handleCopyPrompt = (prompt: string) => {
navigator.clipboard.writeText(prompt);
toast.success("提示词已复制到剪贴板");
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-8"
>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">
- Text to Image
</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
使AI模型
</p>
</motion.div>
<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-gray-500">{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-gray-500">{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-gray-500">{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-gray-500">{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" />
...
</>
) : (
<>
<ImageIcon 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">
<ImageIcon 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">
<img
src={image.url}
alt={`Generated image ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200" />
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div className="flex gap-1">
<Button
size="sm"
variant="secondary"
onClick={() => handleDownload(image.url, image.id)}
>
<Download className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleCopyPrompt(image.prompt)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<CardContent className="p-4">
<div className="space-y-2">
<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-gray-600 line-clamp-2">
{image.prompt}
</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
) : (
<div className="text-center py-12">
<ImageIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400">"生成图像"</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
</div>
</div>
</div>
);
}