fix(buttons): 修复 RippleButton 文字显示问题并解决 ESLint 错误
修复了 RippleButton 组件因 CVA 默认样式与自定义 className 冲突导致的文字不可见问题。 同时修复了项目中的 TypeScript 类型错误和 ESLint 规范问题。 主要修改: 1. 按钮显示修复:为使用红色文字的按钮添加 variant=outline, 为使用白色背景的按钮添加 variant=secondary 2. TypeScript 类型修复:修复 subtle-dots.tsx 中的类型定义错误, 删除不必要的 jest-dom.d.ts 文件 3. ESLint 规范修复:修复 React Hooks 使用规范问题, 将 useRef+forceUpdate 反模式改为 useState, 使用 eslint-disable 注释处理合理的 setState in effect 场景 4. 测试增强:添加按钮显示验证脚本和全面的页面按钮检查脚本
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface DataParticleFlowProps {
|
||||
className?: string;
|
||||
@@ -36,6 +36,7 @@ export function DataParticleFlow({
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const [particles, setParticles] = useState<Particle[]>([]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
const intensityConfig = {
|
||||
subtle: { sizeMin: 3, sizeMax: 8, opacityMin: 0.2, opacityMax: 0.4, moveRange: 80 },
|
||||
@@ -45,8 +46,8 @@ export function DataParticleFlow({
|
||||
|
||||
const shapes: Particle['shape'][] = ['circle', 'square', 'triangle', 'diamond', 'star'];
|
||||
const config = intensityConfig[intensity];
|
||||
|
||||
const generated: Particle[] = Array.from({ length: particleCount }, (_, i) => ({
|
||||
|
||||
const newParticles = Array.from({ length: particleCount }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
@@ -58,8 +59,14 @@ export function DataParticleFlow({
|
||||
shape: shape === 'mixed' ? (shapes[Math.floor(Math.random() * shapes.length)] ?? 'circle') : shape as Particle['shape'],
|
||||
rotation: Math.random() * 360,
|
||||
}));
|
||||
setParticles(generated);
|
||||
|
||||
setParticles(newParticles);
|
||||
}, [particleCount, intensity, shape]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
if (particles.length === 0) {
|
||||
return <div className={`absolute inset-0 overflow-hidden ${className}`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const getShapeStyles = (particle: Particle): React.CSSProperties => {
|
||||
const baseStyles: React.CSSProperties = {
|
||||
|
||||
@@ -22,16 +22,18 @@ export function SubtleDots({
|
||||
delay: number;
|
||||
}>>([]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
const generatedDots = Array.from({ length: count }, (_, i) => ({
|
||||
const newDots = Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
x: 10 + Math.random() * 80,
|
||||
y: 10 + Math.random() * 80,
|
||||
size: 2 + Math.random() * 3,
|
||||
delay: i * 0.3
|
||||
}));
|
||||
setDots(generatedDots);
|
||||
setDots(newDots);
|
||||
}, [count]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
if (dots.length === 0) {
|
||||
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
|
||||
@@ -68,4 +70,4 @@ export function SubtleDots({
|
||||
);
|
||||
}
|
||||
|
||||
export default SubtleDots;
|
||||
export default SubtleDots;
|
||||
|
||||
@@ -3,9 +3,11 @@ import '@testing-library/jest-dom';
|
||||
import { Breadcrumb } from './breadcrumb';
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
|
||||
describe('Breadcrumb', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ProductCTASection() {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<RippleButton
|
||||
href="/contact"
|
||||
variant="secondary"
|
||||
rippleColor="rgba(196, 30, 58, 0.3)"
|
||||
className="bg-white text-[#C41E3A] px-8 py-4 rounded-lg text-lg font-semibold inline-flex items-center justify-center w-full sm:w-auto"
|
||||
>
|
||||
|
||||
@@ -11,16 +11,20 @@ jest.mock('framer-motion', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: any) => <a href={href}>{children}</a>;
|
||||
const MockLink = ({ children, href }: React.PropsWithChildren<{ href: string }>) => <a href={href}>{children}</a>;
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
|
||||
jest.mock('@/components/ui/ripple-button', () => ({
|
||||
RippleButton: ({ children, ...props }: any) => (
|
||||
jest.mock('@/components/ui/ripple-button', () => {
|
||||
const MockRippleButton = ({ children, ...props }: React.PropsWithChildren<unknown>) => (
|
||||
<button {...props} data-testid="ripple-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
);
|
||||
MockRippleButton.displayName = 'MockRippleButton';
|
||||
return { RippleButton: MockRippleButton };
|
||||
});
|
||||
|
||||
describe('AboutSection', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function AboutSection() {
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-12">
|
||||
<h2 id="about-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-6">
|
||||
关于 <span className="tracking-tight font-brand text-[#C41E3A]" style={{ fontWeight: 'normal', WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' }}>{COMPANY_INFO.shortName}</span>
|
||||
关于 <span className="tracking-tight text-[#C41E3A]" style={{ fontFamily: "var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif", fontWeight: 'normal', WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale', textRendering: 'optimizeLegibility' }}>{COMPANY_INFO.shortName}</span>
|
||||
</h2>
|
||||
<p className="text-lg text-[#5C5C5C] mb-8">
|
||||
{COMPANY_INFO.slogan}
|
||||
|
||||
@@ -59,8 +59,9 @@ export function HeroTitle(_props: HeroContentProps) {
|
||||
<InkReveal delay={0.1}>
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6 font-brand"
|
||||
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6"
|
||||
style={{
|
||||
fontFamily: "var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif",
|
||||
fontWeight: 'normal',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
|
||||
@@ -44,6 +44,7 @@ export function ServiceCTASection() {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<RippleButton
|
||||
href="/contact"
|
||||
variant="secondary"
|
||||
rippleColor="rgba(196, 30, 58, 0.3)"
|
||||
className="bg-white text-[#C41E3A] px-8 py-4 rounded-lg text-lg font-semibold inline-flex items-center justify-center w-full sm:w-auto"
|
||||
>
|
||||
|
||||
@@ -97,6 +97,7 @@ export function ServiceHeroSection({ service }: ServiceHeroSectionProps) {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<RippleButton
|
||||
href="/contact"
|
||||
variant="outline"
|
||||
className="border-2 border-[#C41E3A] text-[#C41E3A] hover:bg-[#C41E3A] hover:text-white px-8 py-4 rounded-lg text-lg font-semibold inline-flex items-center justify-center"
|
||||
rippleColor="rgba(196, 30, 58, 0.2)"
|
||||
>
|
||||
@@ -111,6 +112,7 @@ export function ServiceHeroSection({ service }: ServiceHeroSectionProps) {
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
href="#challenges"
|
||||
variant="outline"
|
||||
className="border-2 border-[#C41E3A] text-[#C41E3A] hover:bg-[#C41E3A]/5 px-8 py-4 rounded-lg text-lg font-semibold inline-flex items-center justify-center"
|
||||
rippleColor="rgba(196, 30, 58, 0.2)"
|
||||
>
|
||||
|
||||
@@ -85,14 +85,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
|
||||
function FlipCard({ value, label, maxDigits = 2 }: FlipCardProps) {
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const currentValue = value;
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
if (value !== currentValue) {
|
||||
setPrevValue(currentValue);
|
||||
setCurrentValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
setPrevValue(currentValue);
|
||||
}, [currentValue]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// 将数字转换为数组,每个数字一位
|
||||
const formatNumber = (num: number) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface InkDropProps {
|
||||
size?: number;
|
||||
@@ -244,14 +244,21 @@ interface FloatingInkProps {
|
||||
}
|
||||
|
||||
export function FloatingInk({ count = 15, className = '' }: FloatingInkProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [elements, setElements] = useState<Array<{
|
||||
id: number;
|
||||
type: number;
|
||||
delay: number;
|
||||
props: { left: string; top: string };
|
||||
animX: number;
|
||||
animDuration: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
height?: number;
|
||||
}>>([]);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const elements = useMemo(() => {
|
||||
if (!isMounted) {return [];}
|
||||
const items = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -281,8 +288,9 @@ export function FloatingInk({ count = 15, className = '' }: FloatingInkProps) {
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [count, isMounted]);
|
||||
setElements(items);
|
||||
}, [count]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
return (
|
||||
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
|
||||
@@ -390,11 +398,19 @@ interface StrokePosition {
|
||||
}
|
||||
|
||||
export function InkDecoration({ variant = 'balanced', className = '' }: InkDecorationProps) {
|
||||
const [dropPositions, setDropPositions] = useState<DropPosition[]>([]);
|
||||
const [splashPositions, setSplashPositions] = useState<SplashPosition[]>([]);
|
||||
const [sealPositions, setSealPositions] = useState<SealPosition[]>([]);
|
||||
const [stainPositions, setStainPositions] = useState<StainPosition[]>([]);
|
||||
const [strokePositions, setStrokePositions] = useState<StrokePosition[]>([]);
|
||||
const [positions, setPositions] = useState<{
|
||||
drops: DropPosition[];
|
||||
splashes: SplashPosition[];
|
||||
seals: SealPosition[];
|
||||
stains: StainPosition[];
|
||||
strokes: StrokePosition[];
|
||||
}>({
|
||||
drops: [],
|
||||
splashes: [],
|
||||
seals: [],
|
||||
stains: [],
|
||||
strokes: [],
|
||||
});
|
||||
|
||||
const config = {
|
||||
minimal: { drops: 3, splashes: 1, seals: 1, stains: 1, strokes: 1 },
|
||||
@@ -404,49 +420,49 @@ export function InkDecoration({ variant = 'balanced', className = '' }: InkDecor
|
||||
|
||||
const { drops, splashes, seals, stains, strokes } = config[variant];
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setDropPositions(Array.from({ length: drops }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / drops)}%`,
|
||||
top: `${20 + Math.random() * 60}%`,
|
||||
size: 6 + Math.random() * 14,
|
||||
opacity: 0.06 + Math.random() * 0.1,
|
||||
blur: Math.random() * 3,
|
||||
isRed: i % 3 === 0,
|
||||
duration: 5 + Math.random() * 3,
|
||||
})));
|
||||
|
||||
setSplashPositions(Array.from({ length: splashes }, (_, i) => ({
|
||||
left: `${20 + (i * 60 / splashes)}%`,
|
||||
top: `${15 + Math.random() * 70}%`,
|
||||
size: 40 + Math.random() * 40,
|
||||
duration: 7 + Math.random() * 3,
|
||||
})));
|
||||
|
||||
setSealPositions(Array.from({ length: seals }, (_, i) => ({
|
||||
left: `${25 + (i * 50 / seals)}%`,
|
||||
top: `${25 + Math.random() * 50}%`,
|
||||
size: 25 + Math.random() * 25,
|
||||
duration: 6 + Math.random() * 2,
|
||||
})));
|
||||
|
||||
setStainPositions(Array.from({ length: stains }, (_, i) => ({
|
||||
left: `${10 + (i * 80 / stains)}%`,
|
||||
top: `${30 + Math.random() * 40}%`,
|
||||
size: 80 + Math.random() * 60,
|
||||
duration: 8 + Math.random() * 4,
|
||||
})));
|
||||
|
||||
setStrokePositions(Array.from({ length: strokes }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / strokes)}%`,
|
||||
top: `${40 + Math.random() * 30}%`,
|
||||
width: 100 + Math.random() * 100,
|
||||
duration: 6 + Math.random() * 3,
|
||||
})));
|
||||
setPositions({
|
||||
drops: Array.from({ length: drops }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / drops)}%`,
|
||||
top: `${20 + Math.random() * 60}%`,
|
||||
size: 6 + Math.random() * 14,
|
||||
opacity: 0.06 + Math.random() * 0.1,
|
||||
blur: Math.random() * 3,
|
||||
isRed: i % 3 === 0,
|
||||
duration: 5 + Math.random() * 3,
|
||||
})),
|
||||
splashes: Array.from({ length: splashes }, (_, i) => ({
|
||||
left: `${20 + (i * 60 / splashes)}%`,
|
||||
top: `${15 + Math.random() * 70}%`,
|
||||
size: 40 + Math.random() * 40,
|
||||
duration: 7 + Math.random() * 3,
|
||||
})),
|
||||
seals: Array.from({ length: seals }, (_, i) => ({
|
||||
left: `${25 + (i * 50 / seals)}%`,
|
||||
top: `${25 + Math.random() * 50}%`,
|
||||
size: 25 + Math.random() * 25,
|
||||
duration: 6 + Math.random() * 2,
|
||||
})),
|
||||
stains: Array.from({ length: stains }, (_, i) => ({
|
||||
left: `${10 + (i * 80 / stains)}%`,
|
||||
top: `${30 + Math.random() * 40}%`,
|
||||
size: 80 + Math.random() * 60,
|
||||
duration: 8 + Math.random() * 4,
|
||||
})),
|
||||
strokes: Array.from({ length: strokes }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / strokes)}%`,
|
||||
top: `${40 + Math.random() * 30}%`,
|
||||
width: 100 + Math.random() * 100,
|
||||
duration: 6 + Math.random() * 3,
|
||||
})),
|
||||
});
|
||||
}, [drops, splashes, seals, stains, strokes]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
return (
|
||||
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
|
||||
{dropPositions.map((pos, i) => (
|
||||
{positions.drops.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`drop-${i}`}
|
||||
className="absolute"
|
||||
@@ -472,7 +488,7 @@ export function InkDecoration({ variant = 'balanced', className = '' }: InkDecor
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{splashPositions.map((pos, i) => (
|
||||
{positions.splashes.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`splash-${i}`}
|
||||
className="absolute"
|
||||
@@ -492,7 +508,7 @@ export function InkDecoration({ variant = 'balanced', className = '' }: InkDecor
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{sealPositions.map((pos, i) => (
|
||||
{positions.seals.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`seal-${i}`}
|
||||
className="absolute"
|
||||
@@ -512,7 +528,7 @@ export function InkDecoration({ variant = 'balanced', className = '' }: InkDecor
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{stainPositions.map((pos, i) => (
|
||||
{positions.stains.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`stain-${i}`}
|
||||
className="absolute"
|
||||
@@ -532,7 +548,7 @@ export function InkDecoration({ variant = 'balanced', className = '' }: InkDecor
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{strokePositions.map((pos, i) => (
|
||||
{positions.strokes.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`stroke-${i}`}
|
||||
className="absolute"
|
||||
|
||||
@@ -29,9 +29,9 @@ export const Swipeable = memo(function Swipeable({
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (disabled) return;
|
||||
if (disabled) {return;}
|
||||
const touch = e.targetTouches[0];
|
||||
if (!touch) return;
|
||||
if (!touch) {return;}
|
||||
setTouchEnd(null);
|
||||
setTouchStart({
|
||||
x: touch.clientX,
|
||||
@@ -40,9 +40,9 @@ export const Swipeable = memo(function Swipeable({
|
||||
}, [disabled]);
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (disabled) return;
|
||||
if (disabled) {return;}
|
||||
const touch = e.targetTouches[0];
|
||||
if (!touch) return;
|
||||
if (!touch) {return;}
|
||||
setTouchEnd({
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
@@ -50,7 +50,7 @@ export const Swipeable = memo(function Swipeable({
|
||||
}, [disabled]);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (!touchStart || !touchEnd || disabled) return;
|
||||
if (!touchStart || !touchEnd || disabled) {return;}
|
||||
|
||||
const distanceX = touchStart.x - touchEnd.x;
|
||||
const distanceY = touchStart.y - touchEnd.y;
|
||||
@@ -107,7 +107,7 @@ export const PullToRefresh = memo(function PullToRefresh({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (disabled || isRefreshing) return;
|
||||
if (disabled || isRefreshing) {return;}
|
||||
const touch = e.touches[0];
|
||||
if (touch) {
|
||||
touchStartY.current = touch.clientY;
|
||||
@@ -115,13 +115,13 @@ export const PullToRefresh = memo(function PullToRefresh({
|
||||
}, [disabled, isRefreshing]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (disabled || isRefreshing) return;
|
||||
if (disabled || isRefreshing) {return;}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container || container.scrollTop > 0) return;
|
||||
if (!container || container.scrollTop > 0) {return;}
|
||||
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
if (!touch) {return;}
|
||||
const distance = touch.clientY - touchStartY.current;
|
||||
|
||||
if (distance > 0) {
|
||||
@@ -130,7 +130,7 @@ export const PullToRefresh = memo(function PullToRefresh({
|
||||
}, [disabled, isRefreshing]);
|
||||
|
||||
const handleTouchEnd = useCallback(async () => {
|
||||
if (disabled || isRefreshing) return;
|
||||
if (disabled || isRefreshing) {return;}
|
||||
|
||||
if (pullDistance > 60) {
|
||||
setIsRefreshing(true);
|
||||
@@ -215,7 +215,7 @@ export const LongPress = memo(function LongPress({
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
if (disabled) return;
|
||||
if (disabled) {return;}
|
||||
setIsPressed(true);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onLongPress();
|
||||
@@ -256,13 +256,8 @@ export const LongPress = memo(function LongPress({
|
||||
});
|
||||
|
||||
export function useTouchDevice() {
|
||||
const [isTouchDevice, setIsTouchDevice] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTouchDevice(
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0
|
||||
);
|
||||
const isTouchDevice = useMemo(() => {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
}, []);
|
||||
|
||||
return isTouchDevice;
|
||||
|
||||
Reference in New Issue
Block a user