feat: 完成前端展示网站核心功能开发
✨ 新增功能: - 增强照片展示页面,支持3种视图模式(网格/瀑布流/列表) - 实现分页加载和无限滚动功能 - 完整的搜索和过滤系统,支持实时搜索、标签筛选、排序 - 新增分类浏览页面,提供分类统计和预览 - 新增标签云页面,热度可视化显示 - 面包屑导航和页面间无缝跳转 🎨 用户体验优化: - 响应式设计,完美适配移动端和桌面端 - 智能loading状态和空状态处理 - 悬停效果和交互动画 - 视觉化统计仪表盘 ⚡ 性能优化: - 图片懒加载和智能分页 - 优化的组件渲染和状态管理 - 构建大小优化(187kB gzipped) 📝 更新任务进度文档,完成率达到32.5% Phase 3核心功能基本完成,前端展示网站达到完全可用状态。
This commit is contained in:
@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Calendar, MapPin } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar, MapPin, Grid, List, Eye, Heart } from "lucide-react"
|
||||
|
||||
interface Photo {
|
||||
id: number
|
||||
@ -27,70 +28,237 @@ interface PhotoGalleryProps {
|
||||
onPhotoClick: (photo: Photo) => void
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'masonry' | 'list'
|
||||
|
||||
export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
||||
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set())
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const [page, setPage] = useState(1)
|
||||
const [displayedPhotos, setDisplayedPhotos] = useState<Photo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const photosPerPage = 12
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
// 初始化显示的照片
|
||||
useEffect(() => {
|
||||
if (photos.length > 0) {
|
||||
setDisplayedPhotos(photos.slice(0, photosPerPage))
|
||||
setHasMore(photos.length > photosPerPage)
|
||||
setPage(1)
|
||||
}
|
||||
}, [photos])
|
||||
|
||||
// 加载更多照片
|
||||
const loadMorePhotos = useCallback(() => {
|
||||
if (isLoading || !hasMore) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// 模拟API加载延迟
|
||||
setTimeout(() => {
|
||||
const nextPage = page + 1
|
||||
const startIndex = (nextPage - 1) * photosPerPage
|
||||
const endIndex = startIndex + photosPerPage
|
||||
const newPhotos = photos.slice(startIndex, endIndex)
|
||||
|
||||
if (newPhotos.length > 0) {
|
||||
setDisplayedPhotos(prev => [...prev, ...newPhotos])
|
||||
setPage(nextPage)
|
||||
setHasMore(endIndex < photos.length)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, 500)
|
||||
}, [page, photos, isLoading, hasMore, photosPerPage])
|
||||
|
||||
// 滚动检测
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoading) {
|
||||
loadMorePhotos()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, isLoading, loadMorePhotos])
|
||||
|
||||
const handleImageLoad = (photoId: number) => {
|
||||
setLoadedImages((prev) => new Set(prev).add(photoId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{photos.map((photo) => (
|
||||
<Card
|
||||
key={photo.id}
|
||||
className="group cursor-pointer overflow-hidden border-0 shadow-sm hover:shadow-xl transition-all duration-300 hover:scale-[1.02]"
|
||||
onClick={() => onPhotoClick(photo)}
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
{isHydrated && !loadedImages.has(photo.id) && <div className="absolute inset-0 bg-gray-100 animate-pulse" />}
|
||||
<Image
|
||||
src={photo.src || "/placeholder.svg"}
|
||||
alt={photo.title}
|
||||
fill
|
||||
className={`object-cover transition-all duration-500 group-hover:scale-110 ${
|
||||
isHydrated && loadedImages.has(photo.id) ? "opacity-100" : !isHydrated ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => handleImageLoad(photo.id)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300" />
|
||||
const getGridClasses = () => {
|
||||
switch (viewMode) {
|
||||
case 'grid':
|
||||
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
case 'masonry':
|
||||
return 'columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6'
|
||||
case 'list':
|
||||
return 'space-y-6'
|
||||
default:
|
||||
return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6'
|
||||
}
|
||||
}
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 p-4 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<div className="text-white">
|
||||
<h3 className="font-medium text-lg mb-1">{photo.title}</h3>
|
||||
<p className="text-sm text-white/80 mb-2">{photo.description}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{photo.date}</span>
|
||||
<MapPin className="h-3 w-3 ml-2" />
|
||||
<span>{photo.exif.location}</span>
|
||||
</div>
|
||||
const renderPhotoCard = (photo: Photo) => {
|
||||
const isListView = viewMode === 'list'
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={photo.id}
|
||||
className={`group cursor-pointer overflow-hidden border-0 shadow-sm hover:shadow-xl transition-all duration-300 hover:scale-[1.02] ${
|
||||
viewMode === 'masonry' ? 'break-inside-avoid mb-6' : ''
|
||||
} ${isListView ? 'flex flex-row' : ''}`}
|
||||
onClick={() => onPhotoClick(photo)}
|
||||
>
|
||||
<div className={`relative overflow-hidden ${
|
||||
isListView ? 'w-48 h-32 flex-shrink-0' : 'aspect-[4/3]'
|
||||
}`}>
|
||||
{isHydrated && !loadedImages.has(photo.id) && (
|
||||
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
|
||||
)}
|
||||
<Image
|
||||
src={photo.src || "/placeholder.svg"}
|
||||
alt={photo.title}
|
||||
fill
|
||||
className={`object-cover transition-all duration-500 group-hover:scale-110 ${
|
||||
isHydrated && loadedImages.has(photo.id) ? "opacity-100" : !isHydrated ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => handleImageLoad(photo.id)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300" />
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 p-4 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<div className="text-white">
|
||||
<div className="flex items-center gap-2 text-xs text-white/70">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>点击查看</span>
|
||||
<Heart className="h-3 w-3 ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card content */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{photo.title}</h3>
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{photo.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{/* Card content */}
|
||||
<div className={`p-4 ${isListView ? 'flex-1' : ''}`}>
|
||||
<h3 className="font-medium text-gray-900 mb-2">{photo.title}</h3>
|
||||
{isListView && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{photo.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{photo.tags.slice(0, isListView ? 4 : 2).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{photo.date}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{photo.exif.location}</span>
|
||||
</div>
|
||||
<div>
|
||||
{photo.exif.camera} • {photo.exif.settings}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 视图模式切换 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'masonry' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('masonry')}
|
||||
>
|
||||
<div className="h-4 w-4 flex items-center justify-center">
|
||||
<div className="grid grid-cols-2 gap-px">
|
||||
<div className="w-1 h-1 bg-current"></div>
|
||||
<div className="w-1 h-2 bg-current"></div>
|
||||
<div className="w-1 h-2 bg-current"></div>
|
||||
<div className="w-1 h-1 bg-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
显示 {displayedPhotos.length} / {photos.length} 张照片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 照片网格 */}
|
||||
<div className={getGridClasses()}>
|
||||
{displayedPhotos.map(renderPhotoCard)}
|
||||
</div>
|
||||
|
||||
{/* 加载更多 */}
|
||||
{hasMore && (
|
||||
<div ref={loaderRef} className="py-8 text-center">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-2 border-gray-300 border-t-gray-600"></div>
|
||||
<span className="text-gray-600">正在加载更多...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMorePhotos}
|
||||
className="px-8"
|
||||
>
|
||||
加载更多照片
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无更多照片 */}
|
||||
{!hasMore && displayedPhotos.length > photosPerPage && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
已显示全部 {photos.length} 张照片
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user