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:
雨霖铃
2026-05-28 19:21:54 +08:00
parent 64e23994a6
commit aa23a263fe
94 changed files with 2987 additions and 1844 deletions
-193
View File
@@ -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');
});
});
+102
View File
@@ -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
View File
@@ -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;
}
-109
View File
@@ -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,
};
}