Refactor/optimize ui #24

Merged
zhangxiang merged 33 commits from refactor/optimize-ui into dev 2026-09-01 11:47:02 +08:00
4 changed files with 3 additions and 189 deletions
Showing only changes of commit 95dc07c3f7 - Show all commits
@@ -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 - 信任层与章节式叙事', () => {
+3 -14
View File
@@ -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;