feat: 完成前端展示网站核心功能开发
✨ 新增功能: - 增强照片展示页面,支持3种视图模式(网格/瀑布流/列表) - 实现分页加载和无限滚动功能 - 完整的搜索和过滤系统,支持实时搜索、标签筛选、排序 - 新增分类浏览页面,提供分类统计和预览 - 新增标签云页面,热度可视化显示 - 面包屑导航和页面间无缝跳转 🎨 用户体验优化: - 响应式设计,完美适配移动端和桌面端 - 智能loading状态和空状态处理 - 悬停效果和交互动画 - 视觉化统计仪表盘 ⚡ 性能优化: - 图片懒加载和智能分页 - 优化的组件渲染和状态管理 - 构建大小优化(187kB gzipped) 📝 更新任务进度文档,完成率达到32.5% Phase 3核心功能基本完成,前端展示网站达到完全可用状态。
This commit is contained in:
332
frontend/components/category-page.tsx
Normal file
332
frontend/components/category-page.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {
|
||||
Search,
|
||||
Grid,
|
||||
BarChart3,
|
||||
Camera,
|
||||
ArrowRight,
|
||||
Tag,
|
||||
Clock,
|
||||
Images
|
||||
} from "lucide-react"
|
||||
import { useCategories } from "@/lib/queries"
|
||||
import { Photo } from "@/lib/queries"
|
||||
|
||||
interface CategoryPageProps {
|
||||
photos: Photo[]
|
||||
onCategorySelect: (category: string) => void
|
||||
onPhotosView: () => void
|
||||
}
|
||||
|
||||
export function CategoryPage({ photos, onCategorySelect, onPhotosView }: CategoryPageProps) {
|
||||
const { data: dynamicCategories = [] } = useCategories()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
|
||||
// 计算分类统计
|
||||
const categoryStats = useMemo(() => {
|
||||
const stats = new Map<string, {
|
||||
name: string
|
||||
count: number
|
||||
lastUpdate: string
|
||||
photos: Photo[]
|
||||
}>()
|
||||
|
||||
photos.forEach(photo => {
|
||||
const category = photo.category || '未分类'
|
||||
const existing = stats.get(category)
|
||||
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.photos.push(photo)
|
||||
// 更新最后更新时间
|
||||
if (photo.date > existing.lastUpdate) {
|
||||
existing.lastUpdate = photo.date
|
||||
}
|
||||
} else {
|
||||
stats.set(category, {
|
||||
name: category,
|
||||
count: 1,
|
||||
lastUpdate: photo.date,
|
||||
photos: [photo]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(stats.values()).sort((a, b) => b.count - a.count)
|
||||
}, [photos])
|
||||
|
||||
// 搜索过滤
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!searchQuery.trim()) return categoryStats
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return categoryStats.filter(category =>
|
||||
category.name.toLowerCase().includes(query)
|
||||
)
|
||||
}, [categoryStats, searchQuery])
|
||||
|
||||
// 获取分类颜色
|
||||
const getCategoryColor = (index: number) => {
|
||||
const colors = [
|
||||
'bg-blue-100 text-blue-800',
|
||||
'bg-green-100 text-green-800',
|
||||
'bg-purple-100 text-purple-800',
|
||||
'bg-yellow-100 text-yellow-800',
|
||||
'bg-red-100 text-red-800',
|
||||
'bg-indigo-100 text-indigo-800',
|
||||
'bg-pink-100 text-pink-800',
|
||||
'bg-gray-100 text-gray-800'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 获取分类预览图片
|
||||
const getCategoryPreviewImages = (categoryPhotos: Photo[]) => {
|
||||
return categoryPhotos.slice(0, 4)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 面包屑导航 */}
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink onClick={onPhotosView} className="cursor-pointer">
|
||||
摄影作品
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>分类浏览</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
{/* 页面标题和统计 */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-light text-gray-900 mb-4">分类浏览</h1>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
按分类探索 {photos.length} 张照片,共 {categoryStats.length} 个分类
|
||||
</p>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Images className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{photos.length}</p>
|
||||
<p className="text-sm text-gray-600">总照片数</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Tag className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{categoryStats.length}</p>
|
||||
<p className="text-sm text-gray-600">分类数量</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{categoryStats.length > 0 ?
|
||||
Math.round(photos.length / categoryStats.length) : 0
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">平均每分类</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和视图控制 */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索分类..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 === 'list' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类网格 */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredCategories.map((category, index) => (
|
||||
<Card
|
||||
key={category.name}
|
||||
className="group cursor-pointer hover:shadow-lg transition-all duration-300 hover:scale-[1.02]"
|
||||
onClick={() => onCategorySelect(category.name)}
|
||||
>
|
||||
{/* 照片预览网格 */}
|
||||
<div className="aspect-[4/3] bg-gray-100 relative overflow-hidden">
|
||||
{getCategoryPreviewImages(category.photos).length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-1 h-full">
|
||||
{getCategoryPreviewImages(category.photos).map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`relative overflow-hidden ${
|
||||
idx === 0 && category.photos.length === 1 ? 'col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.title}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Camera className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停叠加层 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-300" />
|
||||
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<ArrowRight className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类信息 */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-medium text-gray-900">{category.name}</h3>
|
||||
<Badge className={getCategoryColor(index)} variant="secondary">
|
||||
{category.count} 张
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
最后更新: {new Date(category.lastUpdate).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Camera className="h-4 w-4 mr-1" />
|
||||
<span>点击查看所有照片</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 列表视图 */
|
||||
<div className="space-y-4">
|
||||
{filteredCategories.map((category, index) => (
|
||||
<Card
|
||||
key={category.name}
|
||||
className="p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => onCategorySelect(category.name)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex -space-x-2">
|
||||
{getCategoryPreviewImages(category.photos).slice(0, 3).map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="w-12 h-12 rounded-lg border-2 border-white overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{category.name}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{category.count} 张照片 • 最后更新: {new Date(category.lastUpdate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getCategoryColor(index)} variant="secondary">
|
||||
{category.count}
|
||||
</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{filteredCategories.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Camera className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{searchQuery ? '未找到匹配的分类' : '暂无分类'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{searchQuery ? '尝试使用不同的关键词搜索' : '开始上传照片并为其分类'}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
清除搜索
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user