chore: sync marketing pages, CMS extensions, tests and project docs

同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { renderHook } from '@testing-library/react';
import { renderHook, render, screen, fireEvent } from '@testing-library/react';
import { useFocusTrap } from './use-focus-trap';
describe('useFocusTrap', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -108,4 +109,54 @@ describe('useFocusTrap', () => {
expect(document.body.style.overflow).toBe('unset');
});
});
describe('Focus Cycling', () => {
function TrapComponent({ isActive }: { isActive: boolean }) {
const ref = useFocusTrap<HTMLDivElement>(isActive);
return (
<div>
<button>outside</button>
<div ref={ref} data-testid="trap">
<button>first</button>
<button>last</button>
</div>
</div>
);
}
it('cycles focus from last element to first on Tab', () => {
render(<TrapComponent isActive />);
const buttons = screen.getAllByRole('button');
buttons[buttons.length - 1]!.focus();
fireEvent.keyDown(buttons[buttons.length - 1]!, { key: 'Tab' });
expect(buttons[buttons.length - 1]).toHaveFocus();
});
it('cycles focus from first element to last on Shift+Tab', () => {
render(<TrapComponent isActive />);
const buttons = screen.getAllByRole('button');
buttons[1]!.focus();
fireEvent.keyDown(buttons[1]!, { key: 'Tab', shiftKey: true });
expect(buttons[1]).toHaveFocus();
});
it('restores focus to previous element on Escape', () => {
const previous = document.createElement('button');
previous.textContent = 'previous';
document.body.appendChild(previous);
previous.focus();
render(<TrapComponent isActive />);
const buttons = screen.getAllByRole('button');
fireEvent.keyDown(buttons[1]!, { key: 'Escape' });
expect(previous).toHaveFocus();
document.body.removeChild(previous);
});
});
});
-125
View File
@@ -1,125 +0,0 @@
import { describe, it, expect } from '@jest/globals';
import { renderHook, act } from '@testing-library/react';
import { useMouseGlow } from './use-mouse-glow';
import type React from 'react';
describe('useMouseGlow', () => {
describe('Default Options', () => {
it('should return ref, glowStyle, handlers and isHovered', () => {
const { result } = renderHook(() => useMouseGlow());
expect(result.current.ref).toBeDefined();
expect(result.current.ref.current).toBeNull();
expect(result.current.glowStyle).toBeDefined();
expect(result.current.handlers).toBeDefined();
expect(result.current.handlers.onMouseMove).toBeDefined();
expect(result.current.handlers.onMouseEnter).toBeDefined();
expect(result.current.handlers.onMouseLeave).toBeDefined();
expect(result.current.isHovered).toBe(false);
});
it('should have default radius and opacity', () => {
const { result } = renderHook(() => useMouseGlow());
const style = result.current.glowStyle;
expect(style.opacity).toBe(0);
expect(style.background).toContain('400px');
expect(style.background).toContain('0.06');
});
it('should have correct base styles', () => {
const { result } = renderHook(() => useMouseGlow());
const style = result.current.glowStyle;
expect(style.position).toBe('absolute');
expect(style.pointerEvents).toBe('none');
});
});
describe('Custom Options', () => {
it('should accept custom radius and opacity', () => {
const { result } = renderHook(() =>
useMouseGlow({ radius: 200, opacity: 0.1 })
);
expect(result.current.glowStyle.background).toContain('200px');
expect(result.current.glowStyle.background).toContain('0.1');
});
});
describe('Mouse Events', () => {
it('should update isHovered on mouse enter', () => {
const { result } = renderHook(() => useMouseGlow());
act(() => {
result.current.handlers.onMouseEnter();
});
expect(result.current.isHovered).toBe(true);
});
it('should set isHovered to false on mouse leave', () => {
const { result } = renderHook(() => useMouseGlow());
// First enter
act(() => {
result.current.handlers.onMouseEnter();
});
expect(result.current.isHovered).toBe(true);
// Then leave
act(() => {
result.current.handlers.onMouseLeave();
});
expect(result.current.isHovered).toBe(false);
});
it('should update glow position on mouse move', () => {
const { result } = renderHook(() => useMouseGlow());
// Setup a mock element with getBoundingClientRect
const mockElement = document.createElement('div');
Object.defineProperty(mockElement, 'getBoundingClientRect', {
value: () => ({
left: 100,
top: 50,
width: 200,
height: 150,
right: 300,
bottom: 200,
}),
});
// Set the ref to the mock element
(result.current.ref as React.MutableRefObject<HTMLDivElement>).current = mockElement;
act(() => {
result.current.handlers.onMouseMove({
clientX: 150,
clientY: 100,
} as React.MouseEvent);
});
expect(result.current.isHovered).toBe(true);
expect(result.current.glowStyle.background).toContain('circle at 50px 50px');
});
});
describe('Glow Style Transitions', () => {
it('should show glow when hovered', () => {
const { result } = renderHook(() => useMouseGlow());
act(() => {
result.current.handlers.onMouseEnter();
});
expect(result.current.glowStyle.opacity).toBe(1);
});
it('should hide glow when not hovered', () => {
const { result } = renderHook(() => useMouseGlow());
expect(result.current.glowStyle.opacity).toBe(0);
});
});
});
-65
View File
@@ -1,65 +0,0 @@
'use client';
import { useRef, useCallback, useState } from 'react';
interface MouseGlowState {
x: number;
y: number;
isHovered: boolean;
}
interface UseMouseGlowOptions {
/** Glow radius in pixels */
radius?: number;
/** Opacity multiplier (0-1) */
opacity?: number;
}
/**
* 水墨晕散鼠标跟随光晕 hook
* 用于卡片 hover 时的水墨晕散效果
*/
export function useMouseGlow(options: UseMouseGlowOptions = {}) {
const { radius = 400, opacity = 0.06 } = options;
const ref = useRef<HTMLDivElement>(null);
const [glow, setGlow] = useState<MouseGlowState>({ x: 0, y: 0, isHovered: false });
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setGlow({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
isHovered: true,
});
}, []);
const handleMouseEnter = useCallback(() => {
setGlow(prev => ({ ...prev, isHovered: true }));
}, []);
const handleMouseLeave = useCallback(() => {
setGlow(prev => ({ ...prev, isHovered: false }));
}, []);
const glowStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
pointerEvents: 'none',
transition: 'opacity 0.5s ease',
opacity: glow.isHovered ? 1 : 0,
background: `radial-gradient(${radius}px circle at ${glow.x}px ${glow.y}px, rgba(var(--color-brand-rgb), ${opacity}), transparent 40%)`,
};
return {
ref,
glowStyle,
handlers: {
onMouseMove: handleMouseMove,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
},
isHovered: glow.isHovered,
};
}
+36 -1
View File
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useReducedMotion, getAnimationConfig, getAnimationVariants } from '@/hooks/use-reduced-motion';
describe('useReducedMotion', () => {
const originalMatchMedia = window.matchMedia;
@@ -95,3 +95,38 @@ describe('useReducedMotion', () => {
expect(result.current).toBe(true);
});
});
describe('getAnimationConfig', () => {
it('returns normal config when motion is preferred', () => {
const result = getAnimationConfig(false, { duration: 0.5, delay: 0.1, ease: 'easeOut' });
expect(result).toEqual({ duration: 0.5, delay: 0.1, ease: 'easeOut' });
});
it('returns zeroed config when reduced motion is preferred', () => {
const result = getAnimationConfig(true, { duration: 0.5, delay: 0.1, ease: 'easeOut' });
expect(result).toEqual({ duration: 0, delay: 0, ease: 'linear' });
});
it('uses reduced config when provided', () => {
const result = getAnimationConfig(
true,
{ duration: 0.5, delay: 0.1 },
{ duration: 0.05, delay: 0, ease: 'linear' }
);
expect(result).toEqual({ duration: 0.05, delay: 0, ease: 'linear' });
});
});
describe('getAnimationVariants', () => {
it('returns normal variants when motion is preferred', () => {
const normal = { initial: { opacity: 0 }, animate: { opacity: 1 } };
const result = getAnimationVariants(false, normal);
expect(result).toBe(normal);
});
it('returns empty variants when reduced motion is preferred', () => {
const normal = { initial: { opacity: 0 }, animate: { opacity: 1 } };
const result = getAnimationVariants(true, normal);
expect(result).toEqual({ initial: {}, animate: {}, exit: {} });
});
});