refactor: 迁移 i18n 系统从 react-i18next 到 chrome.i18n
- 移除 react-i18next、i18next 及相关依赖
- 删除旧的 i18n/ 目录和 useLazyTranslation 工具
- 新增 utils/chromeI18n.ts 类型安全 wrapper(useI18n Hook + getMessage)
- 生成 public/_locales/{zh,en}/messages.json(298 个翻译 key)
- 批量更新 39+ 组件文件的导入和翻译调用
- 转换翻译键格式:namespace:key → namespace_key
- 修复 ErrorBoundary/PageErrorBoundary 从 withTranslation HOC 改为直接调用 getMessage
- 更新 vitest.setup.ts mock 加载实际翻译文本
- 修复 11 个测试文件的断言以匹配中文翻译
- TypeScript、ESLint、547 项测试全部通过
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,193 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
// 强行砸碎当前模块的 Mock 封锁链,拉取真实的源码进行满血硬核测试
|
||||
vi.unmock('@/utils/useLazyTranslation');
|
||||
|
||||
// 满血配置多端一致性常驻桩(WXT 规范)
|
||||
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||
|
||||
// Mock 基础 i18n 底座
|
||||
vi.mock('@/i18n', () => ({
|
||||
default: {
|
||||
language: 'en',
|
||||
addResourceBundle: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock 核心 react-i18next 管道
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: vi.fn((ns: string | string[]) => {
|
||||
const nsArray = Array.isArray(ns) ? ns : [ns];
|
||||
return {
|
||||
t: (key: string) => `${nsArray.join(',')}:${key}`,
|
||||
i18n: { language: 'en' },
|
||||
ready: true,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock 动态本地化语言包 JSON 实体隔离区
|
||||
const mockTimestampModule = { default: { 'timestamp.key': 'Timestamp Value' } };
|
||||
const mockJwtModule = { default: { 'jwt.key': 'JWT Value' } };
|
||||
const mockZhTimestampModule = { default: { 'timestamp.key': '时间戳值' } };
|
||||
|
||||
vi.mock('@/i18n/locales/en/timestamp.json', () => mockTimestampModule);
|
||||
vi.mock('@/i18n/locales/en/jwt.json', () => mockJwtModule);
|
||||
vi.mock('@/i18n/locales/zh/timestamp.json', () => mockZhTimestampModule);
|
||||
|
||||
// 💡 1. 核心修复点:将 i18nMock 提升至【全域最高生存空间】!
|
||||
// 确保下方所有的 describe 块和测试用例在词法作用域上均能 100% 自由消费。
|
||||
let i18nMock: { language: string; addResourceBundle: ReturnType<typeof vi.fn> };
|
||||
|
||||
/**
|
||||
* 💡 2. 抽取全局通用重置大闸,确保每个测试套件在冷启动时上下文绝对纯净
|
||||
*/
|
||||
const resetTestContext = async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// 动态捕获最新 i18n 实例状态
|
||||
const i18nModule = await import('@/i18n');
|
||||
i18nMock = i18nModule.default as any;
|
||||
i18nMock.language = 'en'; // 强行重置为默认英文环境
|
||||
|
||||
// 安全清空生产文件里的内部私有缓存,杜绝跨用例状态株连
|
||||
const lazyModule = await import('@/utils/useLazyTranslation');
|
||||
if (
|
||||
'__test_clearCache' in lazyModule &&
|
||||
typeof (lazyModule as any).__test_clearCache === 'function'
|
||||
) {
|
||||
(lazyModule as any).__test_clearCache();
|
||||
}
|
||||
};
|
||||
|
||||
describe('preloadNamespaces', () => {
|
||||
beforeEach(async () => {
|
||||
await resetTestContext();
|
||||
});
|
||||
|
||||
it('应该加载指定的命名空间', async () => {
|
||||
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||
await preloadNamespaces(['timestamp']);
|
||||
|
||||
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||
'en',
|
||||
'timestamp',
|
||||
mockTimestampModule.default,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该并行加载多个命名空间', async () => {
|
||||
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||
await preloadNamespaces(['timestamp', 'jwt']);
|
||||
|
||||
expect(i18nMock.addResourceBundle).toHaveBeenCalledTimes(2);
|
||||
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||
'en',
|
||||
'timestamp',
|
||||
mockTimestampModule.default,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该缓存已加载的命名空间,避免重复加载', async () => {
|
||||
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||
await preloadNamespaces(['timestamp']);
|
||||
await preloadNamespaces(['timestamp']);
|
||||
|
||||
expect(i18nMock.addResourceBundle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('应该使用当前语言(中文)', async () => {
|
||||
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||
i18nMock.language = 'zh-CN';
|
||||
|
||||
await preloadNamespaces(['timestamp']);
|
||||
|
||||
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||
'zh',
|
||||
'timestamp',
|
||||
mockZhTimestampModule.default,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('应该跳过不存在的命名空间', async () => {
|
||||
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||
await preloadNamespaces(['nonExistentNamespace']);
|
||||
|
||||
expect(i18nMock.addResourceBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLazyTranslation', () => {
|
||||
beforeEach(async () => {
|
||||
// 💡 3. 修复点:共享全局重置中枢,让第二个测试块在冷启动时也能合法刷新并拥有 i18nMock 实体
|
||||
await resetTestContext();
|
||||
});
|
||||
|
||||
it('应该在挂载时加载命名空间', async () => {
|
||||
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
|
||||
|
||||
const { result } = renderHook(() => useLazyTranslation('timestamp'));
|
||||
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoaded).toBe(true);
|
||||
});
|
||||
|
||||
// 💡 此时 i18nMock 在全域可读,彻底治愈 ReferenceError 报错!
|
||||
expect(i18nMock.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 { result } = renderHook(() => useLazyTranslation(['timestamp', 'jwt']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoaded).toBe(true);
|
||||
});
|
||||
|
||||
expect(i18nMock.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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* chrome.i18n 类型安全 wrapper
|
||||
* 提供与 react-i18next 兼容的接口
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取翻译文本
|
||||
* @param msgId 翻译 key(如 'timestamp_pageTitle')
|
||||
* @param substitutions 占位符替换值(可选)
|
||||
* @returns 翻译后的文本
|
||||
*/
|
||||
export function getMessage(msgId: string, substitutions?: string[]): string {
|
||||
try {
|
||||
return chrome.i18n.getMessage(msgId, substitutions);
|
||||
} catch (error) {
|
||||
console.warn(`[chrome.i18n] 无法获取翻译: ${msgId}`, error);
|
||||
return msgId; // 回退到 key 本身
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前浏览器语言
|
||||
* @returns 语言代码(如 'zh', 'en')
|
||||
*/
|
||||
export function getLanguage(): string {
|
||||
const lang = chrome.i18n.getUILanguage();
|
||||
return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的语言列表
|
||||
*/
|
||||
export const SUPPORTED_LANGUAGES: readonly ('zh' | 'en')[] = ['zh', 'en'] as const;
|
||||
|
||||
/**
|
||||
* 规范化语言代码
|
||||
*/
|
||||
export function normalizeLanguage(lng: string): 'zh' | 'en' {
|
||||
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* react-i18next 兼容的 Hook
|
||||
* 返回 t 函数和相关信息
|
||||
*/
|
||||
export function useI18n(namespace?: string | string[]) {
|
||||
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
||||
|
||||
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||
let msgId = key;
|
||||
|
||||
// 处理 namespace:key 格式(兼容原 i18next 用法)
|
||||
if (key.includes(':')) {
|
||||
msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||
}
|
||||
|
||||
// 先尝试直接查找 key
|
||||
let message = getMessage(msgId);
|
||||
|
||||
// 如果直接查找未命中,尝试命名空间前缀(使用转换后的 msgId)
|
||||
if (message === msgId && namespaces.length > 0) {
|
||||
for (const ns of namespaces) {
|
||||
const candidate = `${ns}_${msgId}`;
|
||||
const result = getMessage(candidate);
|
||||
if (result !== candidate) {
|
||||
message = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理插值
|
||||
if (options) {
|
||||
for (const [placeholder, value] of Object.entries(options)) {
|
||||
message = message.replace(`{{${placeholder}}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
return {
|
||||
t,
|
||||
i18n: {
|
||||
language: getLanguage(),
|
||||
changeLanguage: (_lng?: string) => {
|
||||
// chrome.i18n 无法动态切换语言,需要刷新页面
|
||||
console.warn('[chrome.i18n] 无法动态切换语言,需要刷新页面');
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
isLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载命名空间(无操作,兼容 useLazyTranslation)
|
||||
*/
|
||||
export async function preloadNamespaces(_namespaces: string[]): Promise<void> {
|
||||
// chrome.i18n 是同步的,无需预加载
|
||||
return Promise.resolve();
|
||||
}
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
* JWT 解析工具
|
||||
*/
|
||||
|
||||
import i18n from '@/i18n';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
export interface JwtHeader {
|
||||
alg: string;
|
||||
@@ -43,7 +43,7 @@ export function decodeBase64Url(str: string): string {
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error(i18n.t('jwt:errors.invalidBase64String'));
|
||||
throw new Error(getMessage('jwt_errors_invalidBase64String'));
|
||||
}
|
||||
base64 += new Array(5 - pad).join('=');
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function decodeBase64Url(str: string): string {
|
||||
return decoder.decode(bytes);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
i18n.t('jwt:errors.failedToDecode') + (e instanceof Error ? e.message : String(e)),
|
||||
getMessage('jwt_errors_failedToDecode') + (e instanceof Error ? e.message : String(e)),
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
payload: null,
|
||||
signature: '',
|
||||
raw: { header: '', payload: '', signature: '' },
|
||||
error: i18n.t('jwt:errors.invalidFormat'),
|
||||
error: getMessage('jwt_errors_invalidFormat'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
result.header = JSON.parse(headerJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
i18n.t('jwt:errors.parseHeaderFailed') + (e instanceof Error ? e.message : String(e));
|
||||
getMessage('jwt_errors_parseHeaderFailed') + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function parseJwt(token: string): JwtResult {
|
||||
result.payload = JSON.parse(payloadJson);
|
||||
} catch (e) {
|
||||
result.error =
|
||||
i18n.t('jwt:errors.parsePayloadFailed') + (e instanceof Error ? e.message : String(e));
|
||||
getMessage('jwt_errors_parsePayloadFailed') + (e instanceof Error ? e.message : String(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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'),
|
||||
rightClickRestorer: () => import('@/i18n/locales/zh/rightClickRestorer.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'),
|
||||
rightClickRestorer: () => import('@/i18n/locales/en/rightClickRestorer.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user