feat: 添加面包屑导航组件并优化页面布局

refactor: 重构页面结构和导航逻辑

fix: 修复移动端菜单导航和滚动行为

perf: 优化图片加载性能和资源请求

test: 添加端到端测试和性能测试用例

docs: 更新.gitignore文件

chore: 更新依赖和配置

style: 优化代码格式和类型安全

ci: 调整Playwright测试超时时间

build: 更新Next.js配置和构建选项
This commit is contained in:
张翔
2026-02-28 09:09:04 +08:00
parent 9d01e0982f
commit 9451814ca4
60 changed files with 4078 additions and 148 deletions
+2
View File
@@ -1,3 +1,5 @@
'use client';
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
+2
View File
@@ -1,3 +1,5 @@
'use client';
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
+168
View File
@@ -0,0 +1,168 @@
'use client';
import Image from 'next/image';
import { useState, useCallback, memo } from 'react';
import { cn } from '@/lib/utils';
interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
fill?: boolean;
priority?: boolean;
className?: string;
containerClassName?: string;
sizes?: string;
quality?: number;
placeholder?: 'blur' | 'empty';
blurDataURL?: string;
onLoad?: () => void;
onError?: () => void;
objectFit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
objectPosition?: string;
loading?: 'lazy' | 'eager';
unoptimized?: boolean;
}
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="g">
<stop stop-color="#f0f0f0" offset="20%" />
<stop stop-color="#e0e0e0" offset="50%" />
<stop stop-color="#f0f0f0" offset="70%" />
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="#f0f0f0" />
<rect id="r" width="${w}" height="${h}" fill="url(#g)" />
<animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" />
</svg>`;
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str);
const OptimizedImage = memo(function OptimizedImage({
src,
alt,
width,
height,
fill = false,
priority = false,
className,
containerClassName,
sizes,
quality = 85,
placeholder = 'blur',
blurDataURL,
onLoad,
onError,
objectFit = 'cover',
objectPosition = 'center',
loading = 'lazy',
unoptimized = false,
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const handleLoad = useCallback(() => {
setIsLoading(false);
onLoad?.();
}, [onLoad]);
const handleError = useCallback(() => {
setIsLoading(false);
setHasError(true);
onError?.();
}, [onError]);
const defaultBlurDataURL = blurDataURL || (width && height ? `data:image/svg+xml;base64,${toBase64(shimmer(width, height))}` : undefined);
const objectFitClass = {
contain: 'object-contain',
cover: 'object-cover',
fill: 'object-fill',
none: 'object-none',
'scale-down': 'object-scale-down',
}[objectFit];
if (hasError) {
return (
<div
className={cn(
'flex items-center justify-center bg-gray-100 text-gray-400',
containerClassName
)}
style={width && height ? { width, height } : undefined}
>
<svg
className="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
</div>
);
}
const imageElement = (
<Image
src={src}
alt={alt}
width={fill ? undefined : width}
height={fill ? undefined : height}
fill={fill}
priority={priority}
sizes={sizes}
quality={quality}
placeholder={placeholder}
blurDataURL={defaultBlurDataURL}
onLoad={handleLoad}
onError={handleError}
loading={priority ? 'eager' : loading}
unoptimized={unoptimized}
className={cn(
'transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100',
objectFitClass,
className
)}
style={{ objectPosition }}
/>
);
if (fill) {
return (
<div className={cn('relative overflow-hidden', containerClassName)}>
{imageElement}
{isLoading && (
<div className="absolute inset-0 animate-pulse bg-gray-200" />
)}
</div>
);
}
return (
<div className={cn('relative overflow-hidden', containerClassName)}>
{imageElement}
{isLoading && (
<div
className="absolute inset-0 animate-pulse bg-gray-200"
style={width && height ? { width, height } : undefined}
/>
)}
</div>
);
});
export { OptimizedImage };
export type { OptimizedImageProps };
+269
View File
@@ -0,0 +1,269 @@
'use client';
import { useState, useCallback, useRef, useEffect, memo } from 'react';
import { cn } from '@/lib/utils';
interface SwipeableProps {
children: React.ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
className?: string;
disabled?: boolean;
}
export const Swipeable = memo(function Swipeable({
children,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50,
className,
disabled = false,
}: SwipeableProps) {
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
const [touchEnd, setTouchEnd] = useState<{ x: number; y: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (disabled) return;
const touch = e.targetTouches[0];
if (!touch) return;
setTouchEnd(null);
setTouchStart({
x: touch.clientX,
y: touch.clientY,
});
}, [disabled]);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (disabled) return;
const touch = e.targetTouches[0];
if (!touch) return;
setTouchEnd({
x: touch.clientX,
y: touch.clientY,
});
}, [disabled]);
const onTouchEnd = useCallback(() => {
if (!touchStart || !touchEnd || disabled) return;
const distanceX = touchStart.x - touchEnd.x;
const distanceY = touchStart.y - touchEnd.y;
const isHorizontalSwipe = Math.abs(distanceX) > Math.abs(distanceY);
if (isHorizontalSwipe) {
if (Math.abs(distanceX) > threshold) {
if (distanceX > 0) {
onSwipeLeft?.();
} else {
onSwipeRight?.();
}
}
} else {
if (Math.abs(distanceY) > threshold) {
if (distanceY > 0) {
onSwipeUp?.();
} else {
onSwipeDown?.();
}
}
}
}, [touchStart, touchEnd, threshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, disabled]);
return (
<div
ref={ref}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className={className}
>
{children}
</div>
);
});
interface PullToRefreshProps {
children: React.ReactNode;
onRefresh: () => Promise<void>;
disabled?: boolean;
className?: string;
}
export const PullToRefresh = memo(function PullToRefresh({
children,
onRefresh,
disabled = false,
className,
}: PullToRefreshProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const touchStartY = useRef(0);
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (disabled || isRefreshing) return;
const touch = e.touches[0];
if (touch) {
touchStartY.current = touch.clientY;
}
}, [disabled, isRefreshing]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (disabled || isRefreshing) return;
const container = containerRef.current;
if (!container || container.scrollTop > 0) return;
const touch = e.touches[0];
if (!touch) return;
const distance = touch.clientY - touchStartY.current;
if (distance > 0) {
setPullDistance(Math.min(distance, 100));
}
}, [disabled, isRefreshing]);
const handleTouchEnd = useCallback(async () => {
if (disabled || isRefreshing) return;
if (pullDistance > 60) {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}
setPullDistance(0);
}, [disabled, isRefreshing, pullDistance, onRefresh]);
return (
<div
ref={containerRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={cn('relative overflow-auto', className)}
>
{pullDistance > 0 && (
<div
className="absolute top-0 left-0 right-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-10"
style={{ height: pullDistance }}
>
<div className={cn(
'w-6 h-6 border-2 border-[#C41E3A] border-t-transparent rounded-full',
isRefreshing && 'animate-spin'
)} />
</div>
)}
{children}
</div>
);
});
interface TouchFeedbackProps {
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
export const TouchFeedback = memo(function TouchFeedback({
children,
className,
disabled = false,
}: TouchFeedbackProps) {
const [isPressed, setIsPressed] = useState(false);
return (
<div
className={cn(
'transition-transform duration-100',
isPressed && !disabled && 'scale-95',
className
)}
onTouchStart={() => !disabled && setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
>
{children}
</div>
);
});
interface LongPressProps {
children: React.ReactNode;
onLongPress: () => void;
delay?: number;
className?: string;
disabled?: boolean;
}
export const LongPress = memo(function LongPress({
children,
onLongPress,
delay = 500,
className,
disabled = false,
}: LongPressProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isPressed, setIsPressed] = useState(false);
const handleTouchStart = useCallback(() => {
if (disabled) return;
setIsPressed(true);
timeoutRef.current = setTimeout(() => {
onLongPress();
setIsPressed(false);
}, delay);
}, [disabled, delay, onLongPress]);
const handleTouchEnd = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsPressed(false);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
className={cn(
'transition-transform duration-100',
isPressed && !disabled && 'scale-95',
className
)}
>
{children}
</div>
);
});
export function useTouchDevice() {
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
setIsTouchDevice(
'ontouchstart' in window ||
navigator.maxTouchPoints > 0
);
}, []);
return isTouchDevice;
}
+12 -8
View File
@@ -21,18 +21,22 @@ export function TouchSwipe({
const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
if (e.touches[0]) {
touchStartX.current = e.touches[0].clientX;
}
};
const handleTouchEnd = (e: React.TouchEvent) => {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;
if (e.changedTouches[0]) {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;
if (Math.abs(diff) > threshold) {
if (diff > 0 && onSwipeLeft) {
onSwipeLeft();
} else if (diff < 0 && onSwipeRight) {
onSwipeRight();
if (Math.abs(diff) > threshold) {
if (diff > 0 && onSwipeLeft) {
onSwipeLeft();
} else if (diff < 0 && onSwipeRight) {
onSwipeRight();
}
}
}
};