278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card } from "@/components/ui/card"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import { X, Upload, Image as ImageIcon, Plus, ZoomIn, Edit2 } from "lucide-react"
|
||
import { useDialog } from "@/components/ui/alert-dialog-custom"
|
||
import imageCompression from "browser-image-compression"
|
||
import type { FamilyPhoto } from "@/types/family"
|
||
import { format } from "date-fns"
|
||
import { zhCN } from "date-fns/locale"
|
||
|
||
interface PhotoGalleryProps {
|
||
photos?: FamilyPhoto[]
|
||
photoIds?: string[] // 兼容旧格式
|
||
onChange: (photos: FamilyPhoto[]) => Promise<void> | void
|
||
readonly?: boolean
|
||
}
|
||
|
||
export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange, readonly = false }: PhotoGalleryProps) {
|
||
const { showAlert, showConfirm, showPrompt } = useDialog()
|
||
const [photos, setPhotos] = useState<FamilyPhoto[]>([])
|
||
const [selectedPhoto, setSelectedPhoto] = useState<FamilyPhoto | null>(null)
|
||
const [isUploading, setIsUploading] = useState(false)
|
||
const [editingPhoto, setEditingPhoto] = useState<FamilyPhoto | null>(null)
|
||
const [editCaption, setEditCaption] = useState("")
|
||
|
||
// 加载照片 - 兼容新旧格式
|
||
useEffect(() => {
|
||
// 优先使用新格式 photos
|
||
if (propPhotos && propPhotos.length > 0) {
|
||
setPhotos(propPhotos)
|
||
} else if (photoIds && photoIds.length > 0) {
|
||
// 兼容旧格式:将 string[] 转换为 FamilyPhoto[]
|
||
const convertedPhotos: FamilyPhoto[] = photoIds.map(url => ({
|
||
url,
|
||
uploadedAt: new Date().toISOString() // 旧数据没有时间,用当前时间
|
||
}))
|
||
setPhotos(convertedPhotos)
|
||
} else {
|
||
setPhotos([])
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [JSON.stringify(propPhotos), JSON.stringify(photoIds)])
|
||
|
||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files
|
||
if (!files || files.length === 0) return
|
||
|
||
setIsUploading(true)
|
||
try {
|
||
const newPhotos: FamilyPhoto[] = []
|
||
|
||
for (const file of Array.from(files)) {
|
||
if (!file.type.startsWith('image/')) continue
|
||
|
||
// 压缩图片
|
||
const options = {
|
||
maxSizeMB: 2,
|
||
maxWidthOrHeight: 1920,
|
||
useWebWorker: true,
|
||
}
|
||
|
||
const compressedFile = await imageCompression(file, options)
|
||
|
||
// 上传到服务器
|
||
const formData = new FormData()
|
||
formData.append('file', compressedFile)
|
||
|
||
const response = await fetch('/api/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error('上传失败')
|
||
}
|
||
|
||
const { url } = await response.json()
|
||
newPhotos.push({
|
||
url,
|
||
uploadedAt: new Date().toISOString()
|
||
})
|
||
}
|
||
|
||
// 更新照片列表
|
||
onChange([...photos, ...newPhotos])
|
||
} catch (error) {
|
||
console.error('上传照片失败:', error)
|
||
await showAlert('上传照片失败', '错误')
|
||
} finally {
|
||
setIsUploading(false)
|
||
e.target.value = ''
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (photo: FamilyPhoto) => {
|
||
const confirmed = await showConfirm('确定要删除这张照片吗?', '删除照片')
|
||
if (confirmed) {
|
||
onChange(photos.filter(p => p.url !== photo.url))
|
||
}
|
||
}
|
||
|
||
const handleEditCaption = (photo: FamilyPhoto) => {
|
||
setEditingPhoto(photo)
|
||
setEditCaption(photo.caption || "")
|
||
}
|
||
|
||
const handleSaveCaption = async () => {
|
||
if (!editingPhoto) return
|
||
const updatedPhotos = photos.map(p =>
|
||
p.url === editingPhoto.url
|
||
? { ...p, caption: editCaption.trim() || undefined }
|
||
: p
|
||
)
|
||
|
||
try {
|
||
await onChange(updatedPhotos)
|
||
setEditingPhoto(null)
|
||
setEditCaption("")
|
||
} catch (error) {
|
||
console.error("保存说明失败:", error)
|
||
await showAlert("保存说明失败,请重试", "错误")
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-medium">照片墙 ({photos.length})</h3>
|
||
{!readonly && (
|
||
<label>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleUpload}
|
||
disabled={isUploading}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={isUploading}
|
||
asChild
|
||
>
|
||
<span>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
{isUploading ? '上传中...' : '添加照片'}
|
||
</span>
|
||
</Button>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{photos.length > 0 ? (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||
{photos.map((photo, index) => (
|
||
<div key={photo.url + index} className="relative group">
|
||
<div className="aspect-square">
|
||
<img
|
||
src={photo.url}
|
||
alt={photo.caption || "家族照片"}
|
||
className="w-full h-full object-cover rounded-t-lg border border-border cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={() => setSelectedPhoto(photo)}
|
||
/>
|
||
</div>
|
||
{/* 照片信息区域 */}
|
||
<div className="p-2 bg-muted/50 rounded-b-lg border border-t-0 border-border text-xs">
|
||
<div className="text-muted-foreground">
|
||
{format(new Date(photo.uploadedAt), 'yyyy-MM-dd', { locale: zhCN })}
|
||
</div>
|
||
{photo.caption && (
|
||
<div className="text-foreground mt-1 line-clamp-2">{photo.caption}</div>
|
||
)}
|
||
</div>
|
||
{/* 操作按钮 */}
|
||
{!readonly && (
|
||
<>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="icon"
|
||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={() => handleDelete(photo)}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="icon"
|
||
className="absolute top-2 right-10 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={() => handleEditCaption(photo)}
|
||
title="编辑说明"
|
||
>
|
||
<Edit2 className="h-3 w-3" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="icon"
|
||
className="absolute bottom-12 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={() => setSelectedPhoto(photo)}
|
||
>
|
||
<ZoomIn className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
|
||
暂无照片
|
||
</div>
|
||
)}
|
||
|
||
{/* 照片预览对话框 */}
|
||
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
|
||
<DialogContent className="max-w-4xl" aria-describedby={undefined}>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{selectedPhoto?.caption || "照片预览"}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedPhoto && (
|
||
<div className="space-y-2">
|
||
<img
|
||
src={selectedPhoto.url}
|
||
alt={selectedPhoto.caption || "照片预览"}
|
||
className="w-full h-auto max-h-[70vh] object-contain"
|
||
/>
|
||
<div className="text-sm text-muted-foreground">
|
||
上传于 {format(new Date(selectedPhoto.uploadedAt), 'yyyy年MM月dd日', { locale: zhCN })}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 编辑说明对话框 */}
|
||
<Dialog open={!!editingPhoto} onOpenChange={() => setEditingPhoto(null)}>
|
||
<DialogContent aria-describedby={undefined}>
|
||
<DialogHeader>
|
||
<DialogTitle>编辑照片说明</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="caption">照片说明(选填)</Label>
|
||
<Input
|
||
id="caption"
|
||
value={editCaption}
|
||
onChange={(e) => setEditCaption(e.target.value)}
|
||
placeholder="输入照片说明..."
|
||
maxLength={200}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{editCaption.length}/200</p>
|
||
</div>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleSaveCaption}>
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|