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 144 additions and 48 deletions
Showing only changes of commit 45a92fcc2b - Show all commits
@@ -98,7 +98,7 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
expect(hero.querySelector('img')).toBeNull();
});
it('mounts the brand-red dynamic particle layer (entrance-only, no continuous loop)', () => {
it('mounts the brand-red dynamic particle layer (continuous drift loop)', () => {
render(<HomeContentV15 />);
const hero = screen.getByTestId('hero-section');
@@ -130,7 +130,6 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
render(<HomeContentV15 />);
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
});
});
@@ -7,7 +7,7 @@ jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: jest.fn(() => false),
}));
describe('HeroParticleField - 动态粒子层(入场浮现后静止', () => {
describe('HeroParticleField - 动态粒子层(循环漂移动画', () => {
it('renders an aria-hidden, non-interactive canvas layer', () => {
render(<HeroParticleField />);
const canvas = screen.getByTestId('hero-particle-field');
@@ -31,4 +31,49 @@ describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () =>
expect(() => render(<HeroParticleField />)).not.toThrow();
ctxSpy.mockRestore();
});
it('starts a continuous rAF loop and stops it on unmount when animation is allowed', () => {
// 重置被前置用例 mockReturnValue(true) 污染的共享 mock,确保本用例走动画路径
const { useReducedMotion: urm } = require('@/hooks/use-reduced-motion');
(urm as jest.Mock).mockReturnValue(false);
const ctxStub = {
setTransform: jest.fn(),
clearRect: jest.fn(),
beginPath: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
fillStyle: '',
};
const getCtxSpy = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue(ctxStub as any);
// jsdom 在 window 上提供 requestAnimationFrame;间谍需直接挂在 window 才能被组件调用
const rafSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(() => 1 as unknown as number);
const cafSpy = jest
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation(() => undefined);
// 强制非自动化环境,确保走动画路径(而非 navigator.webdriver 静态降级)
try {
Object.defineProperty(navigator, 'webdriver', {
configurable: true,
get: () => false,
});
} catch {
/* navigator.webdriver 不可重定义时忽略,依赖 rAF 间谍仍应触发循环 */
}
const { unmount } = render(<HeroParticleField />);
// 动画路径:应已调度 rAF(持续循环起点),且 navigator.webdriver 未触发静态降级
expect(rafSpy).toHaveBeenCalled();
unmount();
expect(cafSpy).toHaveBeenCalled();
getCtxSpy.mockRestore();
rafSpy.mockRestore();
cafSpy.mockRestore();
});
});
+97 -39
View File
@@ -1,15 +1,17 @@
'use client';
/**
* Hero 动态粒子层(入场浮现后静止
* Hero 动态粒子层(循环漂移动画
*
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
* - 无持续循环动画rAF 仅在 ~600ms 入场窗内运行,结束即 cancelAnimationFrame
* 并绘制最终静态帧——不突破「无持续循环动画(pulse-soft 除外)」规则。
* - 品牌红单电压:粒子用 --color-brand-rgb#C41E3A),稀疏小点(封顶 64、半径 ≤2.6px),
* - 循环动画(用户特批豁免「无持续循环」铁律)rAF 持续漂移,离屏/隐藏标签页即停。
* - 品牌红单电压:粒子用 --color-brand-rgb#C41E3A),稀疏小点(封顶 56、半径 ≤2.6px),
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏标签页不续帧、ResizeObserver 重排。
* - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
* 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素完全一致,
* 因此视觉回归快照基线不发生漂移。
* - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
*/
@@ -17,13 +19,16 @@ import { useEffect, useRef } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const MAX_DPR = 2;
const MAX_PARTICLES = 64;
const ENTRANCE_MS = 600; // < 700ms 动效上限
const MAX_PARTICLES = 56;
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
const BASE_SPEED = 0.25; // px / 16ms,极缓漂移
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
alpha: number;
}
@@ -64,21 +69,25 @@ export function HeroParticleField({ className }: { className?: string }) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return; // jsdom / SSR / 无 2d 上下文:安全退出,不绘制
const parent = canvas.parentElement;
if (!parent) return;
const brand = readBrandRgb();
let particles: Particle[] = [];
let raf = 0;
let last = 0;
let running = false;
const hasRaf = typeof requestAnimationFrame === 'function';
// Playwright 默认将 navigator.webdriver 置 true → 降级静态帧,保证快照稳定
const isAutomation =
typeof navigator !== 'undefined' &&
(navigator as unknown as { webdriver?: boolean }).webdriver === true;
const paint = (alphaScale: number) => {
const paint = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
ctx.beginPath();
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${
p.alpha * alphaScale
})`;
ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${p.alpha})`;
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
@@ -95,50 +104,99 @@ export function HeroParticleField({ className }: { className?: string }) {
const area = rect.width * rect.height;
const count = Math.min(MAX_PARTICLES, Math.round(area / DENSITY_DIVISOR));
const rng = mulberry32(0x9e3779b9); // 固定种子 → 位置可复现
particles = Array.from({ length: Math.max(0, count) }, () => ({
x: rng() * rect.width,
y: rng() * rect.height,
r: 0.8 + rng() * 1.8,
alpha: 0.35 + rng() * 0.5,
}));
const rng = mulberry32(SEED); // 固定种子 → 位置可复现
particles = Array.from({ length: Math.max(0, count) }, () => {
const ang = rng() * Math.PI * 2;
const sp = BASE_SPEED * (0.5 + rng());
return {
x: rng() * rect.width,
y: rng() * rect.height,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp,
r: 0.8 + rng() * 1.8,
alpha: 0.35 + rng() * 0.5,
};
});
};
// 单帧推进:dt 归一化到 ~60fps,出界回绕(toroidal wrap
const step = (now: number) => {
const dt = last ? Math.min(48, now - last) : 16.67;
last = now;
const k = dt / 16.67;
const w = canvas.clientWidth || canvas.width;
const h = canvas.clientHeight || canvas.height;
for (const p of particles) {
p.x += p.vx * k;
p.y += p.vy * k;
if (p.x < -3) p.x = w + 3;
else if (p.x > w + 3) p.x = -3;
if (p.y < -3) p.y = h + 3;
else if (p.y > h + 3) p.y = -3;
}
paint();
if (running && hasRaf) raf = requestAnimationFrame(step);
};
const start = () => {
if (running || !hasRaf) return;
running = true;
last = 0;
raf = requestAnimationFrame(step);
};
const stop = () => {
running = false;
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
};
build();
if (reduceMotion) {
// 降级:仅一次性静态帧,无 rAF 循环
paint(1);
// 静态降级:reduced-motion / 自动化环境 / 无 rAF(如 jsdom
if (reduceMotion || isAutomation || !hasRaf) {
paint();
} else {
const start =
typeof performance !== 'undefined' ? performance.now() : Date.now();
const animate = (now: number) => {
const t = Math.min(1, (now - start) / ENTRANCE_MS);
// ease-ink [0.22,1,0.36,1] 的入场近似(easeOutCubic
const e = 1 - Math.pow(1 - t, 3);
paint(e);
if (t < 1 && !document.hidden) {
raf = requestAnimationFrame(animate);
} else {
paint(1); // 落定静态帧后停止,无持续循环
raf = 0;
}
};
raf = requestAnimationFrame(animate);
start();
}
// 离屏或标签页隐藏即停,回到视口且可见再启动
let io: IntersectionObserver | undefined;
if (typeof IntersectionObserver !== 'undefined') {
io = new IntersectionObserver(
(entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (visible && document.visibilityState === 'visible' && !reduceMotion && !isAutomation) {
start();
} else {
stop();
}
},
{ threshold: 0.01 },
);
io.observe(canvas);
}
const onVisibility = () => {
if (document.visibilityState !== 'visible') stop();
else if (!reduceMotion && !isAutomation) start();
};
document.addEventListener('visibilitychange', onVisibility);
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
build();
if (reduceMotion) paint(1);
if (reduceMotion || isAutomation || !hasRaf) paint();
});
ro.observe(parent);
}
return () => {
if (raf) cancelAnimationFrame(raf);
stop();
io?.disconnect();
ro?.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
};
}, [reduceMotion]);
@@ -64,12 +64,6 @@ export function HeroProductVisual({ className }: { className?: string }) {
aria-label="数据驱动可视化示意(示例数据)"
className={cn('relative', className)}
>
{/* 眉标:能力定位,非产品名 */}
<div className="mb-4 inline-flex items-center gap-2 text-xs font-bold tracking-[0.2em] uppercase text-brand">
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-hidden="true" />
·
</div>
{/* KPI 卡片:抽象数据可视化 */}
<div className="grid grid-cols-2 gap-3">
{KPIS.map((kpi) => (