0.6.2.0
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user