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

@ -1,11 +1,13 @@
"use client"
import { useEffect } from "react"
import { useEffect, useRef } from "react"
import Image from "next/image"
import { X, ChevronLeft, ChevronRight, Camera, MapPin, Calendar } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { useDrag } from "@use-gesture/react"
import { useSpring, animated } from "react-spring"
import { Photo } from '@/lib/queries'
@ -17,6 +19,41 @@ interface 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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
@ -47,7 +84,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
<Button
variant="ghost"
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}
>
<X className="h-6 w-6" />
@ -57,7 +94,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
<Button
variant="ghost"
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}
>
<ChevronLeft className="h-8 w-8" />
@ -66,7 +103,7 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
<Button
variant="ghost"
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}
>
<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]">
{/* Main image */}
<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
src={photo.src || "/placeholder.svg"}
alt={photo.title}
width={1200}
height={800}
className="max-w-full max-h-full object-contain"
className="max-w-full max-h-full object-contain select-none"
priority
draggable={false}
/>
</div>
</animated.div>
</div>
{/* Photo information */}
@ -144,12 +187,14 @@ export function PhotoModal({ photo, onClose, onPrev, onNext }: PhotoModalProps)
</div>
</div>
{/* Keyboard shortcuts */}
{/* Keyboard shortcuts and gestures */}
<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>ESC - </div>
<div> - </div>
<div className="md:hidden"> - </div>
<div className="hidden md:block"> - </div>
</div>
</div>
</div>