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