From f9821553b30b42e1872e689a08750b995fb1e1e9 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Fri, 29 May 2026 23:00:21 +0800 Subject: [PATCH 1/2] chore: merge develop into main (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): fix release workflow artifact upload issue - Split artifact upload into 3 separate steps (Chrome, Firefox, Source) - Update download steps to match new artifact names - Add if-no-files-found: error for better error handling - Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern * docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 * docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 * fix: remove unnecessary animations from StorageCleaner page - Remove entrance animations (animate-in, fade-in, zoom-in) from all components - Remove scale effect (active:scale-[0.99]) from clean button - Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle - Remove bouncing animation from error icon - Remove pulsing animation from warning banner - Keep animate-spin on button loading spinner as functional indicator * fix: remove unnecessary animations from RightClickRestorer page * fix: remove all unnecessary animations from all pages - Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed) - Remove decorative transition effects (transition-all, transition-colors) from all page components - Remove scale effects (active:scale-95) from buttons - Remove bounce animations (animate-bounce) from icons - Keep functional animate-spin on loading spinners as they provide essential loading feedback Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics * docs: 添加项目文档(copilot-instructions、编码规范、各目录 README) - 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范 - 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节) - 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils - 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes Remove hand-written variantClasses/sizeClasses that duplicated shadcn's buttonVariants. Now extends ButtonProps and imports buttonVariants from components/ui/button for consistent styling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors Replace hardcoded red-200/red-50/red-600 with border-destructive, bg-destructive/5, text-destructive. Use variant='destructive' on Button. Align error log styling with PageErrorBoundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls Use existing translation keys for clear success, copy success, and copy error messages instead of hardcoded Chinese text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(QrCodePreview): use shadcn Button instead of manual button styles Replace hand-written button class strings with shadcn Button component using outline and default variants. Removes ~15 lines of duplicated Tailwind classes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(GlobalSnackbar): clarify relationship with sonner toast Add note explaining when to use GlobalSnackbar (provider context, severity levels, custom positioning) vs sonner toast (simple one-off messages). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ImageUploader): wrap handleClearFile with useCallback Prevents unnecessary re-renders by memoizing the callback, consistent with handleFileChange which already uses useCallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(SwitchButtonGroup): remove redundant containerPadding conditional Both branches of the ternary returned 'p-1'. Inline the constant directly into the className string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add missing openExtensionPage import The function was called in handleOpenInTab but never imported from utils/chromeTabs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(TopBar): add selectedIndex boundary protection in keyboard nav Guard against out-of-bounds access when search results change during keyboard navigation. Check selectedIndex < totalItems before accessing arrays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): remove unnecessary empty-dep useMemo getEntryPointType() is a simple config getter. Replace useMemo(() => ..., []) with a direct call — the empty dep array made the memo pointless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(i18n): add missing translation keys for error boundaries and copy messages Add keys for errorBoundary, pageErrorBoundary, router, and messages.copyEmpty in both zh and en locales. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls Use useTranslation('common') for tooltip, copy empty/success/error toast messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and refresh button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC Use react-i18next withTranslation HOC for class component to translate title, description, and retry button text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(RouterContainer): replace hardcoded Chinese with i18n t() calls Use useTranslation('common') for 404 page title and description with entryPointType interpolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码 - 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm) - 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232) - 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools) - MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式 - 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码 (FileMode.tsx, ImageMode.tsx 及其测试文件) - 更新 README.md 移除对已删除文件的引用 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 * refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 * fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 * refactor: 迁移 i18n 系统从 react-i18next 到 chrome.i18n - 移除 react-i18next、i18next 及相关依赖 - 删除旧的 i18n/ 目录和 useLazyTranslation 工具 - 新增 utils/chromeI18n.ts 类型安全 wrapper(useI18n Hook + getMessage) - 生成 public/_locales/{zh,en}/messages.json(298 个翻译 key) - 批量更新 39+ 组件文件的导入和翻译调用 - 转换翻译键格式:namespace:key → namespace_key - 修复 ErrorBoundary/PageErrorBoundary 从 withTranslation HOC 改为直接调用 getMessage - 更新 vitest.setup.ts mock 加载实际翻译文本 - 修复 11 个测试文件的断言以匹配中文翻译 - TypeScript、ESLint、547 项测试全部通过 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: 移除语言切换按钮 chrome.i18n 语言由浏览器设置决定,不支持运行时切换,移除 TopBar 中的语言切换按钮及相关逻辑。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: 移除英文翻译,仅保留中文 删除 public/_locales/en/ 目录,清理 chromeI18n.ts 中未使用的语言切换相关导出。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: 清理未使用的 i18n key(322 → 245) 移除 77 个未被源码引用的翻译 key,包括: - jsonFormat_* (17): 未接入的子模式标题和按钮 - buttons_* (15): 代码使用 common: 命名空间格式,直接 key 未引用 - qrCode_* (14): 未使用的页面标题和状态提示 - timestamp/common/storageCleaner/jsonDiff 等零散未用 key 保留了通过模板字面量动态引用的 key(storageCleaner_options_*、common_buttons_themeMode_*、jsonFormat_*InputPlaceholder 等)。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: reorganize directory structure into src/ Move all source code directories into src/ for cleaner project structure: - pages/, components/, utils/, config/, providers/, types/, lib/, assets/, entrypoints/ → src/ - Use WXT srcDir config to resolve @/ alias to src/ - Update tsconfig, vitest, eslint, tailwind configs - Remove scattered README.md files from subdirectories - Update documentation (AGENTS.md, CODING_STANDARDS.md, README.md) - Fix pre-existing lint error in RouterProvider.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove meaningless comments Remove 50+ noise comments across the codebase: - AI-generated verbose prose (💡 emoji, '超进化', '大闸', etc.) - Comments that restate what the code obviously does - Comments about deleted code - Import-level noise comments - Overly verbose Chinese section markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: clean up unused code and simplify imports - Remove unused imports and variables across 35 files - Simplify component logic and remove dead code - Clean up test files by removing unnecessary setup - Streamline CI workflow configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: remove HTML-Markdown conversion features (htmlToMarkdown & markdownToHtml) Removed both conversion tools and all related code: - Deleted page components: HtmlToMarkdown, MarkdownToHtml - Deleted utility functions: htmlToMarkdown.ts, markdownToHtml.ts - Deleted unit tests for both utilities - Removed feature configs and PageType entries from storage.d.ts - Removed preview mode types and StorageSchema keys - Removed all i18n translation keys from messages.json - Removed vendor-markdown chunk from wxt.config.ts - Removed marked dependency from package.json - Updated feature count in tests (11 -> 9, page order 10 -> 8) - Updated README, AGENTS.md, and copilot-instructions.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: redesign Dashboard layout with search and recently used tools - Add search input at top to filter tools by name/description - Add 'recently used' section showing last 3 used tools as compact chips - Add 'all tools' section header for the tool card grid - Track tool usage via new 'app/recentlyUsedTools' storage key - Update navigateTo to record recently used tools (max 3, LRU order) - Add i18n keys: dashboard_searchPlaceholder, dashboard_recentlyUsed, dashboard_allTools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: replace ToolCard grid with compact icon+name grid on Dashboard - Replace large ToolCard layout with dense grid (3-6 columns responsive) - Each item shows icon + tool name in a compact tile - Hover highlights icon and text with smooth transitions - Remove ToolCard import from Dashboard (component retained for reuse) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove redundant search input from Dashboard (header already has one) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: optimize TopBar search with Cmd+K shortcut and improved styling - Add global Cmd/Ctrl+K keyboard shortcut to focus search input - Show keyboard shortcut hint (⌘K) in search input when empty - Improve input styling: rounded-lg, softer borders, focus transitions - Improve dropdown: rounded-lg, larger shadow, better spacing - Larger result icons (h-8 w-8) with rounded-lg containers - Refined history section with uppercase label and muted icon - Search icon fades in on focus via group-focus-within Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: improve search history to show recently used tools with direct navigation - Store feature keys (PageType) in history instead of translated labels - Show icon + name + description in history items (same as search results) - Click or Enter on history item navigates directly to the tool page - Remove History icon import (no longer needed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: useI18n fallback for empty string translations from chrome.i18n * refactor: 移除设置页面 - 删除 options 入口点目录及相关文件 - 移除 wxt.config.ts 中 options_ui 配置 - 移除 TopBar 组件中的设置按钮和 onOpenOptions prop - 移除 popup/sidepanel App.tsx 中的 handleOpenOptions - 移除相关测试中的设置页面引用 - 移除 messages.json 中设置相关翻译键 - 通过 lint、typecheck、test 验证 * docs: 更新文档,移除设置页面引用 - README.md: 移除 entrypoints/options/ 目录说明 - AGENTS.md: 更新项目结构描述,移除 options/ 入口点 * docs: add comprehensive page development standards (CODING_STANDARDS.md §11) - 新增 §11 页面开发规范(10 个子节),覆盖目录结构分4级、入口组件命名统一为 Index、业务 Hook 内部结构顺序、常量/子组件/存储键/模式切换/布局约定等规范 - 新增 §11.9 页面开发检查清单(10 步逐项确认) - 更新 AGENTS.md 和 copilot-instructions.md 同步引用新规范章节 - 修正 useLazyTranslation → useI18n 与实际代码保持一致 * refactor(pages): apply UI+Hook separation and unify component naming - Dashboard, RightClickRestorer: rename page component to Index - Jwt: extract useJwt.ts hook + JwtSection.tsx sub-component (127→76 lines) - TextStatistics: extract useTextStatistics.ts hook (63→48 lines) - JsonTools: extract useJsonTools.ts hook + constants.ts (199→109 lines) - All pages now follow CODING_STANDARDS.md §11 pattern * refactor(StorageCleaner): improve style consistency and checkbox alignment - Group storage options, auto-refresh toggle, and clean button into unified card - Remove card wrappers from StorageOptionsGrid and AutoRefreshToggle (now sections within parent card) - Align OptionItem and Select All checkboxes with consistent px-3.5 padding - Distinguish indeterminate vs checked checkbox states visually - Standardize spacing (space-y-4), add select-none to page root - Fix import path for StorageCleanerConfirm to relative ./ * fix(StorageCleaner, Base64Converter): fix layout padding and checkbox alignment - StorageCleaner: add px-3.5 to grid container to prevent content sticking to border - StorageCleaner: adjust select-all bar to pl-3.5 pr-7 for checkbox alignment with grid items - Base64Converter: add px-2 to TextMode and Base64ConverterSection for consistent spacing Co-Authored-By: Claude Opus 4.8 * chore: add initial CLAUDE.md file with reference to @AGENT.md * docs: add comprehensive visual style guide Add VISUAL_STYLE_GUIDE.md to standardize UI development: - Design principles and keywords - Complete color system (CSS variables, semantic usage) - Typography hierarchy and patterns - Spacing, layout, and container specs - Border radius and shadow system - Component specs (Button, Input, Card, Dialog, etc.) - Interaction and animation guidelines - Dark mode implementation rules - Tool color palette and assignment - Code organization standards - Anti-patterns checklist - Quick reference appendix Update .gitignore to allow tracking the style guide. * docs(AGENTS): update i18n section to reflect chrome.i18n migration - Replace react-i18next docs with chrome.i18n API documentation - Update translation file paths from i18n/locales/ to public/_locales/ - Update test mock description for chromeI18n - Remove references to useLazyTranslation, vendor-i18n chunk, and snackbar Co-Authored-By: Claude Opus 4.8 * refactor(content): simplify content script structure - Remove messageHandler.ts (6-line pass-through facade) - Import contextMenuHandler directly from content.ts - Replace local getI18nText() with shared getMessage() from utils/chromeI18n Reduces content script files from 3 to 2 and eliminates i18n logic duplication. Co-Authored-By: Claude Opus 4.8 * refactor(RightClickRestorer): simplify setDomain logic Replace nested if/try/catch with a single ternary expression. isUnsupportedPage() already handles invalid URLs, so new URL() is safe here. Co-Authored-By: Claude Opus 4.8 * refactor(background): extract main world injection script Move the 62-line inline main world injection function from background.ts to a dedicated utils/rightClickInjection.ts file. This separates the RightClickRestorer's DOM patching logic from the service worker's message routing concerns. - background.ts: -62 lines, now delegates to imported function - utils/rightClickInjection.ts: new file with mainWorldInjectionScript() Co-Authored-By: Claude Opus 4.8 * refactor(background): simplify RELOAD_TAB with setTimeout Replace the 30-line chrome.alarms API implementation with a simple setTimeout for delayed tab reloads. Rationale: The only usage is a 1-second delay after storage cleaning. Service workers don't go idle within 1 second, so alarms' persistence benefit is unnecessary here. - Removed: alarm naming, create/clear, listener register/unregister - Added: 4-line setTimeout conditional Co-Authored-By: Claude Opus 4.8 * refactor(storageCleaner): extract executeScript helpers Extract two generic helpers to eliminate repetitive executeScript boilerplate: - runScript(): for size/query operations (5 functions) - runCleanScript(): for cleanup operations (5 functions) Also add CLEAN_OPTION_KEYS constant shared by formatCleaningResult and isEmptyResult. - 428 lines → 379 lines (-49 lines) - Eliminates ~100 lines of duplicated try/catch/result parsing Co-Authored-By: Claude Opus 4.8 * refactor(base64Converter): simplify utility functions - Mark formatFileSize() as @deprecated, delegate to formatBytes - Replace manual for-loop in base64ToBytes() with Uint8Array.from() - Replace nested loops in sniffMimeFromBytes() with Array.every() 5 insertions(+), 23 deletions(-) Co-Authored-By: Claude Opus 4.8 * refactor: replace GlobalSnackbar with sonner toast Remove the custom 373-line GlobalSnackbar component and its Provider/Hook in favor of the already-installed sonner library. Changes: - Delete src/components/GlobalSnackbar.tsx (+ tests) - Remove SnackbarProvider from popup/App.tsx and sidepanel/App.tsx - Replace useSnackbar() calls with toast.success()/toast.error() in: - ImageUploader.tsx - useQrCode.ts - ParsePanel.tsx - LiveClock.tsx - Update test mocks to use sonner instead of GlobalSnackbar -554 lines, +43 lines (net -511 lines) Co-Authored-By: Claude Opus 4.8 * refactor(RouterContainer): remove unnecessary useMemo and hoist constant - Replace useMemo with direct expression for animationClass - Move entryPointType to module scope (value is fixed after mount) Co-Authored-By: Claude Opus 4.8 * refactor: remove unnecessary useMemo calls across components Replace over-cached simple computations with direct expressions: - popup/App.tsx: getEntryPointType() and routerConfig (values are stable) - Dashboard/index.tsx: visibleSet, visibleFeatures, recentFeatures (Set creation + array filter/map are cheaper than useMemo overhead) - useTextStatistics.ts: getTextStats(text) (simple string processing) - ThemeModeProvider.tsx: contextValue object (setMode is stable via useCallback) -39 lines, +28 lines (net -11 lines) Co-Authored-By: Claude Opus 4.8 * refactor: unify formatBytes usage and simplify useContextMenuData 1. Remove formatSize/formatByteSize delegation functions: - storageCleaner.ts:formatSize (was just formatBytes wrapper) - textStatistics.ts:formatByteSize (was just formatBytes wrapper) - Update all callers to import formatBytes directly from @/utils/format 2. Simplify useContextMenuData hook: - Remove unnecessary useCallback wrapping - Inline checkAndConsumeData logic directly in useEffect - Eliminate callback->effect dependency cycle -69 lines, +67 lines (net -2 lines, but removes 2 indirection layers) Co-Authored-By: Claude Opus 4.8 * refactor(storageCleaner): simplify architecture and improve type semantics - Remove RELOAD_TAB message chain, use chrome.tabs.reload() directly - Rename IndexedDB label to '站点存储' for accuracy - Introduce StorageSizeInfo type to distinguish bytes vs count - Rename totalSize to totalBytes for clarity - Merge runCleanScript into runScript to reduce duplication - Rename CleaningResult.success to overallSuccess to avoid confusion - Remove unused domain state and setDomain call Co-Authored-By: Claude Opus 4.8 * refactor: replace require with import for JSON module in vitest.setup.ts * refactor: simplify Timestamp and RightClickRestorer Timestamp: - Extract shared utilities (msToUnit, dayjsFromTimestamp, ModeType) - Remove unnecessary useCallback/useMemo/React.memo - Remove extra info section (relative time, ISO 8601, UTC) - Fix missing translation key timestamp_unitS RightClickRestorer: - Remove website badge (右键已解锁 overlay) - Remove mouse penetration logic for media elements - Remove unnecessary useCallback - Remove redundant setIsLoading call Co-Authored-By: Claude Opus 4.8 * refactor(StorageCleanerConfirm.test.tsx): update IndexedDB label to Chinese translation --------- Co-authored-by: Ubuntu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .github/CODING_STANDARDS.md | 333 ++++++- .github/copilot-instructions.md | 4 +- .gitignore | 1 + AGENTS.md | 48 +- CLAUDE.md | 1 + docs/VISUAL_STYLE_GUIDE.md | 871 ++++++++++++++++++ i18n/README.md | 61 -- public/_locales/zh/messages.json | 6 +- src/components/GlobalSnackbar.tsx | 372 -------- src/components/ImageUploader.tsx | 19 +- src/components/RouterContainer.tsx | 11 +- .../__tests__/GlobalSnackbar.test.tsx | 117 --- .../__tests__/ImageUploader.test.tsx | 9 +- .../__tests__/RouterContainer.test.tsx | 13 +- .../__tests__/StorageCleanerConfirm.test.tsx | 2 +- src/entrypoints/background.ts | 101 +- src/entrypoints/content.ts | 4 +- src/entrypoints/content/contextMenuHandler.ts | 10 +- src/entrypoints/content/messageHandler.ts | 5 - src/entrypoints/popup/App.tsx | 45 +- src/entrypoints/rightClickRestorer.content.ts | 115 --- src/entrypoints/sidepanel/App.tsx | 15 +- .../Base64ConverterSection.tsx | 2 +- src/pages/Base64Converter/TextMode.tsx | 2 +- src/pages/Dashboard/index.tsx | 25 +- src/pages/JsonTools/JsonConvertSection.tsx | 4 +- src/pages/JsonTools/JsonFormatSection.tsx | 4 +- src/pages/JsonTools/constants.ts | 27 + src/pages/JsonTools/index.tsx | 130 +-- src/pages/JsonTools/useJsonTools.ts | 137 +++ src/pages/Jwt/JwtSection.tsx | 38 + src/pages/Jwt/index.tsx | 81 +- src/pages/Jwt/useJwt.ts | 43 + src/pages/QrCode/components/ParsePanel.tsx | 11 +- src/pages/QrCode/hooks/useQrCode.ts | 21 +- src/pages/RightClickRestorer/index.tsx | 2 +- .../useRightClickRestorer.ts | 15 +- .../StorageCleaner/AutoRefreshToggle.tsx | 11 +- src/pages/StorageCleaner/CleaningResult.tsx | 2 +- src/pages/StorageCleaner/OptionItem.tsx | 38 +- .../StorageCleaner/StorageOptionsGrid.tsx | 40 +- src/pages/StorageCleaner/index.tsx | 65 +- src/pages/StorageCleaner/useStorageCleaner.ts | 45 +- src/pages/TextStatistics/index.tsx | 19 +- src/pages/TextStatistics/useTextStatistics.ts | 23 + src/pages/Timestamp/LiveClock.tsx | 50 +- src/pages/Timestamp/ResultView.tsx | 141 +-- src/pages/Timestamp/constants.ts | 16 + src/pages/Timestamp/index.tsx | 31 +- src/pages/Timestamp/useTimestampConverter.ts | 65 +- src/providers/ThemeModeProvider.tsx | 19 +- src/types/storage.d.ts | 2 +- src/utils/__tests__/messages.test.ts | 24 +- src/utils/__tests__/storageCleaner.test.ts | 31 +- src/utils/__tests__/textStatistics.test.ts | 13 +- src/utils/base64Converter.ts | 28 +- src/utils/messages.ts | 2 - src/utils/rightClickInjection.ts | 71 ++ src/utils/storageCleaner.ts | 463 +++++----- src/utils/textStatistics.ts | 16 +- src/utils/useContextMenuData.ts | 66 +- vitest.setup.ts | 16 +- 62 files changed, 2217 insertions(+), 1785 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/VISUAL_STYLE_GUIDE.md delete mode 100644 i18n/README.md delete mode 100644 src/components/GlobalSnackbar.tsx delete mode 100644 src/components/__tests__/GlobalSnackbar.test.tsx delete mode 100644 src/entrypoints/content/messageHandler.ts create mode 100644 src/pages/JsonTools/constants.ts create mode 100644 src/pages/JsonTools/useJsonTools.ts create mode 100644 src/pages/Jwt/JwtSection.tsx create mode 100644 src/pages/Jwt/useJwt.ts create mode 100644 src/pages/TextStatistics/useTextStatistics.ts create mode 100644 src/utils/rightClickInjection.ts diff --git a/.github/CODING_STANDARDS.md b/.github/CODING_STANDARDS.md index 46c6bee..6cacda1 100644 --- a/.github/CODING_STANDARDS.md +++ b/.github/CODING_STANDARDS.md @@ -762,22 +762,335 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState( --- -## 11. 文件组织 +## 11. 页面开发规范 -### 11.1 页面组件结构 +### 11.1 目录结构(按复杂度分级) + +#### 简单页面(单一功能,无子模式) + +适用于 Timestamp、Jwt、TextStatistics、RightClickRestorer 等: ``` src/pages/FeatureName/ -├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件) -├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) -├── constants.ts # 常量定义(可选) -├── LiveClock.tsx # 子组件(可选) -├── ResultView.tsx # 子组件(可选) -└── __tests__/ # 测试文件 - └── index.test.tsx +├── index.tsx # 页面入口组件(default export) +├── useFeatureName.ts # 业务逻辑 Hook(命名导出) +├── constants.ts # 常量定义(可选,命名导出) +├── SubComponent.tsx # 子组件(可选,default export) +└── __tests__/ + └── index.test.tsx # 页面集成测试 ``` -### 11.2 目录职责 +#### 中等页面(含多个子模式/标签页切换) + +适用于 Base64Converter、StorageCleaner 等: + +``` +src/pages/FeatureName/ +├── index.tsx # 页面入口(模式路由 + 顶层布局) +├── useFeatureName.ts # 业务逻辑 Hook(命名导出) +├── SubModeA.tsx # 子模式组件 +├── SubModeB.tsx # 子模式组件 +├── SubComponent.tsx # 可复用子组件 +└── __tests__/ + ├── index.test.tsx + └── SubModeA.test.tsx +``` + +#### 复杂页面(Context + 多组件协作) + +适用于 QrCode、JsonTools 等: + +``` +src/pages/FeatureName/ +├── index.tsx # 页面入口(Provider + 布局) +├── types.ts # 页面专属类型定义 +├── constants.ts # 常量(可选) +├── contexts/ # React Context 定义 +│ └── FeatureContext.ts +├── hooks/ # 页面专属 Hooks +│ └── useFeature.ts +├── components/ # 页面专属子组件 +│ ├── PanelA.tsx +│ └── PanelB.tsx +└── __tests__/ + ├── index.test.tsx + └── useFeature.test.ts +``` + +#### 特殊情况(单个文件即可) + +功能极简的页面(如 Dashboard),仅需 `index.tsx` 一个文件。当 `index.tsx` 超过 **150 行**时,应拆分为 UI + Hook 模式。 + +--- + +### 11.2 页面入口组件(`index.tsx`)规范 + +#### 组件命名 + +- 页面入口组件**统一使用 `Index` 作为函数名**,通过 `export default` 导出 +- 使用 `export default function Index()` 而非匿名默认导出 +- **禁止**混用 `XxxPage` 命名(当前 `RightClickRestorerPage`、`DashboardPage` 不合规范,应统一为 `Index`) + +```typescript +// ✅ 正确 +export default function Index() { ... } + +// ❌ 错误 — 命名不一致 +export default function RightClickRestorerPage() { ... } +export default function DashboardPage() { ... } +``` + +#### 组件职责 + +`index.tsx` 只负责三件事: + +1. **获取翻译函数**(`useI18n`) +2. **调用业务 Hook** 获取状态和操作方法 +3. **渲染 UI 布局**(纯展示,无业务逻辑) + +```typescript +// ✅ 标准页面入口模板 +import { useI18n } from '@/utils/chromeI18n'; +import { useFeatureName } from './useFeatureName'; + +export default function Index() { + const { t } = useI18n('featureName'); + const { state, actions } = useFeatureName(); + + return ( +
+ {/* 纯 UI 渲染 */} +
+ ); +} +``` + +#### 禁止在 `index.tsx` 中编写的内容 + +- ❌ `useState` / `useMemo` / `useCallback`(应放在 Hook 中) +- ❌ 数据转换/格式化逻辑 +- ❌ 异步请求/副作用 +- ❌ 超过 3 行的条件判断逻辑 + +--- + +### 11.3 业务 Hook 规范(`useFeatureName.ts`) + +#### 命名 + +- 文件名:`useXxx.ts`(驼峰命名) +- Hook 函数名:`useXxx()` +- 返回值接口:`UseXxxReturn` + +```typescript +// ✅ 标准 Hook 结构 +export interface UseTimestampConverterReturn { + mode: 'ts2dt' | 'dt2ts'; + input: string; + result: string; + error: string; + setMode: (mode: 'ts2dt' | 'dt2ts') => void; + setInput: (value: string) => void; + handleUseNow: (now: number) => void; +} + +export function useTimestampConverter(): UseTimestampConverterReturn { + // 所有业务逻辑在此 +} +``` + +#### Hook 内部结构(推荐顺序) + +```typescript +export function useFeatureName(): UseFeatureNameReturn { + // 1. i18n + const { t } = useI18n('featureName'); + + // 2. 基础 state(useState) + const [mode, setMode] = useState('default'); + const [input, setInput] = useState(''); + + // 3. 持久化 state(useStorageState) + const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode); + + // 4. 衍生数据(useMemo)— 响应式计算管线 + const result = useMemo(() => { + // 自动计算,无需手动点击"转换"按钮 + }, [input, mode]); + + // 5. 事件处理(useCallback) + const handleAction = useCallback(() => { ... }, [deps]); + + // 6. 副作用(useEffect)— 防抖、初始化、清理 + useEffect(() => { ... }, [deps]); + + // 7. 右键菜单数据(页面需要时) + useContextMenuData({ featureKey: 'featureName', onData: handleContextMenuData }); + + // 8. 返回 + return { mode, input, result, setMode, setInput, handleAction }; +} +``` + +#### 防抖模式 + +当输入框需要防抖时,在 Hook 中实现: + +```typescript +// ✅ 防抖管道 — 在 useMemo 前定义 +const [input, setInput] = useState(''); +const [debouncedInput, setDebouncedInput] = useState(''); + +useEffect(() => { + const handle = setTimeout(() => setDebouncedInput(input), 250); + return () => clearTimeout(handle); +}, [input]); + +// 后续 useMemo 使用 debouncedInput 而非 input +const result = useMemo(() => compute(debouncedInput), [debouncedInput]); +``` + +--- + +### 11.4 常量文件规范(`constants.ts`) + +- 仅在常量超过 **3 个**或需要**导出类型**时创建 +- 使用 `as const` 确保字面量类型 +- 从 `as const` 数组派生联合类型 + +```typescript +// ✅ 标准常量文件 +export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss'; + +export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const; + +export type UnitType = 'ms' | 's'; +export type ZoneType = (typeof ZONES)[number]; +``` + +--- + +### 11.5 子组件规范 + +#### 何时拆分子组件 + +- `index.tsx` 超过 **150 行** +- 存在可复用的 UI 片段(如卡片、面板、结果展示区) +- 需要 `React.memo` 优化的高频渲染区域 + +#### 子组件 Props 模式 + +```typescript +// ✅ 继承 HTML 属性 + 业务 Props +interface ResultViewProps extends React.HTMLAttributes { + result: string; + mode: 'ts2dt' | 'dt2ts'; + showEmptyPlaceholder?: boolean; +} + +// ✅ 使用 React.memo + displayName +const ResultView = React.memo( + ({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => { + const { t } = useI18n('featureName'); + // ... + }, +); +ResultView.displayName = 'ResultView'; +export default ResultView; +``` + +#### 子组件内可以使用 Hook + +子组件可以独立调用 `useI18n`、`useSnackbar` 等全局 Hook,**不需要**通过 props 从父组件传递翻译函数或 toast 方法。 + +--- + +### 11.6 存储键命名规范 + +页面使用的 Storage 键必须遵循 kebab-case 格式:`{功能名}/{用途}`。 + +```typescript +// ✅ 正确 +'base64Converter/pageMode'; +'base64Converter/fileMode/direction'; +'jsonTools/pageMode'; +'qrCode/urlExpanded'; + +// ❌ 错误 +'base64ConverterPageMode'; +'json_tools_page_mode'; +``` + +在 `types/storage.d.ts` 的 `StorageSchema` 中声明所有键。 + +--- + +### 11.7 模式切换通用模式 + +当页面有多个子模式(标签页切换),统一使用以下模式: + +```typescript +// ✅ 标准模式切换 +const VALID_MODES = ['modeA', 'modeB'] as const; +type PageMode = (typeof VALID_MODES)[number]; + +const isValidMode = (val: unknown): val is PageMode => + typeof val === 'string' && (VALID_MODES as readonly string[]).includes(val); + +export default function Index() { + const { t } = useI18n('featureName'); + const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode); + + return ( +
+ setPageMode(v)} + size="small" + /> + {pageMode === 'modeA' ? : } +
+ ); +} +``` + +--- + +### 11.8 页面布局约定 + +- 所有页面根元素使用统一的外层容器: + ``` +
+ ``` +- 不需要 `
` — 该样式已由 `AppRoot` 提供 +- 不需要 `min-h-[500px]` 或固定高度(除非确有必要) +- 卡片容器:`rounded-xl border border-border bg-card text-card-foreground shadow-sm` +- 使用 `space-y-4` 管理纵向间距,不要手动 `mb-4` + +--- + +### 11.9 页面开发检查清单 + +新增功能页面时,逐项确认: + +1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型 +2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、icon、三种渲染模式组件) +3. ✅ 创建页面目录,使用 `Index` 作为组件名 +4. ✅ 业务逻辑提取到 `useXxx.ts` Hook(index.tsx 不超过 150 行) +5. ✅ 需要持久化的 UI 状态使用 `useStorageState` +6. ✅ 常量 ≥3 个时提取到 `constants.ts` +7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译 +8. ✅ 创建 `__tests__/index.test.tsx` 测试文件 +9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` +10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过 + +--- + +### 11.10 目录职责总览 | 目录 | 职责 | | -------------------- | ------------------------------------------------------------ | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4fbef56..ad75f01 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -56,7 +56,7 @@ Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来 ### 页面组件模式 -功能页面遵循 **UI + Hook 分离** 模式: +功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范): ``` pages/FeatureName/ @@ -65,7 +65,7 @@ pages/FeatureName/ └── constants.ts # 常量定义(可选) ``` -- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数 +- 页面组件调用 `useI18n('featureName')` 获取翻译函数 - Hook 负责所有状态管理,通过返回值暴露给页面 - 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`) diff --git a/.gitignore b/.gitignore index d22059f..c5eb473 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ stats-*.json dev/* docs/* +!docs/VISUAL_STYLE_GUIDE.md diff --git a/AGENTS.md b/AGENTS.md index 82efa64..34bd6ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,18 +59,22 @@ public/ # 静态资源(图标、_locales 等) ### 页面组件模式 -典型功能页面遵循 **UI + Hook 分离** 模式: +典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。 ``` src/pages/FeatureName/ -├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件) +├── index.tsx # 页面 UI(纯展示,仅负责渲染布局) ├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑) -└── constants.ts # 常量定义 +├── constants.ts # 常量定义(可选,≥3 个常量时创建) +└── __tests__/ + └── index.test.tsx ``` -- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数 +- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出 - Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 -- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`) +- 子组件可以独立调用 `useI18n` 等全局 Hook +- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 +- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录 ## 关键架构决策 @@ -88,7 +92,7 @@ src/pages/FeatureName/ **浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 -**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。 +**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。 ## 测试环境 @@ -96,35 +100,37 @@ src/pages/FeatureName/ - 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - Setup 文件: `vitest.setup.ts` 自动 mock: - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - - `react-i18next` (返回 key 作为翻译) - - `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译) + - `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译) - `window.matchMedia` - 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}` - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 -## i18n +## i18n (chrome.i18n) -- 命名空间: `common` (默认), `features` -- 翻译键格式: `namespace:key` (如 `features:timestamp.title`) -- 语言: `zh` (默认), `en` -- 翻译文件结构: - - `i18n/locales/{zh,en}/common.json` - 全局通用翻译 - - `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述 - - `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等) -- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON -- 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式 -- 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning +项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。 + +- **翻译文件**: `public/_locales/{zh,en}/messages.json`(Chrome 扩展标准格式) +- **默认语言**: `zh`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置) +- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'` +- **翻译键格式**: + - 直接 key: `t('dashboard_title')` → 查找 `dashboard_title` + - 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search` + - 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key` +- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })` +- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }` +- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning) +- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面 ## 新功能开发清单 1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型 2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) 3. 在 `src/pages/` 创建页面组件 (懒加载): - - `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译 + - `index.tsx` — UI 组件,使用 `useI18n` 获取翻译 - `useFeatureName.ts` — 业务逻辑 Hook - `constants.ts` — 常量(可选) -4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON) +4. 在 `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译 5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` 6. 添加对应的单元测试 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..39c76db --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENT.md diff --git a/docs/VISUAL_STYLE_GUIDE.md b/docs/VISUAL_STYLE_GUIDE.md new file mode 100644 index 0000000..ceb8169 --- /dev/null +++ b/docs/VISUAL_STYLE_GUIDE.md @@ -0,0 +1,871 @@ +# Testing Tools — 视觉规范文档 + +> **版本**: 1.0.0 +> **日期**: 2026-05-29 +> **适用范围**: 所有新页面、新组件、UI 修改 +> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS + +--- + +## 目录 + +1. [设计原则](#1-设计原则) +2. [色彩系统](#2-色彩系统) +3. [排版规范](#3-排版规范) +4. [间距与布局](#4-间距与布局) +5. [圆角与阴影](#5-圆角与阴影) +6. [组件规范](#6-组件规范) +7. [交互与动效](#7-交互与动效) +8. [暗色模式](#8-暗色模式) +9. [工具色彩标识](#9-工具色彩标识) +10. [代码规范](#10-代码规范) +11. [反模式清单](#11-反模式清单) + +--- + +## 1. 设计原则 + +### 1.1 核心定位 + +Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循: + +- **专业克制** — 低饱和度色彩,避免视觉噪音 +- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示 +- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级 +- **一致性至上** — 所有页面、组件遵循同一套视觉语言 + +### 1.2 设计关键词 + +``` +简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影 +``` + +### 1.3 与 shadcn/ui 的关系 + +本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。 + +--- + +## 2. 色彩系统 + +### 2.1 CSS 变量定义 + +所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`: + +#### 亮色模式 (`:root`) + +| 变量名 | HSL 值 | 用途 | 近似色 | +| -------------------------- | ------------------- | ------------- | --------- | +| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` | +| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` | +| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` | +| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` | +| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` | +| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` | +| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` | +| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` | +| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` | +| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` | +| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` | +| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` | +| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` | +| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` | +| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` | +| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` | +| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` | +| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` | +| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` | +| `--radius` | `0.5rem` | 全局圆角 | `8px` | + +#### 暗色模式 (`.dark`) + +暗色模式下所有变量自动反转,保持对比度关系: + +| 变量名 | HSL 值 | 近似色 | +| ---------------------- | ------------------- | --------- | +| `--background` | `222.2 84% 4.9%` | `#020617` | +| `--foreground` | `210 40% 98%` | `#f8fafc` | +| `--primary` | `210 40% 98%` | `#f8fafc` | +| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` | +| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` | +| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` | +| `--border` | `217.2 32.6% 17.5%` | `#1e293b` | + +### 2.2 使用规范 + +```tsx +// ✅ 正确:使用 CSS 变量 +
+ +// ✅ 正确:使用语义化色彩名 + + +// 图标按钮 + + +// 危险操作 + +``` + +### 6.2 Input + +来源:`src/components/ui/input.tsx` + +```tsx +// 标准输入框 + + +// 错误状态 + +``` + +**规范要点**: + +- 高度统一为 `h-10`(40px) +- 等宽字体用于数据输入 +- 占位符使用 `text-muted-foreground/60` +- 错误时边框变红并调整焦点环 + +### 6.3 SwitchButtonGroup + +来源:`src/components/ui/switch.tsx` + +```tsx +// 分段控制器 + +``` + +**规范要点**: + +- 容器:`rounded-lg bg-muted p-1` +- 选中项:`bg-background text-foreground shadow-sm font-semibold` +- 未选中项:`hover:bg-background/50 hover:text-foreground/80` +- 尺寸:`small`(32px)用于工具页,`medium`(36px)标准 + +### 6.4 Card(工具卡片) + +来源:`src/pages/Dashboard/ToolCard.tsx` + +```tsx +// 标准工具卡片结构 +
+ {/* 上半部分:图标 + 标题 + 箭头 */} +
+
+ {/* 图标容器 */} +
+ +
+ {/* 文字 */} +
+

标题

+

描述

+
+
+ +
+ {/* 下半部分:预览区(可选) */} +
{snapshot}
+
+``` + +### 6.5 TextInputArea + +来源:`src/components/TextInputArea.tsx` + +```tsx +// 多行文本输入区 + +``` + +**规范要点**: + +- 外容器:`rounded-md border border-input bg-background shadow-sm` +- 焦点状态:`focus-within:ring-1 focus-within:ring-ring` +- 错误状态:`border-destructive focus-within:ring-destructive` +- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50` +- 字体:`font-mono text-sm` + +### 6.6 Dialog + +来源:`src/components/ui/dialog.tsx` + +```tsx +// 对话框内容 + + + 标题 + 描述文字 + + {/* 内容 */} + + + + + +``` + +### 6.7 Select + +来源:`src/components/ui/select.tsx` + +```tsx + +``` + +### 6.8 Checkbox + +来源:`src/components/ui/checkbox.tsx` + +```tsx +// 标准复选框 + + +// 小型复选框(工具栏内) + +``` + +### 6.9 Badge + +来源:`src/components/ui/badge.tsx` + +| 变体 | 场景 | +| ------------- | ------------------ | +| `default` | 状态标签、分类 | +| `secondary` | 次要标签 | +| `destructive` | 错误标签 | +| `outline` | 可点击标签、筛选器 | + +### 6.10 CopyButton + +来源:`src/components/CopyButton.tsx` + +```tsx +// 标准复制按钮 + + +// 小型复制按钮 + +``` + +**规范要点**: + +- 默认 `variant="ghost" size="icon"` +- 复制成功后变为绿色背景 + 对勾图标 +- 使用 `sonner` toast 提示复制结果 + +--- + +## 7. 交互与动效 + +### 7.1 过渡规范 + +| 属性 | 值 | 场景 | +| ------------------- | ---------- | ----------------------------- | +| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) | +| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup) | +| `duration-200` | 200ms | 复制按钮状态切换 | + +### 7.2 焦点状态 + +所有可交互元素必须有可见的焦点指示器: + +```tsx +// 标准焦点环 +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 + +// 紧凑焦点环(图标按钮) +focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring + +// 输入框焦点 +focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 +``` + +### 7.3 悬停状态 + +```tsx +// 按钮悬停 +hover:bg-accent hover:text-accent-foreground + +// 卡片悬停 +hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)] + +// 链接/文字悬停 +hover:text-foreground hover:underline +``` + +### 7.4 动画规范 + +使用 `tailwindcss-animate` 提供的动画: + +```tsx +// 淡入 +animate-in fade-in duration-150 + +// 淡入 + 缩放(SwitchButtonGroup 选中项) +animate-in fade-in-50 zoom-in-95 duration-150 + +// 从顶部滑入(下拉菜单) +animate-in fade-in slide-in-from-top-2 duration-150 + +// 错误提示出现 +animate-in fade-in slide-in-from-top-1 duration-150 + +// 骨架屏脉冲 +animate-pulse +``` + +### 7.5 禁用状态 + +```tsx +// 统一禁用样式 +disabled:pointer-events-none disabled:opacity-50 +``` + +--- + +## 8. 暗色模式 + +### 8.1 实现方式 + +通过 `darkMode: 'class'`(Tailwind 配置)+ `.dark` 类切换: + +```tsx +// ThemeModeProvider 自动处理 +document.documentElement.classList.toggle('dark', resolvedMode === 'dark'); +``` + +### 8.2 暗色模式下的特殊处理 + +```tsx +// 彩色阴影增强(暗色模式下阴影需要更高透明度) +shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] +dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)] + +// 图标容器背景增强 +bg-[rgba(var(--tool-color),0.08)] +dark:bg-[rgba(var(--tool-color),0.12)] + +// 成功状态文字调整 +text-emerald-600 dark:text-emerald-400 +``` + +### 8.3 暗色模式色彩映射原则 + +| 亮色 | 暗色 | 说明 | +| ---------------- | ---------------- | ------------------------------- | +| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` | +| 浅灰背景 | 深灰背景 | 保持层次关系 | +| 深文字 | 浅文字 | 反转对比度 | +| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 | + +--- + +## 9. 工具色彩标识 + +### 9.1 色板定义 + +每个工具分配一个主题色,定义于 `src/config/features.tsx`: + +```ts +const PALETTE_COLORS: Record = { + primary: '13, 148, 136', // teal (#0d9488) + success: '22, 163, 74', // green (#16a34a) + warning: '217, 119, 6', // amber (#d97706) + error: '220, 38, 38', // red (#dc2626) + secondary: '147, 51, 232', // purple (#9333e8) + info: '37, 99, 235', // blue (#2563eb) +}; +``` + +### 9.2 工具色彩分配 + +| 工具 | 色彩键 | 色值 | +| ----------- | ----------- | ------ | +| 时间戳转换 | `primary` | Teal | +| 存储清理 | `warning` | Amber | +| 二维码工具 | `success` | Green | +| 文本统计 | `secondary` | Purple | +| JWT 解析 | `info` | Blue | +| JSON 对比 | `primary` | Teal | +| Base64 转换 | `info` | Blue | +| 右键还原 | `success` | Green | + +### 9.3 工具色彩使用规范 + +```tsx +// 1. 通过 style 注入 CSS 变量 +
+ +// 2. 图标容器背景(低透明度) +bg-[rgba(var(--tool-color),0.08)] +dark:bg-[rgba(var(--tool-color),0.12)] + +// 3. 图标颜色 + text-[rgb(var(--tool-color))] + +// 4. 悬停边框 +hover:border-[rgba(var(--tool-color),0.45)] + +// 5. 悬停阴影 +hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] + +// 6. 箭头悬停色 +group-hover:text-[rgb(var(--tool-color))] +``` + +**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。 + +--- + +## 10. 代码规范 + +### 10.1 Tailwind 类名组织顺序 + +使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列: + +```tsx +className={cn( + // 1. 布局(display, position, flex, grid) + 'flex items-center justify-between', + // 2. 尺寸(width, height, padding, margin) + 'w-full h-10 px-4', + // 3. 外观(background, border, shadow, rounded) + 'rounded-md border border-input bg-background shadow-sm', + // 4. 文字(color, font, text-align) + 'text-sm font-medium text-foreground', + // 5. 交互(hover, focus, disabled, cursor) + 'hover:bg-accent focus-visible:ring-2 disabled:opacity-50', + // 6. 动画(transition, animate) + 'transition-colors duration-150', + // 7. 条件类 + isActive && 'bg-primary text-primary-foreground', + // 8. 外部传入 + className, +)} +``` + +### 10.2 颜色使用检查清单 + +- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`) +- [ ] 边框使用 `border-border` 及其透明度变体 +- [ ] 文字层级使用 `foreground` → `muted-foreground` → `muted-foreground/60` +- [ ] 错误状态使用 `destructive` 系列 +- [ ] 工具色彩仅用于装饰性元素 + +### 10.3 组件文件组织 + +``` +src/ +├── components/ui/ # shadcn 基础组件(只读,不修改) +├── components/ # 业务组件 +│ ├── CopyButton.tsx +│ ├── SwitchButtonGroup.tsx +│ ├── TextInputArea.tsx +│ └── ... +├── pages/ # 页面组件 +│ ├── / +│ │ ├── index.tsx # 页面入口 +│ │ ├── use.ts # 业务逻辑 Hook +│ │ └── components/ # 页面私有组件 +│ └── ... +└── providers/ # Context Providers +``` + +### 10.4 新增页面模板 + +```tsx +// src/pages/NewTool/index.tsx +import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { useI18n } from '@/utils/chromeI18n'; +import { cn } from '@/lib/utils'; + +export default function Index() { + const { t } = useI18n('newTool'); + + return ( +
+ {/* 页面内容 */} +
+

{t('newTool:title')}

+
+
+ ); +} +``` + +--- + +## 11. 反模式清单 + +以下模式**禁止**在项目中使用: + +### 11.1 色彩反模式 + +```tsx +// ❌ 硬编码颜色 +
+
+
+ +// ❌ 使用非语义化 Tailwind 颜色 +
+
+ +// ✅ 使用 CSS 变量 +
+
+``` + +### 11.2 布局反模式 + +```tsx +// ❌ 固定高度导致内容截断 +
+ +// ✅ 使用 min-height 或自适应 +
+
+ +// ❌ 使用 margin 做组件间距 +
+ +// ✅ 使用 gap +
+``` + +### 11.3 组件反模式 + +```tsx +// ❌ 修改 shadcn/ui 基础组件样式 +// 如需修改,通过 className 覆盖或创建包装组件 + +// ❌ 内联样式用于颜色(工具色彩除外) +
+ +// ❌ 混合使用不同圆角体系 + - * - * - * ); - * } - * ``` - */ -export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult { - // Snackbar 显示状态 - const [open, setOpen] = useState(false); - // 当前显示的消息内容 - const [message, setMessage] = useState(''); - // 消息配置选项 - const [options, setOptions] = useState(initialOptions || {}); - - /** - * 显示消息 - * - * @param {string} newMessage - 要显示的消息文本 - * @param {SnackbarOptions} [newOptions={}] - 新的配置选项 - * - * @description - * - 合并初始选项和新的调用选项 - * - 新选项会覆盖初始选项 - */ - const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => { - setMessage(newMessage); - setOptions({ ...initialOptions, ...newOptions }); - setOpen(true); - }; - - /** - * 关闭消息 - * - * @description - * - 直接将 open 状态设置为 false - */ - const closeMessage = () => { - setOpen(false); - }; - - const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => { - if (reason === 'clickaway') return; - closeMessage(); - }; - - /** - * 传递给 GlobalSnackbar 组件的属性 - * - * @description - * - 组合当前状态和选项为完整的组件 props - * - onClose 使用 handleClose 包装后的版本 - */ - const snackbarProps: GlobalSnackbarProps = { - message, - open, - onClose: handleClose, - severity: options.severity, - autoHideDuration: options.autoHideDuration, - hideIcon: options.hideIcon, - }; - - return { - snackbarProps, - showMessage, - closeMessage, - }; -} - -// --- Context & Provider --- - -/** - * Snackbar Context 的值类型定义 - */ -interface SnackbarContextValue { - showMessage: (message: string, options?: SnackbarOptions) => void; - closeMessage: () => void; -} - -const SnackbarContext = createContext(null); - -/** - * SnackbarProvider 组件的 props 类型 - */ -interface SnackbarProviderProps { - children: ReactNode; - initialOptions?: SnackbarOptions; -} - -/** - * SnackbarProvider 组件 - * - * 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。 - */ -export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element { - const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions); - - return ( - - {children} - - - ); -} - -/** - * useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook - * - * @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration) - * @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象 - * @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误 - * - * @description - * 选项合并策略: - * 1. 调用 showMessage 时传入的 callOptions 优先级最高 - * 2. useSnackbar(options) 传入的 Hook 级别配置次之 - * 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低 - */ -export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue { - const context = useContext(SnackbarContext); - if (!context) { - throw new Error('useSnackbar must be used within SnackbarProvider'); - } - - // 包装 showMessage 以支持 Hook 级别的 initialOptions - const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => { - // 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作 - // 优先级:callOptions > options - const mergedOptions: SnackbarOptions = { - ...(options || {}), - ...(callOptions || {}), - }; - context.showMessage(message, mergedOptions); - }; - - return { - ...context, - showMessage: wrappedShowMessage, - }; -} - -export default GlobalSnackbar; diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index 0173353..6f3e1cc 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react'; import { Image, X } from 'lucide-react'; -import { useSnackbar } from '@/components/GlobalSnackbar'; +import { toast } from 'sonner'; import { useI18n } from '@/utils/chromeI18n'; interface ImageUploaderProps { @@ -30,7 +30,6 @@ const ImageUploader = ({ onDraggingChange, }: ImageUploaderProps) => { const { t } = useI18n('qrCode'); - const { showMessage } = useSnackbar(); const fileInputRef = useRef(null); const handleFileChange = useCallback( @@ -46,11 +45,8 @@ const ImageUploader = ({ URL.revokeObjectURL(previewUrl); } onClearFile(); - showMessage(t('qrCode:imageCleared'), { - severity: 'success', - autoHideDuration: 1000, - }); - }, [previewUrl, onClearFile, showMessage, t]); + toast.success(t('qrCode:imageCleared')); + }, [previewUrl, onClearFile, t]); const handleInputChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { @@ -90,13 +86,10 @@ const ImageUploader = ({ if (file) { try { handleFileChange(file); - showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 }); + toast.success(t('qrCode:imagePasted')); } catch (error) { console.error('处理粘贴图片失败:', error); - showMessage(t('qrCode:imagePasteError'), { - severity: 'error', - autoHideDuration: 3000, - }); + toast.error(t('qrCode:imagePasteError')); } } break; @@ -109,7 +102,7 @@ const ImageUploader = ({ return () => { document.removeEventListener('paste', handlePaste); }; - }, [showMessage, handleFileChange, t]); + }, [handleFileChange, t]); return (
{ - return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; - }, [currentPage]); - - const entryPointType = getEntryPointType(); + const animationClass = + currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; if (!isLoaded) { return ; diff --git a/src/components/__tests__/GlobalSnackbar.test.tsx b/src/components/__tests__/GlobalSnackbar.test.tsx deleted file mode 100644 index 6357556..0000000 --- a/src/components/__tests__/GlobalSnackbar.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, renderHook, screen } from '@testing-library/react'; -import React from 'react'; -import { - GlobalSnackbar, - type GlobalSnackbarProps, - SnackbarProvider, - useSnackbar, - useSnackbarState, -} from '@/components/GlobalSnackbar'; - -describe('GlobalSnackbar 组件系统', () => { - const mockOnClose = vi.fn(); - - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - const defaultProps: GlobalSnackbarProps = { - message: '测试消息', - open: true, - onClose: mockOnClose, - }; - - describe('GlobalSnackbar UI 渲染', () => { - it('应渲染消息内容', () => { - render(); - expect(screen.getByText('测试消息')).toBeInTheDocument(); - }); - - it('当 showAlert 为 true 时应渲染带样式的提示', () => { - render(); - // 验证是否包含消息文本 - const alertElement = screen.getByText('测试消息'); - expect(alertElement).toBeInTheDocument(); - // 验证父元素有正确的样式类 - const parent = alertElement.parentElement; - expect(parent).toHaveClass('flex', 'items-center', 'gap-2'); - }); - - it('当 hideIcon 为 true 时不应渲染图标', () => { - render(); - // 图标使用 lucide-react 的 svg 元素 - const icon = document.querySelector('svg'); - expect(icon).not.toBeInTheDocument(); - }); - - it('应根据 severity 应用不同的样式', () => { - render(); - const message = screen.getByText('测试消息'); - const parent = message.parentElement; - expect(parent).toHaveClass('bg-red-500'); - }); - }); - - describe('useSnackbarState Hook 逻辑', () => { - it('应返回初始状态', () => { - const { result } = renderHook(() => useSnackbarState()); - expect(result.current.snackbarProps.open).toBe(false); - expect(result.current.snackbarProps.message).toBe(''); - }); - - it('showMessage 应更新状态', () => { - const { result } = renderHook(() => useSnackbarState()); - - act(() => { - result.current.showMessage('新消息'); - }); - - expect(result.current.snackbarProps.open).toBe(true); - expect(result.current.snackbarProps.message).toBe('新消息'); - }); - - it('closeMessage 应关闭消息', () => { - const { result } = renderHook(() => useSnackbarState()); - - act(() => { - result.current.showMessage('消息'); - }); - act(() => { - result.current.closeMessage(); - }); - - expect(result.current.snackbarProps.open).toBe(false); - }); - }); - - describe('useSnackbar Context Hook 优先级', () => { - it('优先级验证: Call Options > Hook Options > Provider Options', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper }); - - // 1. 测试 Hook Options 覆盖 Provider Options - act(() => { - result.current.showMessage('消息 1'); - }); - - expect(screen.getByText('消息 1')).toBeInTheDocument(); - - // 2. 测试 Call Options 覆盖 Hook Options - act(() => { - result.current.showMessage('消息 2', { severity: 'error' }); - }); - - expect(screen.getByText('消息 2')).toBeInTheDocument(); - }); - }); -}); diff --git a/src/components/__tests__/ImageUploader.test.tsx b/src/components/__tests__/ImageUploader.test.tsx index fcf48c0..e85ef52 100644 --- a/src/components/__tests__/ImageUploader.test.tsx +++ b/src/components/__tests__/ImageUploader.test.tsx @@ -13,10 +13,11 @@ const mockRevokeObjectURL = vi.fn(); Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL }); Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL }); -vi.mock('@/components/GlobalSnackbar', () => ({ - useSnackbar: () => ({ - showMessage: vi.fn(), - }), +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, })); describe('ImageUploader 组件', () => { diff --git a/src/components/__tests__/RouterContainer.test.tsx b/src/components/__tests__/RouterContainer.test.tsx index 86d50e4..4ab1e39 100644 --- a/src/components/__tests__/RouterContainer.test.tsx +++ b/src/components/__tests__/RouterContainer.test.tsx @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from '@testing-library/react'; import RouterContainer from '@/components/RouterContainer'; import { RouterProvider } from '@/providers/RouterProvider'; -import { SnackbarProvider } from '@/components/GlobalSnackbar'; import type { PageType } from '@/types/storage'; import React from 'react'; @@ -29,11 +28,7 @@ describe('RouterContainer 组件', () => { }); const renderWithProvider = (ui: React.ReactElement) => { - return render( - - {ui} - , - ); + return render({ui}); }; describe('渲染测试', () => { @@ -74,11 +69,7 @@ describe('RouterContainer 组件', () => { const { rerender } = renderWithProvider(); mockRouterValue.currentPage = 'timestamp'; - rerender( - - {} - , - ); + rerender({}); const box = document.querySelector('.page-transition-enter'); expect(box).toBeInTheDocument(); diff --git a/src/components/__tests__/StorageCleanerConfirm.test.tsx b/src/components/__tests__/StorageCleanerConfirm.test.tsx index 3fd0bc7..490ffa4 100644 --- a/src/components/__tests__/StorageCleanerConfirm.test.tsx +++ b/src/components/__tests__/StorageCleanerConfirm.test.tsx @@ -94,7 +94,7 @@ describe('StorageCleanerConfirm 组件', () => { renderComponent({ options: partialOptions }); expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); - expect(screen.getByText(/IndexedDB/)).toBeInTheDocument(); + expect(screen.getByText(/站点存储/)).toBeInTheDocument(); expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument(); expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument(); }); diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 4e05ad4..d1b177b 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -3,6 +3,7 @@ import { browser } from 'wxt/browser'; import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; import { saveContextMenuData } from '@/utils/useContextMenuData'; +import { mainWorldInjectionScript } from '@/utils/rightClickInjection'; export default defineBackground(() => { // 1. 扩展初次安装或更新时,注册右键上下文菜单 @@ -71,68 +72,7 @@ export default defineBackground(() => { try { await browser.scripting.executeScript({ target: { tabId }, - func: () => { - 'use strict'; - const w = window as unknown as Record; - if (w.__testingToolsRightClickPatched) return; - w.__testingToolsRightClickPatched = true; - - const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart']; - - const _origPreventDefault = MouseEvent.prototype.preventDefault; - Object.defineProperty(MouseEvent.prototype, 'preventDefault', { - value: function (this: MouseEvent) { - const t = this.type; - if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) { - return; - } - return _origPreventDefault.call(this); - }, - writable: true, - configurable: true, - }); - - const _origStopPropagation = Event.prototype.stopPropagation; - Object.defineProperty(Event.prototype, 'stopPropagation', { - value: function (this: Event) { - if (PROTECTED.includes(this.type)) return; - return _origStopPropagation.call(this); - }, - writable: true, - configurable: true, - }); - - const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation; - Object.defineProperty(Event.prototype, 'stopImmediatePropagation', { - value: function (this: Event) { - if (PROTECTED.includes(this.type)) return; - return _origStopImmediatePropagation.call(this); - }, - writable: true, - configurable: true, - }); - - let _docOnContextMenu: unknown = null; - Object.defineProperty(document, 'oncontextmenu', { - get() { - return _docOnContextMenu; - }, - set(fn: unknown) { - if (typeof fn === 'function') { - _docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) { - const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call( - this, - e, - ); - return r === false ? true : r; - }; - } else { - _docOnContextMenu = fn; - } - }, - configurable: true, - }); - }, + func: mainWorldInjectionScript, world: 'MAIN', }); @@ -143,41 +83,4 @@ export default defineBackground(() => { return { success: false, message: errorMsg }; } }); - - onMessage(MessageAction.RELOAD_TAB, async (message) => { - const { tabId, delay = 0 } = message.data; - - const executeReload = () => { - browser.tabs.reload(tabId).catch((err) => { - console.error('Failed to execute tab reload operation:', err); - }); - }; - - if (delay <= 0) { - executeReload(); - return { success: true }; - } - - const alarmName = `reload-tab-${tabId}-${Date.now()}`; - - await browser.alarms.create(alarmName, { when: Date.now() + delay }); - - const cleanupTimeout = setTimeout(() => { - browser.alarms.onAlarm.removeListener(alarmListener); - browser.alarms.clear(alarmName).catch(() => {}); - }, delay + 5000); - - const alarmListener = (alarm: { name: string }) => { - if (alarm.name !== alarmName) return; - - clearTimeout(cleanupTimeout); - executeReload(); - browser.alarms.onAlarm.removeListener(alarmListener); - browser.alarms.clear(alarmName).catch(() => {}); - }; - - browser.alarms.onAlarm.addListener(alarmListener); - - return { success: true }; - }); }); diff --git a/src/entrypoints/content.ts b/src/entrypoints/content.ts index de742e7..4cfbc44 100644 --- a/src/entrypoints/content.ts +++ b/src/entrypoints/content.ts @@ -1,10 +1,10 @@ import '../../.wxt/types/imports.d.ts'; -import { initMessageHandler } from './content/messageHandler'; +import { initContextMenuHandler } from './content/contextMenuHandler'; export default defineContentScript({ matches: [''], runAt: 'document_end', main() { - initMessageHandler(); + initContextMenuHandler(); }, }); diff --git a/src/entrypoints/content/contextMenuHandler.ts b/src/entrypoints/content/contextMenuHandler.ts index 651d0c0..cc9b940 100644 --- a/src/entrypoints/content/contextMenuHandler.ts +++ b/src/entrypoints/content/contextMenuHandler.ts @@ -1,17 +1,11 @@ import type { ContextMenuClickedPayload } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages'; import { getTextStats } from '@/utils/textStatistics'; +import { getMessage } from '@/utils/chromeI18n'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; -function getI18nText(key: string, fallback: string): string { - if (typeof chrome !== 'undefined' && chrome.i18n) { - return chrome.i18n.getMessage(key) || fallback; - } - return fallback; -} - function convertTimestamp(input: string): string { - const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp'); + const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp'; const num = Number(input.trim()); if (isNaN(num)) { diff --git a/src/entrypoints/content/messageHandler.ts b/src/entrypoints/content/messageHandler.ts deleted file mode 100644 index 26b3743..0000000 --- a/src/entrypoints/content/messageHandler.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { initContextMenuHandler } from './contextMenuHandler'; - -export function initMessageHandler(): void { - initContextMenuHandler(); -} diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index cdec377..413568a 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -2,27 +2,22 @@ import RouterProvider from '@/providers/RouterProvider'; import TopBar from '@/components/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; -import { SnackbarProvider } from '@/components/GlobalSnackbar'; import { getEntryPointType } from '@/config/features'; -import { useMemo } from 'react'; - export default function App() { - const entryType = useMemo(() => getEntryPointType(), []); + const entryType = getEntryPointType(); - const routerConfig = useMemo(() => { - if (entryType === 'tab') { - return { - syncKey: 'app/tabRoute' as const, - visiblePagesKey: 'app/tabVisiblePages' as const, - pageOrderKey: 'app/tabPageOrder' as const, - }; - } - return { - syncKey: 'app/popupRoute' as const, - visiblePagesKey: 'app/popupVisiblePages' as const, - pageOrderKey: 'app/popupPageOrder' as const, - }; - }, [entryType]); + const routerConfig = + entryType === 'tab' + ? { + syncKey: 'app/tabRoute' as const, + visiblePagesKey: 'app/tabVisiblePages' as const, + pageOrderKey: 'app/tabPageOrder' as const, + } + : { + syncKey: 'app/popupRoute' as const, + visiblePagesKey: 'app/popupVisiblePages' as const, + pageOrderKey: 'app/popupPageOrder' as const, + }; return ( - -
- - - - -
-
+
+ + + + +
); } diff --git a/src/entrypoints/rightClickRestorer.content.ts b/src/entrypoints/rightClickRestorer.content.ts index 5d59201..8d860ff 100644 --- a/src/entrypoints/rightClickRestorer.content.ts +++ b/src/entrypoints/rightClickRestorer.content.ts @@ -1,57 +1,7 @@ import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; -const BADGE_ID = 'testing-tools-right-click-restorer-badge'; -const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style'; - let isRestored = false; -function updateBadge(): void { - const badge = document.getElementById(BADGE_ID); - if (badge) { - badge.style.opacity = isRestored ? '1' : '0'; - } -} - -function createBadge(): void { - if (document.getElementById(BADGE_ID)) return; - - if (!document.getElementById(BADGE_STYLE_ID)) { - const style = document.createElement('style'); - style.id = BADGE_STYLE_ID; - style.textContent = ` - #${BADGE_ID} { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 2147483646; - padding: 6px 12px; - background: #2e7d32; - color: #ffffff; - border-radius: 20px; - font-size: 12px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - box-shadow: 0 2px 8px rgba(0,0,0,0.2); - opacity: 0; - transition: opacity 0.2s ease-out; - pointer-events: none; - user-select: none; - } - @media (prefers-color-scheme: dark) { - #${BADGE_ID} { - background: #4caf50; - box-shadow: 0 2px 8px rgba(0,0,0,0.4); - } - } - `; - document.head.appendChild(style); - } - - const badge = document.createElement('div'); - badge.id = BADGE_ID; - badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501'; - document.body.appendChild(badge); -} - /* ======================== Main World 注入 ======================== */ async function injectMainWorldScript(): Promise { @@ -90,64 +40,6 @@ function installEventIntercepts(): void { ); } -/* ======================== 遮罩层穿透 ======================== */ - -const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']); - -function initMousePenetration(): void { - window.addEventListener( - 'mousedown', - (e) => { - if (!isRestored || e.button !== 2) return; - - const elements = document.elementsFromPoint(e.clientX, e.clientY); - if (!elements.length) return; - - let targetMedia: HTMLElement | null = null; - for (const el of elements) { - if (MEDIA_TAGS.has(el.tagName)) { - targetMedia = el as HTMLElement; - break; - } - } - - if (!targetMedia) return; - - const modified: Array<{ el: HTMLElement; original: string | null }> = []; - let foundMedia = false; - - for (const el of elements) { - const htmlEl = el as HTMLElement; - - if (el === targetMedia) { - foundMedia = true; - const original = htmlEl.style.pointerEvents || null; - htmlEl.style.setProperty('pointer-events', 'all', 'important'); - modified.push({ el: htmlEl, original }); - continue; - } - - if (!foundMedia) { - const original = htmlEl.style.pointerEvents || null; - htmlEl.style.setProperty('pointer-events', 'none', 'important'); - modified.push({ el: htmlEl, original }); - } - } - - setTimeout(() => { - for (const { el, original } of modified) { - if (original === null || original === '') { - el.style.removeProperty('pointer-events'); - } else { - el.style.pointerEvents = original; - } - } - }, 300); - }, - true, - ); -} - /* ======================== 激活保护 ======================== */ async function activateProtection(): Promise { @@ -159,10 +51,8 @@ async function activateProtection(): Promise { if (isRestored) return; installEventIntercepts(); - initMousePenetration(); isRestored = true; - updateBadge(); } /* ======================== 消息通信 ======================== */ @@ -184,11 +74,6 @@ export default defineContentScript({ matches: [''], runAt: 'document_start', main() { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', createBadge, { once: true }); - } else { - createBadge(); - } initMessaging(); }, }); diff --git a/src/entrypoints/sidepanel/App.tsx b/src/entrypoints/sidepanel/App.tsx index 9709e84..80d156b 100644 --- a/src/entrypoints/sidepanel/App.tsx +++ b/src/entrypoints/sidepanel/App.tsx @@ -3,7 +3,6 @@ import RouterProvider from '@/providers/RouterProvider'; import TopBar from '@/components/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; -import { SnackbarProvider } from '@/components/GlobalSnackbar'; import { MessageAction, sendMessage } from '@/utils/messages'; export default function App() { @@ -16,14 +15,12 @@ export default function App() { return ( - -
- - - - -
-
+
+ + + + +
); } diff --git a/src/pages/Base64Converter/Base64ConverterSection.tsx b/src/pages/Base64Converter/Base64ConverterSection.tsx index 06df626..ba4912c 100644 --- a/src/pages/Base64Converter/Base64ConverterSection.tsx +++ b/src/pages/Base64Converter/Base64ConverterSection.tsx @@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP }; return ( -
+
+
{/* 受控方向切流中枢 */}
new Set(visiblePages), [visiblePages]); + const visibleSet = new Set(visiblePages); - const visibleFeatures = useMemo(() => { - return pageOrder - .filter((key) => visibleSet.has(key)) - .map((key) => ({ key, feature: getFeatureByKey(key) })) - .filter((item) => item.feature?.themeColorKey && item.feature.icon != null); - }, [pageOrder, visibleSet]); + const visibleFeatures = pageOrder + .filter((key) => visibleSet.has(key)) + .map((key) => ({ key, feature: getFeatureByKey(key) })) + .filter((item) => item.feature?.themeColorKey && item.feature.icon != null); - const recentFeatures = useMemo(() => { - return recentlyUsedTools - .filter((key) => visibleSet.has(key)) - .map((key) => ({ key, feature: getFeatureByKey(key) })) - .filter((item) => item.feature?.themeColorKey && item.feature.icon != null); - }, [recentlyUsedTools, visibleSet]); + const recentFeatures = recentlyUsedTools + .filter((key) => visibleSet.has(key)) + .map((key) => ({ key, feature: getFeatureByKey(key) })) + .filter((item) => item.feature?.themeColorKey && item.feature.icon != null); const showRecent = recentFeatures.length > 0; diff --git a/src/pages/JsonTools/JsonConvertSection.tsx b/src/pages/JsonTools/JsonConvertSection.tsx index e35c76b..05360c5 100644 --- a/src/pages/JsonTools/JsonConvertSection.tsx +++ b/src/pages/JsonTools/JsonConvertSection.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useI18n } from '@/utils/chromeI18n'; -import { formatByteSize } from '@/utils/textStatistics'; +import { formatBytes } from '@/utils/format'; import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; import { validateJson } from '@/utils/jsonFormatter'; @@ -96,7 +96,7 @@ export default function JsonConvertSection({ {t('jsonFormat:originalSize')}:{' '} - {formatByteSize(result.originalBytes)} + {formatBytes(result.originalBytes)} | diff --git a/src/pages/JsonTools/JsonFormatSection.tsx b/src/pages/JsonTools/JsonFormatSection.tsx index 6c2ea44..ee7d169 100644 --- a/src/pages/JsonTools/JsonFormatSection.tsx +++ b/src/pages/JsonTools/JsonFormatSection.tsx @@ -6,7 +6,7 @@ import { type JsonFormatResult, validateJson, } from '@/utils/jsonFormatter'; -import { formatByteSize } from '@/utils/textStatistics'; +import { formatBytes } from '@/utils/format'; import CopyButton from '@/components/CopyButton'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import TextInputArea from '@/components/TextInputArea'; @@ -127,7 +127,7 @@ export default function JsonFormatSection() { {t('jsonFormat:originalSize')}:{' '} - {formatByteSize(result.originalBytes)} + {formatBytes(result.originalBytes)} | diff --git a/src/pages/JsonTools/constants.ts b/src/pages/JsonTools/constants.ts new file mode 100644 index 0000000..2fb1915 --- /dev/null +++ b/src/pages/JsonTools/constants.ts @@ -0,0 +1,27 @@ +import type { JsonToolsPageMode } from '@/types/storage'; + +export const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = [ + 'diff', + 'format', + 'yaml', + 'toml', + 'minify', +]; + +export const isValidPageMode = (val: unknown): val is JsonToolsPageMode => + typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val); + +export interface ParseState { + value: unknown; + error: string | null; +} + +export const tryParse = (raw: string, invalidMsg: string): ParseState => { + const trimmed = raw.trim(); + if (!trimmed) return { value: undefined, error: null }; + try { + return { value: JSON.parse(trimmed), error: null }; + } catch { + return { value: undefined, error: invalidMsg }; + } +}; diff --git a/src/pages/JsonTools/index.tsx b/src/pages/JsonTools/index.tsx index 3b7fb2b..359b851 100644 --- a/src/pages/JsonTools/index.tsx +++ b/src/pages/JsonTools/index.tsx @@ -1,113 +1,39 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import { useI18n } from '@/utils/chromeI18n'; import JsonDiffInput from './JsonDiffInput'; import DiffResult from './DiffResult'; import DiffNavigator from './DiffNavigator'; import JsonFormatSection from './JsonFormatSection'; -import type { ConvertFunction } from './JsonConvertSection'; import JsonConvertSection from './JsonConvertSection'; -import { diffJson } from './diffEngine'; -import { jsonToYaml } from '@/utils/jsonToYaml'; -import { jsonToToml } from '@/utils/jsonToToml'; -import { minifyJson } from '@/utils/jsonFormatter'; -import { useStorageState } from '@/utils/useStorageState'; -import type { JsonToolsPageMode } from '@/types/storage'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; +import { useJsonTools } from './useJsonTools'; +import type { JsonToolsPageMode } from '@/types/storage'; import type { ViewMode } from './types'; -interface ParseState { - value: unknown; - error: string | null; -} - -const tryParse = (raw: string, invalidMsg: string): ParseState => { - const trimmed = raw.trim(); - if (!trimmed) return { value: undefined, error: null }; - try { - return { value: JSON.parse(trimmed), error: null }; - } catch { - return { value: undefined, error: invalidMsg }; - } -}; - -const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = ['diff', 'format', 'yaml', 'toml', 'minify']; -const isValidPageMode = (val: unknown): val is JsonToolsPageMode => - typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val); - type PageMode = JsonToolsPageMode; export default function Index() { const { t } = useI18n(['jsonDiff', 'jsonFormat']); - const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); - - // Debounce input - const [leftInput, setLeftInput] = useState(''); - const [rightInput, setRightInput] = useState(''); - - // Debounced values - const [debouncedLeft, setDebouncedLeft] = useState(''); - const [debouncedRight, setDebouncedRight] = useState(''); - - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedLeft(leftInput); - setDebouncedRight(rightInput); - }, 250); - return () => clearTimeout(handle); - }, [leftInput, rightInput]); - - // Parse debounced inputs - const parseState = useMemo(() => { - const invalidMsg = t('jsonDiff:invalidJson'); - return { - left: tryParse(debouncedLeft, invalidMsg), - right: tryParse(debouncedRight, invalidMsg), - }; - }, [debouncedLeft, debouncedRight, t]); - - const leftError = parseState.left.error; - const rightError = parseState.right.error; - - const [viewMode, setViewMode] = useState('sideBySide'); - const [currentDiffIndex, setCurrentDiffIndex] = useState(0); - - // Real-time diff computation - const diffResult = useMemo(() => { - const { left, right } = parseState; - if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') { - return null; - } - return diffJson(left.value, right.value); - }, [parseState, debouncedLeft, debouncedRight]); - - const total = diffResult?.diffPaths.length ?? 0; - - const handlePrev = useCallback(() => { - if (total === 0) return; - setCurrentDiffIndex((idx) => (idx - 1 + total) % total); - }, [total]); - - const handleNext = useCallback(() => { - if (total === 0) return; - setCurrentDiffIndex((idx) => (idx + 1) % total); - }, [total]); - - const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; - - const yamlConvert: ConvertFunction = useCallback((text: string) => { - const r = jsonToYaml(text); - return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; - }, []); - - const tomlConvert: ConvertFunction = useCallback((text: string) => { - const r = jsonToToml(text); - return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; - }, []); - - const minifyConvert: ConvertFunction = useCallback((text: string) => { - const r = minifyJson(text); - return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes }; - }, []); + const { + pageMode, + setPageMode, + leftInput, + rightInput, + setLeftInput, + setRightInput, + leftError, + rightError, + viewMode, + setViewMode, + diffResult, + total, + currentDiffIndex, + handlePrev, + handleNext, + activePath, + yamlConvert, + tomlConvert, + minifyConvert, + } = useJsonTools(); return (
@@ -144,10 +70,7 @@ export default function Index() { label={t('jsonDiff:leftLabel')} placeholder={t('jsonDiff:leftPlaceholder')} value={leftInput} - onChange={(val) => { - setLeftInput(val); - setCurrentDiffIndex(0); - }} + onChange={setLeftInput} error={leftError} minRows={9} /> @@ -155,10 +78,7 @@ export default function Index() { label={t('jsonDiff:rightLabel')} placeholder={t('jsonDiff:rightPlaceholder')} value={rightInput} - onChange={(val) => { - setRightInput(val); - setCurrentDiffIndex(0); - }} + onChange={setRightInput} error={rightError} minRows={9} /> diff --git a/src/pages/JsonTools/useJsonTools.ts b/src/pages/JsonTools/useJsonTools.ts new file mode 100644 index 0000000..57d91e2 --- /dev/null +++ b/src/pages/JsonTools/useJsonTools.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useI18n } from '@/utils/chromeI18n'; +import { useStorageState } from '@/utils/useStorageState'; +import { diffJson } from './diffEngine'; +import { jsonToYaml } from '@/utils/jsonToYaml'; +import { jsonToToml } from '@/utils/jsonToToml'; +import { minifyJson } from '@/utils/jsonFormatter'; +import { isValidPageMode, tryParse } from './constants'; +import type { JsonToolsPageMode } from '@/types/storage'; +import type { ConvertFunction } from './JsonConvertSection'; +import type { ViewMode } from './types'; + +export interface UseJsonToolsReturn { + pageMode: JsonToolsPageMode; + setPageMode: (mode: JsonToolsPageMode) => void; + // Diff mode state + leftInput: string; + rightInput: string; + setLeftInput: (val: string) => void; + setRightInput: (val: string) => void; + leftError: string | null; + rightError: string | null; + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + diffResult: ReturnType | null; + total: number; + currentDiffIndex: number; + handlePrev: () => void; + handleNext: () => void; + activePath: string | undefined; + // Convert functions + yamlConvert: ConvertFunction; + tomlConvert: ConvertFunction; + minifyConvert: ConvertFunction; +} + +export function useJsonTools(): UseJsonToolsReturn { + const { t } = useI18n(['jsonDiff', 'jsonFormat']); + const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); + + // Diff inputs + const [leftInput, setLeftInput] = useState(''); + const [rightInput, setRightInput] = useState(''); + const [debouncedLeft, setDebouncedLeft] = useState(''); + const [debouncedRight, setDebouncedRight] = useState(''); + + // Debounce + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedLeft(leftInput); + setDebouncedRight(rightInput); + }, 250); + return () => clearTimeout(handle); + }, [leftInput, rightInput]); + + // Parse debounced inputs + const parseState = useMemo(() => { + const invalidMsg = t('jsonDiff:invalidJson'); + return { + left: tryParse(debouncedLeft, invalidMsg), + right: tryParse(debouncedRight, invalidMsg), + }; + }, [debouncedLeft, debouncedRight, t]); + + const leftError = parseState.left.error; + const rightError = parseState.right.error; + + const [viewMode, setViewMode] = useState('sideBySide'); + const [currentDiffIndex, setCurrentDiffIndex] = useState(0); + + // Real-time diff computation + const diffResult = useMemo(() => { + const { left, right } = parseState; + if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') { + return null; + } + return diffJson(left.value, right.value); + }, [parseState, debouncedLeft, debouncedRight]); + + const total = diffResult?.diffPaths.length ?? 0; + + const handlePrev = useCallback(() => { + if (total === 0) return; + setCurrentDiffIndex((idx) => (idx - 1 + total) % total); + }, [total]); + + const handleNext = useCallback(() => { + if (total === 0) return; + setCurrentDiffIndex((idx) => (idx + 1) % total); + }, [total]); + + const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; + + // Convert functions + const yamlConvert: ConvertFunction = useCallback((text: string) => { + const r = jsonToYaml(text); + return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; + }, []); + + const tomlConvert: ConvertFunction = useCallback((text: string) => { + const r = jsonToToml(text); + return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; + }, []); + + const minifyConvert: ConvertFunction = useCallback((text: string) => { + const r = minifyJson(text); + return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes }; + }, []); + + return { + pageMode, + setPageMode, + leftInput, + rightInput, + setLeftInput: (val: string) => { + setLeftInput(val); + setCurrentDiffIndex(0); + }, + setRightInput: (val: string) => { + setRightInput(val); + setCurrentDiffIndex(0); + }, + leftError, + rightError, + viewMode, + setViewMode, + diffResult, + total, + currentDiffIndex, + handlePrev, + handleNext, + activePath, + yamlConvert, + tomlConvert, + minifyConvert, + }; +} diff --git a/src/pages/Jwt/JwtSection.tsx b/src/pages/Jwt/JwtSection.tsx new file mode 100644 index 0000000..4a11e0f --- /dev/null +++ b/src/pages/Jwt/JwtSection.tsx @@ -0,0 +1,38 @@ +import CopyButton from '@/components/CopyButton'; +import { useI18n } from '@/utils/chromeI18n'; +import { stringifyJson } from '@/utils/jwt'; +import { cn } from '@/lib/utils'; + +interface JwtSectionProps { + title: string; + content: unknown; + colorClass: string; + bgClass: string; + borderClass: string; +} + +export default function JwtSection({ + title, + content, + colorClass, + bgClass, + borderClass, +}: JwtSectionProps) { + const { t } = useI18n('jwt'); + return ( +
+
+ + {title} + + +
+
+        {content ? stringifyJson(content) : t('jwt:invalidFormat')}
+      
+
+ ); +} diff --git a/src/pages/Jwt/index.tsx b/src/pages/Jwt/index.tsx index 6e37771..07aa1cb 100644 --- a/src/pages/Jwt/index.tsx +++ b/src/pages/Jwt/index.tsx @@ -1,91 +1,31 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { parseJwt, stringifyJson } from '@/utils/jwt'; -import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; +import CopyButton from '@/components/CopyButton'; +import JwtSection from './JwtSection'; +import { useJwt } from './useJwt'; import { useI18n } from '@/utils/chromeI18n'; -import { useContextMenuData } from '@/utils/useContextMenuData'; -import { cn } from '@/lib/utils'; - -interface SectionProps { - title: string; - content: unknown; - colorClass: string; - bgClass: string; - borderClass: string; -} - -const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => { - const { t } = useI18n('jwt'); - return ( -
-
- - {title} - - -
-
-        {content ? stringifyJson(content) : t('jwt:invalidFormat')}
-      
-
- ); -}; export default function Index() { const { t } = useI18n(['jwt', 'jsonFormat']); - const [jwtInput, setJwtInput] = useState(''); - - // 2. 防抖中转管道:切断高频键盘敲击时的红色语法闪烁 - const [debouncedInput, setDebouncedInput] = useState(''); - - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedInput(jwtInput); - }, 200); - return () => clearTimeout(handle); - }, [jwtInput]); - - const handleContextMenuData = useCallback((payload: string) => { - const cleaned = payload.replace(/^Bearer\s*/i, '').trim(); - setJwtInput(cleaned); - }, []); - - useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData }); - - const result = useMemo(() => { - if (!debouncedInput.trim()) { - return null; - } - return parseJwt(debouncedInput); - }, [debouncedInput]); + const { jwtInput, result, handleChange, handleClear } = useJwt(); return (
- {/* 输入终端 */} { - const cleaned = val.replace(/^Bearer\s*/i, '').trim(); - setJwtInput(cleaned); - }} + onChange={handleChange} allowCopy={true} showClear={true} externalError={result?.error || undefined} - onClear={() => setJwtInput('')} + onClear={handleClear} /> - {/* 解码看板结果展现 */} {result && !result.error && (
- {/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */} -
- {/* Payload 分区 */} -
- {/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
@@ -120,7 +58,6 @@ export default function Index() {
)} - {/* 当解析错误时的干净中性引导拦截 */} {result?.error && (

diff --git a/src/pages/Jwt/useJwt.ts b/src/pages/Jwt/useJwt.ts new file mode 100644 index 0000000..5f65e3f --- /dev/null +++ b/src/pages/Jwt/useJwt.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useContextMenuData } from '@/utils/useContextMenuData'; +import { parseJwt } from '@/utils/jwt'; +import type { JwtResult } from '@/utils/jwt'; + +export interface UseJwtReturn { + jwtInput: string; + result: JwtResult | null; + handleChange: (val: string) => void; + handleClear: () => void; +} + +export function useJwt(): UseJwtReturn { + const [jwtInput, setJwtInput] = useState(''); + const [debouncedInput, setDebouncedInput] = useState(''); + + // 防抖管道 + useEffect(() => { + const handle = setTimeout(() => setDebouncedInput(jwtInput), 200); + return () => clearTimeout(handle); + }, [jwtInput]); + + // 右键菜单数据 + const handleContextMenuData = useCallback((payload: string) => { + setJwtInput(payload.replace(/^Bearer\s*/i, '').trim()); + }, []); + + useContextMenuData({ featureKey: 'jwt', onData: handleContextMenuData }); + + // 响应式解析 + const result = useMemo(() => { + if (!debouncedInput.trim()) return null; + return parseJwt(debouncedInput); + }, [debouncedInput]); + + const handleChange = useCallback((val: string) => { + setJwtInput(val.replace(/^Bearer\s*/i, '').trim()); + }, []); + + const handleClear = useCallback(() => setJwtInput(''), []); + + return { jwtInput, result, handleChange, handleClear }; +} diff --git a/src/pages/QrCode/components/ParsePanel.tsx b/src/pages/QrCode/components/ParsePanel.tsx index bd84c56..a0a2aa6 100644 --- a/src/pages/QrCode/components/ParsePanel.tsx +++ b/src/pages/QrCode/components/ParsePanel.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react'; +import { toast } from 'sonner'; import TextInputArea from '@/components/TextInputArea'; import ImageUploader from '@/components/ImageUploader'; -import { useSnackbar } from '@/components/GlobalSnackbar'; import { useI18n } from '@/utils/chromeI18n'; import { useQrCodeContext } from '../contexts/QrCodeContext'; import { Label } from '@/components/ui/label'; @@ -9,7 +9,6 @@ import { cn } from '@/lib/utils'; export default function ParsePanel() { const { t } = useI18n('qrCode'); - const { showMessage } = useSnackbar(); const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext(); // 全局粘贴事件监听 @@ -24,7 +23,7 @@ export default function ParsePanel() { const file = items[i].getAsFile(); if (file) { handleFileChange(file); - showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 }); + toast.success(t('qrCode:imagePasted')); } return; } @@ -38,14 +37,14 @@ export default function ParsePanel() { 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 }); + toast.success(t('qrCode:imagePasted')); } catch (error) { console.error('处理 Base64 图片失败:', error); - showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 }); + toast.error(t('qrCode:imagePasteError')); } } }, - [handleFileChange, showMessage, t], + [handleFileChange, t], ); useEffect(() => { diff --git a/src/pages/QrCode/hooks/useQrCode.ts b/src/pages/QrCode/hooks/useQrCode.ts index 706cefd..bedf5c3 100644 --- a/src/pages/QrCode/hooks/useQrCode.ts +++ b/src/pages/QrCode/hooks/useQrCode.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import QRious from 'qrious'; -import { useSnackbar } from '@/components/GlobalSnackbar'; +import { toast } from 'sonner'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { useContextMenuData } from '@/utils/useContextMenuData'; import { useI18n } from '@/utils/chromeI18n'; @@ -10,7 +10,6 @@ import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../typ export function useQrCode(): QrCodeContextValue { const { t } = useI18n('qrCode'); - const { showMessage } = useSnackbar(); const [mode, setMode] = useState('generate'); @@ -89,22 +88,22 @@ export function useQrCode(): QrCodeContextValue { if (result.success && result.data) { setParserState((prev) => ({ ...prev, decodedResult: result.data! })); - showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 }); + toast.success(t('qrCode:parseSuccess')); } else { const errorMsg = result.error || t('qrCode:noQrDetected'); setParserState((prev) => ({ ...prev, parseError: errorMsg })); - showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 }); + toast.error(errorMsg); } } 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 }); + toast.error(errorMsg); } finally { setParserState((prev) => ({ ...prev, parsing: false })); } }, - [t, showMessage], + [t], ); const downloadQrCode = useCallback(() => { @@ -114,8 +113,8 @@ export function useQrCode(): QrCodeContextValue { link.href = qrCodeDataUrl; link.download = 'qrcode.png'; link.click(); - showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 }); - }, [qrCodeDataUrl, showMessage, t]); + toast.success(t('qrCode:qrCodeDownloadSuccess')); + }, [qrCodeDataUrl, t]); const copyQrCode = useCallback(async () => { if (!qrCodeDataUrl) return; @@ -130,12 +129,12 @@ export function useQrCode(): QrCodeContextValue { }), ]); - showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 }); + toast.success(t('qrCode:qrCodeCopySuccess')); } catch (error) { console.error('复制二维码失败:', error); - showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 }); + toast.error(t('qrCode:copyError')); } - }, [qrCodeDataUrl, showMessage, t]); + }, [qrCodeDataUrl, t]); const handleFileChange = useCallback( (file: File) => { diff --git a/src/pages/RightClickRestorer/index.tsx b/src/pages/RightClickRestorer/index.tsx index 3b14f78..35dec5f 100644 --- a/src/pages/RightClickRestorer/index.tsx +++ b/src/pages/RightClickRestorer/index.tsx @@ -5,7 +5,7 @@ import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-re import { useRightClickRestorer } from './useRightClickRestorer'; import { useI18n } from '@/utils/chromeI18n'; -export default function RightClickRestorerPage() { +export default function Index() { const { t } = useI18n('rightClickRestorer'); const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer(); diff --git a/src/pages/RightClickRestorer/useRightClickRestorer.ts b/src/pages/RightClickRestorer/useRightClickRestorer.ts index f7abeb4..b215743 100644 --- a/src/pages/RightClickRestorer/useRightClickRestorer.ts +++ b/src/pages/RightClickRestorer/useRightClickRestorer.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { MessageAction, sendMessageToContent } from '@/utils/messages'; const UNSUPPORTED_PROTOCOLS = new Set([ @@ -39,17 +39,10 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const url = tab?.url; - if (url) { - try { - setDomain(new URL(url).hostname); - } catch { - setDomain(''); - } - } + setDomain(url ? new URL(url).hostname : ''); if (isUnsupportedPage(url)) { setIsUnsupported(true); - setIsLoading(false); return; } @@ -67,7 +60,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn { void load(); }, []); - const unlock = useCallback(async () => { + async function unlock() { if (isUnsupported) return; try { @@ -78,7 +71,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn { } catch (err) { console.error('[RightClickRestorer] Failed to unlock:', err); } - }, [isUnsupported]); + } return { domain, diff --git a/src/pages/StorageCleaner/AutoRefreshToggle.tsx b/src/pages/StorageCleaner/AutoRefreshToggle.tsx index cc21293..6b353fb 100644 --- a/src/pages/StorageCleaner/AutoRefreshToggle.tsx +++ b/src/pages/StorageCleaner/AutoRefreshToggle.tsx @@ -1,8 +1,8 @@ /** - * 自动刷新开关组件 + * 自动刷新开关行 * - * 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。 - * 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。 + * 用于 StorageCleaner 页面的操作区域内(非独立卡片), + * 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。 * * @example * ```tsx @@ -48,15 +48,14 @@ export default function AutoRefreshToggle({ return (

- {/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */} diff --git a/src/pages/StorageCleaner/CleaningResult.tsx b/src/pages/StorageCleaner/CleaningResult.tsx index d546d09..7025838 100644 --- a/src/pages/StorageCleaner/CleaningResult.tsx +++ b/src/pages/StorageCleaner/CleaningResult.tsx @@ -14,7 +14,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning if (!result) return null; - const isSuccess = result.success; + const isSuccess = result.overallSuccess; return (
diff --git a/src/pages/StorageCleaner/OptionItem.tsx b/src/pages/StorageCleaner/OptionItem.tsx index 18c7fe6..a6c96ee 100644 --- a/src/pages/StorageCleaner/OptionItem.tsx +++ b/src/pages/StorageCleaner/OptionItem.tsx @@ -1,59 +1,63 @@ import React from 'react'; -import { formatSize } from '@/utils/storageCleaner'; +import { formatBytes } from '@/utils/format'; import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; -// 引入官方的 Checkbox 原子组件 +import type { StorageSizeInfo } from './useStorageCleaner'; import { Checkbox } from '@/components/ui/checkbox'; interface OptionItemProps extends React.HTMLAttributes { labelKey: string; checked: boolean; - size?: number; - isCount?: boolean; + sizeInfo?: StorageSizeInfo; onChange: () => void; } export default function OptionItem({ labelKey, checked, - size, - isCount = false, + sizeInfo, onChange, className, ...props }: OptionItemProps) { const { t } = useI18n('storageCleaner'); + const sizeValue = sizeInfo?.value; + const isCount = sizeInfo?.displayType === 'count'; + return (
- {/* 左侧数据区域 */} -
+
{t(labelKey)} - {/* 底部容量大小或计数标识 */} - {size !== undefined && size > 0 ? ( - - {isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)} + {sizeValue !== undefined && sizeValue > 0 ? ( + + {isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)} ) : ( - + {t('storageCleaner:noData')} )} diff --git a/src/pages/StorageCleaner/StorageOptionsGrid.tsx b/src/pages/StorageCleaner/StorageOptionsGrid.tsx index 07a4334..f19e36a 100644 --- a/src/pages/StorageCleaner/StorageOptionsGrid.tsx +++ b/src/pages/StorageCleaner/StorageOptionsGrid.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { StorageCleanerOptions } from '@/types/storage'; +import type { StorageSizeInfo } from './useStorageCleaner'; import OptionItem from './OptionItem'; import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; @@ -8,7 +9,7 @@ import { Label } from '@/components/ui/label'; interface StorageOptionsGridProps extends React.HTMLAttributes { options: StorageCleanerOptions; - sizes: Record; + sizes: Record; allSelected: boolean; someSelected: boolean; onOptionChange: (key: keyof StorageCleanerOptions) => void; @@ -27,13 +28,13 @@ export default function StorageOptionsGrid({ }: StorageOptionsGridProps) { const { t } = useI18n('storageCleaner'); - const optionKeys: { key: keyof StorageCleanerOptions; isCount?: boolean }[] = [ - { key: 'localStorage' }, - { key: 'sessionStorage' }, - { key: 'indexedDB' }, - { key: 'cookies' }, - { key: 'cacheStorage', isCount: true }, - { key: 'serviceWorkers', isCount: true }, + const optionKeys: (keyof StorageCleanerOptions)[] = [ + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'cookies', + 'cacheStorage', + 'serviceWorkers', ]; const handleToggleAll = () => { @@ -41,22 +42,15 @@ export default function StorageOptionsGrid({ }; return ( -
-
-
- {optionKeys.map(({ key, isCount }) => ( +
+
+
+ {optionKeys.map((key) => ( onOptionChange(key)} /> ))} @@ -65,9 +59,9 @@ export default function StorageOptionsGrid({
-
diff --git a/src/pages/StorageCleaner/index.tsx b/src/pages/StorageCleaner/index.tsx index b582639..63b1f28 100644 --- a/src/pages/StorageCleaner/index.tsx +++ b/src/pages/StorageCleaner/index.tsx @@ -1,6 +1,6 @@ import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import StorageCleanerConfirm from '@/pages/StorageCleaner/StorageCleanerConfirm'; +import StorageCleanerConfirm from './StorageCleanerConfirm'; import { useStorageCleaner } from './useStorageCleaner'; import StorageOptionsGrid from './StorageOptionsGrid'; import AutoRefreshToggle from './AutoRefreshToggle'; @@ -47,37 +47,42 @@ export default function Index() { } return ( -
- +
+ {/* 操作区域卡片 */} +
+ - + - +
+ +
+
diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts index 5910df4..9b46579 100644 --- a/src/pages/StorageCleaner/useStorageCleaner.ts +++ b/src/pages/StorageCleaner/useStorageCleaner.ts @@ -16,7 +16,6 @@ import { getSessionStorageSize, isRestrictedUrl, } from '@/utils/storageCleaner'; -import { MessageAction, sendMessage } from '@/utils/messages'; import { useI18n } from '@/utils/chromeI18n'; import { toast } from 'sonner'; @@ -34,18 +33,22 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = { selectedTypes: DEFAULT_OPTIONS, }; +export interface StorageSizeInfo { + value: number; + displayType: 'bytes' | 'count'; +} + export interface UseStorageCleanerReturn { - domain: string; error: string; isInitializing: boolean; options: StorageCleanerOptions; - sizes: Record; + sizes: Record; reloadAfterClean: boolean; loading: boolean; result: CleaningResult | null; showConfirm: boolean; setShowConfirm: (show: boolean) => void; - totalSize: number; + totalBytes: number; allSelected: boolean; someSelected: boolean; @@ -57,11 +60,10 @@ export interface UseStorageCleanerReturn { export function useStorageCleaner(): UseStorageCleanerReturn { const { t } = useI18n(['storageCleaner', 'common']); - const [domain, setDomain] = useState(''); const [error, setError] = useState(''); const [isInitializing, setIsInitializing] = useState(true); const [options, setOptions] = useState(DEFAULT_OPTIONS); - const [sizes, setSizes] = useState>({}); + const [sizes, setSizes] = useState>({}); const [reloadAfterClean, setReloadAfterClean] = useState(true); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); @@ -102,7 +104,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn { setError(''); const url = tab.url; const tabId = tab.id!; - setDomain(new URL(url).hostname); const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([ storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES), @@ -122,12 +123,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn { } setSizes({ - cookies: cSize, - localStorage: lsSize, - sessionStorage: ssSize, - indexedDB: idbSize, - cacheStorage: cacheCount, - serviceWorkers: swCount, + cookies: { value: cSize, displayType: 'bytes' }, + localStorage: { value: lsSize, displayType: 'bytes' }, + sessionStorage: { value: ssSize, displayType: 'bytes' }, + indexedDB: { value: idbSize, displayType: 'bytes' }, + cacheStorage: { value: cacheCount, displayType: 'count' }, + serviceWorkers: { value: swCount, displayType: 'count' }, }); } finally { if (currentRequestId === requestIdRef.current) { @@ -217,9 +218,9 @@ export function useStorageCleaner(): UseStorageCleanerReturn { const cleaningResult = await clearStorage(tab.id, tab.url, options); setResult(cleaningResult); - if (reloadAfterClean && cleaningResult.success) { + if (reloadAfterClean && cleaningResult.overallSuccess) { toast.success(t('storageCleaner:cleanSuccessReload')); - await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 }); + await chrome.tabs.reload(tab.id); } else { await loadInfo(); } @@ -231,13 +232,10 @@ export function useStorageCleaner(): UseStorageCleanerReturn { } }, [options, reloadAfterClean, loadInfo, t]); - const totalSize = useMemo(() => { - return ( - (sizes.cookies || 0) + - (sizes.localStorage || 0) + - (sizes.sessionStorage || 0) + - (sizes.indexedDB || 0) - ); + const totalBytes = useMemo(() => { + return Object.values(sizes).reduce((acc, s) => { + return s.displayType === 'bytes' ? acc + (s.value || 0) : acc; + }, 0); }, [sizes]); const selectionMetrics = useMemo(() => { @@ -248,7 +246,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn { }, [options]); return { - domain, error, isInitializing, options, @@ -258,7 +255,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn { result, showConfirm, setShowConfirm, - totalSize, + totalBytes, allSelected: selectionMetrics.all, someSelected: selectionMetrics.some, handleReloadAfterCleanChange, diff --git a/src/pages/TextStatistics/index.tsx b/src/pages/TextStatistics/index.tsx index ef96961..3661285 100644 --- a/src/pages/TextStatistics/index.tsx +++ b/src/pages/TextStatistics/index.tsx @@ -1,32 +1,22 @@ -import { useCallback, useMemo, useState } from 'react'; import TextInputArea from '@/components/TextInputArea'; -import { formatByteSize, getTextStats } from '@/utils/textStatistics'; +import { formatBytes } from '@/utils/format'; import { useI18n } from '@/utils/chromeI18n'; -import { useContextMenuData } from '@/utils/useContextMenuData'; +import { useTextStatistics } from './useTextStatistics'; import { cn } from '@/lib/utils'; export default function Index() { const { t } = useI18n('textStatistics'); - const [text, setText] = useState(''); - - const handleContextMenuData = useCallback((payload: string) => { - setText(payload); - }, []); - - useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData }); - - const stats = useMemo(() => getTextStats(text), [text]); + const { text, stats, setText } = useTextStatistics(); const statItems = [ { label: t('textStatistics:characters'), value: stats.characters }, { label: t('textStatistics:words'), value: stats.words }, { label: t('textStatistics:lines'), value: stats.lines }, - { label: t('textStatistics:bytes'), value: formatByteSize(stats.bytes) }, + { label: t('textStatistics:bytes'), value: formatBytes(stats.bytes) }, ]; return (
- {/* 文本输入区域 */} - {/* 统计结果展示区域 */}
{statItems.map((item) => (
void; +} + +export function useTextStatistics(): UseTextStatisticsReturn { + const [text, setText] = useState(''); + + const handleContextMenuData = useCallback((payload: string) => { + setText(payload); + }, []); + + useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData }); + + const stats = getTextStats(text); + + return { text, stats, setText }; +} diff --git a/src/pages/Timestamp/LiveClock.tsx b/src/pages/Timestamp/LiveClock.tsx index a314758..a75b352 100644 --- a/src/pages/Timestamp/LiveClock.tsx +++ b/src/pages/Timestamp/LiveClock.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Clock } from 'lucide-react'; +import { toast } from 'sonner'; import CopyButton from '@/components/CopyButton'; -import { useSnackbar } from '@/components/GlobalSnackbar'; import type { UnitType } from './constants'; +import { msToUnit } from './constants'; import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; @@ -11,42 +12,20 @@ interface LiveClockProps extends React.HTMLAttributes { onUseNow: (val: number) => void; } -const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => { +export default function LiveClock({ unit, onUseNow, className, ...props }: LiveClockProps) { const { t } = useI18n('timestamp'); - const { showMessage } = useSnackbar(); - const onUseNowRef = useRef(onUseNow); - const [currentDisplay, setCurrentDisplay] = useState(() => { - const initNow = Date.now(); - return { - rawTime: initNow, - text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))), - }; - }); - - useEffect(() => { - onUseNowRef.current = onUseNow; - }, [onUseNow]); + const [rawTime, setRawTime] = useState(() => Date.now()); useEffect(() => { const tick = () => { - const rightNow = Date.now(); - const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000))); - - setCurrentDisplay((prev) => { - if (prev.text === nextText) return prev; - return { rawTime: rightNow, text: nextText }; - }); + setRawTime(Date.now()); }; - const tickId = setInterval(tick, 200); return () => clearInterval(tickId); }, [unit]); - const handleUseNow = useCallback(() => { - onUseNowRef.current(currentDisplay.rawTime); - showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' }); - }, [currentDisplay.rawTime, showMessage, t]); + const text = String(msToUnit(rawTime, unit)); return (
- {currentDisplay.text} + {text}
); -}); - -LiveClock.displayName = 'LiveClock'; - -export default LiveClock; +} diff --git a/src/pages/Timestamp/ResultView.tsx b/src/pages/Timestamp/ResultView.tsx index 71087a7..076d01e 100644 --- a/src/pages/Timestamp/ResultView.tsx +++ b/src/pages/Timestamp/ResultView.tsx @@ -1,119 +1,52 @@ -import React, { useMemo } from 'react'; -import dayjs from '@/utils/dayjs'; +import React from 'react'; import CopyButton from '@/components/CopyButton'; -import type { UnitType } from './constants'; -import { DATE_FORMAT } from './constants'; import { useI18n } from '@/utils/chromeI18n'; import { cn } from '@/lib/utils'; interface ResultViewProps extends React.HTMLAttributes { result: string; - mode: 'ts2dt' | 'dt2ts'; - unit: UnitType; - zone: string; - showEmptyPlaceholder?: boolean; } -const ResultView = React.memo( - ({ - result, - mode, - unit, - zone, - showEmptyPlaceholder = false, - className, - ...props - }: ResultViewProps) => { - const { t } = useI18n('timestamp'); - - const extraInfo = useMemo(() => { - if (!result) return null; - const d = - mode === 'ts2dt' - ? dayjs(result, DATE_FORMAT).tz(zone) - : unit === 'ms' - ? dayjs(Number(result)) - : dayjs.unix(Number(result)); - - return { - relative: d.fromNow(), - iso: d.toISOString(), - utc: d.utc().format(DATE_FORMAT) + ' UTC', - }; - }, [result, mode, zone, unit]); - - if (!result) { - if (!showEmptyPlaceholder) return null; - return ( -
- {t('timestamp:resultEmpty')} -
- ); - } +export default function ResultView({ + result, + showEmptyPlaceholder = false, + className, + ...props +}: ResultViewProps) { + const { t } = useI18n('timestamp'); + if (!result) { + if (!showEmptyPlaceholder) return null; return ( -
- {/* 顶部小标签 */} - - {t('timestamp:resultLabel')} - - -
- - {result} - - -
- -
- {[ - { label: t('timestamp:relativeTime'), value: extraInfo?.relative }, - { label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true }, - { label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true }, - ].map((item) => ( -
- - {item.label} - -
- - {item.value} - - {item.value && ( - - )} -
-
- ))} -
+
+ {t('timestamp:resultEmpty')}
); - }, -); + } -ResultView.displayName = 'ResultView'; + return ( +
+ + {t('timestamp:resultLabel')} + -export default ResultView; +
+ + {result} + + +
+
+ ); +} diff --git a/src/pages/Timestamp/constants.ts b/src/pages/Timestamp/constants.ts index ae08b00..6a84613 100644 --- a/src/pages/Timestamp/constants.ts +++ b/src/pages/Timestamp/constants.ts @@ -1,6 +1,22 @@ +import type { Dayjs } from 'dayjs'; +import dayjs from '@/utils/dayjs'; + export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss'; export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const; +export type ModeType = 'ts2dt' | 'dt2ts'; export type UnitType = 'ms' | 's'; export type ZoneType = (typeof ZONES)[number]; + +const DIVISORS: Record = { ms: 1, s: 1000 }; + +/** Convert milliseconds to display value based on unit. */ +export function msToUnit(ms: number, unit: UnitType): number { + return Math.floor(ms / DIVISORS[unit]); +} + +/** Build a dayjs object from a numeric timestamp according to unit. */ +export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs { + return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts); +} diff --git a/src/pages/Timestamp/index.tsx b/src/pages/Timestamp/index.tsx index e554860..2120708 100644 --- a/src/pages/Timestamp/index.tsx +++ b/src/pages/Timestamp/index.tsx @@ -1,5 +1,6 @@ import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { ZONES } from './constants'; +import type { ModeType, UnitType, ZoneType } from './constants'; import LiveClock from './LiveClock'; import ResultView from './ResultView'; import { useTimestampConverter } from './useTimestampConverter'; @@ -15,6 +16,16 @@ import { SelectValue, } from '@/components/ui/select'; +const MODE_OPTIONS: { value: ModeType; label: string }[] = [ + { value: 'ts2dt', label: 'timestamp:tsToDate' }, + { value: 'dt2ts', label: 'timestamp:dateToTs' }, +]; + +const UNIT_OPTIONS: { value: UnitType; label: string }[] = [ + { value: 'ms', label: 'timestamp:unitMs' }, + { value: 's', label: 'timestamp:unitS' }, +]; + export default function Index() { const { t } = useI18n('timestamp'); @@ -42,11 +53,8 @@ export default function Index() {
setMode(newMode as 'ts2dt' | 'dt2ts')} + options={MODE_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} + onChange={setMode} size="small" /> @@ -57,7 +65,7 @@ export default function Index() { mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate') } value={input} - onChange={(e: { target: { value: string } }) => setInput(e.target.value)} + onChange={(e) => setInput(e.target.value)} className={cn( 'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background', error && 'border-destructive focus-visible:ring-destructive', @@ -70,16 +78,13 @@ export default function Index() {
setUnit(v as 'ms' | 's')} + options={UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) }))} + onChange={setUnit} size="small" className="sm:w-auto shrink-0" /> - setZone(v as ZoneType)}> @@ -100,7 +105,7 @@ export default function Index() {
- +
diff --git a/src/pages/Timestamp/useTimestampConverter.ts b/src/pages/Timestamp/useTimestampConverter.ts index 31d8be8..7562c85 100644 --- a/src/pages/Timestamp/useTimestampConverter.ts +++ b/src/pages/Timestamp/useTimestampConverter.ts @@ -1,39 +1,42 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import dayjs from '@/utils/dayjs'; -import type { UnitType, ZoneType } from './constants'; -import { DATE_FORMAT } from './constants'; +import type { UnitType, ZoneType, ModeType } from './constants'; +import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants'; import { useI18n } from '@/utils/chromeI18n'; import { useContextMenuData } from '@/utils/useContextMenuData'; export interface UseTimestampConverterReturn { - mode: 'ts2dt' | 'dt2ts'; + mode: ModeType; input: string; unit: UnitType; zone: ZoneType; result: string; error: string; - setMode: (mode: 'ts2dt' | 'dt2ts') => void; + setMode: (mode: ModeType) => void; setInput: (value: string) => void; setUnit: (unit: UnitType) => void; setZone: (zone: ZoneType) => void; handleUseNow: (now: number) => void; } +const TIMESTAMP_REGEX = /^\d+$/; +const MS_TIMESTAMP_MIN_LENGTH = 13; + function isTimestampLike(input: string): boolean { const trimmed = input.trim(); - return /^\d+$/.test(trimmed) && trimmed.length >= 10; + return TIMESTAMP_REGEX.test(trimmed) && trimmed.length >= 10; } export function useTimestampConverter(): UseTimestampConverterReturn { const { t } = useI18n('timestamp'); - const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); + const [mode, setMode] = useState('ts2dt'); const [unit, setUnit] = useState('ms'); const [zone, setZone] = useState('Asia/Shanghai'); const [input, setInput] = useState(() => String(Date.now())); - const conversionPipeline = useMemo(() => { + const { result, error } = useMemo(() => { const rawInput = input.trim(); if (!rawInput) return { result: '', error: '' }; @@ -42,7 +45,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { if (isNaN(num)) { return { result: '', error: t('timestamp:errors.invalidNumber') }; } - const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num); + const d = dayjsFromTimestamp(num, unit); if (!d.isValid()) { return { result: '', error: t('timestamp:errors.invalidTimestamp') }; } @@ -53,19 +56,15 @@ export function useTimestampConverter(): UseTimestampConverterReturn { return { result: '', error: t('timestamp:errors.invalidFormat') }; } const ms = d.valueOf(); - const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)); - return { result: outputTs, error: '' }; + return { result: String(msToUnit(ms, unit)), error: '' }; } }, [input, mode, unit, zone, t]); - const { result, error } = conversionPipeline; - - const handleContextMenuData = useCallback((payload: string) => { + const handleContextMenuData = (payload: string) => { const trimmed = payload.trim(); if (isTimestampLike(trimmed)) { setMode('ts2dt'); - const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's'; - setUnit(detectedUnit); + setUnit(trimmed.length >= MS_TIMESTAMP_MIN_LENGTH ? 'ms' : 's'); setInput(trimmed); } else { const d = dayjs(trimmed); @@ -77,30 +76,24 @@ export function useTimestampConverter(): UseTimestampConverterReturn { setInput(trimmed); } } - }, []); + }; useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData }); - const handleUseNow = useCallback( - (now: number) => { - if (mode === 'ts2dt') { - setInput(String(unit === 'ms' ? now : Math.floor(now / 1000))); - } else { - setInput(dayjs(now).tz(zone).format(DATE_FORMAT)); - } - }, - [mode, unit, zone], - ); + const handleUseNow = (now: number) => { + if (mode === 'ts2dt') { + setInput(String(msToUnit(now, unit))); + } else { + setInput(dayjs(now).tz(zone).format(DATE_FORMAT)); + } + }; - const handleSetMode = useCallback( - (newMode: 'ts2dt' | 'dt2ts') => { - setMode(newMode); - if (result && !error) { - setInput(result); - } - }, - [result, error], - ); + const handleSetMode = (newMode: ModeType) => { + setMode(newMode); + if (result && !error) { + setInput(result); + } + }; return { mode, diff --git a/src/providers/ThemeModeProvider.tsx b/src/providers/ThemeModeProvider.tsx index 7a7b75c..02b7997 100644 --- a/src/providers/ThemeModeProvider.tsx +++ b/src/providers/ThemeModeProvider.tsx @@ -1,12 +1,4 @@ -import { - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { browser } from 'wxt/browser'; import { storageUtil } from '@/utils/chromeStorage'; @@ -141,12 +133,11 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) { } }, [resolvedMode]); - const contextValue = useMemo( - () => ({ mode, resolvedMode, setMode }), - [mode, resolvedMode, setMode], + return ( + + {children} + ); - - return {children}; } export function useThemeMode(): ThemeModeContextType { diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index 64fe134..f5ab210 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -189,7 +189,7 @@ export type StorageCleanResult = */ export interface CleaningResult { /** 整体操作是否成功 */ - success: boolean; + overallSuccess: boolean; /** 整体错误信息(如果有) */ error?: string; /** 各项清理的具体结果 */ diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index 7468edb..434361c 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -34,10 +34,14 @@ describe('messages', () => { const mockTab = { id: 123, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual(mockResponse); - expect(mockSendMessage).toHaveBeenCalledWith(MessageAction.RELOAD_TAB, { tabId: 123 }, 123); + expect(mockSendMessage).toHaveBeenCalledWith( + MessageAction.RESTORE_RIGHT_CLICK, + undefined, + 123, + ); }); it('应该支持不带数据的消息发送', async () => { @@ -60,11 +64,11 @@ describe('messages', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); (chrome.tabs.query as any).mockResolvedValue([]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, message: '无法获取当前标签页' }); expect(consoleSpy).toHaveBeenCalledWith( - '[Messaging] 无法获取当前标签页,无法发送动作: reloadTab', + '[Messaging] 无法获取当前标签页,无法发送动作: restoreRightClick', ); consoleSpy.mockRestore(); }); @@ -73,7 +77,7 @@ describe('messages', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); (chrome.tabs.query as any).mockResolvedValue([{ url: 'https://example.com' }]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, message: '无法获取当前标签页' }); consoleSpy.mockRestore(); @@ -87,7 +91,7 @@ describe('messages', () => { const mockTab = { id: 123, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, @@ -104,7 +108,7 @@ describe('messages', () => { const mockTab = { id: 123, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, @@ -121,7 +125,7 @@ describe('messages', () => { const mockTab = { id: 123, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, @@ -138,7 +142,7 @@ describe('messages', () => { const mockTab = { id: 123, url: 'https://example.com' }; (chrome.tabs.query as any).mockResolvedValue([mockTab]); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, @@ -151,7 +155,7 @@ describe('messages', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); (chrome.tabs.query as any).mockRejectedValue(new Error('Query failed')); - const result = await sendMessageToContent(MessageAction.RELOAD_TAB, { tabId: 123 }); + const result = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); expect(result).toEqual({ success: false, diff --git a/src/utils/__tests__/storageCleaner.test.ts b/src/utils/__tests__/storageCleaner.test.ts index 4b2d6c2..9137a38 100644 --- a/src/utils/__tests__/storageCleaner.test.ts +++ b/src/utils/__tests__/storageCleaner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { clearCookies, formatSize, isRestrictedUrl } from '@/utils/storageCleaner'; +import { clearCookies, isRestrictedUrl } from '@/utils/storageCleaner'; +import { formatBytes } from '@/utils/format'; describe('storageCleaner utils', () => { describe('isRestrictedUrl', () => { @@ -48,36 +49,36 @@ describe('storageCleaner utils', () => { }); }); - describe('formatSize', () => { + describe('formatBytes', () => { it('should return "0 B" for 0 bytes', () => { - expect(formatSize(0)).toBe('0 B'); + expect(formatBytes(0)).toBe('0 B'); }); it('should format bytes correctly', () => { - expect(formatSize(500)).toBe('500 B'); + expect(formatBytes(500)).toBe('500 B'); }); it('should format kilobytes correctly', () => { - expect(formatSize(1024)).toBe('1.0 KB'); - expect(formatSize(1536)).toBe('1.5 KB'); - expect(formatSize(2048)).toBe('2.0 KB'); + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(2048)).toBe('2.0 KB'); }); it('should format megabytes correctly', () => { - expect(formatSize(1048576)).toBe('1.00 MB'); - expect(formatSize(1572864)).toBe('1.50 MB'); - expect(formatSize(5242880)).toBe('5.00 MB'); + expect(formatBytes(1048576)).toBe('1.00 MB'); + expect(formatBytes(1572864)).toBe('1.50 MB'); + expect(formatBytes(5242880)).toBe('5.00 MB'); }); it('should format gigabytes correctly', () => { - expect(formatSize(1073741824)).toBe('1.00 GB'); - expect(formatSize(2147483648)).toBe('2.00 GB'); + expect(formatBytes(1073741824)).toBe('1.00 GB'); + expect(formatBytes(2147483648)).toBe('2.00 GB'); }); it('should handle edge cases', () => { - expect(formatSize(1)).toBe('1 B'); - expect(formatSize(1023)).toBe('1023 B'); - expect(formatSize(1025)).toBe('1.0 KB'); + expect(formatBytes(1)).toBe('1 B'); + expect(formatBytes(1023)).toBe('1023 B'); + expect(formatBytes(1025)).toBe('1.0 KB'); }); }); diff --git a/src/utils/__tests__/textStatistics.test.ts b/src/utils/__tests__/textStatistics.test.ts index 308f348..81d4103 100644 --- a/src/utils/__tests__/textStatistics.test.ts +++ b/src/utils/__tests__/textStatistics.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { formatByteSize, getTextStats } from '@/utils/textStatistics'; +import { formatBytes } from '@/utils/format'; +import { getTextStats } from '@/utils/textStatistics'; describe('textStatistics utils', () => { describe('getTextStats', () => { @@ -49,12 +50,12 @@ describe('textStatistics utils', () => { }); }); - describe('formatByteSize', () => { + describe('formatBytes', () => { it('should format bytes correctly', () => { - expect(formatByteSize(100)).toBe('100 B'); - expect(formatByteSize(0)).toBe('0 B'); - expect(formatByteSize(1024)).toBe('1.0 KB'); - expect(formatByteSize(1024 * 1024)).toBe('1.00 MB'); + expect(formatBytes(100)).toBe('100 B'); + expect(formatBytes(0)).toBe('0 B'); + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1024 * 1024)).toBe('1.00 MB'); }); }); }); diff --git a/src/utils/base64Converter.ts b/src/utils/base64Converter.ts index 7d1828e..54ee1f4 100644 --- a/src/utils/base64Converter.ts +++ b/src/utils/base64Converter.ts @@ -213,8 +213,9 @@ export function extractMimeTypeFromDataUri(dataUri: string): string { } /** - * 格式化文件大小显示(兼容旧接口,内部委托给 formatBytes) + * 格式化文件大小显示 * + * @deprecated 直接使用 {@link formatBytes} 代替 * @param bytes 字节数 * @returns 格式化后的字符串 */ @@ -270,12 +271,7 @@ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{ * @returns 字节序列 */ export function base64ToBytes(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); } /** @@ -287,25 +283,11 @@ export function base64ToBytes(b64: string): Uint8Array { export function sniffMimeFromBytes(bytes: Uint8Array): { mime: string; ext: string } | null { for (const sig of MAGIC_BYTE_SIGNATURES) { if (bytes.length < sig.bytes.length) continue; - let matched = true; - for (let i = 0; i < sig.bytes.length; i += 1) { - if (bytes[i] !== sig.bytes[i]) { - matched = false; - break; - } - } - if (!matched) continue; + if (!sig.bytes.every((b, i) => bytes[i] === b)) continue; if (sig.tail) { const { offset, bytes: tailBytes } = sig.tail; if (bytes.length < offset + tailBytes.length) continue; - let tailMatched = true; - for (let i = 0; i < tailBytes.length; i += 1) { - if (bytes[offset + i] !== tailBytes[i]) { - tailMatched = false; - break; - } - } - if (!tailMatched) continue; + if (!tailBytes.every((b, i) => bytes[offset + i] === b)) continue; } return { mime: sig.mime, ext: sig.ext }; } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 476c4bd..b1f9bcb 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,7 +1,6 @@ import { defineExtensionMessaging } from '@webext-core/messaging'; export enum MessageAction { - RELOAD_TAB = 'reloadTab', SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', CONTEXT_MENU_CLICKED = 'contextMenuClicked', RESTORE_RIGHT_CLICK = 'restoreRightClick', @@ -21,7 +20,6 @@ export interface ContextMenuClickedPayload { } 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; [MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean }; diff --git a/src/utils/rightClickInjection.ts b/src/utils/rightClickInjection.ts new file mode 100644 index 0000000..6645fa1 --- /dev/null +++ b/src/utils/rightClickInjection.ts @@ -0,0 +1,71 @@ +/** + * 右键解锁功能的主环境注入脚本 + * + * 由于 content script 运行在 Isolated World,无法直接修改网页主环境的 + * Event.prototype.preventDefault 等原生方法。需要通过 background + * 的 scripting.executeScript({ world: 'MAIN' }) 注入此脚本。 + */ + +/** + * 在网页主环境中执行的注入函数 + * 注意:此函数会被序列化后通过 executeScript 注入,不能引用外部变量 + */ +export function mainWorldInjectionScript(): void { + 'use strict'; + const w = window as unknown as Record; + if (w.__testingToolsRightClickPatched) return; + w.__testingToolsRightClickPatched = true; + + const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart']; + + const _origPreventDefault = MouseEvent.prototype.preventDefault; + Object.defineProperty(MouseEvent.prototype, 'preventDefault', { + value: function (this: MouseEvent) { + const t = this.type; + if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) { + return; + } + return _origPreventDefault.call(this); + }, + writable: true, + configurable: true, + }); + + const _origStopPropagation = Event.prototype.stopPropagation; + Object.defineProperty(Event.prototype, 'stopPropagation', { + value: function (this: Event) { + if (PROTECTED.includes(this.type)) return; + return _origStopPropagation.call(this); + }, + writable: true, + configurable: true, + }); + + const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation; + Object.defineProperty(Event.prototype, 'stopImmediatePropagation', { + value: function (this: Event) { + if (PROTECTED.includes(this.type)) return; + return _origStopImmediatePropagation.call(this); + }, + writable: true, + configurable: true, + }); + + let _docOnContextMenu: unknown = null; + Object.defineProperty(document, 'oncontextmenu', { + get() { + return _docOnContextMenu; + }, + set(fn: unknown) { + if (typeof fn === 'function') { + _docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) { + const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(this, e); + return r === false ? true : r; + }; + } else { + _docOnContextMenu = fn; + } + }, + configurable: true, + }); +} diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts index bf2591b..1238e07 100644 --- a/src/utils/storageCleaner.ts +++ b/src/utils/storageCleaner.ts @@ -1,5 +1,4 @@ import type { CleaningResult, StorageCleanerOptions, StorageCleanResult } from '@/types/storage'; -import { formatBytes } from './format'; const RESTRICTED_PROTOCOLS = [ 'chrome:', @@ -11,6 +10,16 @@ const RESTRICTED_PROTOCOLS = [ 'data:', ] as const; +/** 清理选项的 key 列表(用于遍历结果) */ +const CLEAN_OPTION_KEYS: (keyof StorageCleanerOptions)[] = [ + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'cookies', + 'cacheStorage', + 'serviceWorkers', +]; + export async function getCurrentTab() { // For popup pages, we need to get the active tab from the browser window that triggered the popup. // We should ONLY care about the currently active tab in the last focused window. @@ -59,129 +68,122 @@ export async function getCookieSize(url: string): Promise { } } -export async function getLocalStorageSize(tabId: number): Promise { +/** + * 通用辅助:在指定标签页中执行脚本并返回结果 + * + * @param tabId 标签页 ID + * @param func 在页面上下文中执行的函数 + * @param errorLabel 错误日志前缀 + * @param fallback 执行失败时的回退值 + */ +async function runScript( + tabId: number, + func: () => T | Promise, + errorLabel: string, + fallback: T, +): Promise { try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => { - try { - const encoder = new TextEncoder(); - return Object.entries(localStorage).reduce( - (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, - 0, - ); - } catch { - return 0; - } - }, - }); - return (result?.result as number) || 0; + const [result] = await chrome.scripting.executeScript({ target: { tabId }, func }); + return (result?.result as T) ?? fallback; } catch (error) { - console.error('Failed to get LocalStorage size:', error); - return 0; + console.error(`Failed to ${errorLabel}:`, error); + return fallback; } } +export async function getLocalStorageSize(tabId: number): Promise { + return runScript( + tabId, + () => { + try { + const encoder = new TextEncoder(); + return Object.entries(localStorage).reduce( + (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, + 0, + ); + } catch { + return 0; + } + }, + 'get LocalStorage size', + 0, + ); +} + export async function getSessionStorageSize(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => { - try { - const encoder = new TextEncoder(); - return Object.entries(sessionStorage).reduce( - (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, - 0, - ); - } catch { - return 0; - } - }, - }); - return (result?.result as number) || 0; - } catch (error) { - console.error('Failed to get SessionStorage size:', error); - return 0; - } + return runScript( + tabId, + () => { + try { + const encoder = new TextEncoder(); + return Object.entries(sessionStorage).reduce( + (acc, [k, v]) => acc + encoder.encode(k).length + encoder.encode(v).length, + 0, + ); + } catch { + return 0; + } + }, + 'get SessionStorage size', + 0, + ); } export async function getOriginStorageEstimate(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - try { - if (navigator.storage && navigator.storage.estimate) { - const estimate = await navigator.storage.estimate(); - return estimate.usage || 0; - } - return 0; - } catch { - return 0; + return runScript( + tabId, + async () => { + try { + if (navigator.storage && navigator.storage.estimate) { + const estimate = await navigator.storage.estimate(); + return estimate.usage || 0; } - }, - }); - return (result?.result as number) || 0; - } catch (error) { - console.error('Failed to get origin storage estimate:', error); - return 0; - } + return 0; + } catch { + return 0; + } + }, + 'get origin storage estimate', + 0, + ); } export async function getCacheStorageSize(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - try { - if ('caches' in window) { - const keys = await caches.keys(); - return keys.length; - } - return 0; - } catch { - return 0; + return runScript( + tabId, + async () => { + try { + if ('caches' in window) { + const keys = await caches.keys(); + return keys.length; } - }, - }); - return (result?.result as number) || 0; - } catch (error) { - console.error('Failed to get CacheStorage size:', error); - return 0; - } + return 0; + } catch { + return 0; + } + }, + 'get CacheStorage size', + 0, + ); } export async function getServiceWorkerCount(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - try { - if ('serviceWorker' in navigator) { - const regs = await navigator.serviceWorker.getRegistrations(); - return regs.length; - } - return 0; - } catch { - return 0; + return runScript( + tabId, + async () => { + try { + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + return regs.length; } - }, - }); - return (result?.result as number) || 0; - } catch (error) { - console.error('Failed to get ServiceWorker count:', error); - return 0; - } -} - -/** - * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes) - * - * @param bytes 字节数 - * @returns 格式化后的字符串 - */ -export function formatSize(bytes: number): string { - return formatBytes(bytes); + return 0; + } catch { + return 0; + } + }, + 'get ServiceWorker count', + 0, + ); } export async function clearCookies(url: string): Promise { @@ -203,148 +205,125 @@ export async function clearCookies(url: string): Promise { } } -export async function injectClearLocalStorage(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => { - const count = localStorage.length; - localStorage.clear(); - return { count }; - }, - }); - if (result?.result && typeof result.result === 'object' && 'count' in result.result) { - return { success: true, count: result.result.count }; - } - return { success: false, error: 'No result returned' }; - } catch (error) { - return { success: false, error: String(error) }; +/** + * 通用辅助:执行清理脚本并解析结果 + * + * @param tabId 标签页 ID + * @param func 在页面上下文中执行的清理函数 + * @param errorLabel 错误日志前缀 + */ +async function runCleanScript( + tabId: number, + func: () => { count: number } | Promise<{ count: number }>, + errorLabel: string, +): Promise { + const raw = await runScript(tabId, func, errorLabel, { count: 0 }); + if (raw && typeof raw === 'object' && 'count' in raw) { + return { success: true, count: raw.count }; } + return { success: false, error: 'No result returned' }; +} + +export async function injectClearLocalStorage(tabId: number): Promise { + return runCleanScript( + tabId, + () => { + const count = localStorage.length; + localStorage.clear(); + return { count }; + }, + 'clear LocalStorage', + ); } export async function injectClearSessionStorage(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: () => { - const count = sessionStorage.length; - sessionStorage.clear(); - return { count }; - }, - }); - if (result?.result && typeof result.result === 'object' && 'count' in result.result) { - return { success: true, count: result.result.count }; - } - return { success: false, error: 'No result returned' }; - } catch (error) { - return { success: false, error: String(error) }; - } + return runCleanScript( + tabId, + () => { + const count = sessionStorage.length; + sessionStorage.clear(); + return { count }; + }, + 'clear SessionStorage', + ); } export async function injectClearIndexedDB(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - if (typeof indexedDB.databases === 'function') { - const databases = await indexedDB.databases(); - let count = 0; - for (const db of databases) { - if (db.name) { - const dbName = db.name as string; - try { - await new Promise((resolve, reject) => { - const deleteReq = indexedDB.deleteDatabase(dbName); - const timeout = setTimeout(() => { - console.warn('IndexedDB delete timeout:', dbName); - resolve(); // Timeout, move to next - }, 5000); - - deleteReq.onblocked = () => { - console.warn('IndexedDB delete blocked:', dbName); - clearTimeout(timeout); - resolve(); // Blocked, move to next - }; - deleteReq.onsuccess = () => { - clearTimeout(timeout); - resolve(); - }; - deleteReq.onerror = () => { - clearTimeout(timeout); - reject(new Error(`Failed to delete ${dbName}`)); - }; - }); - count++; - } catch (e) { - console.error('Delete DB error:', e); - } - } - } - return { count }; + return runCleanScript( + tabId, + async () => { + if (typeof indexedDB.databases !== 'function') { + return { count: 0 }; + } + const databases = await indexedDB.databases(); + let count = 0; + for (const db of databases) { + if (!db.name) continue; + const dbName = db.name; + try { + await new Promise((resolve, reject) => { + const deleteReq = indexedDB.deleteDatabase(dbName); + const timeout = setTimeout(() => { + console.warn('IndexedDB delete timeout:', dbName); + resolve(); + }, 5000); + deleteReq.onblocked = () => { + console.warn('IndexedDB delete blocked:', dbName); + clearTimeout(timeout); + resolve(); + }; + deleteReq.onsuccess = () => { + clearTimeout(timeout); + resolve(); + }; + deleteReq.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Failed to delete ${dbName}`)); + }; + }); + count++; + } catch (e) { + console.error('Delete DB error:', e); } - return { error: 'databases_api_unavailable' }; - }, - }); - if (result?.result && typeof result.result === 'object') { - if ('error' in result.result) { - return { success: false, error: String(result.result.error) }; } - if ('count' in result.result) { - return { success: true, count: result.result.count }; - } - } - return { success: false, error: 'No result returned' }; - } catch (error) { - return { success: false, error: String(error) }; - } + return { count }; + }, + 'clear IndexedDB', + ); } export async function injectClearCacheStorage(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - if ('caches' in window) { - const cacheNames = await caches.keys(); - for (const name of cacheNames) { - await caches.delete(name); - } - return { count: cacheNames.length }; + return runCleanScript( + tabId, + async () => { + if ('caches' in window) { + const cacheNames = await caches.keys(); + for (const name of cacheNames) { + await caches.delete(name); } - return { count: 0 }; - }, - }); - if (result?.result && typeof result.result === 'object' && 'count' in result.result) { - return { success: true, count: result.result.count }; - } - return { success: false, error: 'No result returned' }; - } catch (error) { - return { success: false, error: String(error) }; - } + return { count: cacheNames.length }; + } + return { count: 0 }; + }, + 'clear CacheStorage', + ); } export async function injectUnregisterServiceWorkers(tabId: number): Promise { - try { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - func: async () => { - if ('serviceWorker' in navigator) { - const registrations = await navigator.serviceWorker.getRegistrations(); - for (const registration of registrations) { - await registration.unregister(); - } - return { count: registrations.length }; + return runCleanScript( + tabId, + async () => { + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); } - return { count: 0 }; - }, - }); - if (result?.result && typeof result.result === 'object' && 'count' in result.result) { - return { success: true, count: result.result.count }; - } - return { success: false, error: 'No result returned' }; - } catch (error) { - return { success: false, error: String(error) }; - } + return { count: registrations.length }; + } + return { count: 0 }; + }, + 'unregister ServiceWorkers', + ); } export async function clearStorage( @@ -352,39 +331,32 @@ export async function clearStorage( url: string, options: StorageCleanerOptions, ): Promise { - const result: CleaningResult = { success: true }; + const result: CleaningResult = { overallSuccess: true }; if (options.localStorage) { result.localStorage = await injectClearLocalStorage(tabId); } - if (options.sessionStorage) { result.sessionStorage = await injectClearSessionStorage(tabId); } - if (options.indexedDB) { result.indexedDB = await injectClearIndexedDB(tabId); } - if (options.cookies) { result.cookies = await clearCookies(url); } - if (options.cacheStorage) { result.cacheStorage = await injectClearCacheStorage(tabId); } - if (options.serviceWorkers) { result.serviceWorkers = await injectUnregisterServiceWorkers(tabId); } - // Check if any operation failed const failures = Object.values(result).filter( (r): r is StorageCleanResult => r?.success === false, ); - if (failures.length > 0) { - result.success = false; + result.overallSuccess = false; } return result; @@ -396,16 +368,7 @@ export function formatCleaningResult( ): string { const parts: string[] = []; - const optionKeys: (keyof StorageCleanerOptions)[] = [ - 'localStorage', - 'sessionStorage', - 'indexedDB', - 'cookies', - 'cacheStorage', - 'serviceWorkers', - ]; - - for (const key of optionKeys) { + for (const key of CLEAN_OPTION_KEYS) { const r = result[key]; if (r?.success && r.count > 0) { parts.push(`${r.count} ${t(`storageCleaner:options.${key}`)}`); diff --git a/src/utils/textStatistics.ts b/src/utils/textStatistics.ts index 4cb6f17..ece617b 100644 --- a/src/utils/textStatistics.ts +++ b/src/utils/textStatistics.ts @@ -1,5 +1,3 @@ -import { formatBytes } from './format'; - /** * 文本统计信息接口 */ @@ -35,7 +33,7 @@ export function getTextStats(text: string): TextStats { const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); const segments = segmenter.segment(text); for (const segment of segments) { - // isWordLike 为 true 表示该片段是“类词”的(非空格、非标点) + // isWordLike 为 true 表示该片段是"类词"的(非空格、非标点) if (segment.isWordLike) { words++; } @@ -44,7 +42,7 @@ export function getTextStats(text: string): TextStats { // 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词 // 但对中文支持较差 const englishWords = text.match(/\b\w+\b/g) || []; - const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || []; + const chineseChars = text.match(/[一-龥]/g) || []; words = englishWords.length + chineseChars.length; } @@ -57,13 +55,3 @@ export function getTextStats(text: string): TextStats { return { characters, words, lines, bytes }; } - -/** - * 格式化字节大小显示(兼容旧接口,内部委托给 formatBytes) - * - * @param bytes 字节数 - * @returns 格式化后的字符串 - */ -export function formatByteSize(bytes: number): string { - return formatBytes(bytes); -} diff --git a/src/utils/useContextMenuData.ts b/src/utils/useContextMenuData.ts index 71d353e..8c2b1c1 100644 --- a/src/utils/useContextMenuData.ts +++ b/src/utils/useContextMenuData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useEffect } from 'react'; import { storageUtil } from '@/utils/chromeStorage'; import type { ContextMenuPendingData, PageType } from '@/types/storage'; @@ -23,44 +23,58 @@ export interface UseContextMenuDataOptions { * 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]); + const checkAndConsumeData = 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); + } + }; + + void checkAndConsumeData(); + }, [featureKey, onData]); 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(); + void (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); + } + })(); } } }; chrome.storage.onChanged.addListener(handleStorageChange); return () => chrome.storage.onChanged.removeListener(handleStorageChange); - }, [featureKey, checkAndConsumeData]); + }, [featureKey, onData]); } /** diff --git a/vitest.setup.ts b/vitest.setup.ts index 97bef60..a2bae5d 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,11 +1,10 @@ import '@testing-library/jest-dom'; import { afterEach, beforeEach, vi } from 'vitest'; import React from 'react'; +import zhMessages from './public/_locales/zh/messages.json'; -// Load actual zh translations for getMessage mock -const zhMessages: Record = - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('./public/_locales/zh/messages.json'); +// Type assertion to allow string indexing +const zhMessagesMap = zhMessages as Record; vi.mock('@/utils/chromeI18n', () => ({ useI18n: (ns?: string | string[]) => ({ @@ -16,13 +15,13 @@ vi.mock('@/utils/chromeI18n', () => ({ msgId = key.replace(':', '_').replace(/\./g, '_'); } // Try direct key first - if (zhMessages[msgId]) return zhMessages[msgId].message; + if (zhMessagesMap[msgId]) return zhMessagesMap[msgId].message; // Try namespace prefix (using converted msgId) if (ns) { const namespaces = Array.isArray(ns) ? ns : [ns]; for (const n of namespaces) { const candidate = `${n}_${msgId}`; - if (zhMessages[candidate]) return zhMessages[candidate].message; + if (zhMessagesMap[candidate]) return zhMessagesMap[candidate].message; } } return msgId; @@ -33,7 +32,7 @@ vi.mock('@/utils/chromeI18n', () => ({ }, isLoaded: true, }), - getMessage: (msgId: string) => zhMessages[msgId]?.message ?? msgId, + getMessage: (msgId: string) => zhMessagesMap[msgId]?.message ?? msgId, getLanguage: () => 'zh', preloadNamespaces: vi.fn().mockResolvedValue(undefined), })); @@ -125,7 +124,7 @@ const webExtensionMock = { get: vi.fn().mockResolvedValue({}), sendMessage: vi.fn().mockResolvedValue(undefined), create: vi.fn().mockResolvedValue({}), - reload: vi.fn().mockResolvedValue(undefined), // ✅ 承接 MessageAction.RELOAD_TAB 刷新单元测试 + reload: vi.fn().mockResolvedValue(undefined), }, runtime: { id: 'test-extension-id', @@ -202,7 +201,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -// 全局 matchMedia 极客级环境模拟(ThemeModeProvider 依赖) beforeEach(() => { Object.defineProperty(window, 'matchMedia', { writable: true, From 3f447708b7fd5ccfcaf87d5f6228a9d9a284fc32 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Sat, 30 May 2026 12:55:04 +0800 Subject: [PATCH 2/2] fix: replace undefined formatByteSize with formatBytes (#57) JsonConvertSection.tsx and JsonFormatSection.tsx were calling formatByteSize which doesn't exist. The correct function name imported from @/utils/format is formatBytes. Co-authored-by: Ubuntu --- package-lock.json | 196 ++++++--------------- src/pages/JsonTools/JsonConvertSection.tsx | 2 +- src/pages/JsonTools/JsonFormatSection.tsx | 2 +- 3 files changed, 55 insertions(+), 145 deletions(-) diff --git a/package-lock.json b/package-lock.json index af81474..82265b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -211,7 +211,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -228,7 +228,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -245,7 +245,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -255,7 +255,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/code-frame": { @@ -542,7 +542,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -555,7 +555,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -575,7 +575,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -599,7 +599,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -627,7 +627,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -650,7 +650,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -675,7 +675,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -818,7 +818,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -830,7 +829,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -841,7 +839,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -855,7 +852,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -872,7 +868,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -889,7 +884,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -906,7 +900,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -923,7 +916,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -940,7 +932,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -957,7 +948,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,7 +964,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -991,7 +980,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1008,7 +996,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1025,7 +1012,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1042,7 +1028,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1059,7 +1044,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1076,7 +1060,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1093,7 +1076,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1110,7 +1092,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1127,7 +1108,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1144,7 +1124,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1161,7 +1140,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1178,7 +1156,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1195,7 +1172,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1212,7 +1188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1229,7 +1204,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1246,7 +1220,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1263,7 +1236,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1280,7 +1252,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1510,7 +1481,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -1630,7 +1601,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1661,7 +1632,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1688,7 +1659,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1745,7 +1715,6 @@ "version": "0.132.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2534,7 +2503,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2551,7 +2519,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2568,7 +2535,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2585,7 +2551,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2602,7 +2567,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2619,7 +2583,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2636,7 +2599,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2653,7 +2615,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2670,7 +2631,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2687,7 +2647,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2704,7 +2663,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2721,7 +2679,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2738,7 +2695,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2757,7 +2713,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2774,7 +2729,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2788,14 +2742,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@testing-library/dom": { @@ -2890,7 +2842,6 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2907,7 +2858,6 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -2929,7 +2879,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -2980,7 +2929,7 @@ "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2996,7 +2945,7 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3006,7 +2955,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3312,7 +3261,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", - "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -3330,7 +3278,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.1.7", @@ -3369,7 +3316,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.1.7", @@ -3383,7 +3329,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.7", @@ -3399,7 +3344,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", - "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -3517,7 +3461,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3889,7 +3833,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4040,7 +3983,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -4236,7 +4179,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/bundle-name": { @@ -4402,7 +4345,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4674,7 +4616,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-map": { @@ -4797,7 +4739,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -4851,14 +4793,14 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -4957,7 +4899,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/deep-extend": { @@ -5083,7 +5025,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5301,7 +5242,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -5454,7 +5395,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -5528,7 +5468,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5961,7 +5901,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -6049,7 +5988,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6226,7 +6164,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6687,7 +6624,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -7268,7 +7205,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-primitive": { @@ -7533,7 +7470,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7562,7 +7499,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -7603,7 +7540,7 @@ "version": "11.5.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -7832,7 +7769,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7865,7 +7801,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7886,7 +7821,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7907,7 +7841,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7928,7 +7861,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7949,7 +7881,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7970,7 +7901,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7991,7 +7921,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8012,7 +7941,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8033,7 +7961,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8054,7 +7981,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8075,7 +8001,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8411,7 +8336,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -8484,7 +8408,7 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, + "devOptional": true, "license": "CC0-1.0" }, "node_modules/merge2": { @@ -8691,7 +8615,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -9178,7 +9101,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -9218,7 +9141,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { @@ -9238,7 +9160,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9333,7 +9254,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9678,7 +9598,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -10059,7 +9979,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10136,7 +10056,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.132.0", @@ -10303,7 +10222,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -10529,7 +10448,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -10615,7 +10533,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -10626,7 +10544,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10671,7 +10589,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, "license": "MIT" }, "node_modules/std-env": { @@ -11003,7 +10920,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tailwind-merge": { @@ -11132,7 +11049,7 @@ "version": "5.47.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -11191,14 +11108,12 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11208,7 +11123,6 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -11234,7 +11148,7 @@ "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.30" @@ -11247,7 +11161,7 @@ "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tmp": { @@ -11277,7 +11191,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -11290,7 +11204,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -11511,7 +11425,7 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -11521,7 +11435,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unimport": { @@ -11761,7 +11675,6 @@ "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -11862,7 +11775,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", - "dev": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.1.7", @@ -11952,7 +11864,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -12094,7 +12006,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -12111,7 +12023,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -12121,7 +12033,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -12255,7 +12167,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -12461,7 +12372,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -12495,7 +12406,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/y18n": { @@ -12519,7 +12430,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "optional": true, "bin": { diff --git a/src/pages/JsonTools/JsonConvertSection.tsx b/src/pages/JsonTools/JsonConvertSection.tsx index 05360c5..31e38c6 100644 --- a/src/pages/JsonTools/JsonConvertSection.tsx +++ b/src/pages/JsonTools/JsonConvertSection.tsx @@ -103,7 +103,7 @@ export default function JsonConvertSection({ {t('jsonFormat:formattedSize')}:{' '} - {formatByteSize(result.outputBytes)} + {formatBytes(result.outputBytes)}
diff --git a/src/pages/JsonTools/JsonFormatSection.tsx b/src/pages/JsonTools/JsonFormatSection.tsx index ee7d169..d141ed6 100644 --- a/src/pages/JsonTools/JsonFormatSection.tsx +++ b/src/pages/JsonTools/JsonFormatSection.tsx @@ -134,7 +134,7 @@ export default function JsonFormatSection() { {t('jsonFormat:formattedSize')}:{' '} - {formatByteSize(result.formattedBytes)} + {formatBytes(result.formattedBytes)}