Develop (#26)
统一简化多个页面里的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:
@@ -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<CopyButtonProps> = ({
|
||||
showMessage,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (text) {
|
||||
@@ -49,7 +56,8 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor,
|
||||
p: 2.5,
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Skeleton variant="rounded" width={40} height={40} sx={{ borderRadius: 3 }} />
|
||||
<Box>
|
||||
<Skeleton variant="text" width={100} height={20} />
|
||||
<Skeleton variant="text" width={140} height={14} sx={{ mt: 0.5 }} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Skeleton variant="circular" width={12} height={12} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具页面骨架屏
|
||||
*/
|
||||
function ToolPageSkeleton() {
|
||||
return (
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
{/* 标题区域 */}
|
||||
<Skeleton variant="text" width={180} height={28} sx={{ mb: 2 }} />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<Skeleton variant="rounded" width="100%" height={120} sx={{ borderRadius: 3, mb: 2 }} />
|
||||
|
||||
{/* 控制栏 */}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<Skeleton variant="rounded" width={100} height={36} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton variant="rounded" width={80} height={36} sx={{ borderRadius: 2 }} />
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Skeleton variant="rounded" width={90} height={36} sx={{ borderRadius: 2 }} />
|
||||
</Stack>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<Skeleton variant="rounded" width="100%" height={160} sx={{ borderRadius: 3 }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载骨架屏
|
||||
*
|
||||
* @param props - PageSkeletonProps
|
||||
* @returns 骨架屏 JSX 元素
|
||||
*/
|
||||
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||
if (variant === 'tool') {
|
||||
return <ToolPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
},
|
||||
gridAutoRows: '1fr',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<DashboardCardSkeleton key={index} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
PageSkeleton.displayName = 'PageSkeleton';
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
@@ -47,19 +37,7 @@ export default function RouterContainer() {
|
||||
}}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
}
|
||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||
>
|
||||
<PageErrorBoundary resetKey={currentPage}>{Component && <Component />}</PageErrorBoundary>
|
||||
</Suspense>
|
||||
|
||||
@@ -296,7 +296,9 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
||||
aria-selected={selectedIndex === index}
|
||||
sx={{ py: 1 }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>{feature.icon}</ListItemIcon>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
{feature.icon && <feature.icon sx={{ fontSize: 20 }} />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t(feature.labelKey)}
|
||||
secondary={t(feature.descriptionKey)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
|
||||
describe('PageSkeleton 组件', () => {
|
||||
describe('渲染测试', () => {
|
||||
it('默认应渲染 dashboard 骨架屏', () => {
|
||||
const { container } = render(<PageSkeleton />);
|
||||
|
||||
// dashboard 骨架屏包含 6 个卡片
|
||||
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 每个卡片有 4 个 Skeleton(图标、标题、描述、箭头),6 个卡片共 24 个
|
||||
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
|
||||
expect(skeletons.length).toBe(24);
|
||||
});
|
||||
|
||||
it('variant 为 tool 时应渲染工具页面骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
|
||||
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
|
||||
expect(skeletons.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('布局结构测试', () => {
|
||||
it('dashboard 骨架屏应使用 grid 布局', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
const gridContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(gridContainer).toHaveStyle({ display: 'grid' });
|
||||
});
|
||||
|
||||
it('tool 骨架屏应有内边距', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
const toolContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(toolContainer).toHaveStyle({ padding: '20px' }); // 2.5 * 8px
|
||||
});
|
||||
});
|
||||
|
||||
describe('骨架屏元素测试', () => {
|
||||
it('dashboard 骨架屏应包含圆角和边框样式', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 获取第一个卡片容器
|
||||
const card = container.querySelector('[class*="MuiBox-root"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tool 骨架屏应包含圆形和矩形变体', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
const roundedSkeletons = container.querySelectorAll('.MuiSkeleton-rounded');
|
||||
const textSkeletons = container.querySelectorAll('.MuiSkeleton-text');
|
||||
|
||||
expect(roundedSkeletons.length).toBeGreaterThan(0);
|
||||
expect(textSkeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(<RouterContainer />);
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
// 骨架屏使用 Skeleton 组件
|
||||
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('ToolCard 组件', () => {
|
||||
title="测试工具"
|
||||
description="这是一个测试工具"
|
||||
colorKey="primary"
|
||||
icon={<AccessTimeIcon />}
|
||||
icon={AccessTimeIcon}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
@@ -27,23 +27,19 @@ describe('ToolCard 组件', () => {
|
||||
|
||||
it('无描述时仅渲染标题', () => {
|
||||
render(
|
||||
<ToolCard title="仅标题" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||
<ToolCard title="仅标题" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带图标"
|
||||
colorKey="primary"
|
||||
icon={<AccessTimeIcon data-testid="test-icon" />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
const { container } = render(
|
||||
<ToolCard title="带图标" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('提供快照内容时应渲染快照', () => {
|
||||
@@ -51,7 +47,7 @@ describe('ToolCard 组件', () => {
|
||||
<ToolCard
|
||||
title="带快照"
|
||||
colorKey="primary"
|
||||
icon={<AccessTimeIcon />}
|
||||
icon={AccessTimeIcon}
|
||||
onClick={() => {}}
|
||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||
/>,
|
||||
@@ -62,7 +58,7 @@ describe('ToolCard 组件', () => {
|
||||
|
||||
it('未提供快照时不渲染快照区域', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="无快照" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||
<ToolCard title="无快照" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||
@@ -70,7 +66,7 @@ describe('ToolCard 组件', () => {
|
||||
|
||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||
render(
|
||||
<ToolCard title="可聚焦" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||
<ToolCard title="可聚焦" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||
@@ -83,12 +79,7 @@ describe('ToolCard 组件', () => {
|
||||
it('点击时应调用 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<ToolCard
|
||||
title="可点击"
|
||||
colorKey="primary"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
<ToolCard title="可点击" colorKey="primary" icon={AccessTimeIcon} onClick={handleClick} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可点击/ });
|
||||
@@ -103,7 +94,7 @@ describe('ToolCard 组件', () => {
|
||||
<ToolCard
|
||||
title="键盘可触发"
|
||||
colorKey="primary"
|
||||
icon={<AccessTimeIcon />}
|
||||
icon={AccessTimeIcon}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
@@ -120,16 +111,12 @@ describe('ToolCard 组件', () => {
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('应应用自定义颜色代码', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="自定义颜色"
|
||||
colorKey="warning"
|
||||
icon={<AccessTimeIcon data-testid="custom-color-icon" />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
const { container } = render(
|
||||
<ToolCard title="自定义颜色" colorKey="warning" icon={AccessTimeIcon} onClick={() => {}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-color-icon')).toBeInTheDocument();
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-12
@@ -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<SvgIconProps>;
|
||||
/** 默认是否在仪表盘显示 */
|
||||
defaultVisible: boolean;
|
||||
/** 不同显示模式对应的组件 */
|
||||
@@ -70,7 +71,7 @@ export const FEATURES: FeatureConfig[] = [
|
||||
labelKey: 'features:timestamp.title',
|
||||
descriptionKey: 'features:timestamp.description',
|
||||
themeColorKey: 'primary',
|
||||
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
||||
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: <StorageIcon sx={{ fontSize: 20 }} />,
|
||||
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: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
||||
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: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||
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: <VpnKeyIcon sx={{ fontSize: 20 }} />,
|
||||
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: <CompareArrowsIcon sx={{ fontSize: 20 }} />,
|
||||
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: <TransformIcon sx={{ fontSize: 20 }} />,
|
||||
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: <CodeIcon sx={{ fontSize: 20 }} />,
|
||||
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: <ArticleIcon sx={{ fontSize: 20 }} />,
|
||||
icon: ArticleIcon,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: HtmlToMarkdownPage,
|
||||
|
||||
@@ -155,7 +155,7 @@ function SortableFeatureRow({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{feature.icon}
|
||||
{feature.icon && <feature.icon sx={{ fontSize: 20 }} />}
|
||||
</Box>
|
||||
|
||||
{/* 文本信息 */}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5; /* Light mode default */
|
||||
}
|
||||
/* Ensure full size for the root container */
|
||||
#root {
|
||||
|
||||
+2
-55
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<SvgIconProps>;
|
||||
/** 卡片点击事件处理函数 */
|
||||
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}
|
||||
<IconComponent sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
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';
|
||||
@@ -25,7 +25,7 @@ const isValidPreviewMode = (val: unknown): val is HtmlToMarkdownPreviewMode =>
|
||||
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,
|
||||
|
||||
@@ -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('');
|
||||
|
||||
+3
-3
@@ -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 (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
@@ -58,7 +58,7 @@ const Section = ({ title, content, color }: SectionProps) => {
|
||||
|
||||
export default function Index() {
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation(['jwt']);
|
||||
const { t } = useLazyTranslation('jwt');
|
||||
const [jwtInput, setJwtInput] = useState('');
|
||||
|
||||
const result = useMemo(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 优化性能
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export default defineConfig({
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
|
||||
// 3. 调整 chunk 大小警告阈值
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user