This commit is contained in:
freedakgmail
2025-12-10 13:03:10 +08:00
parent 47be495fcf
commit e7167f4340
153 changed files with 1155 additions and 458 deletions
+165 -89
View File
@@ -1,12 +1,12 @@
"use client"
import { useState, useEffect } from "react"
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 } from "lucide-react"
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"
@@ -54,6 +54,12 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
// 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
@@ -63,20 +69,27 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
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 isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
const compressedFile = await imageCompression(file, options)
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', compressedFile)
// 对于视频文件,保留原始文件名;对于压缩后的图片,使用原始文件名
formData.append('file', fileToUpload, file.name)
const response = await fetch('/api/upload', {
method: 'POST',
@@ -84,7 +97,8 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
})
if (!response.ok) {
throw new Error('上传失败')
const errorData = await response.json()
throw new Error(errorData.error || '上传失败')
}
const { url } = await response.json()
@@ -98,9 +112,9 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
// 更新照片列表
onChange([...photos, ...newPhotos])
} catch (error) {
console.error('上传照片失败:', error)
await showAlert('上传照片失败', '错误')
} catch (error: any) {
console.error('上传文件失败:', error)
await showAlert(error.message || '上传文件失败', '错误')
} finally {
setIsUploading(false)
e.target.value = ''
@@ -159,13 +173,13 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> ({photos.length})</h3>
<h3 className="text-sm font-medium"> ({photos.length})</h3>
{!readonly && (
<label>
<input
type="file"
multiple
accept="image/*"
accept="image/*,video/*"
className="hidden"
onChange={handleUpload}
disabled={isUploading}
@@ -179,7 +193,7 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
>
<span>
<Plus className="mr-2 h-4 w-4" />
{isUploading ? '上传中...' : '添加照片'}
{isUploading ? '上传中...' : '添加照片/视频'}
</span>
</Button>
</label>
@@ -187,94 +201,156 @@ export function PhotoGallery({ photos: propPhotos = [], photoIds = [], onChange,
</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 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 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>
{/* 操作按钮 */}
{!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="photo-preview-desc">
<DialogHeader>
<DialogTitle>
{selectedPhoto?.caption || "照片预览"}
{selectedPhoto?.caption || (selectedPhoto && isVideoFile(selectedPhoto.url) ? "视频预览" : "照片预览")}
</DialogTitle>
<DialogDescription id="photo-preview-desc" className="sr-only">
</DialogDescription>
</DialogHeader>
{selectedPhoto && (
<div className="space-y-2">
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || "照片预览"}
className="w-full h-auto max-h-[70vh] object-contain"
/>
{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>