397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
"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> | 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("")
|
||
const [pendingVisibilityUrl, setPendingVisibilityUrl] = useState<string | null>(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<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)) {
|
||
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 (
|
||
<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/*,video/*"
|
||
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="space-y-6">
|
||
{/* 按月份分组显示 */}
|
||
{(() => {
|
||
// 按月份分组
|
||
const sortedPhotos = [...photos].sort((a, b) =>
|
||
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
|
||
)
|
||
const groupedByMonth: Record<string, FamilyPhoto[]> = {}
|
||
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]) => (
|
||
<div key={month}>
|
||
<h4 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||
<span className="w-2 h-2 rounded-full bg-primary"></span>
|
||
{month}
|
||
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
|
||
</h4>
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||
{monthPhotos.map((photo, index) => {
|
||
const isVideo = isVideoFile(photo.url)
|
||
return (
|
||
<div key={photo.url + index} className="relative group">
|
||
<div className="aspect-square">
|
||
{isVideo ? (
|
||
<div
|
||
className="w-full h-full bg-black rounded-t-lg border border-border cursor-pointer hover:opacity-90 transition-opacity flex items-center justify-center relative"
|
||
onClick={() => setSelectedPhoto(photo)}
|
||
>
|
||
<video
|
||
src={photo.url}
|
||
className="w-full h-full object-cover rounded-t-lg"
|
||
muted
|
||
preload="metadata"
|
||
/>
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
|
||
<Play className="h-6 w-6 text-black ml-1" />
|
||
</div>
|
||
</div>
|
||
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
|
||
<Video className="h-3 w-3" />
|
||
视频
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<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), 'MM-dd HH:mm', { locale: zhCN })}
|
||
</div>
|
||
{photo.caption && (
|
||
<div className="text-foreground mt-1 line-clamp-2">{photo.caption}</div>
|
||
)}
|
||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||
<span>展示到总览</span>
|
||
<Switch
|
||
checked={photo.visibleInOverview ?? false}
|
||
onCheckedChange={(checked) => handleToggleOverview(photo, checked)}
|
||
disabled={readonly || pendingVisibilityUrl === photo.url}
|
||
/>
|
||
</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)}
|
||
>
|
||
{isVideo ? <Play className="h-3 w-3" /> : <ZoomIn className="h-3 w-3" />}
|
||
</Button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</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="photo-preview-desc">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{selectedPhoto?.caption || (selectedPhoto && isVideoFile(selectedPhoto.url) ? "视频预览" : "照片预览")}
|
||
</DialogTitle>
|
||
<DialogDescription id="photo-preview-desc" className="sr-only">
|
||
家族媒体预览弹窗,可查看大图或播放视频
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{selectedPhoto && (
|
||
<div className="space-y-2">
|
||
{isVideoFile(selectedPhoto.url) ? (
|
||
<video
|
||
src={selectedPhoto.url}
|
||
controls
|
||
autoPlay
|
||
className="w-full h-auto max-h-[70vh] object-contain bg-black rounded-lg"
|
||
/>
|
||
) : (
|
||
<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="photo-edit-desc">
|
||
<DialogHeader>
|
||
<DialogTitle>编辑照片说明</DialogTitle>
|
||
<DialogDescription id="photo-edit-desc" className="sr-only">
|
||
修改当前照片的备注文字并保存
|
||
</DialogDescription>
|
||
</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>
|
||
)
|
||
}
|