- 创建 /api/enhance 路由,实现图片变清晰API集成 - 在图片标注组件中添加变清晰按钮(Sparkles图标) - 在相册页面添加变清晰按钮 - 系统设置:将抠图设置改名为本地AI设置,添加变清晰API配置 - 支持同步和异步两种模式 - 自动上传到S3存储桶并保存到数据库 - 在相册和出图记录中显示处理后的图片
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||
import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit, Scissors, Sparkles } from "lucide-react";
|
||
import { Input } from "@/components/ui/input";
|
||
import Image from "next/image";
|
||
import { toast } from "sonner";
|
||
|
||
type GalleryImage = {
|
||
id: string;
|
||
messageId: string;
|
||
url: string; // 原图 URL
|
||
thumbnailUrl?: string; // 缩略图 URL(可选,兼容旧数据)
|
||
alt: string;
|
||
imageSize?: string; // 图片尺寸(如 "2K", "4K")
|
||
createdAt: string;
|
||
chatId: string;
|
||
};
|
||
|
||
type Pagination = {
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
};
|
||
|
||
export default function GalleryPage() {
|
||
const router = useRouter();
|
||
const [images, setImages] = useState<GalleryImage[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [pagination, setPagination] = useState<Pagination>({
|
||
page: 1,
|
||
pageSize: 24,
|
||
total: 0,
|
||
totalPages: 0,
|
||
});
|
||
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [pageInput, setPageInput] = useState("");
|
||
const [processingImageId, setProcessingImageId] = useState<string | null>(null);
|
||
const [enhancingImageId, setEnhancingImageId] = useState<string | null>(null);
|
||
|
||
const fetchImages = async (page: number) => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`/api/gallery?page=${page}&pageSize=24`);
|
||
const data = await res.json();
|
||
if (data.images) {
|
||
setImages(data.images);
|
||
setPagination(data.pagination);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch images:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchImages(1);
|
||
}, []);
|
||
|
||
const handleImageClick = (image: GalleryImage) => {
|
||
setSelectedImage(image);
|
||
setDialogOpen(true);
|
||
};
|
||
|
||
const handleDownload = async (url: string, alt: string) => {
|
||
try {
|
||
const response = await fetch(url);
|
||
const blob = await response.blob();
|
||
const blobUrl = window.URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = blobUrl;
|
||
link.download = `${alt || "image"}-${Date.now()}.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.URL.revokeObjectURL(blobUrl);
|
||
} catch (error) {
|
||
console.error("Download failed:", error);
|
||
}
|
||
};
|
||
|
||
const handleEdit = (imageUrl: string) => {
|
||
// 跳转到首页,并将图片URL作为参数传递
|
||
router.push(`/?editImage=${encodeURIComponent(imageUrl)}`);
|
||
};
|
||
|
||
const handleRemoveBackground = async (image: GalleryImage, e: React.MouseEvent) => {
|
||
e.stopPropagation(); // 阻止触发图片点击事件
|
||
|
||
if (processingImageId === image.id) return; // 防止重复点击
|
||
|
||
setProcessingImageId(image.id);
|
||
|
||
try {
|
||
toast.info("正在处理抠图,请稍候...");
|
||
|
||
// 获取原图并转换为Base64
|
||
const response = await fetch(image.url);
|
||
const blob = await response.blob();
|
||
const reader = new FileReader();
|
||
|
||
reader.onloadend = async () => {
|
||
try {
|
||
const base64Data = (reader.result as string).split(",")[1];
|
||
|
||
// 调用抠图API
|
||
const rmbgResponse = await fetch("/api/rmbg", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
image_base64: base64Data,
|
||
prompt: `抠图处理 - ${image.alt}`,
|
||
chatId: image.chatId,
|
||
messageId: image.messageId,
|
||
}),
|
||
});
|
||
|
||
if (!rmbgResponse.ok) {
|
||
const error = await rmbgResponse.json();
|
||
throw new Error(error.error || error.detail || "抠图失败");
|
||
}
|
||
|
||
const result = await rmbgResponse.json();
|
||
|
||
// 刷新相册列表以显示新图片
|
||
await fetchImages(pagination.page);
|
||
|
||
toast.success(`抠图完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
|
||
} catch (error) {
|
||
console.error("[抠图] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "抠图处理失败");
|
||
} finally {
|
||
setProcessingImageId(null);
|
||
}
|
||
};
|
||
|
||
reader.onerror = () => {
|
||
toast.error("图片读取失败");
|
||
setProcessingImageId(null);
|
||
};
|
||
|
||
reader.readAsDataURL(blob);
|
||
} catch (error) {
|
||
console.error("[抠图] 错误:", error);
|
||
toast.error(error instanceof Error ? error.message : "抠图处理失败");
|
||
setProcessingImageId(null);
|
||
}
|
||
};
|
||
|
||
const goToPage = (page: number) => {
|
||
if (page >= 1 && page <= pagination.totalPages) {
|
||
fetchImages(page);
|
||
setPageInput(""); // 清空输入框
|
||
}
|
||
};
|
||
|
||
const handlePageInputSubmit = () => {
|
||
const pageNum = Number.parseInt(pageInput);
|
||
if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= pagination.totalPages) {
|
||
goToPage(pageNum);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="container mx-auto p-6 h-full flex flex-col">
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-2xl font-bold">我的图册</h1>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
共 {pagination.total} 张AI生成的图片
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<div className="text-center py-10 text-muted-foreground">
|
||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4"></div>
|
||
加载中...
|
||
</div>
|
||
</div>
|
||
) : images.length === 0 ? (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<div className="text-center py-10 text-muted-foreground">
|
||
暂无生图记录,快去生成一些图片吧!
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||
{images.map((img) => (
|
||
<div
|
||
key={img.id}
|
||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-lg hover:scale-105"
|
||
style={{ width: "200px", height: "270px", flexShrink: 0 }}
|
||
onClick={() => handleImageClick(img)}
|
||
>
|
||
<div className="relative w-full h-full">
|
||
<Image
|
||
src={img.thumbnailUrl || img.url}
|
||
alt={img.alt}
|
||
width={200}
|
||
height={270}
|
||
className="object-cover w-full h-full"
|
||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, (max-width: 1280px) 20vw, 200px"
|
||
quality={75}
|
||
loading="lazy"
|
||
placeholder="blur"
|
||
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjI3MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjI3MCIgZmlsbD0iI2VlZSIvPjwvc3ZnPg=="
|
||
/>
|
||
</div>
|
||
{/* 🆕 操作按钮(悬停时显示) */}
|
||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 px-2 bg-black/70 hover:bg-black/90 text-white border-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRemoveBackground(img, e);
|
||
}}
|
||
disabled={processingImageId === img.id || enhancingImageId === img.id}
|
||
title="移除背景(抠图)"
|
||
>
|
||
<Scissors className={`h-3 w-3 mr-1 ${processingImageId === img.id ? "animate-spin" : ""}`} />
|
||
{processingImageId === img.id ? "处理中" : "抠图"}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 px-2 bg-black/70 hover:bg-black/90 text-white border-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEnhance(img, e);
|
||
}}
|
||
disabled={enhancingImageId === img.id || processingImageId === img.id}
|
||
title="图片变清晰"
|
||
>
|
||
<Sparkles className={`h-3 w-3 mr-1 ${enhancingImageId === img.id ? "animate-spin" : ""}`} />
|
||
{enhancingImageId === img.id ? "处理中" : "变清晰"}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 px-2 bg-black/70 hover:bg-black/90 text-white border-0"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEdit(img.url);
|
||
}}
|
||
>
|
||
<Edit className="h-3 w-3 mr-1" />
|
||
编辑
|
||
</Button>
|
||
</div>
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2 translate-y-full group-hover:translate-y-0 transition-transform">
|
||
<p className="text-xs text-white truncate">{img.alt}</p>
|
||
<p className="text-xs text-white/70 mt-1">
|
||
{new Date(img.createdAt).toLocaleString("zh-CN", {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
})} • AI生成
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分页信息 */}
|
||
<div className="mt-6">
|
||
{/* 始终显示页码信息 */}
|
||
<div className="text-center text-sm text-muted-foreground mb-3">
|
||
第 {pagination.page} 页 / 共 {pagination.totalPages} 页
|
||
</div>
|
||
|
||
{/* 只有多页时才显示翻页按钮 */}
|
||
{pagination.totalPages > 1 && (
|
||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||
{/* 左侧:第一页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => goToPage(1)}
|
||
disabled={pagination.page === 1}
|
||
title="第一页"
|
||
>
|
||
<ChevronsLeft className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* 上一页 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => goToPage(pagination.page - 1)}
|
||
disabled={pagination.page === 1}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
上一页
|
||
</Button>
|
||
|
||
{/* 页码按钮 */}
|
||
<div className="flex items-center gap-1">
|
||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||
.filter((p) => {
|
||
// 显示当前页前后1页
|
||
return (
|
||
p === 1 ||
|
||
p === pagination.totalPages ||
|
||
Math.abs(p - pagination.page) <= 1
|
||
);
|
||
})
|
||
.map((p, idx, arr) => {
|
||
// 添加省略号
|
||
const showEllipsis = idx > 0 && p - arr[idx - 1] > 1;
|
||
return (
|
||
<div key={p} className="flex items-center">
|
||
{showEllipsis && (
|
||
<span className="px-2 text-muted-foreground">...</span>
|
||
)}
|
||
<Button
|
||
variant={p === pagination.page ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => goToPage(p)}
|
||
>
|
||
{p}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 下一页 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => goToPage(pagination.page + 1)}
|
||
disabled={pagination.page === pagination.totalPages}
|
||
>
|
||
下一页
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* 右侧:最后一页按钮 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => goToPage(pagination.totalPages)}
|
||
disabled={pagination.page === pagination.totalPages}
|
||
title="最后一页"
|
||
>
|
||
<ChevronsRight className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* 页码输入跳转 */}
|
||
<div className="flex items-center gap-2 ml-4">
|
||
<span className="text-sm text-muted-foreground">跳至</span>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={pagination.totalPages}
|
||
value={pageInput}
|
||
onChange={(e) => setPageInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
handlePageInputSubmit();
|
||
}
|
||
}}
|
||
className="w-16 h-8 text-center"
|
||
placeholder={String(pagination.page)}
|
||
/>
|
||
<span className="text-sm text-muted-foreground">页</span>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handlePageInputSubmit}
|
||
disabled={!pageInput}
|
||
>
|
||
跳转
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 图片预览对话框 */}
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
||
{selectedImage && (
|
||
<div className="flex flex-col h-full">
|
||
<div className="flex-1 relative bg-black/5 flex items-center justify-center p-4">
|
||
<div className="relative max-w-full max-h-[70vh]">
|
||
<Image
|
||
src={selectedImage.url}
|
||
alt={selectedImage.alt}
|
||
width={800}
|
||
height={800}
|
||
className="object-contain max-h-[70vh] w-auto"
|
||
unoptimized
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="p-4 border-t bg-background">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex-1">
|
||
<p className="font-medium">{selectedImage.alt}</p>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}
|
||
size="sm"
|
||
>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|