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

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

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

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

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

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

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

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

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

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

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

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

* feat: 调整 chunk 大小警告阈值以优化构建性能
This commit is contained in:
LingandRX
2026-05-20 20:07:44 +08:00
committed by GitHub
parent 7167a43763
commit 373fc0496c
24 changed files with 535 additions and 153 deletions
+10 -2
View File
@@ -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' });
}
+103
View File
@@ -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';
+4 -26
View File
@@ -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>
+3 -1
View File
@@ -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 时应渲染页面内容', () => {
+15 -28
View File
@@ -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();
});
});
});