Your Name 2561c66cb8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: update workflow categories and admin notifications
2026-05-07 19:31:14 +08:00

565 lines
21 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 { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
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;
};
function buildGalleryDownloadFilename(url: string, alt: string) {
const rawUrl = String(url || "").trim();
const rawAlt = String(alt || "").trim();
const safeBase =
rawAlt
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80) || "gallery-image";
let extension = "png";
try {
const pathname = new URL(rawUrl).pathname;
const candidate = pathname.split("/").pop() || "";
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
extension = inferred;
}
} catch {
const candidate = rawUrl.split("/").pop()?.split("?")[0] || "";
const inferred = candidate.split(".").pop()?.trim().toLowerCase();
if (inferred && /^[a-z0-9]{2,5}$/.test(inferred)) {
extension = inferred;
}
}
return `${safeBase}-${Date.now()}.${extension}`;
}
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) => {
const originalUrl = inferOriginalImageUrl(String(url || "").trim());
if (!originalUrl) {
toast.error("图片地址无效");
return;
}
try {
toast.info("正在准备下载...");
const response = await fetch(
buildImageProxyUrl(originalUrl, { delivery: "proxy" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载");
} catch (error) {
console.error("Download failed:", error);
const fallbackLink = document.createElement("a");
fallbackLink.href = originalUrl;
fallbackLink.target = "_blank";
fallbackLink.rel = "noopener noreferrer";
document.body.appendChild(fallbackLink);
fallbackLink.click();
document.body.removeChild(fallbackLink);
toast.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];
const result = await requestLongTaskResult({
endpoint: "/api/enhance",
payload: {
image_base64: base64Data,
prompt: `图片变清晰处理 - ${image.alt}`,
chatId: image.chatId,
messageId: image.messageId,
},
pollingParams: {
prompt: `图片变清晰处理 - ${image.alt}`,
chatId: image.chatId,
messageId: image.messageId,
},
});
// 刷新相册列表以显示新图片
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>
);
}