Files
photography/admin/src/pages/Photos.tsx
xujiang 0ff0a7e995 fix: 修复 Prettier 格式检查和依赖问题
## 修复内容

### 依赖修复
- 安装缺失的 `prettier-plugin-organize-imports` 插件
- 修复 CI/CD 中的 "Cannot find package" 错误
- 更新 package.json 和 bun.lockb

### 代码格式化
- 对所有源文件运行 Prettier 自动格式化
- 统一 import 语句排序和组织
- 修复 49 个文件的代码风格问题
- 确保所有文件符合项目代码规范

### 格式化改进
- Import 语句自动排序和分组
- 统一缩进和空格规范
- 标准化引号和分号使用
- 优化对象和数组格式

## 验证结果
 `bun run format` 通过 - 所有文件格式正确
 `prettier-plugin-organize-imports` 正常工作
 CI/CD 格式检查将通过

## 技术细节
- 添加 prettier-plugin-organize-imports@^4.1.0
- 保持现有 .prettierrc 配置不变
- 格式化涉及 TS/TSX/JS/JSX/JSON/CSS/MD 文件
- 代码功能完全不受影响,仅调整格式
2025-07-14 11:25:05 +08:00

773 lines
27 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.

import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { categoryService } from '@/services/categoryService'
import { Photo, photoService } from '@/services/photoService'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Camera,
Edit,
Eye,
Folder,
Grid,
ImageIcon,
List,
MoreVertical,
Plus,
RefreshCw,
Search,
Tag,
Trash,
} from 'lucide-react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
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 [editingPhoto, setEditingPhoto] = useState<Photo | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [editForm, setEditForm] = useState({
title: '',
description: '',
status: 'draft' as 'draft' | 'published' | 'archived' | 'processing',
})
// 详情对话框状态
const [viewingPhoto, setViewingPhoto] = useState<Photo | null>(null)
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
// 获取照片列表
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 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 editPhotoMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => photoService.updatePhoto(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photos'] })
setIsEditDialogOpen(false)
setEditingPhoto(null)
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]
}
// 处理编辑照片
const handleEditPhoto = (photo: Photo) => {
setEditingPhoto(photo)
setEditForm({
title: photo.title,
description: photo.description,
status: photo.status,
})
setIsEditDialogOpen(true)
}
// 处理查看照片详情
const handleViewPhoto = (photo: Photo) => {
setViewingPhoto(photo)
setIsViewDialogOpen(true)
}
// 提交编辑
const handleSubmitEdit = () => {
if (!editingPhoto) return
editPhotoMutation.mutate({
id: editingPhoto.id,
data: editForm,
})
}
// 刷新列表
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['photos'] })
toast.success('列表已刷新')
}
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">
<Plus 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">
<Search 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?.data?.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')}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
>
<List 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">
<Camera 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">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewPhoto(photo)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditPhoto(photo)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
<Trash 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">
<Camera 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">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewPhoto(photo)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditPhoto(photo)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeletePhoto(photo.id)}>
<Trash 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">
<div className="w-32 h-32 mx-auto mb-6 bg-gray-100 rounded-full flex items-center justify-center">
<ImageIcon className="h-16 w-16 text-gray-300" />
</div>
<h3 className="text-xl font-medium mb-2"></h3>
<p className="text-muted-foreground mb-6"></p>
<div className="flex justify-center gap-4">
<Button onClick={() => navigate('/photos/upload')}>
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* 编辑对话框 */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="edit-title"></Label>
<Input
id="edit-title"
value={editForm.title}
onChange={e => setEditForm({ ...editForm, title: e.target.value })}
placeholder="照片标题"
/>
</div>
<div>
<Label htmlFor="edit-description"></Label>
<Textarea
id="edit-description"
value={editForm.description}
onChange={e => setEditForm({ ...editForm, description: e.target.value })}
placeholder="照片描述"
rows={3}
/>
</div>
<div>
<Label htmlFor="edit-status"></Label>
<Select
value={editForm.status}
onValueChange={value => setEditForm({ ...editForm, status: value as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
</Button>
<Button onClick={handleSubmitEdit} disabled={editPhotoMutation.isPending}>
{editPhotoMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 详情查看对话框 */}
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{viewingPhoto?.title}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{viewingPhoto && (
<div className="space-y-4 py-4">
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
<ImageIcon className="h-16 w-16 text-gray-400" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium"></Label>
<Badge className={getStatusColor(viewingPhoto.status)}>
{getStatusText(viewingPhoto.status)}
</Badge>
</div>
<div>
<Label className="text-sm font-medium"></Label>
<p className="text-sm text-muted-foreground">
{formatFileSize(viewingPhoto.fileSize)}
</p>
</div>
<div>
<Label className="text-sm font-medium"></Label>
<p className="text-sm text-muted-foreground">
{new Date(viewingPhoto.createdAt).toLocaleString()}
</p>
</div>
<div>
<Label className="text-sm font-medium"></Label>
<p className="text-sm text-muted-foreground">
{new Date(viewingPhoto.updatedAt).toLocaleString()}
</p>
</div>
</div>
{viewingPhoto.description && (
<div>
<Label className="text-sm font-medium"></Label>
<p className="text-sm text-muted-foreground mt-1">{viewingPhoto.description}</p>
</div>
)}
{viewingPhoto.categories && viewingPhoto.categories.length > 0 && (
<div>
<Label className="text-sm font-medium"></Label>
<div className="flex flex-wrap gap-2 mt-1">
{viewingPhoto.categories.map(category => (
<Badge key={category.id} variant="secondary">
<Folder className="h-3 w-3 mr-1" />
{category.name}
</Badge>
))}
</div>
</div>
)}
{viewingPhoto.tags && viewingPhoto.tags.length > 0 && (
<div>
<Label className="text-sm font-medium"></Label>
<div className="flex flex-wrap gap-2 mt-1">
{viewingPhoto.tags.map(tag => (
<Badge key={tag.id} variant="outline">
<Tag className="h-3 w-3 mr-1" />
{tag.name}
</Badge>
))}
</div>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsViewDialogOpen(false)}>
</Button>
<Button onClick={() => viewingPhoto && handleEditPhoto(viewingPhoto)}>
<Edit className="h-4 w-4 mr-2" />
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}