Files
photography/frontend/components/category-page.tsx
xujiang 9046befcf1 feat: 完成前端展示网站核心功能开发
 新增功能:
- 增强照片展示页面,支持3种视图模式(网格/瀑布流/列表)
- 实现分页加载和无限滚动功能
- 完整的搜索和过滤系统,支持实时搜索、标签筛选、排序
- 新增分类浏览页面,提供分类统计和预览
- 新增标签云页面,热度可视化显示
- 面包屑导航和页面间无缝跳转

🎨 用户体验优化:
- 响应式设计,完美适配移动端和桌面端
- 智能loading状态和空状态处理
- 悬停效果和交互动画
- 视觉化统计仪表盘

 性能优化:
- 图片懒加载和智能分页
- 优化的组件渲染和状态管理
- 构建大小优化(187kB gzipped)

📝 更新任务进度文档,完成率达到32.5%

Phase 3核心功能基本完成,前端展示网站达到完全可用状态。
2025-07-11 12:27:36 +08:00

332 lines
12 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, 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>
)
}