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:
xujiang
2025-07-09 16:23:18 +08:00
parent c57ec3aa82
commit 72414d0979
62 changed files with 12416 additions and 262 deletions

518
admin/src/pages/Photos.tsx Normal file
View 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>
)
}