统一简化多个页面里的CopyButton调用,删除不再需要的空回调参数

* feat: 添加clearCookies函数的单元测试并修复cookie域名处理逻辑

* feat: 增强 data URI 处理,支持带参数的前缀并更新相关测试

* fix: 修正 AGENTS.md 中 TypeScript 类型检查命令的描述

* feat: 添加 settings.local.json 文件以配置 Bash 权限

* perf: 预设背景色避免 Popup 弹窗白屏闪烁

* perf: 避免图标过早实例化,传递组件引用而非 JSX 节点

* feat: 添加 useLazyTranslation 和 preloadNamespaces 函数以支持动态加载 i18n 命名空间

* feat: 使用 useLazyTranslation 替换 useTranslation 以支持懒加载翻译

* feat: 添加 PageSkeleton 组件及其测试用例以支持页面加载骨架屏

* feat: 使用骨架屏替换加载状态指示器,优化用户体验

* feat: 优化 CopyButton 组件的复制功能,添加定时器管理复制状态

* feat: 调整 chunk 大小警告阈值以优化构建性能
This commit is contained in:
LingandRX
2026-05-20 20:07:44 +08:00
committed by GitHub
parent 7167a43763
commit 373fc0496c
24 changed files with 535 additions and 153 deletions
+175
View File
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { preloadNamespaces } from '@/utils/useLazyTranslation';
// Mock i18n
vi.mock('@/i18n', () => ({
default: {
language: 'en',
addResourceBundle: vi.fn(),
},
}));
// Mock useTranslation
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string[]) => ({
t: (key: string) => `${ns.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
})),
}));
// Mock 动态导入
const mockTimestampModule = { default: { 'timestamp.key': 'Timestamp Value' } };
const mockJwtModule = { default: { 'jwt.key': 'JWT Value' } };
vi.mock('@/i18n/locales/en/timestamp.json', () => mockTimestampModule);
vi.mock('@/i18n/locales/en/jwt.json', () => mockJwtModule);
vi.mock('@/i18n/locales/zh/timestamp.json', () => ({ default: { 'timestamp.key': '时间戳值' } }));
describe('preloadNamespaces', () => {
let i18n: { addResourceBundle: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
i18n = (await import('@/i18n')).default as any;
// 清除缓存
const { __test_clearCache } = await import('@/utils/useLazyTranslation');
__test_clearCache?.();
});
it('应该加载指定的命名空间', async () => {
await preloadNamespaces(['timestamp']);
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
'en',
'timestamp',
mockTimestampModule.default,
true,
true,
);
});
it('应该并行加载多个命名空间', async () => {
await preloadNamespaces(['timestamp', 'jwt']);
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(2);
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
'en',
'timestamp',
mockTimestampModule.default,
true,
true,
);
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
'en',
'jwt',
mockJwtModule.default,
true,
true,
);
});
it('应该缓存已加载的命名空间,避免重复加载', async () => {
await preloadNamespaces(['timestamp']);
await preloadNamespaces(['timestamp']);
// 只应调用一次
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(1);
});
it('应该使用当前语言(中文)', async () => {
const i18nModule = await import('@/i18n');
(i18nModule.default as any).language = 'zh-CN';
await preloadNamespaces(['timestamp']);
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
'zh',
'timestamp',
{ 'timestamp.key': '时间戳值' },
true,
true,
);
// 恢复
(i18nModule.default as any).language = 'en';
});
it('应该跳过不存在的命名空间', async () => {
await preloadNamespaces(['nonExistentNamespace']);
// 不应调用 addResourceBundle
expect(i18n.addResourceBundle).not.toHaveBeenCalled();
});
});
describe('useLazyTranslation', () => {
beforeEach(async () => {
vi.clearAllMocks();
// 清除缓存
const { __test_clearCache } = await import('@/utils/useLazyTranslation');
__test_clearCache?.();
});
it('应该在挂载时加载命名空间', async () => {
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
const i18n = (await import('@/i18n')).default as any;
const { result } = renderHook(() => useLazyTranslation('timestamp'));
// 初始状态应该是未加载
expect(result.current.isLoaded).toBe(false);
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
'en',
'timestamp',
mockTimestampModule.default,
true,
true,
);
});
it('应该返回 useTranslation 的结果', async () => {
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
const { result } = renderHook(() => useLazyTranslation('timestamp'));
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});
expect(result.current.t('key')).toBe('timestamp:key');
expect(result.current.ready).toBe(true);
});
it('应该支持多个命名空间', async () => {
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
const i18n = (await import('@/i18n')).default as any;
const { result } = renderHook(() => useLazyTranslation(['timestamp', 'jwt']));
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(2);
expect(result.current.t('key')).toBe('timestamp,jwt:key');
});
it('应该支持字符串形式的单个命名空间', async () => {
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
const { result } = renderHook(() => useLazyTranslation('timestamp'));
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});
expect(result.current.t('key')).toBe('timestamp:key');
});
});
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
// 语言包动态导入映射
const localeModules: Record<
string,
Record<string, () => Promise<{ default: Record<string, unknown> }>>
> = {
zh: {
timestamp: () => import('@/i18n/locales/zh/timestamp.json'),
storageCleaner: () => import('@/i18n/locales/zh/storageCleaner.json'),
qrCode: () => import('@/i18n/locales/zh/qrCode.json'),
textStatistics: () => import('@/i18n/locales/zh/textStatistics.json'),
jwt: () => import('@/i18n/locales/zh/jwt.json'),
jsonDiff: () => import('@/i18n/locales/zh/jsonDiff.json'),
jsonFormat: () => import('@/i18n/locales/zh/jsonFormat.json'),
base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'),
markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'),
htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'),
},
en: {
timestamp: () => import('@/i18n/locales/en/timestamp.json'),
storageCleaner: () => import('@/i18n/locales/en/storageCleaner.json'),
qrCode: () => import('@/i18n/locales/en/qrCode.json'),
textStatistics: () => import('@/i18n/locales/en/textStatistics.json'),
jwt: () => import('@/i18n/locales/en/jwt.json'),
jsonDiff: () => import('@/i18n/locales/en/jsonDiff.json'),
jsonFormat: () => import('@/i18n/locales/en/jsonFormat.json'),
base64Converter: () => import('@/i18n/locales/en/base64Converter.json'),
markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'),
htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'),
},
};
// 已加载的命名空间缓存
const loadedNamespaces = new Set<string>();
/**
* 清除已加载命名空间的缓存(仅用于测试)
* @internal
*/
export function __test_clearCache(): void {
loadedNamespaces.clear();
}
/**
* 动态加载 i18n 命名空间
*/
async function loadNamespace(ns: string, lng: string): Promise<void> {
const cacheKey = `${lng}:${ns}`;
if (loadedNamespaces.has(cacheKey)) {
return;
}
const langModules = localeModules[lng];
if (!langModules?.[ns]) {
return;
}
try {
const module = await langModules[ns]();
i18n.addResourceBundle(lng, ns, module.default, true, true);
loadedNamespaces.add(cacheKey);
} catch (error) {
console.error(`Failed to load namespace "${ns}" for language "${lng}":`, error);
}
}
/**
* 预加载指定命名空间(可在路由切换时调用)
*/
export async function preloadNamespaces(namespaces: string[]): Promise<void> {
const lng = i18n.language || 'en';
const normalizedLng = lng.startsWith('zh') ? 'zh' : 'en';
await Promise.all(namespaces.map((ns) => loadNamespace(ns, normalizedLng)));
}
/**
* 懒加载翻译 Hook
*
* 与 useTranslation 类似,但会在组件挂载时动态加载指定的命名空间
*
* @param ns - 命名空间或命名空间数组
* @returns useTranslation 的返回值
*/
export function useLazyTranslation(ns: string | string[]) {
const namespaces = useMemo(() => (Array.isArray(ns) ? ns : [ns]), [ns]);
const [isLoaded, setIsLoaded] = useState(false);
const translation = useTranslation(namespaces);
useEffect(() => {
const loadAll = async () => {
await preloadNamespaces(namespaces);
setIsLoaded(true);
};
loadAll();
}, [namespaces]);
return {
...translation,
isLoaded,
};
}