Initial commit: 国土空间规划课程智能体 v1.0
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user