"use client" import { useState, useEffect, useCallback } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { X, Upload, Image as ImageIcon, Plus, ZoomIn, Edit2, Video, Play } 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 readonly?: boolean } export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange, readonly = false }: PhotoGalleryProps) { const { showAlert, showConfirm, showPrompt } = useDialog() const [photos, setPhotos] = useState([]) const [selectedPhoto, setSelectedPhoto] = useState(null) const [isUploading, setIsUploading] = useState(false) const [editingPhoto, setEditingPhoto] = useState(null) const [editCaption, setEditCaption] = useState("") const [pendingVisibilityUrl, setPendingVisibilityUrl] = useState(null) // 加载照片 - 兼容新旧格式 useEffect(() => { // 优先使用新格式 photos if (propPhotos && propPhotos.length > 0) { const normalized = propPhotos.map(photo => ({ ...photo, visibleInOverview: photo.visibleInOverview ?? false, adminVisibleOverride: photo.adminVisibleOverride ?? true, })) setPhotos(normalized) } else if (photoIds && photoIds.length > 0) { // 兼容旧格式:将 string[] 转换为 FamilyPhoto[] const convertedPhotos: FamilyPhoto[] = photoIds.map(url => ({ url, uploadedAt: new Date().toISOString(), // 旧数据没有时间,用当前时间 visibleInOverview: false, adminVisibleOverride: true, })) setPhotos(convertedPhotos) } else { setPhotos([]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(propPhotos), JSON.stringify(photoIds)]) // 判断是否为视频文件 const isVideoFile = useCallback((url: string) => { const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv'] return videoExtensions.some(ext => url.toLowerCase().endsWith(ext)) }, []) const handleUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files || files.length === 0) return setIsUploading(true) try { const newPhotos: FamilyPhoto[] = [] for (const file of Array.from(files)) { const isImage = file.type.startsWith('image/') const isVideo = file.type.startsWith('video/') if (!isImage && !isVideo) continue let fileToUpload: File | Blob = file // 只对图片进行压缩 if (isImage) { const options = { maxSizeMB: 2, maxWidthOrHeight: 1920, useWebWorker: true, } fileToUpload = await imageCompression(file, options) } // 上传到服务器 const formData = new FormData() // 对于视频文件,保留原始文件名;对于压缩后的图片,使用原始文件名 formData.append('file', fileToUpload, file.name) const response = await fetch('/api/upload', { method: 'POST', body: formData, }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || '上传失败') } const { url } = await response.json() newPhotos.push({ url, uploadedAt: new Date().toISOString(), visibleInOverview: false, adminVisibleOverride: true, }) } // 更新照片列表 onChange([...photos, ...newPhotos]) } catch (error: any) { console.error('上传文件失败:', error) await showAlert(error.message || '上传文件失败', '错误') } 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 handleToggleOverview = async (photo: FamilyPhoto, value: boolean) => { if (readonly) return setPendingVisibilityUrl(photo.url) const previous = photos const updatedPhotos = photos.map(p => p.url === photo.url ? { ...p, visibleInOverview: value } : p ) setPhotos(updatedPhotos) try { await onChange(updatedPhotos) } catch (error) { console.error('更新照片展示状态失败:', error) setPhotos(previous) await showAlert('更新照片展示状态失败,请重试', '错误') } finally { setPendingVisibilityUrl(null) } } 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 (

媒体库 ({photos.length})

{!readonly && ( )}
{photos.length > 0 ? (
{/* 按月份分组显示 */} {(() => { // 按月份分组 const sortedPhotos = [...photos].sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime() ) const groupedByMonth: Record = {} sortedPhotos.forEach(photo => { const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月', { locale: zhCN }) if (!groupedByMonth[monthKey]) { groupedByMonth[monthKey] = [] } groupedByMonth[monthKey].push(photo) }) return Object.entries(groupedByMonth).map(([month, monthPhotos]) => (

{month} ({monthPhotos.length})

{monthPhotos.map((photo, index) => { const isVideo = isVideoFile(photo.url) return (
{isVideo ? (
setSelectedPhoto(photo)} >
) : ( {photo.caption setSelectedPhoto(photo)} /> )}
{/* 媒体信息区域 */}
{format(new Date(photo.uploadedAt), 'MM-dd HH:mm', { locale: zhCN })}
{photo.caption && (
{photo.caption}
)}
展示到总览 handleToggleOverview(photo, checked)} disabled={readonly || pendingVisibilityUrl === photo.url} />
{/* 操作按钮 */} {!readonly && ( <> )}
) })}
)) })()}
) : (
暂无照片或视频
)} {/* 媒体预览对话框 */} setSelectedPhoto(null)}> {selectedPhoto?.caption || (selectedPhoto && isVideoFile(selectedPhoto.url) ? "视频预览" : "照片预览")} 家族媒体预览弹窗,可查看大图或播放视频 {selectedPhoto && (
{isVideoFile(selectedPhoto.url) ? (
)}
{/* 编辑说明对话框 */} setEditingPhoto(null)}> 编辑照片说明 修改当前照片的备注文字并保存
setEditCaption(e.target.value)} placeholder="输入照片说明..." maxLength={200} />

{editCaption.length}/200

) }