Your Name 60082aca6f 修复时间显示和图像拓展功能
- 添加formatBeijingTime函数,将所有时间显示转换为北京时间(UTC+8)
- 更新相册、管理后台、精选页面的时间显示
- 图像拓展功能改为直接发送图片URL而不是base64
- 优化API性能,减少数据传输量
2025-12-30 20:16:47 +08:00

517 lines
19 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, Scissors, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import Image from "next/image";
import { toast } from "sonner";
import { formatBeijingTime } from "@/lib/utils";
type GalleryImage = {
id: string;
messageId: string;
url: string; // 原图 URL
thumbnailUrl?: string; // 缩略图 URL(可选,兼容旧数据)
alt: string;
imageSize?: string; // 图片尺寸(如 "2K", "4K")
createdAt: string;
chatId: string;
modelId?: string; // 模型ID,用于标识图片类型(如 "enhance-1.0" 表示变清晰后的图片)
};
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 handleEnhance = async (image: GalleryImage, e: React.MouseEvent) => {
e.stopPropagation(); // 阻止触发图片点击事件
if (enhancingImageId === image.id) return; // 防止重复点击
setEnhancingImageId(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 enhanceResponse = await fetch("/api/enhance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: `图片变清晰处理 - ${image.alt}`,
chatId: image.chatId,
messageId: image.messageId,
}),
});
if (!enhanceResponse.ok) {
const error = await enhanceResponse.json();
throw new Error(error.error || error.detail || "变清晰失败");
}
const result = await enhanceResponse.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 {
setEnhancingImageId(null);
}
};
reader.onerror = () => {
toast.error("图片读取失败");
setEnhancingImageId(null);
};
reader.readAsDataURL(blob);
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰处理失败");
setEnhancingImageId(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=="
/>
{/* 变清晰标识图标 - 右上角 */}
{img.modelId === "enhance-1.0" && (
<div className="absolute top-2 right-2 bg-blue-500/90 text-white rounded-full p-1.5 shadow-lg z-10" title="变清晰后的图片">
<Sparkles className="h-4 w-4" />
</div>
)}
</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">
{formatBeijingTime(img.createdAt, {
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">
{formatBeijingTime(selectedImage.createdAt)}
</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>
);
}