From ac4c853b71dc1bb1b3680ebcfe2d2e952d77ffb3 Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Tue, 1 Sep 2026 08:02:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(home):=20Hero=20=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=20Absorb=20=E5=B1=82=E6=B8=90=E8=BF=9B?= =?UTF-8?q?=E6=8F=8F=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hero-product-visual 升级为客户端组件,趋势线改用 stroke-dashoffset 渐进描边: 滚动入视触发、600ms ease-ink 绘出,填充/端点同步淡入,补齐参考埃森哲的 Glance/Skim/Absorb 三层交互模型。reduced-motion 首帧即静态完整线、零动画; IntersectionObserver 不可用时降级静态。刻意未做 KPI count-up(格式化串解析 hacky 且偏装饰,与动效克制偏好冲突)。 Co-Authored-By: 阿睿 --- .../sections/hero-product-visual.test.tsx | 27 ++++++++ .../sections/hero-product-visual.tsx | 64 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/components/sections/hero-product-visual.test.tsx diff --git a/src/components/sections/hero-product-visual.test.tsx b/src/components/sections/hero-product-visual.test.tsx new file mode 100644 index 0000000..5453eb4 --- /dev/null +++ b/src/components/sections/hero-product-visual.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import HeroProductVisual from './hero-product-visual'; + +jest.mock('@/hooks/use-reduced-motion', () => ({ + useReducedMotion: jest.fn(() => false), +})); + +describe('HeroProductVisual - Absorb 层(渐进描边)', () => { + it('renders the brand-red trend line path', () => { + render(); + const path = document.querySelector('.hero-trend-line'); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute('stroke', 'var(--color-brand)'); + }); + + it('renders the trend line statically (no draw animation) under reduced motion', () => { + const { useReducedMotion } = require('@/hooks/use-reduced-motion'); + (useReducedMotion as jest.Mock).mockReturnValue(true); + render(); + const path = document.querySelector('.hero-trend-line') as SVGPathElement; + expect(path).toBeInTheDocument(); + // reduced-motion:入场即为完整描边(strokeDashoffset = 0),不播放描边动画 + expect(path.style.strokeDashoffset).toBe('0'); + }); +}); diff --git a/src/components/sections/hero-product-visual.tsx b/src/components/sections/hero-product-visual.tsx index 8b78018..b6953da 100644 --- a/src/components/sections/hero-product-visual.tsx +++ b/src/components/sections/hero-product-visual.tsx @@ -1,5 +1,9 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; import { TrendingUp, ArrowUpRight, ArrowDownRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useReducedMotion } from '@/hooks/use-reduced-motion'; // 抽象数据可视化示意(示例数据)——作为品牌「数据驱动」能力的视觉表达,不含具体产品 UI 外壳 const KPIS = [ @@ -19,9 +23,42 @@ function TrendBadge({ trend, label }: { trend: 'up' | 'down'; label: string }) { ); } +const EASE_INK = 'cubic-bezier(0.22, 1, 0.36, 1)'; +// 描边路径长度上限(宽松覆盖实际路径,确保 offset 动画完整绘制整条线) +const TREND_PATH_LENGTH = 600; + export function HeroProductVisual({ className }: { className?: string }) { + const rootRef = useRef(null); + const reduceMotion = useReducedMotion(); + // reduced-motion:首帧即为静态完整线,杜绝任何一闪 + const [drawn, setDrawn] = useState(reduceMotion); + + useEffect(() => { + if (reduceMotion) { + setDrawn(true); + return; + } + const el = rootRef.current; + if (!el || typeof IntersectionObserver === 'undefined') { + setDrawn(true); // 无 IO 支持的降级:直接静态呈现完整线 + return; + } + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setDrawn(true); + io.disconnect(); + } + }, + { threshold: 0.25 }, + ); + io.observe(el); + return () => io.disconnect(); + }, [reduceMotion]); + return (
- {/* 趋势图:品牌色曲线 + 渐变填充 */} + {/* 趋势图:品牌色曲线 + 渐变填充(Absorb 层:滚动入视时曲线渐进描边,数据「长出来」) */}
数据增长趋势 @@ -73,17 +110,40 @@ export function HeroProductVisual({ className }: { className?: string }) { + -
); } + +export default HeroProductVisual;