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:
张翔
2026-04-27 16:27:35 +08:00
parent e83ecddfe5
commit 1832640e8f
20 changed files with 395 additions and 134 deletions
+11 -4
View File
@@ -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 = {
+5 -3
View File
@@ -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 -1
View File
@@ -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(() => {
+1 -1
View File
@@ -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)"
>
+6 -7
View File
@@ -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) => {
+72 -56
View File
@@ -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"
+13 -18
View File
@@ -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;