Your Name 53ddb7e585
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Initial commit - clean repository
2025-12-17 20:15:18 +08:00

348 lines
13 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 { 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 } from "lucide-react";
import { Input } from "@/components/ui/input";
import Image from "next/image";
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 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 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">
<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>
);
}