feat: 完成前端响应式设计优化,增强移动端体验
- 添加触摸手势支持库 (react-spring + @use-gesture/react) - 照片模态框增加左右滑动切换功能 - 照片画廊增加下拉刷新功能 (移动端) - 优化所有按钮符合44px最小触摸目标标准 - 增强移动端导航体验,增加悬停和选中状态 - 创建设备信息检测钩子 (useDeviceInfo) - 开发优化图片组件,支持懒加载和骨架屏 - 改进移动端手势交互和视觉反馈 - 完善响应式断点系统和触摸设备检测 - 前端构建测试成功,开发服务器正常启动 Task 23 completed: 前端响应式设计优化
This commit is contained in:
@ -270,10 +270,20 @@
|
|||||||
- 验证用户认证、分类管理、照片管理等核心功能
|
- 验证用户认证、分类管理、照片管理等核心功能
|
||||||
- 创建API集成测试脚本,验证前后端连接状态
|
- 创建API集成测试脚本,验证前后端连接状态
|
||||||
|
|
||||||
#### 23. 前端响应式设计优化
|
#### 23. ✅ 前端响应式设计优化
|
||||||
**优先级**: 中 🔥
|
**状态**: 已完成 ✅
|
||||||
**预估工作量**: 0.5天
|
**完成时间**: 2025-07-11
|
||||||
**具体任务**: 移动端适配、平板适配、触摸手势、性能优化
|
**完成内容**:
|
||||||
|
- 添加触摸手势支持库 (react-spring + @use-gesture/react)
|
||||||
|
- 照片模态框增加左右滑动切换功能
|
||||||
|
- 照片画廊增加下拉刷新功能 (移动端)
|
||||||
|
- 优化所有按钮符合44px最小触摸目标标准
|
||||||
|
- 增强移动端导航体验,增加悬停和选中状态
|
||||||
|
- 创建设备信息检测钩子 (useDeviceInfo)
|
||||||
|
- 开发优化图片组件,支持懒加载和骨架屏
|
||||||
|
- 改进移动端手势交互和视觉反馈
|
||||||
|
- 完善响应式断点系统和触摸设备检测
|
||||||
|
- 前端构建测试成功,开发服务器正常启动
|
||||||
|
|
||||||
### 🚀 部署和运维 (4项)
|
### 🚀 部署和运维 (4项)
|
||||||
#### 24. 配置生产环境数据库
|
#### 24. 配置生产环境数据库
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Search, X, Calendar, Tag, SlidersHorizontal } from "lucide-react"
|
import { Search, X, Tag, SlidersHorizontal } from "lucide-react"
|
||||||
import { useCategories } from "@/lib/queries"
|
import { useCategories } from "@/lib/queries"
|
||||||
|
|
||||||
interface FilterBarProps {
|
interface FilterBarProps {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
|
|||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
onClick={() => handleTabClick(item)}
|
onClick={() => handleTabClick(item)}
|
||||||
className={`text-gray-700 hover:text-gray-900 transition-colors duration-200 font-light relative pb-1 ${
|
className={`text-gray-700 hover:text-gray-900 transition-colors duration-200 font-light relative pb-1 min-h-[44px] px-2 ${
|
||||||
activeTab === item.id ? "text-gray-900" : ""
|
activeTab === item.id ? "text-gray-900" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -56,7 +56,12 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<Button variant="ghost" size="sm" className="md:hidden" onClick={() => setIsMenuOpen(!isMenuOpen)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="md:hidden min-h-[44px] min-w-[44px] p-2"
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
>
|
||||||
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -64,13 +69,13 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
|
|||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{isMenuOpen && (
|
{isMenuOpen && (
|
||||||
<div className="md:hidden py-4 border-t border-gray-100">
|
<div className="md:hidden py-4 border-t border-gray-100">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-2">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
onClick={() => handleTabClick(item)}
|
onClick={() => handleTabClick(item)}
|
||||||
className={`text-left text-gray-700 hover:text-gray-900 transition-colors duration-200 font-light ${
|
className={`text-left text-gray-700 hover:text-gray-900 transition-colors duration-200 font-light min-h-[44px] px-4 py-2 rounded-md hover:bg-gray-50 ${
|
||||||
activeTab === item.id ? "text-gray-900 font-medium" : ""
|
activeTab === item.id ? "text-gray-900 font-medium bg-gray-50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|||||||
140
frontend/components/optimized-image.tsx
Normal file
140
frontend/components/optimized-image.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useDeviceInfo } from "@/hooks/use-mobile"
|
||||||
|
|
||||||
|
interface OptimizedImageProps {
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
className?: string
|
||||||
|
priority?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
onLoad?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptimizedImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
className = "",
|
||||||
|
priority = false,
|
||||||
|
onClick,
|
||||||
|
onLoad
|
||||||
|
}: OptimizedImageProps) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
const [isInView, setIsInView] = useState(false)
|
||||||
|
const [hasError, setHasError] = useState(false)
|
||||||
|
const imgRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { isMobile, viewport } = useDeviceInfo()
|
||||||
|
|
||||||
|
// 交叉观察器用于懒加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (priority) {
|
||||||
|
setIsInView(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsInView(true)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: isMobile ? "50px" : "100px", // 移动端更小的预加载距离
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imgRef.current) {
|
||||||
|
observer.observe(imgRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [priority, isMobile])
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
setIsLoaded(true)
|
||||||
|
onLoad?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setHasError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设备选择合适的图片质量
|
||||||
|
const getOptimizedSrc = (originalSrc: string) => {
|
||||||
|
if (hasError) return "/placeholder.svg"
|
||||||
|
|
||||||
|
// 如果是移动端,可以考虑使用更低质量的图片
|
||||||
|
if (isMobile && viewport.width < 768) {
|
||||||
|
// 这里可以添加图片质量优化逻辑
|
||||||
|
// 例如:添加 ?quality=80&w=400 等参数
|
||||||
|
return originalSrc
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalSrc
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={imgRef}
|
||||||
|
className={`relative overflow-hidden ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 骨架屏加载状态 */}
|
||||||
|
{!isLoaded && isInView && (
|
||||||
|
<div className="absolute inset-0 bg-gray-200 animate-pulse">
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="w-8 h-8 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs">图片加载失败</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 实际图片 */}
|
||||||
|
{isInView && !hasError && (
|
||||||
|
<Image
|
||||||
|
src={getOptimizedSrc(src)}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={!width || !height}
|
||||||
|
className={`object-cover transition-opacity duration-500 ${
|
||||||
|
isLoaded ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
priority={priority}
|
||||||
|
draggable={false}
|
||||||
|
sizes={
|
||||||
|
isMobile
|
||||||
|
? "(max-width: 768px) 100vw, 50vw"
|
||||||
|
: "(max-width: 1024px) 50vw, 33vw"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 渐进式加载效果 */}
|
||||||
|
{isLoaded && (
|
||||||
|
<div className={`absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 transition-opacity duration-300 ${onClick ? "group-hover:opacity-100" : ""}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -5,7 +5,9 @@ import Image from "next/image"
|
|||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Calendar, MapPin, Grid, List, Eye, Heart } from "lucide-react"
|
import { Calendar, MapPin, Grid, List, Eye, Heart, RefreshCw } from "lucide-react"
|
||||||
|
import { useDrag } from "@use-gesture/react"
|
||||||
|
import { useSpring, animated } from "react-spring"
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: number
|
id: number
|
||||||
@ -26,11 +28,12 @@ interface Photo {
|
|||||||
interface PhotoGalleryProps {
|
interface PhotoGalleryProps {
|
||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
onPhotoClick: (photo: Photo) => void
|
onPhotoClick: (photo: Photo) => void
|
||||||
|
onRefresh?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'masonry' | 'list'
|
type ViewMode = 'grid' | 'masonry' | 'list'
|
||||||
|
|
||||||
export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
export function PhotoGallery({ photos, onPhotoClick, onRefresh }: PhotoGalleryProps) {
|
||||||
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set())
|
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set())
|
||||||
const [isHydrated, setIsHydrated] = useState(false)
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||||
@ -38,7 +41,57 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
const [displayedPhotos, setDisplayedPhotos] = useState<Photo[]>([])
|
const [displayedPhotos, setDisplayedPhotos] = useState<Photo[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const loaderRef = useRef<HTMLDivElement>(null)
|
const loaderRef = useRef<HTMLDivElement>(null)
|
||||||
|
const galleryRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 下拉刷新动画
|
||||||
|
const [{ y }, api] = useSpring(() => ({ y: 0 }))
|
||||||
|
|
||||||
|
// 下拉刷新手势
|
||||||
|
const bindRefresh = useDrag(
|
||||||
|
({ down, movement: [, my], velocity: [, vy], direction: [, yDir] }) => {
|
||||||
|
// 只在页面顶部且向下拖拽时触发
|
||||||
|
const isAtTop = window.scrollY === 0
|
||||||
|
const isPullingDown = yDir > 0 && my > 0
|
||||||
|
|
||||||
|
if (!isAtTop || !isPullingDown) return
|
||||||
|
|
||||||
|
const refreshThreshold = 60
|
||||||
|
const shouldRefresh = my > refreshThreshold || vy > 0.3
|
||||||
|
|
||||||
|
if (shouldRefresh && !down && !isRefreshing && onRefresh) {
|
||||||
|
handleRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
api.start({
|
||||||
|
y: down ? Math.min(my, 80) : 0,
|
||||||
|
immediate: down,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
axis: 'y',
|
||||||
|
bounds: { top: 0 },
|
||||||
|
rubberband: true,
|
||||||
|
filterTaps: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (!onRefresh || isRefreshing) return
|
||||||
|
|
||||||
|
setIsRefreshing(true)
|
||||||
|
try {
|
||||||
|
await onRefresh()
|
||||||
|
setPage(1)
|
||||||
|
setHasMore(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新失败:', error)
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const photosPerPage = 12
|
const photosPerPage = 12
|
||||||
|
|
||||||
@ -189,7 +242,29 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<animated.div
|
||||||
|
ref={galleryRef}
|
||||||
|
{...bindRefresh()}
|
||||||
|
style={{ transform: y.to(py => `translateY(${py}px)`) }}
|
||||||
|
className="space-y-6 touch-pan-x"
|
||||||
|
>
|
||||||
|
{/* 下拉刷新指示器 */}
|
||||||
|
{onRefresh && (
|
||||||
|
<animated.div
|
||||||
|
style={{
|
||||||
|
opacity: y.to([0, 30, 60], [0, 0.5, 1]),
|
||||||
|
transform: y.to(py => `translateY(${Math.max(0, py - 60)}px)`)
|
||||||
|
}}
|
||||||
|
className="fixed top-16 left-1/2 transform -translate-x-1/2 z-40 bg-white/90 backdrop-blur-sm rounded-full p-3 shadow-lg border md:hidden"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-5 w-5 text-gray-600 ${
|
||||||
|
isRefreshing ? 'animate-spin' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 视图模式切换 */}
|
{/* 视图模式切换 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -197,6 +272,7 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('grid')}
|
onClick={() => setViewMode('grid')}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2"
|
||||||
>
|
>
|
||||||
<Grid className="h-4 w-4" />
|
<Grid className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -204,6 +280,7 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
variant={viewMode === 'masonry' ? 'default' : 'outline'}
|
variant={viewMode === 'masonry' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('masonry')}
|
onClick={() => setViewMode('masonry')}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2"
|
||||||
>
|
>
|
||||||
<div className="h-4 w-4 flex items-center justify-center">
|
<div className="h-4 w-4 flex items-center justify-center">
|
||||||
<div className="grid grid-cols-2 gap-px">
|
<div className="grid grid-cols-2 gap-px">
|
||||||
@ -218,9 +295,22 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
className="min-h-[44px] min-w-[44px] p-2"
|
||||||
>
|
>
|
||||||
<List className="h-4 w-4" />
|
<List className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="min-h-[44px] px-4 hidden md:flex"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
@ -259,6 +349,6 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
|
|||||||
已显示全部 {photos.length} 张照片
|
已显示全部 {photos.length} 张照片
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</animated.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { X, ChevronLeft, ChevronRight, Camera, MapPin, Calendar } from "lucide-react"
|
import { X, ChevronLeft, ChevronRight, Camera, MapPin, Calendar } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { useDrag } from "@use-gesture/react"
|
||||||
|
import { useSpring, animated } from "react-spring"
|
||||||
|
|
||||||
import { Photo } from '@/lib/queries'
|
import { Photo } from '@/lib/queries'
|
||||||
|
|
||||||
@ -17,6 +19,41 @@ interface PhotoModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps) {
|
export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 触摸手势支持
|
||||||
|
const [{ x }, api] = useSpring(() => ({ x: 0 }))
|
||||||
|
|
||||||
|
const bind = useDrag(
|
||||||
|
({ down, movement: [mx], velocity: [vx], direction: [xDir], cancel }) => {
|
||||||
|
// 如果滑动速度足够快或者滑动距离超过阈值,触发切换
|
||||||
|
const trigger = Math.abs(mx) > window.innerWidth * 0.3 || Math.abs(vx) > 0.5
|
||||||
|
|
||||||
|
if (trigger && !down) {
|
||||||
|
// 根据滑动方向决定前进或后退
|
||||||
|
if (xDir > 0) {
|
||||||
|
onPrev() // 向右滑动,显示上一张
|
||||||
|
} else {
|
||||||
|
onNext() // 向左滑动,显示下一张
|
||||||
|
}
|
||||||
|
cancel() // 取消手势
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置拖拽动画
|
||||||
|
api.start({
|
||||||
|
x: down ? mx : 0,
|
||||||
|
immediate: down,
|
||||||
|
config: { tension: 300, friction: 30 }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
axis: 'x',
|
||||||
|
bounds: { left: -window.innerWidth, right: window.innerWidth },
|
||||||
|
rubberband: true,
|
||||||
|
filterTaps: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@ -47,7 +84,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute top-4 right-4 z-10 text-white hover:bg-white/10"
|
className="absolute top-4 right-4 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
@ -57,7 +94,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10"
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-8 w-8" />
|
<ChevronLeft className="h-8 w-8" />
|
||||||
@ -66,7 +103,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10"
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 text-white hover:bg-white/10 min-h-[44px] min-w-[44px] p-2"
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-8 w-8" />
|
<ChevronRight className="h-8 w-8" />
|
||||||
@ -75,16 +112,22 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
|
|||||||
<div className="w-full max-w-7xl mx-auto grid lg:grid-cols-3 gap-6 h-full max-h-[90vh]">
|
<div className="w-full max-w-7xl mx-auto grid lg:grid-cols-3 gap-6 h-full max-h-[90vh]">
|
||||||
{/* Main image */}
|
{/* Main image */}
|
||||||
<div className="lg:col-span-2 relative flex items-center justify-center">
|
<div className="lg:col-span-2 relative flex items-center justify-center">
|
||||||
<div className="relative w-full h-full max-h-[80vh] flex items-center justify-center">
|
<animated.div
|
||||||
|
ref={containerRef}
|
||||||
|
{...bind()}
|
||||||
|
style={{ x }}
|
||||||
|
className="relative w-full h-full max-h-[80vh] flex items-center justify-center touch-pan-y cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={photo.src || "/placeholder.svg"}
|
src={photo.src || "/placeholder.svg"}
|
||||||
alt={photo.title}
|
alt={photo.title}
|
||||||
width={1200}
|
width={1200}
|
||||||
height={800}
|
height={800}
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain select-none"
|
||||||
priority
|
priority
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</animated.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Photo information */}
|
{/* Photo information */}
|
||||||
@ -144,12 +187,14 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard shortcuts */}
|
{/* Keyboard shortcuts and gestures */}
|
||||||
<div className="pt-4 border-t border-gray-200">
|
<div className="pt-4 border-t border-gray-200">
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">快捷键</h3>
|
<h3 className="text-sm font-medium text-gray-900 mb-2">操作说明</h3>
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
<div>ESC - 关闭</div>
|
<div>ESC - 关闭</div>
|
||||||
<div>← → - 切换图片</div>
|
<div>← → - 切换图片</div>
|
||||||
|
<div className="md:hidden">左右滑动 - 切换图片</div>
|
||||||
|
<div className="hidden md:block">拖拽图片 - 切换图片</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
const TABLET_BREAKPOINT = 1024
|
||||||
|
const TOUCH_SUPPORT = typeof window !== 'undefined' && 'ontouchstart' in window
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
@ -17,3 +19,47 @@ export function useIsMobile() {
|
|||||||
|
|
||||||
return !!isMobile
|
return !!isMobile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeviceInfo() {
|
||||||
|
const [deviceInfo, setDeviceInfo] = React.useState({
|
||||||
|
isMobile: false,
|
||||||
|
isTablet: false,
|
||||||
|
isDesktop: false,
|
||||||
|
hasTouch: false,
|
||||||
|
viewport: { width: 0, height: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateDeviceInfo = () => {
|
||||||
|
const width = window.innerWidth
|
||||||
|
const height = window.innerHeight
|
||||||
|
|
||||||
|
setDeviceInfo({
|
||||||
|
isMobile: width < MOBILE_BREAKPOINT,
|
||||||
|
isTablet: width >= MOBILE_BREAKPOINT && width < TABLET_BREAKPOINT,
|
||||||
|
isDesktop: width >= TABLET_BREAKPOINT,
|
||||||
|
hasTouch: TOUCH_SUPPORT,
|
||||||
|
viewport: { width, height }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const mqt = window.matchMedia(`(max-width: ${TABLET_BREAKPOINT - 1}px)`)
|
||||||
|
|
||||||
|
updateDeviceInfo()
|
||||||
|
|
||||||
|
const handleResize = () => updateDeviceInfo()
|
||||||
|
|
||||||
|
mql.addEventListener("change", handleResize)
|
||||||
|
mqt.addEventListener("change", handleResize)
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mql.removeEventListener("change", handleResize)
|
||||||
|
mqt.removeEventListener("change", handleResize)
|
||||||
|
window.removeEventListener("resize", handleResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return deviceInfo
|
||||||
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@
|
|||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"@tanstack/react-query": "^5.81.5",
|
"@tanstack/react-query": "^5.81.5",
|
||||||
"@tanstack/react-query-devtools": "^5.81.5",
|
"@tanstack/react-query-devtools": "^5.81.5",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@ -61,6 +62,7 @@
|
|||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-spring": "^10.0.1",
|
||||||
"recharts": "2.15.0",
|
"recharts": "2.15.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
|
|||||||
Reference in New Issue
Block a user