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

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
"use client"
import { useState, useEffect } from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
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"
interface FilterBarProps {

View File

@ -43,7 +43,7 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
<button
key={item.name}
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" : ""
}`}
>
@ -56,7 +56,12 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
</div>
{/* 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" />}
</Button>
</div>
@ -64,13 +69,13 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {
{/* Mobile Navigation */}
{isMenuOpen && (
<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) => (
<button
key={item.name}
onClick={() => handleTabClick(item)}
className={`text-left text-gray-700 hover:text-gray-900 transition-colors duration-200 font-light ${
activeTab === item.id ? "text-gray-900 font-medium" : ""
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 bg-gray-50" : ""
}`}
>
{item.name}

View 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>
)
}

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>
)
}

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>

View File

@ -1,6 +1,8 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
const TABLET_BREAKPOINT = 1024
const TOUCH_SUPPORT = typeof window !== 'undefined' && 'ontouchstart' in window
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
@ -17,3 +19,47 @@ export function useIsMobile() {
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
}

View File

@ -43,6 +43,7 @@
"@radix-ui/react-tooltip": "1.1.6",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.81.5",
"@use-gesture/react": "^10.3.1",
"autoprefixer": "^10.4.20",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
@ -61,6 +62,7 @@
"react-dom": "^19",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"react-spring": "^10.0.1",
"recharts": "2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",