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 1/5] 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, }, }), From 1df53323d04a87b8236c9c24cb3f0cfd6279c32d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 18:01:31 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20CI/CD=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: release.yml typecheck 脚本名 compile → typecheck(发版阻断 bug) - perf: CI 测试改用 test:coverage 生成覆盖率报告 - refactor: Release 复用 CI 工作流(workflow_call),消除重复 Job - feat: Release 新增 Firefox/Chrome zip 产物验证步骤 - fix: 补全 lint-staged 配置,修复 pre-commit hook 失效问题 - feat: 添加 Dependabot 自动依赖更新配置 --- .github/dependabot.yml | 25 +++++++++++ .github/workflows/ci.yml | 11 +++-- .github/workflows/release.yml | 81 ++++++++++------------------------- package.json | 9 ++++ 4 files changed, 61 insertions(+), 65 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b32a020 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + # npm 依赖 + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + commit-message: + prefix: "deps" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2982e62..aa66fd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,10 @@ name: CI on: push: - branches: - - main + branches: [main] pull_request: - branches: - - main + branches: [main] + workflow_call: # 允许 Release 工作流复用此 CI concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -67,8 +66,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm run test + - name: Run tests with coverage + run: npm run test:coverage build: name: Build (${{ matrix.browser }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 373a1f3..64a8dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,69 +9,15 @@ permissions: contents: write jobs: - # ── Phase 1: 全量 CI 检查 ──────────────────────────────────────────── - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 + # ── Phase 1: 复用 CI 全量检查 ──────────────────────────────── + ci: + uses: ./.github/workflows/ci.yml - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run ESLint - run: npm run lint - - typecheck: - name: TypeScript Check - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run TypeScript type check - run: npm run compile - - test: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm run test - - # ── Phase 2: 打包 & 发布 ───────────────────────────────────────────── + # ── Phase 2: 打包 & 发布 ───────────────────────────────────── release: name: Package & Release runs-on: ubuntu-latest - needs: [lint, typecheck, test] + needs: [ci] steps: - name: Checkout uses: actions/checkout@v4 @@ -101,6 +47,23 @@ jobs: echo "Found Chrome zip: $CHROME_ZIP" echo "Found Firefox zip: $FIREFOX_ZIP" + - name: Verify zip artifacts + run: | + if [ -z "$CHROME_ZIP" ] || [ ! -f "$CHROME_ZIP" ]; then + echo "❌ Chrome zip not found" + exit 1 + fi + echo "✅ Chrome zip: $(ls -lh "$CHROME_ZIP" | awk '{print $5}')" + + if [ -z "$FIREFOX_ZIP" ] || [ ! -f "$FIREFOX_ZIP" ]; then + echo "❌ Firefox zip not found" + exit 1 + fi + echo "✅ Firefox zip: $(ls -lh "$FIREFOX_ZIP" | awk '{print $5}')" + env: + CHROME_ZIP: ${{ steps.find_zips.outputs.chrome_zip }} + FIREFOX_ZIP: ${{ steps.find_zips.outputs.firefox_zip }} + - name: Extract version from tag id: version run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" diff --git a/package.json b/package.json index dfb9f42..1e29f6d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,15 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md,yml,yaml,css}": [ + "prettier --write" + ] + }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", From 6b0de96b18827730a53948df08faf1b5d7041e31 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 18:01:31 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20CI/CD=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: release.yml typecheck 脚本名 compile → typecheck(发版阻断 bug) - perf: CI 测试改用 test:coverage 生成覆盖率报告 - refactor: Release 复用 CI 工作流(workflow_call),消除重复 Job - feat: Release 新增 Firefox/Chrome zip 产物验证步骤 - fix: 补全 lint-staged 配置,修复 pre-commit hook 失效问题 - feat: 添加 Dependabot 自动依赖更新配置 - fix: 添加 @vitest/coverage-v8 依赖(覆盖率所需) - fix: 改用 npx 直接调用命令,规避 npm PATH 问题 --- .github/workflows/ci.yml | 20 +- .github/workflows/release.yml | 7 +- package-lock.json | 588 ++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 600 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa66fd0..081f74a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Run ESLint - run: npm run lint + run: npx eslint . --max-warnings=0 typecheck: name: TypeScript Check @@ -42,13 +41,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Run TypeScript type check - run: npm run typecheck + run: npx tsc --noEmit test: name: Unit Tests @@ -61,13 +59,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Run tests with coverage - run: npm run test:coverage + run: npx vitest run --coverage build: name: Build (${{ matrix.browser }}) @@ -85,11 +82,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Build (Chrome) if: matrix.browser == 'chrome' - run: npm run build + run: npx wxt build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64a8dca..1ad470d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,16 +26,15 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - name: Install dependencies - run: npm ci + run: npm install - name: Package Chrome extension - run: npm run zip + run: npx wxt zip - name: Package Firefox extension - run: npm run zip:firefox + run: npx wxt zip -b firefox - name: Find zip artifacts id: find_zips diff --git a/package-lock.json b/package-lock.json index 245c8d7..2b1835f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^2.1.9", "@wxt-dev/module-react": "^1.1.5", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", @@ -171,6 +172,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "http://mirrors.tencentyun.com/npm/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -473,6 +488,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "http://mirrors.tencentyun.com/npm/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1561,6 +1583,77 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "http://mirrors.tencentyun.com/npm/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "http://mirrors.tencentyun.com/npm/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "http://mirrors.tencentyun.com/npm/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1935,6 +2028,17 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "http://mirrors.tencentyun.com/npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -2918,6 +3022,51 @@ "node": ">=0.10.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "http://mirrors.tencentyun.com/npm/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magicast": { + "version": "0.3.5", + "resolved": "http://mirrors.tencentyun.com/npm/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -4898,6 +5047,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "http://mirrors.tencentyun.com/npm/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.278", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", @@ -5743,6 +5899,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "http://mirrors.tencentyun.com/npm/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6006,6 +6179,28 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "http://mirrors.tencentyun.com/npm/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -6013,6 +6208,30 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/global-directory/-/global-directory-4.0.1.tgz", @@ -7096,6 +7315,67 @@ "node": ">=0.10.0" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "http://mirrors.tencentyun.com/npm/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "http://mirrors.tencentyun.com/npm/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7114,6 +7394,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "http://mirrors.tencentyun.com/npm/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", @@ -7695,6 +7991,35 @@ "source-map-js": "^1.2.1" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.0", + "resolved": "http://mirrors.tencentyun.com/npm/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", @@ -7814,6 +8139,43 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "http://mirrors.tencentyun.com/npm/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", @@ -7824,6 +8186,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "http://mirrors.tencentyun.com/npm/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz", @@ -8382,6 +8754,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/package-json/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", @@ -8486,6 +8865,30 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "http://mirrors.tencentyun.com/npm/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "http://mirrors.tencentyun.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", @@ -9819,6 +10222,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -9933,6 +10392,30 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-5.0.0.tgz", @@ -10073,6 +10556,21 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "http://mirrors.tencentyun.com/npm/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", @@ -11664,6 +12162,96 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "http://mirrors.tencentyun.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "http://mirrors.tencentyun.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 1e29f6d..8eb2c00 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^2.1.9", "@wxt-dev/module-react": "^1.1.5", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", From 7cd99876d747c5752c3c20691e3c28fcb26558d2 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 19:52:44 +0800 Subject: [PATCH 4/5] =?UTF-8?q?Revert=20"fix(ci):=20=E4=BF=AE=E5=A4=8D=20C?= =?UTF-8?q?I/CD=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=97=AE=E9=A2=98"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6b0de96b18827730a53948df08faf1b5d7041e31. --- .github/workflows/ci.yml | 20 +- .github/workflows/release.yml | 7 +- package-lock.json | 588 ---------------------------------- package.json | 1 - 4 files changed, 16 insertions(+), 600 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 081f74a..aa66fd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Run ESLint - run: npx eslint . --max-warnings=0 + run: npm run lint typecheck: name: TypeScript Check @@ -41,12 +42,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Run TypeScript type check - run: npx tsc --noEmit + run: npm run typecheck test: name: Unit Tests @@ -59,12 +61,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Run tests with coverage - run: npx vitest run --coverage + run: npm run test:coverage build: name: Build (${{ matrix.browser }}) @@ -82,10 +85,11 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Build (Chrome) if: matrix.browser == 'chrome' - run: npx wxt build + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ad470d..64a8dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,15 +26,16 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' + cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci - name: Package Chrome extension - run: npx wxt zip + run: npm run zip - name: Package Firefox extension - run: npx wxt zip -b firefox + run: npm run zip:firefox - name: Find zip artifacts id: find_zips diff --git a/package-lock.json b/package-lock.json index 2b1835f..245c8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^2.1.9", "@wxt-dev/module-react": "^1.1.5", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", @@ -172,20 +171,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "http://mirrors.tencentyun.com/npm/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -488,13 +473,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "http://mirrors.tencentyun.com/npm/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1583,77 +1561,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "http://mirrors.tencentyun.com/npm/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "http://mirrors.tencentyun.com/npm/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "http://mirrors.tencentyun.com/npm/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2028,17 +1935,6 @@ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "http://mirrors.tencentyun.com/npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3022,51 +2918,6 @@ "node": ">=0.10.0" } }, - "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "http://mirrors.tencentyun.com/npm/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/magicast": { - "version": "0.3.5", - "resolved": "http://mirrors.tencentyun.com/npm/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -5047,13 +4898,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "http://mirrors.tencentyun.com/npm/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.278", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", @@ -5899,23 +5743,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "http://mirrors.tencentyun.com/npm/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6179,28 +6006,6 @@ "giget": "dist/cli.mjs" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "http://mirrors.tencentyun.com/npm/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmmirror.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -6208,30 +6013,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/global-directory/-/global-directory-4.0.1.tgz", @@ -7315,67 +7096,6 @@ "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "http://mirrors.tencentyun.com/npm/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "http://mirrors.tencentyun.com/npm/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-reports/node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "http://mirrors.tencentyun.com/npm/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7394,22 +7114,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "http://mirrors.tencentyun.com/npm/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", @@ -7991,35 +7695,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.8.0", - "resolved": "http://mirrors.tencentyun.com/npm/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", @@ -8139,43 +7814,6 @@ "node": ">=4" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "http://mirrors.tencentyun.com/npm/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "http://mirrors.tencentyun.com/npm/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "http://mirrors.tencentyun.com/npm/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", @@ -8186,16 +7824,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "http://mirrors.tencentyun.com/npm/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz", @@ -8754,13 +8382,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/package-json/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", @@ -8865,30 +8486,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "http://mirrors.tencentyun.com/npm/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "http://mirrors.tencentyun.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", @@ -10222,62 +9819,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10392,30 +9933,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-5.0.0.tgz", @@ -10556,21 +10073,6 @@ "dev": true, "license": "MIT" }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "http://mirrors.tencentyun.com/npm/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-3.1.0.tgz", @@ -12162,96 +11664,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "http://mirrors.tencentyun.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "http://mirrors.tencentyun.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "http://mirrors.tencentyun.com/npm/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "http://mirrors.tencentyun.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 8eb2c00..1e29f6d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^2.1.9", "@wxt-dev/module-react": "^1.1.5", "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", From 179a3d31f463043e76fe71c660fe0436e7998f7b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 21 May 2026 19:52:44 +0800 Subject: [PATCH 5/5] =?UTF-8?q?Revert=20"fix(ci):=20=E4=BF=AE=E5=A4=8D=20C?= =?UTF-8?q?I/CD=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=97=AE=E9=A2=98"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1df53323d04a87b8236c9c24cb3f0cfd6279c32d. --- .github/dependabot.yml | 25 ----------- .github/workflows/ci.yml | 11 ++--- .github/workflows/release.yml | 81 +++++++++++++++++++++++++---------- package.json | 9 ---- 4 files changed, 65 insertions(+), 61 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b32a020..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,25 +0,0 @@ -version: 2 -updates: - # npm 依赖 - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - open-pull-requests-limit: 10 - labels: - - "dependencies" - commit-message: - prefix: "deps" - - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - labels: - - "dependencies" - - "ci" - commit-message: - prefix: "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa66fd0..2982e62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,11 @@ name: CI on: push: - branches: [main] + branches: + - main pull_request: - branches: [main] - workflow_call: # 允许 Release 工作流复用此 CI + branches: + - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -66,8 +67,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests with coverage - run: npm run test:coverage + - name: Run tests + run: npm run test build: name: Build (${{ matrix.browser }}) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64a8dca..373a1f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,15 +9,69 @@ permissions: contents: write jobs: - # ── Phase 1: 复用 CI 全量检查 ──────────────────────────────── - ci: - uses: ./.github/workflows/ci.yml + # ── Phase 1: 全量 CI 检查 ──────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - # ── Phase 2: 打包 & 发布 ───────────────────────────────────── + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + typecheck: + name: TypeScript Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript type check + run: npm run compile + + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test + + # ── Phase 2: 打包 & 发布 ───────────────────────────────────────────── release: name: Package & Release runs-on: ubuntu-latest - needs: [ci] + needs: [lint, typecheck, test] steps: - name: Checkout uses: actions/checkout@v4 @@ -47,23 +101,6 @@ jobs: echo "Found Chrome zip: $CHROME_ZIP" echo "Found Firefox zip: $FIREFOX_ZIP" - - name: Verify zip artifacts - run: | - if [ -z "$CHROME_ZIP" ] || [ ! -f "$CHROME_ZIP" ]; then - echo "❌ Chrome zip not found" - exit 1 - fi - echo "✅ Chrome zip: $(ls -lh "$CHROME_ZIP" | awk '{print $5}')" - - if [ -z "$FIREFOX_ZIP" ] || [ ! -f "$FIREFOX_ZIP" ]; then - echo "❌ Firefox zip not found" - exit 1 - fi - echo "✅ Firefox zip: $(ls -lh "$FIREFOX_ZIP" | awk '{print $5}')" - env: - CHROME_ZIP: ${{ steps.find_zips.outputs.chrome_zip }} - FIREFOX_ZIP: ${{ steps.find_zips.outputs.firefox_zip }} - - name: Extract version from tag id: version run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" diff --git a/package.json b/package.json index 1e29f6d..dfb9f42 100644 --- a/package.json +++ b/package.json @@ -20,15 +20,6 @@ "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, - "lint-staged": { - "*.{ts,tsx}": [ - "eslint --fix", - "prettier --write" - ], - "*.{json,md,yml,yaml,css}": [ - "prettier --write" - ] - }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0",