Refactor/optimize ui #24
@@ -126,11 +126,6 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
|
|||||||
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
|
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the abstract data-driven visual without product UI shell', () => {
|
|
||||||
render(<HomeContentV15 />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('HomeContentV15 - 信任层与章节式叙事', () => {
|
describe('HomeContentV15 - 信任层与章节式叙事', () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { motion } from 'framer-motion';
|
|||||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
|
|
||||||
import { HeroParticleField } from '@/components/sections/hero-particle-field';
|
import { HeroParticleField } from '@/components/sections/hero-particle-field';
|
||||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -178,12 +177,12 @@ function HeroSection({ heroData }: {
|
|||||||
{!reduceMotion && (
|
{!reduceMotion && (
|
||||||
<div ref={spotRef} className="absolute inset-0 pointer-events-none hero-dot-spot opacity-60" aria-hidden="true" />
|
<div ref={spotRef} className="absolute inset-0 pointer-events-none hero-dot-spot opacity-60" aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
{/* L3 动态粒子(品牌红点缀):入场浮现后静止,rAF 仅跑 ~600ms,无持续循环;
|
{/* L3 动态粒子(品牌红点缀):rAF 持续循环漂移;
|
||||||
reduced-motion 下退化为一次性静态帧 */}
|
reduced-motion / 自动化环境下降级为一次性静态帧 */}
|
||||||
<HeroParticleField />
|
<HeroParticleField />
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
|
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
|
||||||
<div className="grid items-center gap-16 lg:grid-cols-[1.05fr_0.95fr]">
|
<div className="grid items-center gap-16">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
{/* 品牌口号 Badge:浅色画布上的品牌红信号(hero 不重复渲染 Logo,避免与导航栏重复) */}
|
{/* 品牌口号 Badge:浅色画布上的品牌红信号(hero 不重复渲染 Logo,避免与导航栏重复) */}
|
||||||
{slogan && (
|
{slogan && (
|
||||||
@@ -241,16 +240,6 @@ function HeroSection({ heroData }: {
|
|||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:产品视觉(SVG 数据看板示意,浅色卡片浮于浅色画布) */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 40 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.45, delay: 0.3, ease: EASE_OUT }}
|
|
||||||
className="relative mt-16 lg:mt-0"
|
|
||||||
>
|
|
||||||
<HeroProductVisual className="max-w-xl w-full lg:ml-auto" />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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(<HeroProductVisual />);
|
|
||||||
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(<HeroProductVisual />);
|
|
||||||
const path = document.querySelector('.hero-trend-line') as SVGPathElement;
|
|
||||||
expect(path).toBeInTheDocument();
|
|
||||||
// reduced-motion:入场即为完整描边(strokeDashoffset = 0),不播放描边动画
|
|
||||||
expect(path.style.strokeDashoffset).toBe('0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'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 = [
|
|
||||||
{ label: '营收同比', value: '+32.6%', trend: 'up', note: '较上期' },
|
|
||||||
{ label: '订单履约率', value: '98.2%', trend: 'up', note: '目标 95%' },
|
|
||||||
{ label: '库存周转', value: '5.8 天', trend: 'down', note: '提速 1.2 天' },
|
|
||||||
{ label: '客户新增', value: '+18.4%', trend: 'up', note: '本月' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function TrendBadge({ trend, label }: { trend: 'up' | 'down'; label: string }) {
|
|
||||||
const Icon = trend === 'up' ? ArrowUpRight : ArrowDownRight;
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-text-muted">
|
|
||||||
<Icon className="h-3 w-3" />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={rootRef}
|
|
||||||
data-testid="hero-product-visual"
|
|
||||||
role="img"
|
|
||||||
aria-label="数据驱动可视化示意(示例数据)"
|
|
||||||
className={cn('relative', className)}
|
|
||||||
>
|
|
||||||
{/* KPI 卡片:抽象数据可视化 */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{KPIS.map((kpi) => (
|
|
||||||
<div key={kpi.label} className="border border-border-primary bg-bg-secondary p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
|
||||||
<span className="text-[11px] font-medium text-text-muted">{kpi.label}</span>
|
|
||||||
<TrendingUp className="h-3.5 w-3.5 text-brand" />
|
|
||||||
</div>
|
|
||||||
<div className="text-xl font-bold tabular-nums leading-none text-ink sm:text-2xl">
|
|
||||||
{kpi.value}
|
|
||||||
</div>
|
|
||||||
<TrendBadge trend={kpi.trend} label={kpi.note} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 趋势图:品牌色曲线 + 渐变填充(Absorb 层:滚动入视时曲线渐进描边,数据「长出来」) */}
|
|
||||||
<div className="mt-3 border border-border-primary bg-bg-primary p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-ink">数据增长趋势</span>
|
|
||||||
<span className="text-[10px] text-text-muted">示例数据 · 近 12 个月</span>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 360 120"
|
|
||||||
className="h-24 w-full sm:h-28"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="hero-visual-fill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="var(--color-brand)" stopOpacity="0.16" />
|
|
||||||
<stop offset="100%" stopColor="var(--color-brand)" stopOpacity="0" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
{[24, 56, 88].map((y) => (
|
|
||||||
<line key={y} x1="0" y1={y} x2="360" y2={y} stroke="var(--color-border-light)" strokeWidth="1" />
|
|
||||||
))}
|
|
||||||
<path
|
|
||||||
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20 L360,120 L0,120 Z"
|
|
||||||
fill="url(#hero-visual-fill)"
|
|
||||||
style={{
|
|
||||||
opacity: drawn ? 1 : 0,
|
|
||||||
transition: reduceMotion ? undefined : 'opacity 600ms ease 120ms',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="hero-trend-line"
|
|
||||||
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20"
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--color-brand)"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
style={{
|
|
||||||
strokeDasharray: TREND_PATH_LENGTH,
|
|
||||||
strokeDashoffset: drawn ? 0 : TREND_PATH_LENGTH,
|
|
||||||
transition: reduceMotion
|
|
||||||
? undefined
|
|
||||||
: `stroke-dashoffset 600ms ${EASE_INK}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="360"
|
|
||||||
cy="20"
|
|
||||||
r="4"
|
|
||||||
fill="var(--color-brand)"
|
|
||||||
style={{
|
|
||||||
opacity: drawn ? 1 : 0,
|
|
||||||
transition: reduceMotion ? undefined : 'opacity 400ms ease 520ms',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HeroProductVisual;
|
|
||||||
Reference in New Issue
Block a user