feat(ui/ux): 优化用户体验和可访问性

- 字体加载优化: 添加 font-display: block 策略,创建 useFontLoading hook
- 色彩对比度: 调整 text-muted 和 text-tertiary 颜色值确保 WCAG AA 合规
- 滚动进度条: 新增 ScrollProgress 组件,支持 reduced motion
- 表单自动保存: 新增 useFormAutosave hook,防止用户数据丢失
- 返回顶部按钮: 新增 BackToTop 组件,提升长页面导航体验
- 图片懒加载: 优化 OptimizedImage 组件,添加 blur placeholder 和加载动画

所有新组件均包含完整测试,1450+ 测试通过
This commit is contained in:
张翔
2026-03-28 11:21:04 +08:00
parent ebaa7f3c50
commit a003f1192e
15 changed files with 1280 additions and 234 deletions
+68
View File
@@ -0,0 +1,68 @@
import { renderHook, waitFor } from '@testing-library/react';
import { useFontLoading } from './use-font-loading';
describe('useFontLoading', () => {
const originalFonts = document.fonts;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
Object.defineProperty(document, 'fonts', {
value: originalFonts,
writable: true,
});
});
it('should return true when fonts API is not supported', async () => {
// 完全删除 fonts 属性来模拟不支持的情况
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalFonts = (document as unknown as Record<string, unknown>).fonts;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as unknown as Record<string, unknown>).fonts;
const { result } = renderHook(() => useFontLoading());
await waitFor(() => {
expect(result.current).toBe(true);
});
// 恢复 fonts 属性
Object.defineProperty(document, 'fonts', {
value: originalFonts,
writable: true,
configurable: true,
});
});
it('should return true when font is loaded', async () => {
const mockLoad = jest.fn().mockResolvedValue([]);
Object.defineProperty(document, 'fonts', {
value: { load: mockLoad },
writable: true,
});
const { result } = renderHook(() => useFontLoading('Aoyagi Reisho'));
await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockLoad).toHaveBeenCalledWith('1em "Aoyagi Reisho"');
});
it('should return true when font loading fails', async () => {
const mockLoad = jest.fn().mockRejectedValue(new Error('Font load failed'));
Object.defineProperty(document, 'fonts', {
value: { load: mockLoad },
writable: true,
});
const { result } = renderHook(() => useFontLoading());
await waitFor(() => {
expect(result.current).toBe(true);
});
});
});
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useState, useEffect } from 'react';
export function useFontLoading(fontFamily: string = 'Aoyagi Reisho') {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
// 检查字体是否已加载
if ('fonts' in document) {
document.fonts
.load(`1em "${fontFamily}"`)
.then(() => {
if (!cancelled) {
setIsLoaded(true);
}
})
.catch(() => {
// 如果字体加载失败,仍然标记为已加载,使用回退字体
if (!cancelled) {
setIsLoaded(true);
}
});
} else {
// 不支持 Font Loading API 的浏览器,使用 setTimeout 避免同步 setState
const timer = setTimeout(() => {
if (!cancelled) {
setIsLoaded(true);
}
}, 0);
return () => clearTimeout(timer);
}
return () => {
cancelled = true;
};
}, [fontFamily]);
return isLoaded;
}
export function useFontPreloader(fontUrls: string[]) {
useEffect(() => {
fontUrls.forEach((url) => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.href = url;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
}, [fontUrls]);
}
+150
View File
@@ -0,0 +1,150 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFormAutosave } from './use-form-autosave';
describe('useFormAutosave', () => {
const mockKey = 'test_form';
const mockInitialData = { name: '', email: '' };
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should initialize with initial data', () => {
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.lastSaved).toBeNull();
expect(result.current.isRestored).toBe(false);
});
it('should restore data from localStorage', () => {
const savedData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date().toISOString(),
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
expect(result.current.data).toEqual(savedData.data);
expect(result.current.isRestored).toBe(true);
});
it('should not restore expired data', () => {
const expiredData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), // 25 hours ago
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(expiredData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
})
);
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.isRestored).toBe(false);
});
it('should update data and auto-save after debounce', async () => {
const onSave = jest.fn();
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
onSave,
debounceMs: 1000,
})
);
act(() => {
result.current.updateData({ name: 'John' });
});
expect(result.current.data.name).toBe('John');
expect(result.current.lastSaved).toBeNull();
act(() => {
jest.advanceTimersByTime(1000);
});
await waitFor(() => {
expect(result.current.lastSaved).not.toBeNull();
});
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
});
it('should clear saved data', () => {
const savedData = {
data: { name: 'John', email: 'john@example.com' },
timestamp: new Date().toISOString(),
};
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
})
);
act(() => {
result.current.clearSavedData();
});
expect(result.current.data).toEqual(mockInitialData);
expect(result.current.lastSaved).toBeNull();
expect(result.current.isRestored).toBe(false);
expect(localStorage.getItem(`form_autosave_${mockKey}`)).toBeNull();
});
it('should save immediately when called', async () => {
const onSave = jest.fn();
const { result } = renderHook(() =>
useFormAutosave({
key: mockKey,
initialData: mockInitialData,
onSave,
debounceMs: 5000,
})
);
act(() => {
result.current.updateData({ name: 'John' });
});
// 等待状态更新
await waitFor(() => {
expect(result.current.data.name).toBe('John');
});
act(() => {
result.current.saveImmediately();
});
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
});
expect(result.current.lastSaved).not.toBeNull();
});
});
+146
View File
@@ -0,0 +1,146 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseFormAutosaveOptions<T> {
key: string;
initialData: T;
onSave?: (data: T) => void;
debounceMs?: number;
maxAge?: number; // 数据最大保存时间(毫秒),默认 24 小时
}
interface AutosaveState<T> {
data: T;
lastSaved: Date | null;
isRestored: boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useFormAutosave<T extends Record<string, any>>({
key,
initialData,
onSave,
debounceMs = 1000,
maxAge = 24 * 60 * 60 * 1000, // 24 小时
}: UseFormAutosaveOptions<T>) {
const [state, setState] = useState<AutosaveState<T>>({
data: initialData,
lastSaved: null,
isRestored: false,
});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const storageKey = `form_autosave_${key}`;
// 从 localStorage 恢复数据
useEffect(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
const savedTime = new Date(parsed.timestamp).getTime();
const now = Date.now();
// 检查数据是否过期
if (now - savedTime < maxAge) {
setState({
data: { ...initialData, ...parsed.data },
lastSaved: new Date(parsed.timestamp),
isRestored: true,
});
} else {
// 数据过期,清除
localStorage.removeItem(storageKey);
}
}
} catch (error) {
console.error('Failed to restore form data:', error);
}
}, [storageKey, initialData, maxAge]);
// 自动保存到 localStorage
const saveToStorage = useCallback((data: T) => {
try {
const payload = {
data,
timestamp: new Date().toISOString(),
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setState(prev => ({
...prev,
lastSaved: new Date(),
}));
onSave?.(data);
} catch (error) {
console.error('Failed to save form data:', error);
}
}, [storageKey, onSave]);
// 防抖保存
const debouncedSave = useCallback((data: T) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
saveToStorage(data);
}, debounceMs);
}, [debounceMs, saveToStorage]);
// 更新数据
const updateData = useCallback((updates: Partial<T> | ((prev: T) => Partial<T>)) => {
setState(prev => {
const newData = typeof updates === 'function'
? { ...prev.data, ...updates(prev.data) }
: { ...prev.data, ...updates };
debouncedSave(newData);
return {
...prev,
data: newData,
};
});
}, [debouncedSave]);
// 清除保存的数据
const clearSavedData = useCallback(() => {
try {
localStorage.removeItem(storageKey);
setState({
data: initialData,
lastSaved: null,
isRestored: false,
});
} catch (error) {
console.error('Failed to clear form data:', error);
}
}, [storageKey, initialData]);
// 立即保存(用于表单提交前)
const saveImmediately = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
saveToStorage(state.data);
}, [state.data, saveToStorage]);
// 清理
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return {
data: state.data,
lastSaved: state.lastSaved,
isRestored: state.isRestored,
updateData,
clearSavedData,
saveImmediately,
};
}