From 373fc0496c3a5d04427bdc8c5e561023af6c3c49 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Wed, 20 May 2026 20:07:44 +0800 Subject: [PATCH] Develop (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一简化多个页面里的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 大小警告阈值以优化构建性能 --- components/CopyButton.tsx | 12 +- components/PageSkeleton.tsx | 103 +++++++++++ components/RouterContainer.tsx | 30 +-- components/TopBar.tsx | 4 +- components/__tests__/PageSkeleton.test.tsx | 67 +++++++ components/__tests__/RouterContainer.test.tsx | 10 +- components/__tests__/ToolCard.test.tsx | 43 ++--- config/features.tsx | 25 +-- entrypoints/options/App.tsx | 2 +- entrypoints/popup/index.html | 1 + i18n/index.ts | 57 +----- pages/Base64Converter/index.tsx | 4 +- pages/Dashboard/ToolCard.tsx | 11 +- pages/HtmlToMarkdown/index.tsx | 4 +- pages/JsonTools/index.tsx | 4 +- pages/Jwt/index.tsx | 6 +- pages/MarkdownToHtml/index.tsx | 4 +- pages/QrCode/index.tsx | 4 +- pages/StorageCleaner/index.tsx | 4 +- pages/TextStatistics/index.tsx | 4 +- pages/Timestamp/index.tsx | 4 +- utils/__tests__/useLazyTranslation.test.ts | 175 ++++++++++++++++++ utils/useLazyTranslation.ts | 107 +++++++++++ wxt.config.ts | 3 + 24 files changed, 535 insertions(+), 153 deletions(-) create mode 100644 components/PageSkeleton.tsx create mode 100644 components/__tests__/PageSkeleton.test.tsx create mode 100644 utils/__tests__/useLazyTranslation.test.ts create mode 100644 utils/useLazyTranslation.ts diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx index e615edc..2e77700 100644 --- a/components/CopyButton.tsx +++ b/components/CopyButton.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { IconButton, Tooltip } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import CheckIcon from '@mui/icons-material/Check'; @@ -42,6 +42,13 @@ export const CopyButton: React.FC = ({ showMessage, }) => { const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); const handleCopy = async () => { if (text) { @@ -49,7 +56,8 @@ export const CopyButton: React.FC = ({ if (success) { showMessage?.('复制成功', { severity: 'success' }); setCopied(true); - setTimeout(() => setCopied(false), 1500); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); } else { showMessage?.('复制失败', { severity: 'error' }); } diff --git a/components/PageSkeleton.tsx b/components/PageSkeleton.tsx new file mode 100644 index 0000000..6f6b44a --- /dev/null +++ b/components/PageSkeleton.tsx @@ -0,0 +1,103 @@ +/** + * PageSkeleton 组件 - 页面加载骨架屏 + * + * 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡 + * 避免白屏闪烁,减少布局偏移 + */ +import { Box, Skeleton, Stack, useTheme } from '@mui/material'; +import { alpha } from '@mui/material'; + +interface PageSkeletonProps { + /** 骨架屏类型 */ + variant?: 'dashboard' | 'tool'; +} + +/** + * 仪表盘卡片骨架屏 + */ +function DashboardCardSkeleton() { + const theme = useTheme(); + const borderColor = alpha(theme.palette.divider, 0.5); + + return ( + + + + + + + + + + + + + ); +} + +/** + * 工具页面骨架屏 + */ +function ToolPageSkeleton() { + return ( + + {/* 标题区域 */} + + + {/* 输入区域 */} + + + {/* 控制栏 */} + + + + + + + + {/* 结果区域 */} + + + ); +} + +/** + * 页面加载骨架屏 + * + * @param props - PageSkeletonProps + * @returns 骨架屏 JSX 元素 + */ +export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) { + if (variant === 'tool') { + return ; + } + + return ( + + {Array.from({ length: 6 }).map((_, index) => ( + + ))} + + ); +} + +PageSkeleton.displayName = 'PageSkeleton'; diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index 45f5de3..d7a71be 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,8 +1,9 @@ -import { Box, CircularProgress } from '@mui/material'; +import { Box } from '@mui/material'; import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; import { Suspense, useMemo } from 'react'; import PageErrorBoundary from '@/components/PageErrorBoundary'; +import PageSkeleton from '@/components/PageSkeleton'; export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); @@ -16,18 +17,7 @@ export default function RouterContainer() { }, []); if (!isLoaded) { - return ( - - - - ); + return ; } const currentFeature = FEATURES.find((f) => f.key === currentPage); @@ -47,19 +37,7 @@ export default function RouterContainer() { }} > - - - } + fallback={} > {Component && } diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 778ba86..42b438f 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -296,7 +296,9 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) aria-selected={selectedIndex === index} sx={{ py: 1 }} > - {feature.icon} + + {feature.icon && } + { + describe('渲染测试', () => { + it('默认应渲染 dashboard 骨架屏', () => { + const { container } = render(); + + // dashboard 骨架屏包含 6 个卡片 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => { + const { container } = render(); + + // 每个卡片有 4 个 Skeleton(图标、标题、描述、箭头),6 个卡片共 24 个 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(24); + }); + + it('variant 为 tool 时应渲染工具页面骨架', () => { + const { container } = render(); + + // tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBe(6); + }); + }); + + describe('布局结构测试', () => { + it('dashboard 骨架屏应使用 grid 布局', () => { + const { container } = render(); + const gridContainer = container.firstChild as HTMLElement; + + expect(gridContainer).toHaveStyle({ display: 'grid' }); + }); + + it('tool 骨架屏应有内边距', () => { + const { container } = render(); + const toolContainer = container.firstChild as HTMLElement; + + expect(toolContainer).toHaveStyle({ padding: '20px' }); // 2.5 * 8px + }); + }); + + describe('骨架屏元素测试', () => { + it('dashboard 骨架屏应包含圆角和边框样式', () => { + const { container } = render(); + + // 获取第一个卡片容器 + const card = container.querySelector('[class*="MuiBox-root"]'); + expect(card).toBeInTheDocument(); + }); + + it('tool 骨架屏应包含圆形和矩形变体', () => { + const { container } = render(); + + const roundedSkeletons = container.querySelectorAll('.MuiSkeleton-rounded'); + const textSkeletons = container.querySelectorAll('.MuiSkeleton-text'); + + expect(roundedSkeletons.length).toBeGreaterThan(0); + expect(textSkeletons.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx index e5db9da..0de1a86 100644 --- a/components/__tests__/RouterContainer.test.tsx +++ b/components/__tests__/RouterContainer.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import RouterContainer from '@/components/RouterContainer'; import { RouterProvider } from '@/providers/RouterProvider'; import { SnackbarProvider } from '@/components/GlobalSnackbar'; @@ -37,10 +37,12 @@ describe('RouterContainer 组件', () => { }; describe('渲染测试', () => { - it('isLoaded 为 false 时应渲染加载状态', () => { + it('isLoaded 为 false 时应渲染骨架屏', () => { mockRouterValue.isLoaded = false; - renderWithProvider(); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); + const { container } = renderWithProvider(); + // 骨架屏使用 Skeleton 组件 + const skeletons = container.querySelectorAll('.MuiSkeleton-root'); + expect(skeletons.length).toBeGreaterThan(0); }); it('isLoaded 为 true 时应渲染页面内容', () => { diff --git a/components/__tests__/ToolCard.test.tsx b/components/__tests__/ToolCard.test.tsx index 492cec3..63d3c83 100644 --- a/components/__tests__/ToolCard.test.tsx +++ b/components/__tests__/ToolCard.test.tsx @@ -16,7 +16,7 @@ describe('ToolCard 组件', () => { title="测试工具" description="这是一个测试工具" colorKey="primary" - icon={} + icon={AccessTimeIcon} onClick={() => {}} />, ); @@ -27,23 +27,19 @@ describe('ToolCard 组件', () => { it('无描述时仅渲染标题', () => { render( - } onClick={() => {}} />, + {}} />, ); expect(screen.getByText('仅标题')).toBeInTheDocument(); }); it('应渲染图标', () => { - render( - } - onClick={() => {}} - />, + const { container } = render( + {}} />, ); - expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + const svgElement = container.querySelector('svg'); + expect(svgElement).toBeInTheDocument(); }); it('提供快照内容时应渲染快照', () => { @@ -51,7 +47,7 @@ describe('ToolCard 组件', () => { } + icon={AccessTimeIcon} onClick={() => {}} snapshot={
快照内容
} />, @@ -62,7 +58,7 @@ describe('ToolCard 组件', () => { it('未提供快照时不渲染快照区域', () => { const { container } = render( - } onClick={() => {}} />, + {}} />, ); expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); @@ -70,7 +66,7 @@ describe('ToolCard 组件', () => { it('应使用 CardActionArea 渲染,支持键盘聚焦', () => { render( - } onClick={() => {}} />, + {}} />, ); const button = screen.getByRole('button', { name: /可聚焦/ }); @@ -83,12 +79,7 @@ describe('ToolCard 组件', () => { it('点击时应调用 onClick', () => { const handleClick = vi.fn(); render( - } - onClick={handleClick} - />, + , ); const button = screen.getByRole('button', { name: /可点击/ }); @@ -103,7 +94,7 @@ describe('ToolCard 组件', () => { } + icon={AccessTimeIcon} onClick={handleClick} />, ); @@ -120,16 +111,12 @@ describe('ToolCard 组件', () => { describe('样式测试', () => { it('应应用自定义颜色代码', () => { - render( - } - onClick={() => {}} - />, + const { container } = render( + {}} />, ); - expect(screen.getByTestId('custom-color-icon')).toBeInTheDocument(); + const svgElement = container.querySelector('svg'); + expect(svgElement).toBeInTheDocument(); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index b20b258..7c4add7 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -1,4 +1,5 @@ -import { type ComponentType, lazy, ReactNode } from 'react'; +import { type ComponentType, lazy } from 'react'; +import type { SvgIconProps } from '@mui/material/SvgIcon'; import type { PageType } from '@/types/storage'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import StorageIcon from '@mui/icons-material/Storage'; @@ -38,8 +39,8 @@ export interface FeatureConfig { descriptionKey: string; /** 主题颜色键(用于仪表盘卡片,映射到 theme.palette[key].main) */ themeColorKey?: PaletteColorKey; - /** 图标组件(用于仪表盘卡片) */ - icon?: ReactNode; + /** 图标组件引用(用于仪表盘卡片,按需实例化) */ + icon?: ComponentType; /** 默认是否在仪表盘显示 */ defaultVisible: boolean; /** 不同显示模式对应的组件 */ @@ -70,7 +71,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:timestamp.title', descriptionKey: 'features:timestamp.description', themeColorKey: 'primary', - icon: , + icon: AccessTimeIcon, defaultVisible: true, components: { popup: TimestampPage, @@ -83,7 +84,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:storageCleaner.title', descriptionKey: 'features:storageCleaner.description', themeColorKey: 'warning', - icon: , + icon: StorageIcon, defaultVisible: true, components: { popup: StorageCleanerPage, @@ -96,7 +97,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:qrCode.title', descriptionKey: 'features:qrCode.description', themeColorKey: 'success', - icon: , + icon: QrCodeIcon, defaultVisible: true, components: { popup: QrCodePage, @@ -109,7 +110,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:textStatistics.title', descriptionKey: 'features:textStatistics.description', themeColorKey: 'secondary', - icon: , + icon: DescriptionIcon, defaultVisible: true, components: { popup: TextStatisticsPage, @@ -122,7 +123,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:jwt.title', descriptionKey: 'features:jwt.description', themeColorKey: 'info', - icon: , + icon: VpnKeyIcon, defaultVisible: true, components: { popup: JwtPage, @@ -135,7 +136,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:jsonDiff.title', descriptionKey: 'features:jsonDiff.description', themeColorKey: 'primary', - icon: , + icon: CompareArrowsIcon, defaultVisible: true, components: { popup: JsonToolsPage, @@ -148,7 +149,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:base64Converter.title', descriptionKey: 'features:base64Converter.description', themeColorKey: 'info', - icon: , + icon: TransformIcon, defaultVisible: true, components: { popup: Base64ConverterPage, @@ -161,7 +162,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:markdownToHtml.title', descriptionKey: 'features:markdownToHtml.description', themeColorKey: 'secondary', - icon: , + icon: CodeIcon, defaultVisible: true, components: { popup: MarkdownToHtmlPage, @@ -174,7 +175,7 @@ export const FEATURES: FeatureConfig[] = [ labelKey: 'features:htmlToMarkdown.title', descriptionKey: 'features:htmlToMarkdown.description', themeColorKey: 'secondary', - icon: , + icon: ArticleIcon, defaultVisible: true, components: { popup: HtmlToMarkdownPage, diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 8b83389..5605344 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -155,7 +155,7 @@ function SortableFeatureRow({ flexShrink: 0, }} > - {feature.icon} + {feature.icon && } {/* 文本信息 */} diff --git a/entrypoints/popup/index.html b/entrypoints/popup/index.html index c65b971..8f522bb 100644 --- a/entrypoints/popup/index.html +++ b/entrypoints/popup/index.html @@ -13,6 +13,7 @@ margin: 0; padding: 0; overflow: hidden; + background-color: #f5f5f5; /* Light mode default */ } /* Ensure full size for the root container */ #root { diff --git a/i18n/index.ts b/i18n/index.ts index 11aa529..a6e9e00 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -4,60 +4,20 @@ import LanguageDetector from 'i18next-browser-languagedetector'; import { storageUtil } from '@/utils/chromeStorage'; import dayjs from 'dayjs'; -// 导入语言文件 +// 同步加载全局命名空间(所有页面都需要) import commonZh from './locales/zh/common.json'; import featuresZh from './locales/zh/features.json'; import commonEn from './locales/en/common.json'; import featuresEn from './locales/en/features.json'; -import timestampZh from './locales/zh/timestamp.json'; -import timestampEn from './locales/en/timestamp.json'; -import storageCleanerZh from './locales/zh/storageCleaner.json'; -import storageCleanerEn from './locales/en/storageCleaner.json'; -import qrCodeZh from './locales/zh/qrCode.json'; -import qrCodeEn from './locales/en/qrCode.json'; -import textStatisticsZh from './locales/zh/textStatistics.json'; -import textStatisticsEn from './locales/en/textStatistics.json'; -import jwtZh from './locales/zh/jwt.json'; -import jwtEn from './locales/en/jwt.json'; -import jsonDiffZh from './locales/zh/jsonDiff.json'; -import jsonDiffEn from './locales/en/jsonDiff.json'; -import jsonFormatZh from './locales/zh/jsonFormat.json'; -import jsonFormatEn from './locales/en/jsonFormat.json'; -import base64ConverterZh from './locales/zh/base64Converter.json'; -import base64ConverterEn from './locales/en/base64Converter.json'; -import markdownToHtmlZh from './locales/zh/markdownToHtml.json'; -import markdownToHtmlEn from './locales/en/markdownToHtml.json'; -import htmlToMarkdownZh from './locales/zh/htmlToMarkdown.json'; -import htmlToMarkdownEn from './locales/en/htmlToMarkdown.json'; const resources = { zh: { common: commonZh, features: featuresZh, - timestamp: timestampZh, - storageCleaner: storageCleanerZh, - qrCode: qrCodeZh, - textStatistics: textStatisticsZh, - jwt: jwtZh, - jsonDiff: jsonDiffZh, - jsonFormat: jsonFormatZh, - base64Converter: base64ConverterZh, - markdownToHtml: markdownToHtmlZh, - htmlToMarkdown: htmlToMarkdownZh, }, en: { common: commonEn, features: featuresEn, - timestamp: timestampEn, - storageCleaner: storageCleanerEn, - qrCode: qrCodeEn, - textStatistics: textStatisticsEn, - jwt: jwtEn, - jsonDiff: jsonDiffEn, - jsonFormat: jsonFormatEn, - base64Converter: base64ConverterEn, - markdownToHtml: markdownToHtmlEn, - htmlToMarkdown: htmlToMarkdownEn, }, }; @@ -120,20 +80,7 @@ i18n resources, fallbackLng: 'en', lng: syncLng || undefined, - ns: [ - 'common', - 'features', - 'timestamp', - 'storageCleaner', - 'qrCode', - 'textStatistics', - 'jwt', - 'jsonDiff', - 'jsonFormat', - 'base64Converter', - 'markdownToHtml', - 'htmlToMarkdown', - ], + ns: ['common', 'features'], defaultNS: 'common', debug: false, interpolation: { diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx index 94f07ea..66e7d66 100644 --- a/pages/Base64Converter/index.tsx +++ b/pages/Base64Converter/index.tsx @@ -2,7 +2,7 @@ import { Box, Container, Stack } from '@mui/material'; import TextFieldsIcon from '@mui/icons-material/TextFields'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import ImageIcon from '@mui/icons-material/Image'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import PageHeader from '@/components/PageHeader'; import { base64ConverterPageStyles } from '@/config/pageTheme'; import { useStorageState } from '@/utils/useStorageState'; @@ -20,7 +20,7 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode => type PageMode = Base64ConverterPageMode; export default function Index() { - const { t } = useTranslation(['base64Converter']); + const { t } = useLazyTranslation('base64Converter'); const [pageMode, setPageMode] = useStorageState( 'base64Converter/pageMode', 'text', diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx index d6318aa..6844ad6 100644 --- a/pages/Dashboard/ToolCard.tsx +++ b/pages/Dashboard/ToolCard.tsx @@ -6,7 +6,8 @@ */ import { alpha, Box, Card, CardActionArea, Stack, Typography, useTheme } from '@mui/material'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; -import React from 'react'; +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { ComponentType } from 'react'; import type { PaletteColorKey } from '@/config/features'; /** @@ -21,8 +22,8 @@ interface ToolCardProps { snapshot?: React.ReactNode; /** 主题色键,映射到 theme.palette[key].main */ colorKey: PaletteColorKey; - /** 工具图标元素 */ - icon: React.ReactNode; + /** 图标组件引用 */ + icon: ComponentType; /** 卡片点击事件处理函数 */ onClick: () => void; } @@ -38,7 +39,7 @@ export default function ToolCard({ description, snapshot, colorKey, - icon, + icon: IconComponent, onClick, }: ToolCardProps) { const theme = useTheme(); @@ -93,7 +94,7 @@ export default function ToolCard({ color: colorCode, }} > - {icon} + typeof val === 'string' && ['split', 'preview', 'markdown'].includes(val); export default function HtmlToMarkdownPage() { - const { t } = useTranslation('htmlToMarkdown'); + const { t } = useLazyTranslation('htmlToMarkdown'); const [previewMode, setPreviewMode] = useStorageState( 'htmlToMarkdown/previewMode', 'split' as HtmlToMarkdownPreviewMode, diff --git a/pages/JsonTools/index.tsx b/pages/JsonTools/index.tsx index b54a041..3135eaa 100644 --- a/pages/JsonTools/index.tsx +++ b/pages/JsonTools/index.tsx @@ -4,7 +4,7 @@ import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; import DataObjectIcon from '@mui/icons-material/DataObject'; import TransformIcon from '@mui/icons-material/Transform'; import CompressIcon from '@mui/icons-material/Compress'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import PageHeader from '@/components/PageHeader'; import { jsonDiffPageStyles } from '@/config/pageTheme'; import JsonDiffInput from './JsonDiffInput'; @@ -46,7 +46,7 @@ const isValidPageMode = (val: unknown): val is JsonToolsPageMode => type PageMode = JsonToolsPageMode; export default function Index() { - const { t } = useTranslation(['jsonDiff', 'jsonFormat']); + const { t } = useLazyTranslation(['jsonDiff', 'jsonFormat']); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [leftInput, setLeftInput] = useState(''); const [rightInput, setRightInput] = useState(''); diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index 65e56b8..03de6ec 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -6,7 +6,7 @@ import PageHeader from '@/components/PageHeader'; import { stringifyJson, parseJwt } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; interface SectionProps { title: string; @@ -15,7 +15,7 @@ interface SectionProps { } const Section = ({ title, content, color }: SectionProps) => { - const { t } = useTranslation(['jwt']); + const { t } = useLazyTranslation('jwt'); return ( { export default function Index() { const { showMessage } = useSnackbar(); - const { t } = useTranslation(['jwt']); + const { t } = useLazyTranslation('jwt'); const [jwtInput, setJwtInput] = useState(''); const result = useMemo(() => { diff --git a/pages/MarkdownToHtml/index.tsx b/pages/MarkdownToHtml/index.tsx index 5dd3853..6a744ab 100644 --- a/pages/MarkdownToHtml/index.tsx +++ b/pages/MarkdownToHtml/index.tsx @@ -14,7 +14,7 @@ import CodeIcon from '@mui/icons-material/Code'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import PrintIcon from '@mui/icons-material/Print'; import DownloadIcon from '@mui/icons-material/Download'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import PageHeader from '@/components/PageHeader'; import CopyButton from '@/components/CopyButton'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; @@ -109,7 +109,7 @@ const PREVIEW_STYLES = ` `; export default function MarkdownToHtmlPage() { - const { t } = useTranslation('markdownToHtml'); + const { t } = useLazyTranslation('markdownToHtml'); const [previewMode, setPreviewMode] = useStorageState( 'markdownToHtml/previewMode', 'split' as MarkdownToHtmlPreviewMode, diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx index 8f1cb6a..ecc58c7 100644 --- a/pages/QrCode/index.tsx +++ b/pages/QrCode/index.tsx @@ -5,10 +5,10 @@ import QrCodeToUrlSection from '@/pages/QrCode/QrCodeToUrlSection'; import { useStorageState } from '@/utils/useStorageState'; import { qrCodePageStyles } from '@/config/pageTheme'; import PageHeader from '@/components/PageHeader'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; export default function Index() { - const { t } = useTranslation(['qrCode']); + const { t } = useLazyTranslation('qrCode'); const theme = useTheme(); const isDesktop = useMediaQuery(theme.breakpoints.up('md')); diff --git a/pages/StorageCleaner/index.tsx b/pages/StorageCleaner/index.tsx index e2d5892..22682a2 100644 --- a/pages/StorageCleaner/index.tsx +++ b/pages/StorageCleaner/index.tsx @@ -9,11 +9,11 @@ import StorageOptionsGrid from './StorageOptionsGrid'; import AutoRefreshToggle from './AutoRefreshToggle'; import ErrorDisplay from './ErrorDisplay'; import CleaningResult from './CleaningResult'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; export default function Index() { const { showMessage } = useSnackbar(); - const { t } = useTranslation(['storageCleaner']); + const { t } = useLazyTranslation('storageCleaner'); const { domain, error, diff --git a/pages/TextStatistics/index.tsx b/pages/TextStatistics/index.tsx index 444cabf..20c58c7 100644 --- a/pages/TextStatistics/index.tsx +++ b/pages/TextStatistics/index.tsx @@ -5,7 +5,7 @@ import TextInputArea from '@/components/TextInputArea'; import DescriptionIcon from '@mui/icons-material/Description'; import { formatByteSize, getTextStats } from '@/utils/textStatistics'; import { textStatisticsPageStyles } from '@/config/pageTheme'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; /** * 文本统计页面组件 @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'; * 提供实时的文本分析功能,包括字符数、单词数、行数和字节大小。 */ export default function Index() { - const { t } = useTranslation(['textStatistics']); + const { t } = useLazyTranslation('textStatistics'); const [text, setText] = useState(''); // 实时计算统计信息,使用 useMemo 优化性能 diff --git a/pages/Timestamp/index.tsx b/pages/Timestamp/index.tsx index 2769b90..ae8bac8 100644 --- a/pages/Timestamp/index.tsx +++ b/pages/Timestamp/index.tsx @@ -7,10 +7,10 @@ import { timestampPageStyles, ZONES } from '@/config/pageTheme'; import LiveClock from './LiveClock'; import ResultView from './ResultView'; import { useTimestampConverter } from './useTimestampConverter'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; export default function Index() { - const { t } = useTranslation(['timestamp']); + const { t } = useLazyTranslation('timestamp'); const { mode, tsInput, diff --git a/utils/__tests__/useLazyTranslation.test.ts b/utils/__tests__/useLazyTranslation.test.ts new file mode 100644 index 0000000..24f3c14 --- /dev/null +++ b/utils/__tests__/useLazyTranslation.test.ts @@ -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 }; + + 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'); + }); +}); diff --git a/utils/useLazyTranslation.ts b/utils/useLazyTranslation.ts new file mode 100644 index 0000000..0fa32d4 --- /dev/null +++ b/utils/useLazyTranslation.ts @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; + +// 语言包动态导入映射 +const localeModules: Record< + string, + Record Promise<{ default: Record }>> +> = { + 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(); + +/** + * 清除已加载命名空间的缓存(仅用于测试) + * @internal + */ +export function __test_clearCache(): void { + loadedNamespaces.clear(); +} + +/** + * 动态加载 i18n 命名空间 + */ +async function loadNamespace(ns: string, lng: string): Promise { + 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 { + 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, + }; +} diff --git a/wxt.config.ts b/wxt.config.ts index 40cc0e4..3a04e50 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -41,6 +41,9 @@ export default defineConfig({ comments: false, }, }, + + // 3. 调整 chunk 大小警告阈值 + chunkSizeWarningLimit: 600, }, }), });