Merge develop into main (#27)

* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB
This commit is contained in:
LingandRX
2026-05-21 01:34:35 +08:00
committed by GitHub
parent 373fc0496c
commit a5c34e5428
52 changed files with 2610 additions and 622 deletions
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useRef } from 'react';
import { Box, IconButton, Typography } from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
const ImageUploader = ({
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
onPreviewUrlChange(URL.createObjectURL(file));
},
[onFileChange, onPreviewUrlChange],
);
const handleClearFile = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
onClearFile();
showMessage(t('qrCode:imageCleared'), {
severity: 'success',
autoHideDuration: 1000,
});
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(true);
};
const handleDragLeave = () => {
onDraggingChange(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [showMessage, handleFileChange, t]);
return (
<Box
className="qr-flex-grow"
sx={qrCodePageStyles.DROPZONE(dragging, !!selectedFile)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
style={{ display: 'none' }}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{selectedFile ? (
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
<img
src={previewUrl}
alt="QR Code Preview"
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearFile();
}}
sx={qrCodePageStyles.CLEAR_BUTTON}
>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{selectedFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:clickToChange')}
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('qrCode:clickToUpload')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:supportFormats')}
</Typography>
</>
)}
</label>
</Box>
);
};
export default ImageUploader;
+15 -6
View File
@@ -1,5 +1,6 @@
import { alpha, Box, Stack, SxProps, Theme, Typography } from '@mui/material';
import { ReactNode } from 'react';
import { alpha, Box, Stack, SxProps, Theme, Typography, useTheme } from '@mui/material';
import { ReactNode, useMemo } from 'react';
import { getEntryPointType } from '@/config/features';
/**
* PageHeader 组件属性接口
@@ -7,7 +8,7 @@ import { ReactNode } from 'react';
export interface PageHeaderProps {
/** 要显示的图标组件 */
icon: ReactNode;
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
/** 图标的颜色,默认使用主题 primary.main 色 */
iconColor?: string;
/** 主标题文本 */
title: string;
@@ -53,7 +54,7 @@ export interface PageHeaderProps {
*/
export default function PageHeader({
icon,
iconColor = '#1976d2',
iconColor,
title,
subtitle,
badge,
@@ -62,6 +63,14 @@ export default function PageHeader({
subtitleSx,
sx,
}: PageHeaderProps) {
const theme = useTheme();
const resolvedIconColor = iconColor ?? theme.palette.primary.main;
const entryPointType = useMemo(() => getEntryPointType(), []);
if (entryPointType === 'popup') {
return null;
}
return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
{/* 图标容器 */}
@@ -69,8 +78,8 @@ export default function PageHeader({
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(iconColor, 0.1),
color: iconColor,
bgcolor: alpha(resolvedIconColor, 0.1),
color: resolvedIconColor,
display: 'flex',
...iconSx,
}}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Button, Typography } from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface QrCodePreviewProps {
/** 二维码 Data URL */
qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void;
/** 复制回调 */
onCopy: () => void;
}
const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => {
const { t } = useLazyTranslation('qrCode');
if (!qrCodeDataUrl) {
return (
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
<Typography variant="body2" color="text.secondary" sx={qrCodePageStyles.PLACEHOLDER_TEXT}>
{t('qrCode:qrCodeWillShow')}
</Typography>
</Box>
);
}
return (
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={onDownload}
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
>
{t('qrCode:downloadButton')}
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={onCopy}
sx={qrCodePageStyles.COPY_BUTTON}
>
{t('qrCode:copyQrButton')}
</Button>
</Box>
</Box>
</Box>
);
};
export default QrCodePreview;
+208
View File
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import ImageUploader from '@/components/ImageUploader';
// 模拟 URL API
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
// 模拟 showMessage
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
}),
}));
describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn();
const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn();
const defaultProps = {
selectedFile: null,
onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile,
previewUrl: '',
onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false,
onDraggingChange: mockOnDraggingChange,
};
beforeEach(() => {
vi.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:test-url');
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
});
it('当没有选中文件时应显示 ImageIcon', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
});
it('当选中文件时应显示文件预览', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
});
it('当选中文件时应显示预览图片', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'blob:test-url');
});
it('当选中文件时应显示清除按钮', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
});
it('应包含隐藏的文件输入框', () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'file');
expect(input).toHaveAttribute('accept', 'image/*');
});
});
describe('文件选择交互', () => {
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(input, { target: { files: [mockFile] } });
});
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
});
});
describe('拖拽交互', () => {
it('拖拽进入时应调用 onDraggingChange(true)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragOver(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
});
it('拖拽离开时应调用 onDraggingChange(false)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragLeave(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
});
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const dropEvent = new Event('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [mockFile],
},
});
Object.defineProperty(dropEvent, 'preventDefault', {
value: vi.fn(),
});
fireEvent(dropzone, dropEvent);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
});
});
describe('清除文件功能', () => {
it('点击清除按钮时应调用 onClearFile', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
});
it('清除文件时应撤销预览 URL', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
});
});
describe('粘贴功能', () => {
it('监听粘贴事件', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ImageUploader {...defaultProps} />);
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
it('组件卸载时应移除粘贴事件监听', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = render(<ImageUploader {...defaultProps} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
});
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
});
+40 -12
View File
@@ -2,8 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CloseIcon from '@mui/icons-material/Close';
import { render, screen } from '@testing-library/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
vi.mock('@/config/features', () => ({
getEntryPointType: vi.fn(() => 'sidepanel'),
}));
const theme = createTheme();
function renderWithTheme(ui: React.ReactElement) {
return render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
}
describe('PageHeader 组件系统', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -22,32 +33,37 @@ describe('PageHeader 组件系统', () => {
describe('PageHeader UI 渲染', () => {
it('应渲染页面标题栏&副标题', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
});
it('应渲染图标', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
});
it('应渲染自定义图标&图标颜色', () => {
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
});
it('应默认使用主题 primary 色', () => {
renderWithTheme(<PageHeader {...defaultProps} icon={<CloseIcon />} />);
expect(screen.getByTestId('CloseIcon')).toHaveStyle(`color: ${theme.palette.primary.main};`);
});
it('应渲染 badge 组件', () => {
const badge = <span data-testid="test-badge">New</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();
});
it('应渲染 badge 与 title 并排布局', () => {
const badge = <span data-testid="side-badge">v1.0</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
renderWithTheme(<PageHeader {...defaultProps} badge={badge} />);
const title = screen.getByText('时间戳转换');
const badgeEl = screen.getByTestId('side-badge');
expect(title).toBeInTheDocument();
@@ -57,13 +73,15 @@ describe('PageHeader 组件系统', () => {
describe('PageHeader 条件渲染', () => {
it('subtitle 为 undefined 时不应渲染副标题', () => {
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
const { container } = renderWithTheme(
<PageHeader icon={<AccessTimeIcon />} title="仅标题" />,
);
const captionElements = container.querySelectorAll('p');
expect(captionElements.length).toBe(0);
});
it('subtitle 为空字符串时不应渲染副标题', () => {
const { container } = render(
const { container } = renderWithTheme(
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
);
const captionElements = container.querySelectorAll('p');
@@ -71,14 +89,14 @@ describe('PageHeader 组件系统', () => {
});
it('badge 为 undefined 时不应渲染 badge 区域', () => {
render(<PageHeader {...defaultProps} />);
renderWithTheme(<PageHeader {...defaultProps} />);
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
});
});
describe('PageHeader 样式扩展', () => {
it('iconSx 应作为属性传递给图标容器', () => {
const { container } = render(
const { container } = renderWithTheme(
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
);
const iconContainer = container.querySelector('div');
@@ -86,21 +104,31 @@ describe('PageHeader 组件系统', () => {
});
it('titleSx 应作为属性传递给标题', () => {
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
renderWithTheme(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
const titleEl = screen.getByText('时间戳转换');
expect(titleEl).toBeInTheDocument();
});
it('subtitleSx 应作为属性传递给副标题', () => {
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
renderWithTheme(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
expect(subtitleEl).toBeInTheDocument();
});
it('sx 应作为属性传递给外层容器', () => {
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
const { container } = renderWithTheme(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
const outerElement = container.firstChild;
expect(outerElement).toBeTruthy();
});
});
describe('PageHeader 入口点隐藏', () => {
it('popup 模式下应返回 null', async () => {
const { getEntryPointType } = await import('@/config/features');
vi.mocked(getEntryPointType).mockReturnValue('popup');
const { container } = renderWithTheme(<PageHeader {...defaultProps} />);
expect(container.innerHTML).toBe('');
});
});
});
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview';
describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
const testDataUrl = 'data:image/png;base64,test123';
render(
<QrCodePreview
qrCodeDataUrl={testDataUrl}
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
const img = screen.getByAltText('QR Code');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', testDataUrl);
});
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
});
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击下载按钮时应调用 onDownload 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText('qrCode:downloadButton'));
expect(mockOnDownload).toHaveBeenCalledTimes(1);
});
it('点击复制按钮时应调用 onCopy 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
expect(mockOnCopy).toHaveBeenCalledTimes(1);
});
});
});
+3 -3
View File
@@ -685,8 +685,8 @@ export const qrCodePageStyles = {
width: '100%',
} as const,
QR_PREVIEW_IMAGE: {
maxWidth: '100%',
height: 'auto',
width: 250,
height: 250,
display: 'block',
} as const,
QR_PREVIEW_ACTIONS: {
@@ -719,7 +719,7 @@ export const qrCodePageStyles = {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
height: 250,
border: '2px dashed',
borderColor: dragging || hasFile ? 'success.main' : 'divider',
borderRadius: 3,
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
createAllContextMenus,
parseContextMenuClick,
CONTEXT_MENU_CONFIGS,
MAX_PAYLOAD_LENGTH,
} from '@/utils/contextMenu';
import { MessageAction } from '@/utils/messages';
describe('background 菜单注册与分流', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('菜单注册', () => {
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
});
it('应该创建父级菜单 Testing Tools', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'testing-tools-parent',
title: 'Testing Tools',
}),
);
});
it('应该创建 JWT 解析子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'jwt',
title: '🔑 解析 JWT',
parentId: 'testing-tools-parent',
}),
);
});
it('应该创建网页链接转二维码子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'qrCode-page',
title: '🔗 网页链接转二维码',
contexts: ['page'],
}),
);
});
});
describe('菜单点击解析', () => {
const createMockOnClickData = (
overrides: Partial<chrome.contextMenus.OnClickData> = {},
): chrome.contextMenus.OnClickData => ({
menuItemId: 'test',
editable: false,
pageUrl: 'https://example.com',
...overrides,
});
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
const info = createMockOnClickData({
menuItemId: 'jwt',
selectionText: 'test-token',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'test-token' },
});
});
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
const info = createMockOnClickData({
menuItemId: 'unknown',
pageUrl: undefined,
});
const result = parseContextMenuClick('unknown', info);
expect(result).toEqual({
success: false,
error: '无法获取有效数据',
});
});
it('当文本超过最大长度限制时应截断', () => {
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: longText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result.success).toBe(true);
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
});
it('当文本未超过最大长度限制时应保持原样', () => {
const shortText = 'short text';
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: shortText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'textStatistics', payload: 'short text' },
});
});
it('应该正确处理页面 URL 菜单点击', () => {
const info = createMockOnClickData({
menuItemId: 'storageCleaner',
pageUrl: 'https://example.com/page',
});
const result = parseContextMenuClick('storageCleaner', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
});
});
});
describe('消息类型定义', () => {
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
});
});
});
+45 -1
View File
@@ -1,8 +1,52 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { MessageAction, onMessage } from '@/utils/messages';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
import { saveContextMenuData } from '@/utils/useContextMenuData';
export default defineBackground(() => {
browser.runtime.onInstalled.addListener(() => {
createAllContextMenus();
});
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info);
if (!result.success || !result.data) {
if (result.error) {
console.warn('[Context Menu]', result.error);
}
return;
}
const { featureKey, payload } = result.data;
try {
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
} catch {
// sidepanel 未打开或无法通信,继续执行其他方案
}
// 保存数据到 storagepopup 打开后会读取
await saveContextMenuData({ featureKey, payload });
// 打开 popup 弹窗
try {
await browser.action.openPopup();
} catch (err) {
// openPopup 在无活动窗口时会失败(如窗口失焦、特殊页面等)
// 数据已保存到 storage,用户手动打开 popup 仍可正常使用
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标:', err);
await chrome.storage.local.remove('contextMenu/pendingData');
}
});
// 监听扩展图标点击事件,打开侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
+62
View File
@@ -0,0 +1,62 @@
import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics';
import { showTimestampResult, showTextStatsResult, hidePopover } from './uiPopover';
import type { ContextMenuClickedPayload } from '@/utils/messages';
function convertTimestamp(input: string): string {
const num = Number(input.trim());
if (isNaN(num)) {
return '无效时间戳';
}
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
if (isNaN(d.getTime())) {
return '无效时间戳';
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
let lastClickX = 0;
let lastClickY = 0;
document.addEventListener(
'contextmenu',
(e) => {
lastClickX = e.clientX;
lastClickY = e.clientY;
},
true,
);
export function initContextMenuHandler(): void {
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
switch (featureKey) {
case 'timestamp': {
const result = convertTimestamp(payload);
showTimestampResult(lastClickX, lastClickY, payload, result);
break;
}
case 'textStatistics': {
const stats = getTextStats(payload);
showTextStatsResult(lastClickX, lastClickY, payload, stats);
break;
}
default:
hidePopover();
break;
}
});
}
+5 -1
View File
@@ -1 +1,5 @@
export function initMessageHandler() {}
import { initContextMenuHandler } from './contextMenuHandler';
export function initMessageHandler(): void {
initContextMenuHandler();
}
+247
View File
@@ -0,0 +1,247 @@
const POPOVER_ID = 'testing-tools-popover';
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
function injectStyles(): void {
if (document.getElementById(POPOVER_STYLE_ID)) return;
const style = document.createElement('style');
style.id = POPOVER_STYLE_ID;
style.textContent = `
#${POPOVER_ID} {
position: fixed;
z-index: 2147483647;
max-width: 400px;
min-width: 200px;
padding: 12px 16px;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
line-height: 1.5;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
#${POPOVER_ID}.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
#${POPOVER_ID} .popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#${POPOVER_ID} .popover-title {
font-weight: 600;
font-size: 12px;
color: #a0a0b0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#${POPOVER_ID} .popover-close {
background: none;
border: none;
color: #808090;
cursor: pointer;
padding: 2px;
font-size: 16px;
line-height: 1;
}
#${POPOVER_ID} .popover-close:hover {
color: #e0e0e0;
}
#${POPOVER_ID} .popover-content {
word-break: break-all;
white-space: pre-wrap;
}
#${POPOVER_ID} .popover-label {
color: #808090;
font-size: 11px;
margin-bottom: 4px;
}
#${POPOVER_ID} .popover-value {
color: #ffffff;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 14px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-bottom: 8px;
}
#${POPOVER_ID} .popover-value:last-child {
margin-bottom: 0;
}
#${POPOVER_ID} .stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${POPOVER_ID} .stat-item {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
#${POPOVER_ID} .stat-label {
font-size: 11px;
color: #808090;
}
#${POPOVER_ID} .stat-value {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
`;
document.head.appendChild(style);
}
function getOrCreatePopover(): HTMLElement {
let popover = document.getElementById(POPOVER_ID);
if (!popover) {
injectStyles();
popover = document.createElement('div');
popover.id = POPOVER_ID;
document.body.appendChild(popover);
}
return popover;
}
function positionPopover(popover: HTMLElement, x: number, y: number): void {
const rect = popover.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x;
let top = y;
if (left + rect.width > viewportWidth - 16) {
left = viewportWidth - rect.width - 16;
}
if (left < 16) {
left = 16;
}
if (top + rect.height > viewportHeight - 16) {
top = y - rect.height - 8;
}
if (top < 16) {
top = 16;
}
popover.style.left = `${left}px`;
popover.style.top = `${top}px`;
}
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
export function showPopover(
x: number,
y: number,
content: string,
title?: string,
duration: number = 5000,
): void {
const popover = getOrCreatePopover();
const titleHtml = title
? `<div class="popover-header">
<span class="popover-title">${title}</span>
<button class="popover-close" onclick="this.closest('#${POPOVER_ID}').classList.remove('visible')">&times;</button>
</div>`
: '';
popover.innerHTML = `
${titleHtml}
<div class="popover-content">${content}</div>
`;
popover.classList.remove('visible');
requestAnimationFrame(() => {
positionPopover(popover, x, y);
popover.classList.add('visible');
});
if (hideTimeout) {
clearTimeout(hideTimeout);
}
if (duration > 0) {
hideTimeout = setTimeout(() => {
hidePopover();
}, duration);
}
}
export function hidePopover(): void {
const popover = document.getElementById(POPOVER_ID);
if (popover) {
popover.classList.remove('visible');
}
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
const content = `
<div class="popover-label">输入时间戳</div>
<div class="popover-value">${timestamp}</div>
<div class="popover-label">转换结果</div>
<div class="popover-value">${result}</div>
`;
showPopover(x, y, content, '⏰ 时间戳转换');
}
export function showTextStatsResult(
x: number,
y: number,
text: string,
stats: { characters: number; words: number; lines: number; bytes: number },
): void {
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
const content = `
<div class="popover-label">选中文本</div>
<div class="popover-value">${truncatedText}</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-label">字符</div>
<div class="stat-value">${stats.characters}</div>
</div>
<div class="stat-item">
<div class="stat-label">单词</div>
<div class="stat-value">${stats.words}</div>
</div>
<div class="stat-item">
<div class="stat-label">行数</div>
<div class="stat-value">${stats.lines}</div>
</div>
<div class="stat-item">
<div class="stat-label">字节</div>
<div class="stat-value">${stats.bytes}</div>
</div>
</div>
`;
showPopover(x, y, content, '📊 文本统计');
}
+26 -9
View File
@@ -1,31 +1,48 @@
{
"pageTitle": "QR Code Tools",
"pageSubtitle": "Generate and parse QR codes",
"urlToQr": "URL to QR Code",
"qrToUrl": "QR Code to URL",
"urlInputLabel": "Enter URL",
"urlInputPlaceholder": "https://example.com",
"generateMode": "Generate QR Code",
"parseMode": "Parse QR Code",
"urlToQr": "Text to QR Code",
"qrToUrl": "QR Code to Text",
"urlInputLabel": "Enter URL or Text",
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
"generateButton": "Generate QR Code",
"generating": "Generating...",
"qrCodeWillShow": "QR code will be shown here",
"downloadButton": "Download QR Code",
"copyQrButton": "Copy QR Code Image",
"qrCodeSuccess": "QR code generated successfully",
"qrCodeDownloadSuccess": "QR code downloaded successfully",
"qrCodeCopySuccess": "QR code copied to clipboard",
"selectImage": "Please select a QR code image",
"parseSuccess": "QR code parsed successfully",
"noQrDetected": "No QR code detected",
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
"parseError": "Failed to parse QR code, please try again",
"imagePasted": "Image pasted successfully",
"generateError": "Failed to generate QR code, please check the input",
"copyError": "Copy failed, please try again",
"imagePasted": "Image pasted, parsing...",
"imagePasteError": "Failed to paste image, please try again",
"imageCleared": "Image cleared",
"clickToUpload": "Click, drag, or paste to upload QR code image",
"supportFormats": "Supports PNG, JPG, WEBP formats",
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
"parseButton": "Parse QR Code",
"parsing": "Parsing...",
"resultLabel": "Parsing Result",
"copyTooltip": "Copy",
"enterUrlError": "Please enter a URL",
"clickToChange": "Click to change image"
"enterUrlError": "Please enter URL or text",
"clickToChange": "Click to change image",
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
"autoGenerateHint": "QR code will be generated automatically after input",
"autoParseHint": "QR code will be parsed automatically after upload"
}
+26 -9
View File
@@ -1,31 +1,48 @@
{
"pageTitle": "二维码工具",
"pageSubtitle": "生成和解析二维码",
"urlToQr": "URL 转二维码",
"qrToUrl": "二维码转 URL",
"urlInputLabel": "输入 URL",
"urlInputPlaceholder": "https://example.com",
"generateMode": "生成二维码",
"parseMode": "解析二维码",
"urlToQr": "文本转二维码",
"qrToUrl": "二维码转文本",
"urlInputLabel": "输入 URL 或文本",
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
"generateButton": "生成二维码",
"generating": "生成中...",
"qrCodeWillShow": "二维码将显示在这里",
"downloadButton": "下载二维码",
"copyQrButton": "复制二维码",
"qrCodeSuccess": "二维码生成成功",
"qrCodeDownloadSuccess": "二维码下载成功",
"qrCodeCopySuccess": "二维码已复制到剪贴板",
"selectImage": "请选择二维码图片",
"parseSuccess": "二维码解析成功",
"noQrDetected": "未检测到二维码",
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
"parseError": "解析二维码失败,请重试",
"imagePasted": "图片粘贴成功",
"generateError": "生成二维码失败,请检查输入内容",
"copyError": "复制失败,请重试",
"imagePasted": "图片粘贴成功,正在解析...",
"imagePasteError": "粘贴图片失败,请重试",
"imageCleared": "图片已清除",
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
"supportFormats": "支持 PNG、JPG、WEBP 格式",
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
"parseButton": "解析二维码",
"parsing": "解析中...",
"resultLabel": "解析结果",
"copyTooltip": "复制",
"enterUrlError": "请输入 URL",
"clickToChange": "点击更换图片"
"enterUrlError": "请输入 URL 或文本",
"clickToChange": "点击更换图片",
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
"autoGenerateHint": "输入内容后将自动生成二维码",
"autoParseHint": "上传图片后将自动解析二维码"
}
+20
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton';
import { textToBase64, base64ToText } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useContextMenuData } from '@/utils/useContextMenuData';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
@@ -26,6 +27,25 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const [error, setError] = useState<string | null>(null);
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
const handleContextMenuData = useCallback(
(payload: string) => {
setInput(payload);
setDirection('decode');
setError(null);
try {
const decoded = base64ToText(payload);
setOutput(decoded);
} catch (e) {
const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
setError(i18nKey ? t(i18nKey) : message || t('conversionFailed'));
}
},
[t],
);
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const actionLabel = direction === 'encode' ? t('encode') : t('decode');
const placeholder =
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
@@ -2,6 +2,24 @@ import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Base64ConverterPage from '../index';
// Mock useLazyTranslation
vi.mock('@/utils/useLazyTranslation', () => ({
useLazyTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
isLoaded: true,
}),
}));
// Mock getEntryPointType
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
return {
...actual,
getEntryPointType: () => 'sidepanel',
};
});
// Mock 子组件
vi.mock('../TextMode', () => ({
default: () => <div data-testid="text-mode">TextMode</div>,
+2 -2
View File
@@ -1,7 +1,7 @@
import { Box, IconButton, Typography } from '@mui/material';
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { jsonDiffPageStyles } from '@/config/pageTheme';
interface DiffNavigatorProps {
@@ -13,7 +13,7 @@ interface DiffNavigatorProps {
}
export default function DiffNavigator({ total, currentIndex, onPrev, onNext }: DiffNavigatorProps) {
const { t } = useTranslation(['jsonDiff']);
const { t } = useLazyTranslation('jsonDiff');
if (total === 0) {
return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { Box, Stack, Typography, useTheme } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { jsonDiffPageStyles, surfaceTint } from '@/config/pageTheme';
import JsonTree from './JsonTree';
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types';
@@ -12,7 +12,7 @@ interface DiffResultProps {
}
export default function DiffResult({ result, viewMode, activePath }: DiffResultProps) {
const { t } = useTranslation(['jsonDiff']);
const { t } = useLazyTranslation('jsonDiff');
if (viewMode === 'sideBySide') {
return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { formatByteSize } from '@/utils/textStatistics';
import { useSnackbar } from '@/components/GlobalSnackbar';
import CopyButton from '@/components/CopyButton';
@@ -43,7 +43,7 @@ export default function JsonConvertSection({
convertFunction,
convertButtonKey = 'convertButton',
}: JsonConvertSectionProps) {
const { t } = useTranslation(['jsonFormat']);
const { t } = useLazyTranslation('jsonFormat');
const { showMessage } = useSnackbar();
const [input, setInput] = useState('');
+2 -2
View File
@@ -9,7 +9,7 @@ import {
TextField,
Typography,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import {
formatJson,
validateJson,
@@ -32,7 +32,7 @@ const INDENT_OPTIONS = [2, 4, 6, 8] as const;
* 支持一键复制格式化后的 JSON。
*/
export default function JsonFormatSection() {
const { t } = useTranslation(['jsonFormat']);
const { t } = useLazyTranslation('jsonFormat');
const { showMessage } = useSnackbar();
const [input, setInput] = useState('');
+9 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Container, Paper, Stack, Typography } from '@mui/material';
import { useSnackbar } from '@/components/GlobalSnackbar';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
@@ -7,6 +7,7 @@ import { stringifyJson, parseJwt } from '@/utils/jwt';
import CopyButton from '@/components/CopyButton';
import TextInputArea from '@/components/TextInputArea';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useContextMenuData } from '@/utils/useContextMenuData';
interface SectionProps {
title: string;
@@ -61,6 +62,13 @@ export default function Index() {
const { t } = useLazyTranslation('jwt');
const [jwtInput, setJwtInput] = useState('');
const handleContextMenuData = useCallback((payload: string) => {
const cleaned = payload.replace(/^Bearer\s*/i, '').trim();
setJwtInput(cleaned);
}, []);
useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData });
const result = useMemo(() => {
if (!jwtInput.trim()) {
return null;
-292
View File
@@ -1,292 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
CircularProgress,
IconButton,
InputAdornment,
Stack,
TextField,
Typography,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import ImageIcon from '@mui/icons-material/Image';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ClearIcon from '@mui/icons-material/Clear';
import CopyButton from '@/components/CopyButton';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useTranslation } from 'react-i18next';
interface QrCodeToUrlSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
/** 桌面端强制展开(隐藏折叠交互) */
forceExpanded?: boolean;
}
const QrCodeToUrlSection = ({
expanded,
onExpandedChange,
forceExpanded = false,
}: QrCodeToUrlSectionProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 清理预览 URL,防止内存泄漏
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
const handleFileChange = useCallback((file: File) => {
setQrCodeFile(file);
setPreviewUrl(URL.createObjectURL(file));
setParseError('');
setParsedUrl('');
}, []);
const handleClearFile = () => {
setQrCodeFile(null);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl('');
setParsedUrl('');
setParseError('');
showMessage(t('qrCode:imageCleared'), {
severity: 'success',
autoHideDuration: 1000,
});
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(true);
};
const handleDragLeave = () => {
setDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
const parseQrCode = async () => {
if (!qrCodeFile) {
showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 });
return;
}
try {
setParsing(true);
setParseError('');
setParsedUrl('');
const result = await parseQrCodeFromFile(qrCodeFile);
if (result.success && result.data) {
setParsedUrl(result.data);
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
showMessage(result.error || t('qrCode:noQrDetected'), {
severity: 'error',
autoHideDuration: 1000,
});
}
} catch (error) {
console.error('解析二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(false);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (!expanded) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [expanded, showMessage, handleFileChange, t]);
return (
<Accordion
expanded={forceExpanded ? true : expanded}
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
<LinkIcon color="success" />
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
{t('qrCode:qrToUrl')}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<Box
className="qr-flex-grow"
sx={qrCodePageStyles.DROPZONE(dragging, !!qrCodeFile)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
style={{ display: 'none' }}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{qrCodeFile ? (
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
<img
src={previewUrl}
alt="QR Code Preview"
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClearFile();
}}
sx={qrCodePageStyles.CLEAR_BUTTON}
>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:clickToChange')}
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('qrCode:clickToUpload')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('qrCode:supportFormats')}
</Typography>
</>
)}
</label>
</Box>
<Button
variant="contained"
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
onClick={parseQrCode}
disabled={parsing}
sx={qrCodePageStyles.PRIMARY_BUTTON}
>
{parsing ? t('qrCode:parsing') : t('qrCode:parseButton')}
</Button>
<Box sx={qrCodePageStyles.RESULT_INPUT}>
<TextField
label={t('qrCode:resultLabel')}
value={parsedUrl}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={parsedUrl}
tooltip={t('qrCode:copyTooltip')}
size="small"
color={qrCodePageStyles.primaryColor}
showMessage={showMessage}
/>
</InputAdornment>
),
},
}}
sx={qrCodePageStyles.INPUT_STYLE}
/>
</Box>
{parseError && (
<Alert severity="error" sx={{ borderRadius: 3 }}>
{parseError}
</Alert>
)}
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default QrCodeToUrlSection;
-187
View File
@@ -1,187 +0,0 @@
import React, { useState } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
CircularProgress,
Stack,
TextField,
Typography,
} from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import QRious from 'qrious';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useTranslation } from 'react-i18next';
interface UrlToQrCodeSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
/** 桌面端强制展开(隐藏折叠交互) */
forceExpanded?: boolean;
}
const UrlToQrCodeSection = ({
expanded,
onExpandedChange,
forceExpanded = false,
}: UrlToQrCodeSectionProps) => {
const { t } = useTranslation(['qrCode']);
const { showMessage } = useSnackbar();
const [urlInput, setUrlInput] = useState('');
const [urlError, setUrlError] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generating, setGenerating] = useState(false);
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlInput(e.target.value);
setUrlError('');
};
const generateQrCode = async () => {
if (!urlInput) {
setUrlError(t('qrCode:enterUrlError'));
return;
}
try {
setGenerating(true);
setUrlError('');
let url = urlInput;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
// 使用 QRious 替代 qrcode 库,体积更小
const qr = new QRious({
value: url,
size: 250,
level: 'H',
foreground: '#000000',
background: '#FFFFFF',
});
setQrCodeDataUrl(qr.toDataURL());
showMessage(t('qrCode:qrCodeSuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('生成二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
} finally {
setGenerating(false);
}
};
const downloadQrCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 300 });
};
const copyQrCode = async () => {
if (!qrCodeDataUrl) return;
try {
const response = await fetch(qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 });
}
};
return (
<Accordion
expanded={forceExpanded ? true : expanded}
onChange={forceExpanded ? undefined : (_, isExpanded) => onExpandedChange(isExpanded)}
sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={qrCodePageStyles.ACCORDION_SUMMARY}>
<Box sx={qrCodePageStyles.ACCORDION_TITLE_ICON}>
<QrCodeIcon color="primary" />
<Typography variant="subtitle1" sx={qrCodePageStyles.ACCORDION_TITLE_TEXT}>
{t('qrCode:urlToQr')}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<TextField
label={t('qrCode:urlInputLabel')}
placeholder={t('qrCode:urlInputPlaceholder')}
value={urlInput}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!urlError}
helperText={urlError}
sx={qrCodePageStyles.INPUT_STYLE}
/>
<Button
variant="contained"
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
onClick={generateQrCode}
disabled={generating}
sx={qrCodePageStyles.PRIMARY_BUTTON}
>
{generating ? t('qrCode:generating') : t('qrCode:generateButton')}
</Button>
<Box className="qr-flex-grow" sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
{qrCodeDataUrl ? (
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={downloadQrCode}
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
>
{t('qrCode:downloadButton')}
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={copyQrCode}
sx={qrCodePageStyles.COPY_BUTTON}
>
{t('qrCode:copyQrButton')}
</Button>
</Box>
</Box>
) : (
<Typography
variant="body2"
color="text.secondary"
sx={qrCodePageStyles.PLACEHOLDER_TEXT}
>
{t('qrCode:qrCodeWillShow')}
</Typography>
)}
</Box>
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default UrlToQrCodeSection;
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import QrCodePage from '../index';
// Mock useLazyTranslation
vi.mock('@/utils/useLazyTranslation', () => ({
useLazyTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
isLoaded: true,
}),
}));
// Mock getEntryPointType
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
return {
...actual,
getEntryPointType: () => 'sidepanel',
};
});
// Mock 子组件
vi.mock('@/components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
}));
vi.mock('@/components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>,
}));
// Mock QRious
vi.mock('qrious', () => ({
default: vi.fn().mockImplementation(() => ({
toDataURL: () => 'data:image/png;base64,mock',
})),
}));
// Mock useSnackbar
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
}),
}));
describe('QrCodePage', () => {
it('应该默认渲染生成模式', () => {
render(<QrCodePage />);
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
it('应该渲染模式切换按钮', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument();
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
});
it('应该渲染页面标题', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
});
it('切换到解析模式应该渲染 ImageUploader', () => {
render(<QrCodePage />);
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
});
it('切换回生成模式应该渲染 QrCodePreview', () => {
render(<QrCodePage />);
// 先切换到解析模式
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
// 再切换回生成模式
fireEvent.click(screen.getByText('qrCode:urlToQr'));
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
it('应该渲染输入区域', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
});
it('应该渲染双栏布局容器', () => {
const { container } = render(<QrCodePage />);
const gridContainer = container.querySelector('.MuiGrid-container');
expect(gridContainer).toBeInTheDocument();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { Grid } from '@mui/material';
import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useQrCodeContext } from '../contexts/QrCodeContext';
export default function GeneratePanel() {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<TextInputArea
title={t('qrCode:urlInputLabel')}
value={generatorState.textToEncode}
onChange={setTextToEncode}
placeholder={t('qrCode:urlInputPlaceholder')}
showCount
showClear
allowCopy
externalError={generatorState.inputError}
showMessage={showMessage}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</Grid>
</Grid>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useCallback } from 'react';
import { Grid } from '@mui/material';
import TextInputArea from '@/components/TextInputArea';
import ImageUploader from '@/components/ImageUploader';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useQrCodeContext } from '../contexts/QrCodeContext';
export default function ParsePanel() {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
// 全局粘贴事件监听
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
// 检查是否有图片
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
}
return;
}
}
// 检查是否有 Base64 字符串
const text = e.clipboardData?.getData('text/plain');
if (text && text.startsWith('data:image/')) {
e.preventDefault();
try {
const response = await fetch(text);
const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type });
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理 Base64 图片失败:', error);
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
}
}
},
[handleFileChange, showMessage, t],
);
useEffect(() => {
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [handlePaste]);
return (
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<ImageUploader
selectedFile={parserState.selectedFile}
onFileChange={handleFileChange}
onClearFile={handleClearFile}
previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
dragging={parserState.dragging}
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextInputArea
title={t('qrCode:resultLabel')}
value={parserState.decodedResult}
readOnly
showClear={false}
allowCopy
placeholder=""
externalError={parserState.parseError}
showMessage={showMessage}
/>
</Grid>
</Grid>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext } from 'react';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
export interface QrCodeContextValue {
// 模式
mode: QrCodeMode;
setMode: (mode: QrCodeMode) => void;
// 生成器状态
generatorState: QrCodeGeneratorState;
setTextToEncode: (text: string) => void;
generateQrCode: (text: string) => Promise<void>;
downloadQrCode: () => void;
copyQrCode: () => Promise<void>;
// 解析器状态
parserState: QrCodeParserState;
setParserState: React.Dispatch<React.SetStateAction<QrCodeParserState>>;
parseQrCode: (file: File) => Promise<void>;
handleFileChange: (file: File) => void;
handleClearFile: () => void;
}
export const QrCodeContext = createContext<QrCodeContextValue | null>(null);
export function useQrCodeContext() {
const context = useContext(QrCodeContext);
if (!context) {
throw new Error('useQrCodeContext must be used within QrCodeProvider');
}
return context;
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import QRious from 'qrious';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeMode, QrCodeGeneratorState, QrCodeParserState } from '../types';
export function useQrCode(): QrCodeContextValue {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
// 当前模式
const [mode, setMode] = useState<QrCodeMode>('generate');
// 二维码生成器状态
const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
textToEncode: '',
qrCodeDataUrl: '',
generating: false,
inputError: '',
});
// 二维码解析器状态
const [parserState, setParserState] = useState<QrCodeParserState>({
decodedResult: '',
parsing: false,
parseError: '',
selectedFile: null,
previewUrl: '',
dragging: false,
});
// 生成二维码
const generateQrCode = useCallback(
async (text: string) => {
if (!text) {
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: '' }));
return;
}
try {
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
let url = text;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const qr = new QRious({
value: url,
size: 250,
level: 'H',
foreground: '#000000',
background: '#FFFFFF',
});
setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: qr.toDataURL() }));
} catch (error) {
console.error('生成二维码失败:', error);
setGeneratorState((prev) => ({
...prev,
inputError: t('qrCode:generateError'),
}));
showMessage(t('qrCode:generateError'), { severity: 'error', autoHideDuration: 3000 });
} finally {
setGeneratorState((prev) => ({ ...prev, generating: false }));
}
},
[t, showMessage],
);
// 使用 useRef 存储 generateQrCode 的最新引用,避免无限循环
const generateQrCodeRef = useRef(generateQrCode);
generateQrCodeRef.current = generateQrCode;
// 防抖处理输入文本(200ms
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200);
// 当防抖后的文本变化时,自动生成二维码
useEffect(() => {
if (debouncedTextToEncode && mode === 'generate') {
generateQrCodeRef.current(debouncedTextToEncode);
}
}, [debouncedTextToEncode, mode]);
// 设置输入文本
const setTextToEncode = useCallback((text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text }));
}, []);
// 处理右键菜单数据
const handleContextMenuData = useCallback((payload: string) => {
setMode('generate');
setGeneratorState((prev) => ({ ...prev, textToEncode: payload }));
}, []);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
// 解析二维码
const parseQrCode = useCallback(
async (file: File) => {
try {
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
const result = await parseQrCodeFromFile(file);
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
},
[t, showMessage],
);
// 下载二维码
const downloadQrCode = useCallback(() => {
if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
}, [generatorState.qrCodeDataUrl, showMessage, t]);
// 复制二维码
const copyQrCode = useCallback(async () => {
if (!generatorState.qrCodeDataUrl) return;
try {
const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
}
}, [generatorState.qrCodeDataUrl, showMessage, t]);
// 处理文件选择
const handleFileChange = useCallback(
(file: File) => {
setParserState((prev) => {
// 释放旧的预览 URL
if (prev.previewUrl) {
URL.revokeObjectURL(prev.previewUrl);
}
return {
...prev,
selectedFile: file,
previewUrl: URL.createObjectURL(file),
decodedResult: '',
parseError: '',
};
});
// 自动解析
parseQrCode(file);
},
[parseQrCode],
);
// 清除文件
const handleClearFile = useCallback(() => {
if (parserState.previewUrl) {
URL.revokeObjectURL(parserState.previewUrl);
}
setParserState((prev) => ({
...prev,
selectedFile: null,
previewUrl: '',
decodedResult: '',
parseError: '',
}));
}, [parserState.previewUrl]);
return {
mode,
setMode,
generatorState,
setTextToEncode,
generateQrCode,
downloadQrCode,
copyQrCode,
parserState,
setParserState,
parseQrCode,
handleFileChange,
handleClearFile,
};
}
+38 -60
View File
@@ -1,74 +1,52 @@
import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material';
import { Box, Container, useMediaQuery, useTheme } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/pages/QrCode/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState';
import { qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode';
import GeneratePanel from './components/GeneratePanel';
import ParsePanel from './components/ParsePanel';
import type { QrCodeMode } from './types';
export default function Index() {
const { t } = useLazyTranslation('qrCode');
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
const qrCode = useQrCode();
// 使用自定义钩子管理展开状态(移动端使用)
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
// 初始化未完成时显示加载状态
if (!urlInitialized || !qrInitialized) {
return (
<Container sx={qrCodePageStyles.LOADING_CONTAINER}>
<CircularProgress />
</Container>
);
}
const sections = isDesktop ? (
<>
<Box sx={qrCodePageStyles.GRID_CELL}>
<UrlToQrCodeSection
expanded={urlExpanded}
onExpandedChange={setUrlExpanded}
forceExpanded={isDesktop}
/>
</Box>
<Box sx={qrCodePageStyles.GRID_CELL}>
<QrCodeToUrlSection
expanded={qrExpanded}
onExpandedChange={setQrExpanded}
forceExpanded={isDesktop}
/>
</Box>
</>
) : (
<>
<UrlToQrCodeSection expanded={urlExpanded} onExpandedChange={setUrlExpanded} />
<QrCodeToUrlSection expanded={qrExpanded} onExpandedChange={setQrExpanded} />
</>
);
// 模式选项
const modeOptions = [
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') },
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') },
];
return (
<Box>
<Container
maxWidth={isDesktop ? 'lg' : false}
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
>
<PageHeader
title={t('qrCode:pageTitle')}
subtitle={t('qrCode:pageSubtitle')}
icon={<QrCodeIcon />}
iconColor={qrCodePageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
<QrCodeContext.Provider value={qrCode}>
<Box>
<Container
maxWidth={isDesktop ? 'lg' : false}
sx={{ py: 2, maxWidth: isDesktop ? undefined : 400 }}
>
<PageHeader
title={t('qrCode:pageTitle')}
subtitle={t('qrCode:pageSubtitle')}
icon={<QrCodeIcon />}
iconColor={qrCodePageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
{isDesktop ? (
<Box sx={qrCodePageStyles.LAYOUT_GRID}>{sections}</Box>
) : (
<Stack spacing={3}>{sections}</Stack>
)}
</Container>
</Box>
<SwitchButtonGroup
value={qrCode.mode}
options={modeOptions}
onChange={qrCode.setMode}
sx={{ mb: 3 }}
/>
{qrCode.mode === 'generate' ? <GeneratePanel /> : <ParsePanel />}
</Container>
</Box>
</QrCodeContext.Provider>
);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 二维码工具页面的状态类型定义
*/
/** 二维码生成模式 */
export type QrCodeMode = 'generate' | 'parse';
/** 二维码生成器的状态 */
export interface QrCodeGeneratorState {
/** 输入文本(URL 或任意文本) */
textToEncode: string;
/** 生成的二维码 Data URL */
qrCodeDataUrl: string;
/** 是否正在生成 */
generating: boolean;
/** 输入错误信息 */
inputError: string;
}
/** 二维码解析器的状态 */
export interface QrCodeParserState {
/** 解析结果文本 */
decodedResult: string;
/** 是否正在解析 */
parsing: boolean;
/** 解析错误信息 */
parseError: string;
/** 当前选中的文件 */
selectedFile: File | null;
/** 文件预览 URL */
previewUrl: string;
/** 是否正在拖拽 */
dragging: boolean;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Box, Switch, Typography } from '@mui/material';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface AutoRefreshToggleProps {
autoRefresh: boolean;
@@ -8,7 +8,7 @@ interface AutoRefreshToggleProps {
}
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
return (
<Box sx={storageCleanerPageStyles.AUTO_REFRESH_CONTAINER}>
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
+2 -2
View File
@@ -2,14 +2,14 @@ import { Alert, Box } from '@mui/material';
import type { CleaningResult } from '@/types/storage';
import { formatCleaningResult } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface CleaningResultProps {
result: CleaningResult | null;
}
export default function CleaningResult({ result }: CleaningResultProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
if (!result) return null;
return (
+2 -2
View File
@@ -3,7 +3,7 @@ import StorageIcon from '@mui/icons-material/Storage';
import PageHeader from '@/components/PageHeader';
import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
/**
* DomainHeader 组件属性接口
@@ -29,7 +29,7 @@ interface DomainHeaderProps {
* ```
*/
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
return (
<PageHeader
icon={<StorageIcon sx={{ fontSize: 22 }} />}
+2 -2
View File
@@ -1,14 +1,14 @@
import { Box, Container, Typography } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ErrorDisplayProps {
error: string;
}
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
return (
<Container sx={storageCleanerPageStyles.ERROR_DISPLAY_CONTAINER}>
<Box sx={{ width: '100%', maxWidth: 320 }}>
+2 -2
View File
@@ -1,7 +1,7 @@
import { Box, Checkbox, Typography } from '@mui/material';
import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface OptionItemProps {
labelKey: string;
@@ -18,7 +18,7 @@ export default function OptionItem({
isCount = false,
onChange,
}: OptionItemProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
return (
<Box sx={storageCleanerPageStyles.OPTION_ITEM(checked)}>
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
@@ -10,7 +10,7 @@ import {
import type { StorageCleanerOptions } from '@/types/storage';
import Button from '@/components/Button';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export interface StorageCleanerConfirmProps {
open: boolean;
@@ -25,7 +25,7 @@ export function StorageCleanerConfirm({
onConfirm,
options,
}: StorageCleanerConfirmProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
const selectedOptions = Object.entries(options)
.filter(([_, value]) => value)
+2 -2
View File
@@ -2,7 +2,7 @@ import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage';
import OptionItem from './OptionItem';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface StorageOptionsGridProps {
options: StorageCleanerOptions;
@@ -21,7 +21,7 @@ export default function StorageOptionsGrid({
onOptionChange,
onSelectAll,
}: StorageOptionsGridProps) {
const { t } = useTranslation(['storageCleaner']);
const { t } = useLazyTranslation('storageCleaner');
const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [
{ key: 'localStorage' },
+2 -2
View File
@@ -18,7 +18,7 @@ import {
isRestrictedUrl,
} from '@/utils/storageCleaner';
import { MessageAction, sendMessage } from '@/utils/messages';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
@@ -66,7 +66,7 @@ export interface UseStorageCleanerOptions {
export function useStorageCleaner({
showMessage,
}: UseStorageCleanerOptions): UseStorageCleanerReturn {
const { t } = useTranslation(['storageCleaner', 'common']);
const { t } = useLazyTranslation(['storageCleaner', 'common']);
const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>('');
const [isInitializing, setIsInitializing] = useState<boolean>(true);
+8 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { alpha, Box, Container, Grid, Paper, Typography } from '@mui/material';
import PageHeader from '@/components/PageHeader';
import TextInputArea from '@/components/TextInputArea';
@@ -6,6 +6,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
import { textStatisticsPageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useContextMenuData } from '@/utils/useContextMenuData';
/**
* 文本统计页面组件
@@ -16,6 +17,12 @@ export default function Index() {
const { t } = useLazyTranslation('textStatistics');
const [text, setText] = useState('');
const handleContextMenuData = useCallback((payload: string) => {
setText(payload);
}, []);
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
// 实时计算统计信息,使用 useMemo 优化性能
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
const stats = useMemo(() => getTextStats(text), [text]);
+2 -2
View File
@@ -5,7 +5,7 @@ import CopyButton from '@/components/CopyButton';
import { useSnackbar } from '@/components/GlobalSnackbar';
import type { UnitType } from '@/config/pageTheme';
import { timestampPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface LiveClockProps {
unit: UnitType;
@@ -14,7 +14,7 @@ interface LiveClockProps {
const LiveClock = React.memo(({ unit, onUseNow }: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now());
const { t } = useTranslation(['timestamp']);
const { t } = useLazyTranslation('timestamp');
const { showMessage } = useSnackbar();
const onUseNowRef = useRef(onUseNow);
+2 -2
View File
@@ -4,7 +4,7 @@ import dayjs from '@/utils/dayjs';
import CopyButton from '@/components/CopyButton';
import type { UnitType } from '@/config/pageTheme';
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ResultViewProps {
result: string;
@@ -17,7 +17,7 @@ interface ResultViewProps {
const ResultView = React.memo(
({ result, mode, unit, zone, showEmptyPlaceholder = false }: ResultViewProps) => {
const { t } = useTranslation(['timestamp']);
const { t } = useLazyTranslation('timestamp');
const extraInfo = useMemo(() => {
if (!result) return null;
+54 -2
View File
@@ -2,7 +2,8 @@ import { useCallback, useState } from 'react';
import dayjs from '@/utils/dayjs';
import type { UnitType, ZoneType } from '@/config/pageTheme';
import { DATE_FORMAT } from '@/config/pageTheme';
import { useTranslation } from 'react-i18next';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useContextMenuData } from '@/utils/useContextMenuData';
export interface UseTimestampConverterReturn {
// State
@@ -23,8 +24,17 @@ export interface UseTimestampConverterReturn {
convert: () => void;
}
/**
* 判断输入是否为时间戳(纯数字或长度 >= 10 的数字字符串)
*/
function isTimestampLike(input: string): boolean {
const trimmed = input.trim();
if (!/^\d+$/.test(trimmed)) return false;
return trimmed.length >= 10;
}
export function useTimestampConverter(): UseTimestampConverterReturn {
const { t } = useTranslation(['timestamp']);
const { t } = useLazyTranslation('timestamp');
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
const [tsInput, setTsInput] = useState(() => String(Date.now()));
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
@@ -63,6 +73,48 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
}
}, [mode, tsInput, dtInput, unit, zone, t]);
// 处理右键菜单传递的数据
const handleContextMenuData = useCallback(
(payload: string) => {
const trimmed = payload.trim();
if (isTimestampLike(trimmed)) {
// 看起来是时间戳,切换到 ts2dt 模式
setMode('ts2dt');
setTsInput(trimmed);
// 如果是 13 位毫秒级时间戳,自动选择 ms 单位
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
setUnit(detectedUnit);
// 直接执行转换
const num = Number(trimmed);
if (!isNaN(num)) {
const d = detectedUnit === 'ms' ? dayjs(num) : dayjs.unix(num);
if (d.isValid()) {
setError('');
setResult(d.tz(zone).format(DATE_FORMAT));
}
}
} else {
// 尝试作为日期时间解析
const d = dayjs(trimmed);
if (d.isValid()) {
setMode('dt2ts');
setDtInput(d.format(DATE_FORMAT));
// 直接执行转换
const ms = d.valueOf();
setError('');
setResult(String(ms));
} else {
// 无法识别,作为时间戳处理
setMode('ts2dt');
setTsInput(trimmed);
}
}
},
[zone],
);
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
const handleUseNow = useCallback(
(now: number) => {
if (mode === 'ts2dt') {
+48 -1
View File
@@ -7,13 +7,14 @@ import {
useRef,
useState,
} from 'react';
import type { PageType, StorageSchema } from '@/types/storage';
import type { PageType, StorageSchema, ContextMenuPendingData } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import {
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
} from '@/config/features';
import { saveContextMenuData, CONTEXT_MENU_DATA_EXPIRY_MS } from '@/utils/useContextMenuData';
/**
* 校验是否为合法的页面类型
@@ -187,6 +188,40 @@ export function RouterProvider({
.then(() => {
if (!cancelled) {
setIsLoaded(true);
// 检查 URL 参数中的右键菜单数据
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const feature = params.get('feature') as PageType | null;
const payload = params.get('payload');
if (feature && payload && isValidPage(feature)) {
saveContextMenuData({ featureKey: feature, payload }).catch(console.error);
navigateTo(feature);
// 清理 URL 参数
const url = new URL(window.location.href);
url.searchParams.delete('feature');
url.searchParams.delete('payload');
window.history.replaceState({}, '', url.toString());
return;
}
}
// 检查 storage 中的右键菜单待处理数据(用于 openPopup 场景)
storageUtil
.get('contextMenu/pendingData', undefined)
.then((pendingData) => {
if (
pendingData &&
isValidPage(pendingData.featureKey) &&
Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS
) {
navigateTo(pendingData.featureKey as PageType);
// 不在这里清除数据,让目标页面的 useContextMenuData 来消费和清除
}
})
.catch(console.error);
}
})
.catch(console.error);
@@ -253,6 +288,18 @@ export function RouterProvider({
setPageOrder(newOrder);
}
}
// 监听右键菜单数据变化,自动跳转到对应页面(用于 popup 已打开的场景)
if (changes['contextMenu/pendingData']) {
const newData = changes['contextMenu/pendingData']
.newValue as ContextMenuPendingData | null;
if (
newData &&
isValidPage(newData.featureKey) &&
Date.now() - newData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS
) {
setCurrentPage(newData.featureKey as PageType);
}
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
+14
View File
@@ -126,6 +126,20 @@ export interface StorageSchema {
'htmlToMarkdown/previewMode': HtmlToMarkdownPreviewMode;
/** 语言偏好设置 */
'app/language': string;
/** 右键菜单待处理数据 */
'contextMenu/pendingData': ContextMenuPendingData;
}
/**
* 右键菜单待处理数据
*/
export interface ContextMenuPendingData {
/** 功能标识 */
featureKey: PageType;
/** 捕获的文本或图片 URL */
payload: string;
/** 数据创建时间戳 */
timestamp: number;
}
/**
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
CONTEXT_MENU_CONFIGS,
createAllContextMenus,
parseContextMenuClick,
MAX_PAYLOAD_LENGTH,
} from '@/utils/contextMenu';
describe('contextMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('CONTEXT_MENU_CONFIGS', () => {
it('应该包含 7 个菜单项配置', () => {
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7);
});
it('应该有一个父级菜单项 Testing Tools', () => {
const parentMenu = CONTEXT_MENU_CONFIGS.find((c) => c.id === 'testing-tools-parent');
expect(parentMenu).toBeDefined();
expect(parentMenu?.title).toBe('Testing Tools');
expect(parentMenu?.parentId).toBeUndefined();
});
it('应该有 4 个 selection 上下文的子菜单', () => {
const selectionMenus = CONTEXT_MENU_CONFIGS.filter(
(c) => c.contexts[0] === 'selection' && c.parentId === 'testing-tools-parent',
);
expect(selectionMenus).toHaveLength(4);
expect(selectionMenus.map((m) => m.id)).toEqual([
'jwt',
'base64Converter',
'textStatistics',
'timestamp',
]);
});
it('应该有 2 个 page 上下文的子菜单', () => {
const pageMenus = CONTEXT_MENU_CONFIGS.filter(
(c) => c.contexts[0] === 'page' && c.parentId === 'testing-tools-parent',
);
expect(pageMenus).toHaveLength(2);
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
});
});
describe('createAllContextMenus', () => {
it('应该为每个配置调用 chrome.contextMenus.create', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7);
});
it('应该使用正确的参数创建菜单项', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
id: 'testing-tools-parent',
title: 'Testing Tools',
contexts: ['all'],
parentId: undefined,
});
expect(chrome.contextMenus.create).toHaveBeenCalledWith({
id: 'jwt',
title: '🔑 解析 JWT',
contexts: ['selection'],
parentId: 'testing-tools-parent',
});
});
});
describe('parseContextMenuClick', () => {
const createMockOnClickData = (
overrides: Partial<chrome.contextMenus.OnClickData> = {},
): chrome.contextMenus.OnClickData => ({
menuItemId: 'test',
editable: false,
pageUrl: 'https://example.com',
...overrides,
});
it('当点击 qrCode-page 菜单时应返回 qrCode 功能和 pageUrl', () => {
const info = createMockOnClickData({
pageUrl: 'https://example.com/page',
});
const result = parseContextMenuClick('qrCode-page', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'qrCode', payload: 'https://example.com/page' },
});
});
it('当点击有 selectionText 的菜单时应返回对应功能和选中文本', () => {
const info = createMockOnClickData({
selectionText: 'selected text',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'selected text' },
});
});
it('当点击 timestamp 菜单时应正确映射功能键', () => {
const info = createMockOnClickData({
selectionText: '1234567890',
});
const result = parseContextMenuClick('timestamp', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'timestamp', payload: '1234567890' },
});
});
it('当点击 storageCleaner 菜单时应返回 pageUrl', () => {
const info = createMockOnClickData({
pageUrl: 'https://example.com',
});
const result = parseContextMenuClick('storageCleaner', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'storageCleaner', payload: 'https://example.com' },
});
});
it('当没有 selectionText 和 pageUrl 时应返回错误', () => {
const info = createMockOnClickData({
pageUrl: undefined,
});
const result = parseContextMenuClick('someMenu', info);
expect(result).toEqual({
success: false,
error: '无法获取有效数据',
});
});
it('selectionText 优先于 pageUrl', () => {
const info = createMockOnClickData({
selectionText: 'selected text',
pageUrl: 'https://example.com',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'selected text' },
});
});
it('当文本超过最大长度限制时应截断', () => {
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
const info = createMockOnClickData({
selectionText: longText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result.success).toBe(true);
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
});
it('当文本未超过最大长度限制时应保持原样', () => {
const shortText = 'short text';
const info = createMockOnClickData({
selectionText: shortText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'textStatistics', payload: 'short text' },
});
});
});
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useContextMenuData,
saveContextMenuData,
clearContextMenuData,
} from '@/utils/useContextMenuData';
describe('useContextMenuData', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('saveContextMenuData', () => {
it('应该保存数据到 storage 并添加时间戳', async () => {
const data = { featureKey: 'jwt' as const, payload: 'test-token' };
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
await saveContextMenuData(data);
expect(chrome.storage.local.set).toHaveBeenCalledWith({
'contextMenu/pendingData': {
featureKey: 'jwt',
payload: 'test-token',
timestamp: 1704110400000,
},
});
});
it('应该正确处理不同的 featureKey', async () => {
const data = { featureKey: 'timestamp' as const, payload: '1234567890' };
await saveContextMenuData(data);
expect(chrome.storage.local.set).toHaveBeenCalledWith(
expect.objectContaining({
'contextMenu/pendingData': expect.objectContaining({
featureKey: 'timestamp',
payload: '1234567890',
}),
}),
);
});
});
describe('clearContextMenuData', () => {
it('应该从 storage 中删除数据', async () => {
await clearContextMenuData();
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
});
});
describe('useContextMenuData Hook', () => {
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
const mockData = {
featureKey: 'jwt',
payload: 'test-token',
timestamp: Date.now(),
};
(chrome.storage.local.get as any).mockResolvedValue({
'contextMenu/pendingData': mockData,
});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
await vi.waitFor(() => {
expect(onData).toHaveBeenCalledWith('test-token');
});
});
it('当 storage 中没有数据时不应调用 onData 回调', async () => {
(chrome.storage.local.get as any).mockResolvedValue({});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
await vi.waitFor(() => {
expect(onData).not.toHaveBeenCalled();
});
});
it('当 featureKey 不匹配时不应调用 onData 回调', async () => {
const mockData = {
featureKey: 'timestamp',
payload: '1234567890',
timestamp: Date.now(),
};
(chrome.storage.local.get as any).mockResolvedValue({
'contextMenu/pendingData': mockData,
});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
await vi.waitFor(() => {
expect(onData).not.toHaveBeenCalled();
});
});
it('当数据过期时不应调用 onData 回调并删除数据', async () => {
const now = Date.now();
vi.setSystemTime(now);
const mockData = {
featureKey: 'jwt',
payload: 'test-token',
timestamp: now - 6000,
};
(chrome.storage.local.get as any).mockResolvedValue({
'contextMenu/pendingData': mockData,
});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
await vi.waitFor(() => {
expect(onData).not.toHaveBeenCalled();
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
});
});
it('消费数据后应删除 storage 中的数据', async () => {
const mockData = {
featureKey: 'jwt',
payload: 'test-token',
timestamp: Date.now(),
};
(chrome.storage.local.get as any).mockResolvedValue({
'contextMenu/pendingData': mockData,
});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
await vi.waitFor(() => {
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
});
});
it('当 storage 变化且 featureKey 匹配时应调用 onData 回调', async () => {
(chrome.storage.local.get as any).mockResolvedValue({});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
const mockData = {
featureKey: 'jwt',
payload: 'new-token',
timestamp: Date.now(),
};
(chrome.storage.local.get as any).mockResolvedValue({
'contextMenu/pendingData': mockData,
});
await act(async () => {
storageChangeHandler({
'contextMenu/pendingData': { newValue: mockData },
});
});
await vi.waitFor(() => {
expect(onData).toHaveBeenCalledWith('new-token');
});
});
it('当 storage 变化但 featureKey 不匹配时不应调用 onData 回调', async () => {
(chrome.storage.local.get as any).mockResolvedValue({});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
const mockData = {
featureKey: 'timestamp',
payload: '1234567890',
timestamp: Date.now(),
};
await act(async () => {
storageChangeHandler({
'contextMenu/pendingData': { newValue: mockData },
});
});
expect(onData).not.toHaveBeenCalled();
});
it('当 storage 变化但数据被删除时不应调用 onData 回调', async () => {
(chrome.storage.local.get as any).mockResolvedValue({});
const onData = vi.fn();
renderHook(() => useContextMenuData({ featureKey: 'jwt', onData }));
const storageChangeHandler = (chrome.storage.onChanged.addListener as any).mock.calls[0][0];
await act(async () => {
storageChangeHandler({
'contextMenu/pendingData': { newValue: null },
});
});
expect(onData).not.toHaveBeenCalled();
});
it('组件卸载时应移除 storage 变化监听器', () => {
const { unmount } = renderHook(() =>
useContextMenuData({ featureKey: 'jwt', onData: vi.fn() }),
);
unmount();
expect(chrome.storage.onChanged.removeListener).toHaveBeenCalled();
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { PageType } from '@/types/storage';
export interface ContextMenuItemConfig {
id: string;
title: string;
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
parentId?: string;
}
export interface ContextMenuClickedInfo {
featureKey: PageType;
payload: string;
}
export interface ParseResult {
success: boolean;
data?: ContextMenuClickedInfo;
error?: string;
}
const PARENT_MENU_ID = 'testing-tools-parent';
export const MAX_PAYLOAD_LENGTH = 10000;
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
'qrCode-page': 'qrCode',
};
/**
* 将菜单项 ID 转换为 PageType
* 如果存在显式映射则使用映射,否则直接使用 menuItemId
*/
function getMenuPageType(menuItemId: string): PageType {
return MENU_ID_TO_PAGE_TYPE[menuItemId] ?? (menuItemId as PageType);
}
export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
{
id: PARENT_MENU_ID,
title: 'Testing Tools',
contexts: [chrome.contextMenus.ContextType.ALL],
},
{
id: 'jwt',
title: '🔑 解析 JWT',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'base64Converter',
title: '🔄 Base64 解码',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'textStatistics',
title: '📊 统计选中文本',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'timestamp',
title: '⏰ 转换时间戳',
contexts: [chrome.contextMenus.ContextType.SELECTION],
parentId: PARENT_MENU_ID,
},
{
id: 'storageCleaner',
title: '🧹 清理当前网站存储',
contexts: [chrome.contextMenus.ContextType.PAGE],
parentId: PARENT_MENU_ID,
},
{
id: 'qrCode-page',
title: '🔗 网页链接转二维码',
contexts: [chrome.contextMenus.ContextType.PAGE],
parentId: PARENT_MENU_ID,
},
];
export function createAllContextMenus(): void {
for (const config of CONTEXT_MENU_CONFIGS) {
chrome.contextMenus.create({
id: config.id,
title: config.title,
contexts: config.contexts,
parentId: config.parentId,
});
}
}
export function parseContextMenuClick(
menuItemId: string,
info: chrome.contextMenus.OnClickData,
): ParseResult {
const featureKey = getMenuPageType(menuItemId);
if (info.selectionText) {
const text = info.selectionText;
if (text.length > MAX_PAYLOAD_LENGTH) {
return {
success: true,
data: { featureKey, payload: text.substring(0, MAX_PAYLOAD_LENGTH) },
};
}
return {
success: true,
data: { featureKey, payload: text },
};
}
if (info.pageUrl) {
return {
success: true,
data: { featureKey, payload: info.pageUrl },
};
}
return { success: false, error: '无法获取有效数据' };
}
+7
View File
@@ -3,6 +3,7 @@ import { defineExtensionMessaging } from '@webext-core/messaging';
export enum MessageAction {
RELOAD_TAB = 'reloadTab',
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
}
export interface MessageResponse {
@@ -11,9 +12,15 @@ export interface MessageResponse {
error?: string;
}
export interface ContextMenuClickedPayload {
featureKey: string;
payload: string;
}
export interface ProtocolMap {
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import type { ContextMenuPendingData, PageType } from '@/types/storage';
const STORAGE_KEY = 'contextMenu/pendingData' as const;
/** 右键菜单数据过期时间(毫秒) */
export const CONTEXT_MENU_DATA_EXPIRY_MS = 5000;
export interface UseContextMenuDataOptions {
/** 当前页面的功能标识 */
featureKey: PageType;
/** 收到数据时的回调函数 */
onData: (payload: string) => void;
}
/**
* 自定义 Hook:处理右键菜单传递的数据
*
* 使用方式:
* 1. 在页面组件中调用此 Hook
* 2. 传入当前页面的 featureKey 和数据处理回调
* 3. Hook 会自动从 storage 中读取并消费匹配的数据
*/
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
const checkAndConsumeData = useCallback(async () => {
try {
const data = await storageUtil.get(STORAGE_KEY, undefined);
if (!data) return;
if (data.featureKey !== featureKey) return;
if (Date.now() - data.timestamp > CONTEXT_MENU_DATA_EXPIRY_MS) {
await storageUtil.remove(STORAGE_KEY);
return;
}
await storageUtil.remove(STORAGE_KEY);
onData(data.payload);
} catch (error) {
console.error('[useContextMenuData] 处理右键菜单数据失败:', error);
}
}, [featureKey, onData]);
useEffect(() => {
checkAndConsumeData();
}, [checkAndConsumeData]);
useEffect(() => {
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes[STORAGE_KEY]) {
const newData = changes[STORAGE_KEY].newValue as ContextMenuPendingData | null;
if (newData && newData.featureKey === featureKey) {
checkAndConsumeData();
}
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [featureKey, checkAndConsumeData]);
}
/**
* 保存右键菜单数据到 storage
* 由 RouterProvider 或入口组件调用
*/
export async function saveContextMenuData(
data: Omit<ContextMenuPendingData, 'timestamp'>,
): Promise<void> {
const pendingData: ContextMenuPendingData = {
...data,
timestamp: Date.now(),
};
await storageUtil.set(STORAGE_KEY, pendingData);
}
/**
* 清除右键菜单待处理数据
*/
export async function clearContextMenuData(): Promise<void> {
await storageUtil.remove(STORAGE_KEY);
}
+24
View File
@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
/**
* useDebounce Hook - 防抖值
*
* @param value - 需要防抖的值
* @param delay - 延迟时间(毫秒)
* @returns 防抖后的值
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
+22
View File
@@ -70,6 +70,28 @@ Object.defineProperty(global, 'chrome', {
getAll: vi.fn().mockResolvedValue([]),
remove: vi.fn().mockResolvedValue(undefined),
},
contextMenus: {
create: vi.fn(),
onClicked: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
ContextType: {
ALL: 'all',
PAGE: 'page',
FRAME: 'frame',
SELECTION: 'selection',
LINK: 'link',
EDITABLE: 'editable',
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
LAUNCHER: 'launcher',
BROWSER_ACTION: 'browser_action',
PAGE_ACTION: 'page_action',
ACTION: 'action',
},
},
},
writable: true,
});
+58 -9
View File
@@ -1,4 +1,59 @@
import { defineConfig } from 'wxt';
import type { Plugin } from 'vite';
/**
* 仅在 HTML 多入口构建(popup / options / sidepanel)中启用 manualChunks 拆分 vendor。
* WXT 对 background / content-script 使用 lib 模式(IIFE),该模式不支持 manualChunks。
*/
function manualChunksForHtmlOnly(): Plugin {
return {
name: 'manual-chunks-html-only',
outputOptions(rawOptions) {
// lib 模式(IIFE)不支持 manualChunks,仅对 ES/CJS 格式启用
if (rawOptions.format === 'iife' || rawOptions.format === 'umd') {
return;
}
rawOptions.manualChunks = (id: string) => {
if (!id.includes('node_modules')) return;
// React 核心
if (id.includes('/react-dom/') || (id.includes('/react/') && !id.includes('/react-dom/'))) {
return 'vendor-react';
}
// MUI + Emotion
if (
id.includes('@mui/') ||
id.includes('@emotion/') ||
id.includes('hoist-non-react-statics') ||
id.includes('csstype') ||
id.includes('@popperjs/')
) {
return 'vendor-mui';
}
// 国际化
if (
id.includes('i18next') ||
id.includes('react-i18next') ||
id.includes('intl-messageformat')
) {
return 'vendor-i18n';
}
// QR 相关
if (id.includes('qr-scanner') || id.includes('qrious')) {
return 'vendor-qr';
}
// 拖拽
if (id.includes('@dnd-kit')) {
return 'vendor-dnd';
}
// Markdown
if (id.includes('marked')) {
return 'vendor-markdown';
}
};
},
};
}
// See https://wxt.dev/api/config.html
export default defineConfig({
@@ -16,6 +71,7 @@ export default defineConfig({
'tabs',
'cookies',
'sidePanel',
'contextMenus',
],
host_permissions: ['<all_urls>'],
action: {
@@ -30,19 +86,12 @@ export default defineConfig({
},
},
vite: () => ({
plugins: [manualChunksForHtmlOnly()],
build: {
// 1. 切换压缩器为 terser
minify: 'terser',
// 2. 配置 terser 强制转义所有非 ASCII 字符
terserOptions: {
format: {
ascii_only: true,
comments: false,
},
format: { ascii_only: true, comments: false },
},
// 3. 调整 chunk 大小警告阈值
chunkSizeWarningLimit: 600,
},
}),