From a5c34e542816c2fc431c71b34fe97fd8a0a67436 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Thu, 21 May 2026 01:34:35 +0800 Subject: [PATCH] Merge develop into main (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- components/ImageUploader.tsx | 179 +++++++++++ components/PageHeader.tsx | 21 +- components/QrCodePreview.tsx | 56 ++++ components/__tests__/ImageUploader.test.tsx | 208 +++++++++++++ components/__tests__/PageHeader.test.tsx | 52 +++- components/__tests__/QrCodePreview.test.tsx | 92 ++++++ config/pageTheme.ts | 6 +- entrypoints/__tests__/background.test.ts | 144 +++++++++ entrypoints/background.ts | 46 ++- entrypoints/content/contextMenuHandler.ts | 62 ++++ entrypoints/content/messageHandler.ts | 6 +- entrypoints/content/uiPopover.ts | 247 +++++++++++++++ i18n/locales/en/qrCode.json | 35 ++- i18n/locales/zh/qrCode.json | 35 ++- pages/Base64Converter/TextMode.tsx | 20 ++ .../Base64Converter/__tests__/index.test.tsx | 18 ++ pages/JsonTools/DiffNavigator.tsx | 4 +- pages/JsonTools/DiffResult.tsx | 4 +- pages/JsonTools/JsonConvertSection.tsx | 4 +- pages/JsonTools/JsonFormatSection.tsx | 4 +- pages/Jwt/index.tsx | 10 +- pages/QrCode/QrCodeToUrlSection.tsx | 292 ------------------ pages/QrCode/UrlToQrCodeSection.tsx | 187 ----------- pages/QrCode/__tests__/index.test.tsx | 91 ++++++ pages/QrCode/components/GeneratePanel.tsx | 37 +++ pages/QrCode/components/ParsePanel.tsx | 86 ++++++ pages/QrCode/contexts/QrCodeContext.ts | 32 ++ pages/QrCode/hooks/useQrCode.ts | 212 +++++++++++++ pages/QrCode/index.tsx | 98 +++--- pages/QrCode/types.ts | 34 ++ pages/StorageCleaner/AutoRefreshToggle.tsx | 4 +- pages/StorageCleaner/CleaningResult.tsx | 4 +- pages/StorageCleaner/DomainHeader.tsx | 4 +- pages/StorageCleaner/ErrorDisplay.tsx | 4 +- pages/StorageCleaner/OptionItem.tsx | 4 +- .../StorageCleaner/StorageCleanerConfirm.tsx | 4 +- pages/StorageCleaner/StorageOptionsGrid.tsx | 4 +- pages/StorageCleaner/useStorageCleaner.ts | 4 +- pages/TextStatistics/index.tsx | 9 +- pages/Timestamp/LiveClock.tsx | 4 +- pages/Timestamp/ResultView.tsx | 4 +- pages/Timestamp/useTimestampConverter.ts | 56 +++- providers/RouterProvider.tsx | 49 ++- types/storage.d.ts | 14 + utils/__tests__/contextMenu.test.ts | 189 ++++++++++++ utils/__tests__/useContextMenuData.test.ts | 225 ++++++++++++++ utils/contextMenu.ts | 123 ++++++++ utils/messages.ts | 7 + utils/useContextMenuData.ts | 85 +++++ utils/useDebounce.ts | 24 ++ vitest.setup.ts | 22 ++ wxt.config.ts | 67 +++- 52 files changed, 2610 insertions(+), 622 deletions(-) create mode 100644 components/ImageUploader.tsx create mode 100644 components/QrCodePreview.tsx create mode 100644 components/__tests__/ImageUploader.test.tsx create mode 100644 components/__tests__/QrCodePreview.test.tsx create mode 100644 entrypoints/__tests__/background.test.ts create mode 100644 entrypoints/content/contextMenuHandler.ts create mode 100644 entrypoints/content/uiPopover.ts delete mode 100644 pages/QrCode/QrCodeToUrlSection.tsx delete mode 100644 pages/QrCode/UrlToQrCodeSection.tsx create mode 100644 pages/QrCode/__tests__/index.test.tsx create mode 100644 pages/QrCode/components/GeneratePanel.tsx create mode 100644 pages/QrCode/components/ParsePanel.tsx create mode 100644 pages/QrCode/contexts/QrCodeContext.ts create mode 100644 pages/QrCode/hooks/useQrCode.ts create mode 100644 pages/QrCode/types.ts create mode 100644 utils/__tests__/contextMenu.test.ts create mode 100644 utils/__tests__/useContextMenuData.test.ts create mode 100644 utils/contextMenu.ts create mode 100644 utils/useContextMenuData.ts create mode 100644 utils/useDebounce.ts diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx new file mode 100644 index 0000000..c1144af --- /dev/null +++ b/components/ImageUploader.tsx @@ -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(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) => { + if (e.target.files && e.target.files.length > 0) { + handleFileChange(e.target.files[0]); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + onDraggingChange(true); + }; + + const handleDragLeave = () => { + onDraggingChange(false); + }; + + const handleDrop = (e: React.DragEvent) => { + 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 ( + + + + + ); +}; + +export default ImageUploader; diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx index f708a9c..b08050d 100644 --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -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 ( {/* 图标容器 */} @@ -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, }} diff --git a/components/QrCodePreview.tsx b/components/QrCodePreview.tsx new file mode 100644 index 0000000..424397e --- /dev/null +++ b/components/QrCodePreview.tsx @@ -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 ( + + + {t('qrCode:qrCodeWillShow')} + + + ); + } + + return ( + + + QR Code + + + + + + + ); +}; + +export default QrCodePreview; diff --git a/components/__tests__/ImageUploader.test.tsx b/components/__tests__/ImageUploader.test.tsx new file mode 100644 index 0000000..b618578 --- /dev/null +++ b/components/__tests__/ImageUploader.test.tsx @@ -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(); + expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument(); + expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument(); + }); + + it('当没有选中文件时应显示 ImageIcon', () => { + render(); + expect(screen.getByTestId('ImageIcon')).toBeInTheDocument(); + }); + + it('当选中文件时应显示文件预览', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + render( + , + ); + expect(screen.getByText('test.png')).toBeInTheDocument(); + expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument(); + }); + + it('当选中文件时应显示预览图片', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + render( + , + ); + 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( + , + ); + expect(screen.getByTestId('ClearIcon')).toBeInTheDocument(); + }); + + it('应包含隐藏的文件输入框', () => { + render(); + 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(); + 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(); + const dropzone = container.firstChild as HTMLElement; + + fireEvent.dragOver(dropzone); + expect(mockOnDraggingChange).toHaveBeenCalledWith(true); + }); + + it('拖拽离开时应调用 onDraggingChange(false)', () => { + const { container } = render(); + const dropzone = container.firstChild as HTMLElement; + + fireEvent.dragLeave(dropzone); + expect(mockOnDraggingChange).toHaveBeenCalledWith(false); + }); + + it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => { + const { container } = render(); + 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( + , + ); + + 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( + , + ); + + 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(); + + expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); + }); + + it('组件卸载时应移除粘贴事件监听', () => { + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + const { unmount } = render(); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); + }); + }); + + describe('样式测试', () => { + it('拖拽状态时应应用拖拽样式', () => { + const { container } = render(); + const dropzone = container.firstChild as HTMLElement; + expect(dropzone).toBeInTheDocument(); + }); + + it('有文件时应应用有文件样式', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + const { container } = render( + , + ); + const dropzone = container.firstChild as HTMLElement; + expect(dropzone).toBeInTheDocument(); + }); + }); +}); diff --git a/components/__tests__/PageHeader.test.tsx b/components/__tests__/PageHeader.test.tsx index a601f24..00908df 100644 --- a/components/__tests__/PageHeader.test.tsx +++ b/components/__tests__/PageHeader.test.tsx @@ -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({ui}); +} + describe('PageHeader 组件系统', () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,32 +33,37 @@ describe('PageHeader 组件系统', () => { describe('PageHeader UI 渲染', () => { it('应渲染页面标题栏&副标题', () => { - render(); + renderWithTheme(); expect(screen.getByText('时间戳转换')).toBeInTheDocument(); expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument(); }); it('应渲染图标', () => { - render(); + renderWithTheme(); expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument(); }); it('应渲染自定义图标&图标颜色', () => { - render(} iconColor="#FF0000" />); + renderWithTheme(} iconColor="#FF0000" />); expect(screen.getByTestId('CloseIcon')).toBeInTheDocument(); expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;'); }); + it('应默认使用主题 primary 色', () => { + renderWithTheme(} />); + expect(screen.getByTestId('CloseIcon')).toHaveStyle(`color: ${theme.palette.primary.main};`); + }); + it('应渲染 badge 组件', () => { const badge = New; - render(); + renderWithTheme(); expect(screen.getByTestId('test-badge')).toBeInTheDocument(); expect(screen.getByText('New')).toBeInTheDocument(); }); it('应渲染 badge 与 title 并排布局', () => { const badge = v1.0; - render(); + renderWithTheme(); 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(} title="仅标题" />); + const { container } = renderWithTheme( + } title="仅标题" />, + ); const captionElements = container.querySelectorAll('p'); expect(captionElements.length).toBe(0); }); it('subtitle 为空字符串时不应渲染副标题', () => { - const { container } = render( + const { container } = renderWithTheme( } title="标题" subtitle="" />, ); const captionElements = container.querySelectorAll('p'); @@ -71,14 +89,14 @@ describe('PageHeader 组件系统', () => { }); it('badge 为 undefined 时不应渲染 badge 区域', () => { - render(); + renderWithTheme(); expect(screen.queryByText('v1.0')).not.toBeInTheDocument(); }); }); describe('PageHeader 样式扩展', () => { it('iconSx 应作为属性传递给图标容器', () => { - const { container } = render( + const { container } = renderWithTheme( , ); const iconContainer = container.querySelector('div'); @@ -86,21 +104,31 @@ describe('PageHeader 组件系统', () => { }); it('titleSx 应作为属性传递给标题', () => { - render(); + renderWithTheme(); const titleEl = screen.getByText('时间戳转换'); expect(titleEl).toBeInTheDocument(); }); it('subtitleSx 应作为属性传递给副标题', () => { - render(); + renderWithTheme(); const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化'); expect(subtitleEl).toBeInTheDocument(); }); it('sx 应作为属性传递给外层容器', () => { - const { container } = render(); + const { container } = renderWithTheme(); 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(); + expect(container.innerHTML).toBe(''); + }); + }); }); diff --git a/components/__tests__/QrCodePreview.test.tsx b/components/__tests__/QrCodePreview.test.tsx new file mode 100644 index 0000000..efe3d20 --- /dev/null +++ b/components/__tests__/QrCodePreview.test.tsx @@ -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(); + expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 有值时应显示二维码图片', () => { + const testDataUrl = 'data:image/png;base64,test123'; + render( + , + ); + const img = screen.getByAltText('QR Code'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', testDataUrl); + }); + + it('当 qrCodeDataUrl 有值时应显示下载按钮', () => { + render( + , + ); + expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 有值时应显示复制按钮', () => { + render( + , + ); + expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => { + render(); + expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument(); + expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument(); + }); + }); + + describe('交互测试', () => { + it('点击下载按钮时应调用 onDownload 回调', () => { + render( + , + ); + fireEvent.click(screen.getByText('qrCode:downloadButton')); + expect(mockOnDownload).toHaveBeenCalledTimes(1); + }); + + it('点击复制按钮时应调用 onCopy 回调', () => { + render( + , + ); + fireEvent.click(screen.getByText('qrCode:copyQrButton')); + expect(mockOnCopy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/config/pageTheme.ts b/config/pageTheme.ts index a4446db..22273b8 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -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, diff --git a/entrypoints/__tests__/background.test.ts b/entrypoints/__tests__/background.test.ts new file mode 100644 index 0000000..21d5d32 --- /dev/null +++ b/entrypoints/__tests__/background.test.ts @@ -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 => ({ + 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'); + }); + }); +}); diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 549ca01..daab621 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -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 未打开或无法通信,继续执行其他方案 + } + + // 保存数据到 storage,popup 打开后会读取 + 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) { diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts new file mode 100644 index 0000000..f412274 --- /dev/null +++ b/entrypoints/content/contextMenuHandler.ts @@ -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; + } + }); +} diff --git a/entrypoints/content/messageHandler.ts b/entrypoints/content/messageHandler.ts index 7993b0c..26b3743 100644 --- a/entrypoints/content/messageHandler.ts +++ b/entrypoints/content/messageHandler.ts @@ -1 +1,5 @@ -export function initMessageHandler() {} +import { initContextMenuHandler } from './contextMenuHandler'; + +export function initMessageHandler(): void { + initContextMenuHandler(); +} diff --git a/entrypoints/content/uiPopover.ts b/entrypoints/content/uiPopover.ts new file mode 100644 index 0000000..008f82c --- /dev/null +++ b/entrypoints/content/uiPopover.ts @@ -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 | null = null; + +export function showPopover( + x: number, + y: number, + content: string, + title?: string, + duration: number = 5000, +): void { + const popover = getOrCreatePopover(); + + const titleHtml = title + ? `
+ ${title} + +
` + : ''; + + popover.innerHTML = ` + ${titleHtml} +
${content}
+ `; + + 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 = ` +
输入时间戳
+
${timestamp}
+
转换结果
+
${result}
+ `; + 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 = ` +
选中文本
+
${truncatedText}
+
+
+
字符
+
${stats.characters}
+
+
+
单词
+
${stats.words}
+
+
+
行数
+
${stats.lines}
+
+
+
字节
+
${stats.bytes}
+
+
+ `; + showPopover(x, y, content, '📊 文本统计'); +} diff --git a/i18n/locales/en/qrCode.json b/i18n/locales/en/qrCode.json index a1098e1..ac529b9 100644 --- a/i18n/locales/en/qrCode.json +++ b/i18n/locales/en/qrCode.json @@ -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" } diff --git a/i18n/locales/zh/qrCode.json b/i18n/locales/zh/qrCode.json index f90698a..4a77e84 100644 --- a/i18n/locales/zh/qrCode.json +++ b/i18n/locales/zh/qrCode.json @@ -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": "上传图片后将自动解析二维码" } diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx index 6d580fa..4400141 100644 --- a/pages/Base64Converter/TextMode.tsx +++ b/pages/Base64Converter/TextMode.tsx @@ -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(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'); diff --git a/pages/Base64Converter/__tests__/index.test.tsx b/pages/Base64Converter/__tests__/index.test.tsx index fd17319..c099382 100644 --- a/pages/Base64Converter/__tests__/index.test.tsx +++ b/pages/Base64Converter/__tests__/index.test.tsx @@ -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(); + return { + ...actual, + getEntryPointType: () => 'sidepanel', + }; +}); + // Mock 子组件 vi.mock('../TextMode', () => ({ default: () =>
TextMode
, diff --git a/pages/JsonTools/DiffNavigator.tsx b/pages/JsonTools/DiffNavigator.tsx index 2573e4e..96004c1 100644 --- a/pages/JsonTools/DiffNavigator.tsx +++ b/pages/JsonTools/DiffNavigator.tsx @@ -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 ( diff --git a/pages/JsonTools/DiffResult.tsx b/pages/JsonTools/DiffResult.tsx index 1967f90..ef043e3 100644 --- a/pages/JsonTools/DiffResult.tsx +++ b/pages/JsonTools/DiffResult.tsx @@ -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 ( diff --git a/pages/JsonTools/JsonConvertSection.tsx b/pages/JsonTools/JsonConvertSection.tsx index f7a1cd5..c99beb8 100644 --- a/pages/JsonTools/JsonConvertSection.tsx +++ b/pages/JsonTools/JsonConvertSection.tsx @@ -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(''); diff --git a/pages/JsonTools/JsonFormatSection.tsx b/pages/JsonTools/JsonFormatSection.tsx index 6de69d6..966d797 100644 --- a/pages/JsonTools/JsonFormatSection.tsx +++ b/pages/JsonTools/JsonFormatSection.tsx @@ -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(''); diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index 03de6ec..4238a8b 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -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; diff --git a/pages/QrCode/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx deleted file mode 100644 index 1bc99ed..0000000 --- a/pages/QrCode/QrCodeToUrlSection.tsx +++ /dev/null @@ -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(null); - const [previewUrl, setPreviewUrl] = useState(''); - const [parsedUrl, setParsedUrl] = useState(''); - const [parseError, setParseError] = useState(''); - const [parsing, setParsing] = useState(false); - const [dragging, setDragging] = useState(false); - - const fileInputRef = useRef(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) => { - if (e.target.files && e.target.files.length > 0) { - handleFileChange(e.target.files[0]); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(true); - }; - - const handleDragLeave = () => { - setDragging(false); - }; - - const handleDrop = (e: React.DragEvent) => { - 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 ( - onExpandedChange(isExpanded)} - sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION} - > - } sx={qrCodePageStyles.ACCORDION_SUMMARY}> - - - - {t('qrCode:qrToUrl')} - - - - - - - - - - - - - - - - - ), - }, - }} - sx={qrCodePageStyles.INPUT_STYLE} - /> - - - {parseError && ( - - {parseError} - - )} - - - - ); -}; - -export default QrCodeToUrlSection; diff --git a/pages/QrCode/UrlToQrCodeSection.tsx b/pages/QrCode/UrlToQrCodeSection.tsx deleted file mode 100644 index dc7edfc..0000000 --- a/pages/QrCode/UrlToQrCodeSection.tsx +++ /dev/null @@ -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) => { - 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 ( - onExpandedChange(isExpanded)} - sx={forceExpanded ? qrCodePageStyles.ACCORDION_DESKTOP : qrCodePageStyles.ACCORDION} - > - } sx={qrCodePageStyles.ACCORDION_SUMMARY}> - - - - {t('qrCode:urlToQr')} - - - - - - - - - - - {qrCodeDataUrl ? ( - - QR Code - - - - - - ) : ( - - {t('qrCode:qrCodeWillShow')} - - )} - - - - - ); -}; - -export default UrlToQrCodeSection; diff --git a/pages/QrCode/__tests__/index.test.tsx b/pages/QrCode/__tests__/index.test.tsx new file mode 100644 index 0000000..f8b9adf --- /dev/null +++ b/pages/QrCode/__tests__/index.test.tsx @@ -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(); + return { + ...actual, + getEntryPointType: () => 'sidepanel', + }; +}); + +// Mock 子组件 +vi.mock('@/components/QrCodePreview', () => ({ + default: () =>
QrCodePreview
, +})); + +vi.mock('@/components/ImageUploader', () => ({ + default: () =>
ImageUploader
, +})); + +// 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(); + expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); + }); + + it('应该渲染模式切换按钮', () => { + render(); + expect(screen.getByText('qrCode:urlToQr')).toBeInTheDocument(); + expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument(); + }); + + it('应该渲染页面标题', () => { + render(); + expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument(); + expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument(); + }); + + it('切换到解析模式应该渲染 ImageUploader', () => { + render(); + fireEvent.click(screen.getByText('qrCode:qrToUrl')); + expect(screen.getByTestId('image-uploader')).toBeInTheDocument(); + expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument(); + }); + + it('切换回生成模式应该渲染 QrCodePreview', () => { + render(); + // 先切换到解析模式 + 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(); + expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument(); + }); + + it('应该渲染双栏布局容器', () => { + const { container } = render(); + const gridContainer = container.querySelector('.MuiGrid-container'); + expect(gridContainer).toBeInTheDocument(); + }); +}); diff --git a/pages/QrCode/components/GeneratePanel.tsx b/pages/QrCode/components/GeneratePanel.tsx new file mode 100644 index 0000000..d48835d --- /dev/null +++ b/pages/QrCode/components/GeneratePanel.tsx @@ -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 ( + + + + + + + + + ); +} diff --git a/pages/QrCode/components/ParsePanel.tsx b/pages/QrCode/components/ParsePanel.tsx new file mode 100644 index 0000000..a7414b6 --- /dev/null +++ b/pages/QrCode/components/ParsePanel.tsx @@ -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 ( + + + setParserState((prev) => ({ ...prev, previewUrl: url }))} + dragging={parserState.dragging} + onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))} + /> + + + + + + ); +} diff --git a/pages/QrCode/contexts/QrCodeContext.ts b/pages/QrCode/contexts/QrCodeContext.ts new file mode 100644 index 0000000..e89d1be --- /dev/null +++ b/pages/QrCode/contexts/QrCodeContext.ts @@ -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; + downloadQrCode: () => void; + copyQrCode: () => Promise; + + // 解析器状态 + parserState: QrCodeParserState; + setParserState: React.Dispatch>; + parseQrCode: (file: File) => Promise; + handleFileChange: (file: File) => void; + handleClearFile: () => void; +} + +export const QrCodeContext = createContext(null); + +export function useQrCodeContext() { + const context = useContext(QrCodeContext); + if (!context) { + throw new Error('useQrCodeContext must be used within QrCodeProvider'); + } + return context; +} diff --git a/pages/QrCode/hooks/useQrCode.ts b/pages/QrCode/hooks/useQrCode.ts new file mode 100644 index 0000000..cf1769d --- /dev/null +++ b/pages/QrCode/hooks/useQrCode.ts @@ -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('generate'); + + // 二维码生成器状态 + const [generatorState, setGeneratorState] = useState({ + textToEncode: '', + qrCodeDataUrl: '', + generating: false, + inputError: '', + }); + + // 二维码解析器状态 + const [parserState, setParserState] = useState({ + 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, + }; +} diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx index ecc58c7..be9262e 100644 --- a/pages/QrCode/index.tsx +++ b/pages/QrCode/index.tsx @@ -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 ( - - - - ); - } - - const sections = isDesktop ? ( - <> - - - - - - - - ) : ( - <> - - - - ); + // 模式选项 + const modeOptions = [ + { value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') }, + { value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') }, + ]; return ( - - - } - iconColor={qrCodePageStyles.primaryColor} - sx={{ mb: 2.5 }} - /> + + + + } + iconColor={qrCodePageStyles.primaryColor} + sx={{ mb: 2.5 }} + /> - {isDesktop ? ( - {sections} - ) : ( - {sections} - )} - - + + + {qrCode.mode === 'generate' ? : } + + + ); } diff --git a/pages/QrCode/types.ts b/pages/QrCode/types.ts new file mode 100644 index 0000000..24572db --- /dev/null +++ b/pages/QrCode/types.ts @@ -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; +} diff --git a/pages/StorageCleaner/AutoRefreshToggle.tsx b/pages/StorageCleaner/AutoRefreshToggle.tsx index 0b3277e..a261cc3 100644 --- a/pages/StorageCleaner/AutoRefreshToggle.tsx +++ b/pages/StorageCleaner/AutoRefreshToggle.tsx @@ -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 ( diff --git a/pages/StorageCleaner/CleaningResult.tsx b/pages/StorageCleaner/CleaningResult.tsx index 384ce1c..e166bf0 100644 --- a/pages/StorageCleaner/CleaningResult.tsx +++ b/pages/StorageCleaner/CleaningResult.tsx @@ -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 ( diff --git a/pages/StorageCleaner/DomainHeader.tsx b/pages/StorageCleaner/DomainHeader.tsx index b31ee83..999b2b8 100644 --- a/pages/StorageCleaner/DomainHeader.tsx +++ b/pages/StorageCleaner/DomainHeader.tsx @@ -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 ( } diff --git a/pages/StorageCleaner/ErrorDisplay.tsx b/pages/StorageCleaner/ErrorDisplay.tsx index 8205145..6398070 100644 --- a/pages/StorageCleaner/ErrorDisplay.tsx +++ b/pages/StorageCleaner/ErrorDisplay.tsx @@ -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 ( diff --git a/pages/StorageCleaner/OptionItem.tsx b/pages/StorageCleaner/OptionItem.tsx index 4b03b73..f4f8bfb 100644 --- a/pages/StorageCleaner/OptionItem.tsx +++ b/pages/StorageCleaner/OptionItem.tsx @@ -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 ( diff --git a/pages/StorageCleaner/StorageCleanerConfirm.tsx b/pages/StorageCleaner/StorageCleanerConfirm.tsx index 7bd92d5..0a7c3de 100644 --- a/pages/StorageCleaner/StorageCleanerConfirm.tsx +++ b/pages/StorageCleaner/StorageCleanerConfirm.tsx @@ -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) diff --git a/pages/StorageCleaner/StorageOptionsGrid.tsx b/pages/StorageCleaner/StorageOptionsGrid.tsx index f4d2d37..a631d61 100644 --- a/pages/StorageCleaner/StorageOptionsGrid.tsx +++ b/pages/StorageCleaner/StorageOptionsGrid.tsx @@ -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' }, diff --git a/pages/StorageCleaner/useStorageCleaner.ts b/pages/StorageCleaner/useStorageCleaner.ts index ddd5020..89f0f20 100644 --- a/pages/StorageCleaner/useStorageCleaner.ts +++ b/pages/StorageCleaner/useStorageCleaner.ts @@ -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(''); const [error, setError] = useState(''); const [isInitializing, setIsInitializing] = useState(true); diff --git a/pages/TextStatistics/index.tsx b/pages/TextStatistics/index.tsx index 20c58c7..2caa79e 100644 --- a/pages/TextStatistics/index.tsx +++ b/pages/TextStatistics/index.tsx @@ -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]); diff --git a/pages/Timestamp/LiveClock.tsx b/pages/Timestamp/LiveClock.tsx index 4b0ce7c..38f4aff 100644 --- a/pages/Timestamp/LiveClock.tsx +++ b/pages/Timestamp/LiveClock.tsx @@ -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); diff --git a/pages/Timestamp/ResultView.tsx b/pages/Timestamp/ResultView.tsx index 3bc5594..9faa5b6 100644 --- a/pages/Timestamp/ResultView.tsx +++ b/pages/Timestamp/ResultView.tsx @@ -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; diff --git a/pages/Timestamp/useTimestampConverter.ts b/pages/Timestamp/useTimestampConverter.ts index c86ba18..3fac4a1 100644 --- a/pages/Timestamp/useTimestampConverter.ts +++ b/pages/Timestamp/useTimestampConverter.ts @@ -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') { diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index f87c650..10017d2 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -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); diff --git a/types/storage.d.ts b/types/storage.d.ts index 4b55efb..7d95029 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -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; } /** diff --git a/utils/__tests__/contextMenu.test.ts b/utils/__tests__/contextMenu.test.ts new file mode 100644 index 0000000..49c09f9 --- /dev/null +++ b/utils/__tests__/contextMenu.test.ts @@ -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 => ({ + 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' }, + }); + }); + }); +}); diff --git a/utils/__tests__/useContextMenuData.test.ts b/utils/__tests__/useContextMenuData.test.ts new file mode 100644 index 0000000..e0c9b97 --- /dev/null +++ b/utils/__tests__/useContextMenuData.test.ts @@ -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(); + }); + }); +}); diff --git a/utils/contextMenu.ts b/utils/contextMenu.ts new file mode 100644 index 0000000..5452a9f --- /dev/null +++ b/utils/contextMenu.ts @@ -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 = { + '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: '无法获取有效数据' }; +} diff --git a/utils/messages.ts b/utils/messages.ts index 0589406..075b992 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -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(); diff --git a/utils/useContextMenuData.ts b/utils/useContextMenuData.ts new file mode 100644 index 0000000..71d353e --- /dev/null +++ b/utils/useContextMenuData.ts @@ -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, +): Promise { + const pendingData: ContextMenuPendingData = { + ...data, + timestamp: Date.now(), + }; + await storageUtil.set(STORAGE_KEY, pendingData); +} + +/** + * 清除右键菜单待处理数据 + */ +export async function clearContextMenuData(): Promise { + await storageUtil.remove(STORAGE_KEY); +} diff --git a/utils/useDebounce.ts b/utils/useDebounce.ts new file mode 100644 index 0000000..e9a70ac --- /dev/null +++ b/utils/useDebounce.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +/** + * useDebounce Hook - 防抖值 + * + * @param value - 需要防抖的值 + * @param delay - 延迟时间(毫秒) + * @returns 防抖后的值 + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/vitest.setup.ts b/vitest.setup.ts index c6fb4ec..6544534 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -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, }); diff --git a/wxt.config.ts b/wxt.config.ts index 3a04e50..2b19027 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -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: [''], 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, }, }),