docs: add Hero ink-data-morph implementation plan
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
# Hero 数字水墨动画实现计划
|
||||
|
||||
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||
|
||||
**目标:** 为 Hero 区域实现"数字水墨"Canvas 2D 粒子动画,传达"传统智慧 → 数字化转型"的品牌叙事。
|
||||
|
||||
**架构:** 新建 `InkDataMorph` Canvas 2D 粒子组件,三阶段状态机驱动(扩散 → 沉淀 → 数据化),集成到 `hero-section-v2.tsx` 中作为背景层,文字内容层在 Canvas 之上。
|
||||
|
||||
**技术栈:** React 18 + Canvas 2D API + requestAnimationFrame + Jest
|
||||
|
||||
**规格文档:** `docs/superpowers/specs/2026-05-03-hero-ink-data-morph-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 新建 | `src/components/effects/ink-data-morph.tsx` | 数字水墨粒子引擎组件 |
|
||||
| 新建 | `src/components/effects/ink-data-morph.test.tsx` | 组件单元测试 |
|
||||
| 修改 | `src/components/effects/index.ts` | 导出新组件 |
|
||||
| 修改 | `src/components/sections/hero-section-v2.tsx` | 集成 InkDataMorph + 调整背景色/延迟 |
|
||||
|
||||
---
|
||||
|
||||
### 任务 1:InkDataMorph 组件 — 粒子数据结构与初始化
|
||||
|
||||
**文件:**
|
||||
- 创建:`src/components/effects/ink-data-morph.tsx`
|
||||
|
||||
- [ ] **步骤 1:创建组件骨架与粒子类型定义**
|
||||
|
||||
创建 `src/components/effects/ink-data-morph.tsx`,包含完整的类型定义和组件骨架:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
interface InkDataMorphProps {
|
||||
particleCount?: number;
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
connectionColor?: string;
|
||||
connectionDistance?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
initialRadius: number;
|
||||
dataRadius: number;
|
||||
opacity: number;
|
||||
isAccent: boolean;
|
||||
phase: 'spreading' | 'settling' | 'morphing' | 'complete';
|
||||
spreadTime: number;
|
||||
maxSpreadTime: number;
|
||||
settleTime: number;
|
||||
morphProgress: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
delay: number;
|
||||
age: number;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
particleCount: 150,
|
||||
primaryColor: '#1C1C1C',
|
||||
accentColor: '#C41E3A',
|
||||
connectionColor: '#C41E3A',
|
||||
connectionDistance: 80,
|
||||
} as const;
|
||||
|
||||
function easeOutQuart(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 4);
|
||||
}
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export function InkDataMorph({
|
||||
particleCount = DEFAULTS.particleCount,
|
||||
primaryColor = DEFAULTS.primaryColor,
|
||||
accentColor = DEFAULTS.accentColor,
|
||||
connectionColor = DEFAULTS.connectionColor,
|
||||
connectionDistance = DEFAULTS.connectionDistance,
|
||||
className = '',
|
||||
}: InkDataMorphProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const animationRef = useRef<number | undefined>(undefined);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const createParticle = useCallback((
|
||||
cx: number,
|
||||
cy: number,
|
||||
W: number,
|
||||
H: number,
|
||||
delay: number,
|
||||
): Particle => {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = Math.random() * 10;
|
||||
const speed = 0.3 + Math.random() * 1.5;
|
||||
|
||||
return {
|
||||
x: cx + Math.cos(angle) * dist,
|
||||
y: cy + Math.sin(angle) * dist,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
radius: 1.5 + Math.random() * 4,
|
||||
initialRadius: 1.5 + Math.random() * 4,
|
||||
dataRadius: 1 + Math.random() * 2,
|
||||
opacity: 0.4 + Math.random() * 0.4,
|
||||
isAccent: Math.random() > 0.75,
|
||||
phase: 'spreading',
|
||||
spreadTime: 0,
|
||||
maxSpreadTime: 60 + Math.random() * 80,
|
||||
settleTime: 0,
|
||||
morphProgress: 0,
|
||||
targetX: W * (0.55 + Math.random() * 0.35),
|
||||
targetY: H * (0.15 + Math.random() * 0.6),
|
||||
delay,
|
||||
age: 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initParticles = useCallback((W: number, H: number) => {
|
||||
const center1 = { x: W * 0.7, y: H * 0.35 };
|
||||
const center2 = { x: W * 0.25, y: H * 0.65 };
|
||||
const count1 = Math.floor(particleCount * 0.67);
|
||||
const count2 = particleCount - count1;
|
||||
|
||||
const particles: Particle[] = [];
|
||||
for (let i = 0; i < count1; i++) {
|
||||
particles.push(createParticle(center1.x, center1.y, W, H, 0));
|
||||
}
|
||||
for (let i = 0; i < count2; i++) {
|
||||
particles.push(createParticle(center2.x, center2.y, W, H, 30));
|
||||
}
|
||||
particlesRef.current = particles;
|
||||
}, [particleCount, createParticle]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`absolute inset-0 w-full h-full pointer-events-none ${className}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:验证文件无语法错误**
|
||||
|
||||
运行:`npx tsc --noEmit src/components/effects/ink-data-morph.tsx`
|
||||
预期:无错误输出
|
||||
|
||||
- [ ] **步骤 3:Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/effects/ink-data-morph.tsx
|
||||
git commit -m "feat(effects): add InkDataMorph component skeleton with particle types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 2:InkDataMorph 组件 — 三阶段动画引擎
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/components/effects/ink-data-morph.tsx`
|
||||
|
||||
- [ ] **步骤 1:在组件内添加 updateParticle、drawParticle、drawConnections、animate 函数**
|
||||
|
||||
在 `InkDataMorph` 组件的 `return` 语句之前,添加以下函数:
|
||||
|
||||
```tsx
|
||||
const updateParticle = useCallback((p: Particle) => {
|
||||
p.age++;
|
||||
if (p.age < p.delay) return;
|
||||
|
||||
if (p.phase === 'spreading') {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vx *= 0.98;
|
||||
p.vy *= 0.98;
|
||||
p.spreadTime++;
|
||||
if (p.spreadTime >= p.maxSpreadTime) {
|
||||
p.phase = 'settling';
|
||||
}
|
||||
} else if (p.phase === 'settling') {
|
||||
p.settleTime++;
|
||||
p.opacity = Math.max(0.15, p.opacity - 0.003);
|
||||
if (p.settleTime > 60) {
|
||||
p.phase = 'morphing';
|
||||
}
|
||||
} else if (p.phase === 'morphing') {
|
||||
p.morphProgress = Math.min(1, p.morphProgress + 0.008);
|
||||
const t = easeInOutCubic(p.morphProgress);
|
||||
p.x = lerp(p.x, p.targetX, t * 0.02);
|
||||
p.y = lerp(p.y, p.targetY, t * 0.02);
|
||||
p.radius = lerp(p.radius, p.dataRadius, t * 0.02);
|
||||
p.opacity = lerp(p.opacity, 0.2, t * 0.01);
|
||||
if (p.morphProgress >= 1) {
|
||||
p.phase = 'complete';
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawParticle = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
primaryRgb: string,
|
||||
accentRgb: string,
|
||||
) => {
|
||||
if (p.age < p.delay) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, Math.max(0.5, p.radius), 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.isAccent
|
||||
? `rgba(${accentRgb}, ${p.opacity})`
|
||||
: `rgba(${primaryRgb}, ${p.opacity})`;
|
||||
ctx.fill();
|
||||
|
||||
if (p.radius > 3 && p.phase === 'spreading') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.radius * 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.isAccent
|
||||
? `rgba(${accentRgb}, ${p.opacity * 0.1})`
|
||||
: `rgba(${primaryRgb}, ${p.opacity * 0.08})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const drawConnections = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
particles: Particle[],
|
||||
connectionRgb: string,
|
||||
) => {
|
||||
const morphed = particles.filter(
|
||||
p => p.phase === 'morphing' && p.morphProgress > 0.3
|
||||
);
|
||||
ctx.save();
|
||||
for (let i = 0; i < morphed.length; i++) {
|
||||
for (let j = i + 1; j < morphed.length; j++) {
|
||||
const dx = morphed[i].x - morphed[j].x;
|
||||
const dy = morphed[i].y - morphed[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < connectionDistance) {
|
||||
const alpha =
|
||||
(1 - dist / connectionDistance) *
|
||||
0.08 *
|
||||
Math.min(morphed[i].morphProgress, morphed[j].morphProgress);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(morphed[i].x, morphed[i].y);
|
||||
ctx.lineTo(morphed[j].x, morphed[j].y);
|
||||
ctx.strokeStyle = `rgba(${connectionRgb}, ${alpha})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}, [connectionDistance]);
|
||||
|
||||
const drawBgOrbs = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
W: number,
|
||||
H: number,
|
||||
accentRgb: string,
|
||||
primaryRgb: string,
|
||||
) => {
|
||||
const orbs = [
|
||||
{ x: W * 0.65, y: H * 0.3, r: 300, rgb: accentRgb, alpha: 0.03 },
|
||||
{ x: W * 0.3, y: H * 0.7, r: 250, rgb: primaryRgb, alpha: 0.02 },
|
||||
];
|
||||
for (const o of orbs) {
|
||||
const grad = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r);
|
||||
grad.addColorStop(0, `rgba(${o.rgb}, ${o.alpha})`);
|
||||
grad.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(o.x - o.r, o.y - o.r, o.r * 2, o.r * 2);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:添加 animate 函数和 useEffect**
|
||||
|
||||
在 `drawBgOrbs` 之后、`return` 之前添加:
|
||||
|
||||
```tsx
|
||||
const animate = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width / 2;
|
||||
const H = canvas.height / 2;
|
||||
const primaryRgb = hexToRgb(primaryColor);
|
||||
const accentRgb = hexToRgb(accentColor);
|
||||
const connectionRgb = hexToRgb(connectionColor);
|
||||
|
||||
ctx.setTransform(2, 0, 0, 2, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#FAFAF5';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
drawBgOrbs(ctx, W, H, accentRgb, primaryRgb);
|
||||
|
||||
const particles = particlesRef.current;
|
||||
let allComplete = true;
|
||||
|
||||
for (const p of particles) {
|
||||
updateParticle(p);
|
||||
drawParticle(ctx, p, primaryRgb, accentRgb);
|
||||
if (p.phase !== 'complete') allComplete = false;
|
||||
}
|
||||
|
||||
drawConnections(ctx, particles, connectionRgb);
|
||||
|
||||
if (allComplete) {
|
||||
animationRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}, [primaryColor, accentColor, connectionColor, updateParticle, drawParticle, drawConnections, drawBgOrbs]);
|
||||
|
||||
const drawStaticFinal = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width / 2;
|
||||
const H = canvas.height / 2;
|
||||
const primaryRgb = hexToRgb(primaryColor);
|
||||
const accentRgb = hexToRgb(accentColor);
|
||||
const connectionRgb = hexToRgb(connectionColor);
|
||||
|
||||
ctx.setTransform(2, 0, 0, 2, 0, 0);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#FAFAF5';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
drawBgOrbs(ctx, W, H, accentRgb, primaryRgb);
|
||||
|
||||
const particles = particlesRef.current;
|
||||
for (const p of particles) {
|
||||
p.x = p.targetX;
|
||||
p.y = p.targetY;
|
||||
p.radius = p.dataRadius;
|
||||
p.opacity = 0.2;
|
||||
p.phase = 'complete';
|
||||
drawParticle(ctx, p, primaryRgb, accentRgb);
|
||||
}
|
||||
|
||||
drawConnections(ctx, particles, connectionRgb);
|
||||
}, [primaryColor, accentColor, connectionColor, drawParticle, drawConnections, drawBgOrbs]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const W = parent.clientWidth;
|
||||
const H = parent.clientHeight;
|
||||
canvas.width = W * 2;
|
||||
canvas.height = H * 2;
|
||||
|
||||
initParticles(W, H);
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
drawStaticFinal();
|
||||
return;
|
||||
}
|
||||
|
||||
startTimeRef.current = performance.now();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current !== undefined) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [shouldReduceMotion, initParticles, animate, drawStaticFinal]);
|
||||
```
|
||||
|
||||
- [ ] **步骤 3:验证 TypeScript 编译**
|
||||
|
||||
运行:`npx tsc --noEmit src/components/effects/ink-data-morph.tsx`
|
||||
预期:无错误
|
||||
|
||||
- [ ] **步骤 4:Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/effects/ink-data-morph.tsx
|
||||
git commit -m "feat(effects): add three-phase animation engine to InkDataMorph"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 3:InkDataMorph 单元测试
|
||||
|
||||
**文件:**
|
||||
- 创建:`src/components/effects/ink-data-morph.test.tsx`
|
||||
|
||||
- [ ] **步骤 1:编写测试**
|
||||
|
||||
创建 `src/components/effects/ink-data-morph.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { render } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { InkDataMorph } from './ink-data-morph';
|
||||
|
||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: () => false,
|
||||
}));
|
||||
|
||||
describe('InkDataMorph', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a canvas element', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-hidden="true"', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('should have pointer-events-none class', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toHaveClass('pointer-events-none');
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph className="custom-class" />
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should accept custom props without crashing', () => {
|
||||
const { container } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph
|
||||
particleCount={100}
|
||||
primaryColor="#333333"
|
||||
accentColor="#FF0000"
|
||||
connectionColor="#FF0000"
|
||||
connectionDistance={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const canvas = container.querySelector('canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not start requestAnimationFrame when reduced motion is preferred', () => {
|
||||
jest.resetModules();
|
||||
jest.doMock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: () => true,
|
||||
}));
|
||||
|
||||
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
|
||||
|
||||
const { InkDataMorph: ReducedInkDataMorph } = require('./ink-data-morph');
|
||||
render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<ReducedInkDataMorph />
|
||||
</div>
|
||||
);
|
||||
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
rafSpy.mockRestore();
|
||||
jest.dontMock('@/hooks/use-reduced-motion');
|
||||
});
|
||||
|
||||
it('should cleanup requestAnimationFrame on unmount', () => {
|
||||
const cafSpy = jest.spyOn(window, 'cancelAnimationFrame');
|
||||
|
||||
const { unmount } = render(
|
||||
<div style={{ width: 800, height: 600, position: 'relative' }}>
|
||||
<InkDataMorph />
|
||||
</div>
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(cafSpy).toHaveBeenCalled();
|
||||
cafSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:运行测试**
|
||||
|
||||
运行:`npx jest src/components/effects/ink-data-morph.test.tsx --no-coverage`
|
||||
预期:所有测试通过
|
||||
|
||||
- [ ] **步骤 3:Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/effects/ink-data-morph.test.tsx
|
||||
git commit -m "test(effects): add InkDataMorph unit tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 4:导出 InkDataMorph 组件
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/components/effects/index.ts`
|
||||
|
||||
- [ ] **步骤 1:添加导出**
|
||||
|
||||
在 `src/components/effects/index.ts` 末尾添加一行:
|
||||
|
||||
```ts
|
||||
export { InkDataMorph } from './ink-data-morph';
|
||||
```
|
||||
|
||||
- [ ] **步骤 2:验证导出**
|
||||
|
||||
运行:`npx tsc --noEmit`
|
||||
预期:无错误
|
||||
|
||||
- [ ] **步骤 3:Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/effects/index.ts
|
||||
git commit -m "feat(effects): export InkDataMorph from index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 5:集成 InkDataMorph 到 Hero Section
|
||||
|
||||
**文件:**
|
||||
- 修改:`src/components/sections/hero-section-v2.tsx`
|
||||
|
||||
- [ ] **步骤 1:添加 dynamic import 并修改 Hero 组件**
|
||||
|
||||
对 `hero-section-v2.tsx` 做以下修改:
|
||||
|
||||
1. 在文件顶部 import 区域添加 dynamic import(在 `import { useReducedMotion }` 之后):
|
||||
|
||||
```tsx
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const InkDataMorph = dynamic(
|
||||
() => import('@/components/effects/ink-data-morph').then(mod => ({ default: mod.InkDataMorph })),
|
||||
{ ssr: false }
|
||||
);
|
||||
```
|
||||
|
||||
2. 将 `<section>` 的 `className` 从 `bg-white` 改为 `bg-[#FAFAF5]`:
|
||||
|
||||
```tsx
|
||||
className="relative min-h-screen flex flex-col justify-center overflow-hidden bg-[#FAFAF5]"
|
||||
```
|
||||
|
||||
3. 在 `<section>` 开始标签之后、`<div className="container-wide` 之前,添加 Canvas 层:
|
||||
|
||||
```tsx
|
||||
<InkDataMorph />
|
||||
```
|
||||
|
||||
4. 修改各 motion 元素的 delay,对齐 Canvas 动画时序:
|
||||
|
||||
- 品牌标签 delay:`0` → `1.6`
|
||||
- 标题 delay:`0.1` → `1.9`
|
||||
- 副标题 delay:`0.2` → `2.2`
|
||||
- 描述 delay:`0.3` → `2.5`
|
||||
- 按钮组 delay:`0.4` → `2.8`
|
||||
|
||||
具体修改:
|
||||
- 品牌标签 `transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}` → `transition={{ duration: 0.5, delay: 1.6, ease: [0.16, 1, 0.3, 1] }}`
|
||||
- 标题 `transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}` → `transition={{ duration: 0.5, delay: 1.9, ease: [0.16, 1, 0.3, 1] }}`
|
||||
- 副标题 `transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}` → `transition={{ duration: 0.5, delay: 2.2, ease: [0.16, 1, 0.3, 1] }}`
|
||||
- 描述 `transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}` → `transition={{ duration: 0.5, delay: 2.5, ease: [0.16, 1, 0.3, 1] }}`
|
||||
- 按钮组 `transition={{ duration: 0.5, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}` → `transition={{ duration: 0.5, delay: 2.8, ease: [0.16, 1, 0.3, 1] }}`
|
||||
|
||||
- [ ] **步骤 2:验证 TypeScript 编译**
|
||||
|
||||
运行:`npx tsc --noEmit`
|
||||
预期:无错误
|
||||
|
||||
- [ ] **步骤 3:Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/sections/hero-section-v2.tsx
|
||||
git commit -m "feat(hero): integrate InkDataMorph animation with adjusted timing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 任务 6:视觉验证与最终检查
|
||||
|
||||
**文件:** 无文件修改
|
||||
|
||||
- [ ] **步骤 1:启动开发服务器并验证**
|
||||
|
||||
运行:`npm run dev`
|
||||
|
||||
验证清单:
|
||||
1. 打开首页,观察 Hero 区域三阶段动画
|
||||
2. 确认背景色为宣纸暖白(#FAFAF5)
|
||||
3. 确认文字在 Canvas 粒子之上
|
||||
4. 确认动画完成后无持续 CPU 开销(DevTools Performance 面板)
|
||||
5. 切换到移动端视口(375px),确认动画流畅
|
||||
6. 在 DevTools 中模拟 `prefers-reduced-motion: reduce`,确认直接显示静态数据网络
|
||||
|
||||
- [ ] **步骤 2:运行完整测试套件**
|
||||
|
||||
运行:`npx jest src/components/effects/ink-data-morph.test.tsx --no-coverage`
|
||||
预期:所有测试通过
|
||||
|
||||
- [ ] **步骤 3:运行 lint**
|
||||
|
||||
运行:`npx next lint --file src/components/effects/ink-data-morph.tsx --file src/components/sections/hero-section-v2.tsx`
|
||||
预期:无错误
|
||||
|
||||
- [ ] **步骤 4:最终 Commit(如有 lint 修复)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: lint fixes for ink-data-morph integration"
|
||||
```
|
||||
Reference in New Issue
Block a user