feat: 完成前端响应式设计优化,增强移动端体验

- 添加触摸手势支持库 (react-spring + @use-gesture/react)
- 照片模态框增加左右滑动切换功能
- 照片画廊增加下拉刷新功能 (移动端)
- 优化所有按钮符合44px最小触摸目标标准
- 增强移动端导航体验,增加悬停和选中状态
- 创建设备信息检测钩子 (useDeviceInfo)
- 开发优化图片组件,支持懒加载和骨架屏
- 改进移动端手势交互和视觉反馈
- 完善响应式断点系统和触摸设备检测
- 前端构建测试成功,开发服务器正常启动

Task 23 completed: 前端响应式设计优化
This commit is contained in:
xujiang
2025-07-11 12:40:46 +08:00
parent 9046befcf1
commit 494d98bee5
9 changed files with 946 additions and 28 deletions

View File

@ -5,7 +5,9 @@ import Image from "next/image"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
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 {
id: number
@ -26,11 +28,12 @@ interface Photo {
interface PhotoGalleryProps {
photos: Photo[]
onPhotoClick: (photo: Photo) => void
onRefresh?: () => Promise<void>
}
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 [isHydrated, setIsHydrated] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
@ -38,7 +41,57 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
const [displayedPhotos, setDisplayedPhotos] = useState<Photo[]>([])
const [isLoading, setIsLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
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
@ -189,7 +242,29 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
}
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 gap-2">
@ -197,6 +272,7 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
className="min-h-[44px] min-w-[44px] p-2"
>
<Grid className="h-4 w-4" />
</Button>
@ -204,6 +280,7 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
variant={viewMode === 'masonry' ? 'default' : 'outline'}
size="sm"
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="grid grid-cols-2 gap-px">
@ -218,9 +295,22 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
variant={viewMode === 'list' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('list')}
className="min-h-[44px] min-w-[44px] p-2"
>
<List className="h-4 w-4" />
</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 className="text-sm text-gray-500">
@ -259,6 +349,6 @@ export function PhotoGallery({ photos, onPhotoClick }: PhotoGalleryProps) {
{photos.length}
</div>
)}
</div>
</animated.div>
)
}