feat: 完成后端-管理后台集成及部署配置
🚀 主要功能: - 完善后端API服务层,实现完整的CRUD操作 - 开发管理后台所有核心页面 (仪表板、照片、分类、标签、用户、设置) - 完成前后端完全集成,所有API接口正常对接 - 配置完整的CI/CD流水线,支持自动化部署 🎯 后端完善: - 实现PhotoService, CategoryService, TagService, UserService - 添加完整的API处理器和路由配置 - 支持Docker容器化部署 - 添加数据库迁移和健康检查 🎨 管理后台完成: - 仪表板: 实时统计数据展示 - 照片管理: 完整的CRUD操作,支持批量处理 - 分类管理: 树形结构展示和管理 - 标签管理: 颜色标签和统计信息 - 用户管理: 角色权限控制 - 系统设置: 多标签配置界面 - 添加pre-commit代码质量检查 🔧 部署配置: - Docker Compose完整配置 - 后端CI/CD流水线 (Docker部署) - 管理后台CI/CD流水线 (静态文件部署) - 前端CI/CD流水线优化 - 自动化脚本: 部署、备份、监控 - 完整的部署文档和运维指南 ✅ 集成完成: - 所有API接口正常连接 - 认证系统完整集成 - 数据获取和状态管理 - 错误处理和用户反馈 - 响应式设计优化
This commit is contained in:
518
admin/src/pages/Photos.tsx
Normal file
518
admin/src/pages/Photos.tsx
Normal file
@ -0,0 +1,518 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
PhotoIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
FilterIcon,
|
||||
MoreVerticalIcon,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
DownloadIcon,
|
||||
GridIcon,
|
||||
ListIcon
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { photoService } from '@/services/photoService'
|
||||
import { categoryService } from '@/services/categoryService'
|
||||
import { tagService } from '@/services/tagService'
|
||||
|
||||
type ViewMode = 'grid' | 'list'
|
||||
|
||||
interface PhotoFilters {
|
||||
search: string
|
||||
status: string
|
||||
categoryId: string
|
||||
tagId: string
|
||||
dateRange: string
|
||||
}
|
||||
|
||||
export default function Photos() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 状态管理
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<number[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [filters, setFilters] = useState<PhotoFilters>({
|
||||
search: '',
|
||||
status: '',
|
||||
categoryId: '',
|
||||
tagId: '',
|
||||
dateRange: ''
|
||||
})
|
||||
|
||||
// 获取照片列表
|
||||
const { data: photosData, isLoading: photosLoading } = useQuery({
|
||||
queryKey: ['photos', { page, ...filters }],
|
||||
queryFn: () => photoService.getPhotos({
|
||||
page,
|
||||
limit: 20,
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
category_id: filters.categoryId ? parseInt(filters.categoryId) : undefined,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
})
|
||||
})
|
||||
|
||||
// 获取分类列表
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ['categories-all'],
|
||||
queryFn: () => categoryService.getCategories()
|
||||
})
|
||||
|
||||
// 获取标签列表
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['tags-all'],
|
||||
queryFn: () => tagService.getAllTags()
|
||||
})
|
||||
|
||||
// 删除照片
|
||||
const deletePhotoMutation = useMutation({
|
||||
mutationFn: photoService.deletePhoto,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
toast.success('照片删除成功')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 批量删除照片
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: photoService.batchDelete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
setSelectedPhotos([])
|
||||
toast.success('批量删除成功')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || '批量删除失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 批量更新状态
|
||||
const batchUpdateMutation = useMutation({
|
||||
mutationFn: ({ ids, status }: { ids: number[], status: string }) =>
|
||||
photoService.batchUpdate(ids, { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
setSelectedPhotos([])
|
||||
toast.success('状态更新成功')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || '状态更新失败')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedPhotos(photosData?.photos.map(photo => photo.id) || [])
|
||||
} else {
|
||||
setSelectedPhotos([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPhoto = (photoId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedPhotos([...selectedPhotos, photoId])
|
||||
} else {
|
||||
setSelectedPhotos(selectedPhotos.filter(id => id !== photoId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchAction = (action: string, value?: string) => {
|
||||
if (selectedPhotos.length === 0) {
|
||||
toast.error('请先选择照片')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
if (confirm('确定要删除选中的照片吗?')) {
|
||||
batchDeleteMutation.mutate(selectedPhotos)
|
||||
}
|
||||
} else if (action === 'status' && value) {
|
||||
batchUpdateMutation.mutate({ ids: selectedPhotos, status: value })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePhoto = (photoId: number) => {
|
||||
if (confirm('确定要删除这张照片吗?')) {
|
||||
deletePhotoMutation.mutate(photoId)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return 'bg-green-100 text-green-800'
|
||||
case 'draft': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'archived': return 'bg-gray-100 text-gray-800'
|
||||
case 'processing': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published': return '已发布'
|
||||
case 'draft': return '草稿'
|
||||
case 'archived': return '已归档'
|
||||
case 'processing': return '处理中'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">照片管理</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
共 {photosData?.total || 0} 张照片
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/photos/upload')} className="flex items-center gap-2">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
上传照片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* 搜索 */}
|
||||
<div className="flex-1 relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜索照片..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex gap-2">
|
||||
<Select value={filters.status} onValueChange={(value) => setFilters({ ...filters, status: value })}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部状态</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filters.categoryId} onValueChange={(value) => setFilters({ ...filters, categoryId: value })}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">全部分类</SelectItem>
|
||||
{categories?.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 视图模式 */}
|
||||
<div className="flex gap-1 border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{selectedPhotos.length > 0 && (
|
||||
<Card className="mb-6 border-primary">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">
|
||||
已选择 {selectedPhotos.length} 张照片
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedPhotos([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
更改状态
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => handleBatchAction('status', 'published')}>
|
||||
设为已发布
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleBatchAction('status', 'draft')}>
|
||||
设为草稿
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleBatchAction('status', 'archived')}>
|
||||
设为已归档
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleBatchAction('delete')}
|
||||
disabled={batchDeleteMutation.isPending}
|
||||
>
|
||||
删除选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 照片列表 */}
|
||||
{photosLoading ? (
|
||||
<div className={viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6' : 'space-y-4'}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className={viewMode === 'grid' ? 'aspect-square mb-4' : 'h-20 w-20 mb-4'} />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : photosData?.photos?.length ? (
|
||||
<>
|
||||
{/* 全选复选框 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Checkbox
|
||||
checked={selectedPhotos.length === photosData.photos.length && photosData.photos.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">全选</span>
|
||||
</div>
|
||||
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{photosData.photos.map((photo) => (
|
||||
<Card key={photo.id} className="group">
|
||||
<CardContent className="p-4">
|
||||
<div className="relative mb-4">
|
||||
<div className="aspect-square bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<PhotoIcon className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* 复选框 */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<Checkbox
|
||||
checked={selectedPhotos.includes(photo.id)}
|
||||
onCheckedChange={(checked) => handleSelectPhoto(photo.id, checked as boolean)}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 w-8 p-0 bg-white">
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}`)}>
|
||||
<EyeIcon className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}/edit`)}>
|
||||
<EditIcon className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium truncate mb-2">{photo.title}</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge className={getStatusColor(photo.status)}>
|
||||
{getStatusText(photo.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(photo.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(photo.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{photosData.photos.map((photo) => (
|
||||
<Card key={photo.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={selectedPhotos.includes(photo.id)}
|
||||
onCheckedChange={(checked) => handleSelectPhoto(photo.id, checked as boolean)}
|
||||
/>
|
||||
|
||||
<div className="h-16 w-16 bg-gray-100 rounded flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{photo.title}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{photo.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<Badge className={getStatusColor(photo.status)}>
|
||||
{getStatusText(photo.status)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(photo.fileSize)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(photo.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}`)}>
|
||||
<EyeIcon className="h-4 w-4 mr-2" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(`/photos/${photo.id}/edit`)}>
|
||||
<EditIcon className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{photosData.pages > 1 && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(Math.min(5, photosData.pages))].map((_, i) => {
|
||||
const pageNum = i + 1
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={page === pageNum ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === photosData.pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center">
|
||||
<PhotoIcon className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">暂无照片</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
开始上传您的第一张照片吧
|
||||
</p>
|
||||
<Button onClick={() => navigate('/photos/upload')}>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
上传照片
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user