## 修复内容 ### 前端 (Frontend) - 修复 ESLint 错误:未使用变量重命名为下划线前缀 - 修复 TypeScript 类型错误:完善 BackendPhoto 接口定义 - 修复引号转义问题:搜索结果显示优化 - 优化 useEffect 依赖:添加 useCallback 避免无限循环 - 移除未使用的导入和变量 ### 后端 (Backend) - 修复 go vet 错误:测试文件中的字段名称不匹配 - 修复数组访问错误:使用正确的结构体字段路径 - 统一代码格式:go fmt 自动格式化 ### 管理后台 (Admin) - 创建缺失的 ESLint 配置文件 - 修复 React 导入缺失问题 - 确保 TypeScript 编译通过 ## CI/CD 改进 - 验证了前端、后端、管理后台的完整构建流程 - 所有 lint 检查、类型检查、测试均通过 - 为自动化部署做好准备 ## 技术细节 - 前端:修复 5+ ESLint 错误,完善类型定义 - 后端:修复 3+ go vet 错误,通过所有测试 - 管理后台:创建 ESLint 配置,修复导入问题 - 所有模块均可正常构建和运行
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
"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>
|
||
)
|
||
} |