chore: merge develop into main (#56)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(storageCleaner): extract executeScript helpers

Extract two generic helpers to eliminate repetitive executeScript boilerplate:

- runScript<T>(): 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* refactor(StorageCleanerConfirm.test.tsx): update IndexedDB label to Chinese translation

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LingandRX
2026-05-29 23:00:21 +08:00
committed by GitHub
parent 945780def8
commit f9821553b3
62 changed files with 2217 additions and 1785 deletions
-372
View File
@@ -1,372 +0,0 @@
/**
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
*
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
* 1. 作为受控组件使用:通过 props 控制显示状态
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
*
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
*
* @module GlobalSnackbar
* @version 1.1.0
*
* @example
* ```tsx
* // 方式一:受控组件
* <GlobalSnackbar
* message="操作成功"
* open={isOpen}
* onClose={() => setIsOpen(false)}
* severity="success"
* />
*
* // 方式二:Hook 方式 (局部状态)
* const { snackbarProps, showMessage } = useSnackbarState();
* showMessage('Hello!', { severity: 'info' });
*
* // 方式三:Context 方式 (全局状态)
* // 在根组件包裹 Provider
* <SnackbarProvider>
* <App />
* </SnackbarProvider>
*
* // 在子组件中使用
* const { showMessage } = useSnackbar();
* showMessage('Global Message');
* ```
*/
import {
JSX,
useState,
useRef,
createContext,
useContext,
useEffect,
type ReactNode,
type SyntheticEvent,
} from 'react';
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
/**
* Snackbar 消息严重程度类型
* @description 决定 Alert 组件的颜色和图标
* - success: 绿色,成功提示
* - info: 蓝色,信息提示
* - warning: 橙色,警告提示
* - error: 红色,错误提示
*/
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
/**
* GlobalSnackbar 组件的属性接口
* @interface GlobalSnackbarProps
*/
export interface GlobalSnackbarProps {
/** 消息内容,要显示的提示文本 */
message: string;
/** 是否显示 Snackbar */
open: boolean;
/** 关闭回调函数 */
onClose: () => void;
/** 消息级别,影响颜色和图标样式,默认 'info' */
severity?: SnackbarSeverity;
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
autoHideDuration?: number;
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
showAlert?: boolean;
/** 是否隐藏 Alert 图标,默认 false */
hideIcon?: boolean;
/** 自定义样式,透传给外层 Snackbar 组件 */
sx?: React.CSSProperties;
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
alertSx?: React.CSSProperties;
}
/**
* showMessage 方法的选项配置
* @interface SnackbarOptions
*/
export interface SnackbarOptions {
/** 消息级别:success | info | warning | error */
severity?: SnackbarSeverity;
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
autoHideDuration?: number;
/** 是否隐藏 Alert 图标 */
hideIcon?: boolean;
/** 是否使用 Alert 组件包裹 */
showAlert?: boolean;
}
/**
* useSnackbarState Hook 的返回值类型
* @interface UseSnackbarStateResult
*/
export interface UseSnackbarStateResult {
/** 传递给 GlobalSnackbar 组件的属性对象 */
snackbarProps: GlobalSnackbarProps;
/** 显示消息的方法 */
showMessage: (message: string, options?: SnackbarOptions) => void;
/** 关闭消息的方法 */
closeMessage: () => void;
}
/**
* GlobalSnackbar 组件的默认属性配置
* @description 提供类型安全的默认值选择
*/
const defaultProps: Required<
Pick<
GlobalSnackbarProps,
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
>
> = {
severity: 'info',
autoHideDuration: 2000,
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
showAlert: true,
hideIcon: false,
};
const severityConfig: Record<
SnackbarSeverity,
{ icon: React.ElementType; bgClass: string; textClass: string }
> = {
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
};
/**
* GlobalSnackbar 组件
*
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
*
* @param {GlobalSnackbarProps} props - 组件属性
* @returns {JSX.Element}
*/
export function GlobalSnackbar({
message,
open,
onClose,
severity = defaultProps.severity,
autoHideDuration = defaultProps.autoHideDuration,
showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon,
}: GlobalSnackbarProps): JSX.Element | null {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (open && autoHideDuration > 0) {
timerRef.current = setTimeout(() => {
onClose();
}, autoHideDuration);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}
return undefined;
}, [open, autoHideDuration, onClose]);
if (!open) return null;
const config = severityConfig[severity];
const IconComponent = config.icon;
return (
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
{showAlert ? (
<div
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
style={{ minWidth: '140px' }}
>
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="text-xs font-bold">{message}</span>
</div>
) : (
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
)}
</div>
);
}
/**
* useSnackbarState - 消息提示的 Hook 方式
*
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
* 适合在组件内部使用,无需额外的状态管理代码。
*
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
*
* @description
* - 自动管理 Snackbar 的显示/隐藏状态
* - 支持链式调用 showMessage
* - 合并初始选项和调用时选项
*
* @example
* ```tsx
* function MyComponent() {
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
* severity: 'info',
* autoHideDuration: 3000,
* });
*
* const handleSave = () => {
* // 业务逻辑...
* showMessage('保存成功!', { severity: 'success' });
* };
*
* return (
* <>
* <button onClick={handleSave}>保存</button>
* <GlobalSnackbar {...snackbarProps} />
* </>
* );
* }
* ```
*/
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
// Snackbar 显示状态
const [open, setOpen] = useState(false);
// 当前显示的消息内容
const [message, setMessage] = useState('');
// 消息配置选项
const [options, setOptions] = useState<SnackbarOptions>(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<SnackbarContextValue | null>(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 (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* 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;
+6 -13
View File
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div
+5 -6
View File
@@ -1,21 +1,20 @@
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
import { Suspense, useMemo } from 'react';
import { Suspense } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react';
const entryPointType = getEntryPointType();
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const animationClass = useMemo(() => {
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 <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
@@ -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(<GlobalSnackbar {...defaultProps} />);
expect(screen.getByText('测试消息')).toBeInTheDocument();
});
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
// 验证是否包含消息文本
const alertElement = screen.getByText('测试消息');
expect(alertElement).toBeInTheDocument();
// 验证父元素有正确的样式类
const parent = alertElement.parentElement;
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
});
it('当 hideIcon 为 true 时不应渲染图标', () => {
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
// 图标使用 lucide-react 的 svg 元素
const icon = document.querySelector('svg');
expect(icon).not.toBeInTheDocument();
});
it('应根据 severity 应用不同的样式', () => {
render(<GlobalSnackbar {...defaultProps} severity="error" />);
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 }) => (
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
);
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();
});
});
});
@@ -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 组件', () => {
@@ -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(
<SnackbarProvider>
<RouterProvider>{ui}</RouterProvider>
</SnackbarProvider>,
);
return render(<RouterProvider>{ui}</RouterProvider>);
};
describe('渲染测试', () => {
@@ -74,11 +69,7 @@ describe('RouterContainer 组件', () => {
const { rerender } = renderWithProvider(<RouterContainer />);
mockRouterValue.currentPage = 'timestamp';
rerender(
<SnackbarProvider>
<RouterProvider>{<RouterContainer />}</RouterProvider>
</SnackbarProvider>,
);
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument();
@@ -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();
});
+2 -99
View File
@@ -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<string, unknown>;
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 };
});
});
+2 -2
View File
@@ -1,10 +1,10 @@
import '../../.wxt/types/imports.d.ts';
import { initMessageHandler } from './content/messageHandler';
import { initContextMenuHandler } from './content/contextMenuHandler';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
initMessageHandler();
initContextMenuHandler();
},
});
@@ -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)) {
@@ -1,5 +0,0 @@
import { initContextMenuHandler } from './contextMenuHandler';
export function initMessageHandler(): void {
initContextMenuHandler();
}
+19 -26
View File
@@ -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 (
<RouterProvider
@@ -30,14 +25,12 @@ export default function App() {
visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey}
>
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</SnackbarProvider>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</RouterProvider>
);
}
@@ -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<void> {
@@ -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<void> {
@@ -159,10 +51,8 @@ async function activateProtection(): Promise<void> {
if (isRestored) return;
installEventIntercepts();
initMousePenetration();
isRestored = true;
updateBadge();
}
/* ======================== 消息通信 ======================== */
@@ -184,11 +74,6 @@ export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
} else {
createBadge();
}
initMessaging();
},
});
+6 -9
View File
@@ -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 (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</SnackbarProvider>
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</RouterProvider>
);
}
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
};
return (
<div className="w-full flex flex-col space-y-4">
<div className="w-full flex flex-col space-y-4 px-2">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
+1 -1
View File
@@ -88,7 +88,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
};
return (
<div className="w-full flex flex-col space-y-4">
<div className="w-full flex flex-col space-y-4 px-2">
{/* 受控方向切流中枢 */}
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
+10 -15
View File
@@ -1,29 +1,24 @@
import { useRouter } from '@/providers/RouterProvider';
import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage';
import { useMemo } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
export default function DashboardPage() {
export default function Index() {
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
const { t } = useI18n(['features']);
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
const visibleSet = new Set<string>(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;
+2 -2
View File
@@ -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({
<span>
{t('jsonFormat:originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatByteSize(result.originalBytes)}
{formatBytes(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
+2 -2
View File
@@ -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() {
<span>
{t('jsonFormat:originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatByteSize(result.originalBytes)}
{formatBytes(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
+27
View File
@@ -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 };
}
};
+25 -105
View File
@@ -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<ViewMode>('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 (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
@@ -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}
/>
+137
View File
@@ -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<typeof diffJson> | 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<ViewMode>('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,
};
}
+38
View File
@@ -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 (
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
<div className="flex justify-between items-center mb-2 select-none">
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
{title}
</span>
<CopyButton
text={JSON.stringify(content)}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
</pre>
</div>
);
}
+9 -72
View File
@@ -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 (
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
<div className="flex justify-between items-center mb-2 select-none">
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
{title}
</span>
<CopyButton
text={JSON.stringify(content)}
className="h-6 w-6 rounded-md border text-muted-foreground"
/>
</div>
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
{content ? stringifyJson(content) : t('jwt:invalidFormat')}
</pre>
</div>
);
};
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 (
<div className="p-4 w-full flex flex-col space-y-4 select-none">
<div className="flex flex-col gap-4">
{/* 输入终端 */}
<TextInputArea
minRows={5}
maxRows={10}
placeholder={t('jwt_placeholder')}
value={jwtInput}
onChange={(val) => {
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 && (
<div className="flex flex-col gap-4">
{/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */}
<Section
<JwtSection
title={t('jwt:headerTitle')}
content={result.header}
colorClass="text-[#fb015b] dark:text-rose-400"
@@ -93,16 +33,14 @@ export default function Index() {
bgClass="bg-[#fb015b]/5 dark:bg-rose-500/5"
/>
{/* Payload 分区 */}
<Section
<JwtSection
title={t('jwt:payloadTitle')}
content={result.payload}
colorClass="text-[#a03aff] dark:text-purple-400" // 针对暗黑模式略微调高对比度
colorClass="text-[#a03aff] dark:text-purple-400"
borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
bgClass="bg-[#a03aff]/5 dark:bg-purple-500/5"
/>
{/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
@@ -120,7 +58,6 @@ export default function Index() {
</div>
)}
{/* 当解析错误时的干净中性引导拦截 */}
{result?.error && (
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
<p className="text-xs font-semibold text-muted-foreground/80">
+43
View File
@@ -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 };
}
+5 -6
View File
@@ -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(() => {
+10 -11
View File
@@ -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<QrCodeMode>('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) => {
+1 -1
View File
@@ -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();
@@ -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,
@@ -1,8 +1,8 @@
/**
* 自动刷新开关组件
* 自动刷新开关
*
* 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
* 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
* 用于 StorageCleaner 页面的操作区域内(非独立卡片),
* 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
*
* @example
* ```tsx
@@ -48,15 +48,14 @@ export default function AutoRefreshToggle({
return (
<div
className={cn(
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex justify-between items-center',
'w-full px-3.5 py-3 border-t border-border flex justify-between items-center',
className,
)}
{...props}
>
{/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
<Label
htmlFor="auto-refresh-switch"
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
>
{t('storageCleaner:autoRefresh')}
</Label>
+1 -1
View File
@@ -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 (
<div className={cn('w-full', className)} {...props}>
+21 -17
View File
@@ -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<HTMLDivElement> {
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 (
<div
onClick={onChange}
className={cn(
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none',
'flex justify-between items-center py-2.5 px-3.5 rounded-lg border cursor-pointer select-none transition-colors',
checked
? 'bg-primary/5 border-primary/30 shadow-sm'
: 'bg-transparent border-transparent hover:bg-muted/70',
: 'bg-transparent border-transparent hover:bg-muted/50',
className,
)}
{...props}
>
{/* 左侧数据区域 */}
<div className="flex-1 min-w-0 mr-4">
<div className="flex-1 min-w-0 mr-3">
<span
className={cn(
'block text-xs font-semibold leading-tight truncate',
checked ? 'text-foreground font-bold' : 'text-foreground/80',
'block text-xs font-semibold leading-tight truncate transition-colors',
checked ? 'text-foreground' : 'text-foreground/75',
)}
>
{t(labelKey)}
</span>
{/* 底部容量大小或计数标识 */}
{size !== undefined && size > 0 ? (
<span className="block text-[10px] font-mono font-medium text-muted-foreground/80 mt-0.5 tabular-nums">
{isCount ? `${size} ${t('storageCleaner:countUnit')}` : formatSize(size)}
{sizeValue !== undefined && sizeValue > 0 ? (
<span
className={cn(
'block text-[10px] font-mono font-medium mt-0.5 tabular-nums transition-colors',
checked ? 'text-primary/70' : 'text-muted-foreground/70',
)}
>
{isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)}
</span>
) : (
<span className="block text-[10px] font-medium text-muted-foreground/60 mt-0.5 italic">
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
{t('storageCleaner:noData')}
</span>
)}
+17 -23
View File
@@ -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<HTMLDivElement> {
options: StorageCleanerOptions;
sizes: Record<string, number>;
sizes: Record<string, StorageSizeInfo>;
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 (
<div
className={cn(
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
className,
)}
{...props}
>
<div className="p-3">
<div className="grid grid-cols-2 gap-2.5 items-stretch">
{optionKeys.map(({ key, isCount }) => (
<div className={cn('w-full overflow-hidden', className)} {...props}>
<div className="px-3.5 pt-3.5 pb-2">
<div className="grid grid-cols-2 gap-2 items-stretch">
{optionKeys.map((key) => (
<OptionItem
key={key}
labelKey={`storageCleaner:options.${key}`}
checked={options[key]}
size={sizes[key]}
isCount={isCount}
sizeInfo={sizes[key]}
onChange={() => onOptionChange(key)}
/>
))}
@@ -65,9 +59,9 @@ export default function StorageOptionsGrid({
<div
onClick={handleToggleAll}
className="border-t border-border flex justify-between items-center px-4 py-2.5 bg-muted/20 hover:bg-muted/50 cursor-pointer select-none"
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
>
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer">
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
{t('storageCleaner:selectAll')}
</Label>
@@ -75,7 +69,7 @@ export default function StorageOptionsGrid({
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => onSelectAll(checked === true)}
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=indeterminate]:bg-primary"
className="h-4 w-4 shrink-0 rounded border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-muted-foreground/40"
/>
</div>
</div>
+35 -30
View File
@@ -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 (
<div className="p-4 w-full flex flex-col space-y-3.5">
<StorageOptionsGrid
options={options}
sizes={sizes}
allSelected={allSelected}
someSelected={someSelected}
onOptionChange={handleOptionChange}
onSelectAll={handleSelectAll}
/>
<div className="p-4 w-full flex flex-col space-y-4 select-none">
{/* 操作区域卡片 */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<StorageOptionsGrid
options={options}
sizes={sizes}
allSelected={allSelected}
someSelected={someSelected}
onOptionChange={handleOptionChange}
onSelectAll={handleSelectAll}
/>
<AutoRefreshToggle
reloadAfterClean={reloadAfterClean}
onChange={handleReloadAfterCleanChange}
/>
<AutoRefreshToggle
reloadAfterClean={reloadAfterClean}
onChange={handleReloadAfterCleanChange}
/>
<Button
variant="destructive"
size="default"
onClick={() => setShowConfirm(true)}
disabled={isButtonDisabled}
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('storageCleaner:cleaning')}
</>
) : (
t('storageCleaner:cleanNow')
)}
</Button>
<div className="px-3.5 pb-3.5 pt-1">
<Button
variant="destructive"
size="default"
onClick={() => setShowConfirm(true)}
disabled={isButtonDisabled}
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('storageCleaner:cleaning')}
</>
) : (
t('storageCleaner:cleanNow')
)}
</Button>
</div>
</div>
<CleaningResult result={result} />
+21 -24
View File
@@ -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<string, number>;
sizes: Record<string, StorageSizeInfo>;
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<string>('');
const [error, setError] = useState<string>('');
const [isInitializing, setIsInitializing] = useState<boolean>(true);
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [sizes, setSizes] = useState<Record<string, number>>({});
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(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,
+4 -15
View File
@@ -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 (
<div className="p-4 w-full space-y-4">
{/* 文本输入区域 */}
<TextInputArea
value={text}
onChange={setText}
@@ -37,7 +27,6 @@ export default function Index() {
allowCopy={true}
/>
{/* 统计结果展示区域 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{statItems.map((item) => (
<div
@@ -0,0 +1,23 @@
import { useCallback, useState } from 'react';
import { getTextStats, type TextStats } from '@/utils/textStatistics';
import { useContextMenuData } from '@/utils/useContextMenuData';
export interface UseTextStatisticsReturn {
text: string;
stats: TextStats;
setText: (text: string) => 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 };
}
+14 -36
View File
@@ -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<HTMLDivElement> {
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 (
<div
@@ -61,12 +40,15 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
</span>
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
{currentDisplay.text}
{text}
</span>
<button
type="button"
onClick={handleUseNow}
onClick={() => {
onUseNow(rawTime);
toast.success(t('timestamp:usedSuccess'));
}}
title={t('timestamp:useNowTooltip')}
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
@@ -74,14 +56,10 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
</button>
<CopyButton
text={currentDisplay.text}
text={text}
tooltip={t('timestamp:copyTsTooltip')}
className="h-7 w-7 rounded-md border"
/>
</div>
);
});
LiveClock.displayName = 'LiveClock';
export default LiveClock;
}
+37 -104
View File
@@ -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<HTMLDivElement> {
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 (
<div
className={cn(
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
className,
)}
{...props}
>
{t('timestamp:resultEmpty')}
</div>
);
}
export default function ResultView({
result,
showEmptyPlaceholder = false,
className,
...props
}: ResultViewProps) {
const { t } = useI18n('timestamp');
if (!result) {
if (!showEmptyPlaceholder) return null;
return (
<div className={cn('flex flex-col w-full', className)} {...props}>
{/* 顶部小标签 */}
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
{t('timestamp:resultLabel')}
</span>
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
{result}
</span>
<CopyButton
text={result}
tooltip={t('timestamp:copyResultTooltip')}
className="h-8 w-8 rounded-md shrink-0 border"
/>
</div>
<div className="bg-muted/40 p-4 rounded-xl border border-border/50 flex flex-col gap-3">
{[
{ 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) => (
<div
key={item.label}
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1.5 py-0.5 border-b border-border/30 last:border-0 pb-2 sm:pb-0 last:pb-0"
>
<span className="text-muted-foreground font-semibold text-xs shrink-0 select-none">
{item.label}
</span>
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
<span
className={cn(
'text-xs text-foreground/90 font-medium break-all text-left sm:text-right tabular-nums',
item.isMono && 'font-mono text-[11px]',
)}
>
{item.value}
</span>
{item.value && (
<CopyButton
text={item.value}
tooltip={t('timestamp:copyTooltip')}
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
/>
)}
</div>
</div>
))}
</div>
<div
className={cn(
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
className,
)}
{...props}
>
{t('timestamp:resultEmpty')}
</div>
);
},
);
}
ResultView.displayName = 'ResultView';
return (
<div className={cn('flex flex-col w-full', className)} {...props}>
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
{t('timestamp:resultLabel')}
</span>
export default ResultView;
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
{result}
</span>
<CopyButton
text={result}
tooltip={t('timestamp:copyResultTooltip')}
className="h-8 w-8 rounded-md shrink-0 border"
/>
</div>
</div>
);
}
+16
View File
@@ -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<UnitType, number> = { 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);
}
+18 -13
View File
@@ -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() {
<div className="flex flex-col gap-4">
<SwitchButtonGroup
value={mode}
options={[
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
]}
onChange={(newMode) => 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() {
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
<SwitchButtonGroup
value={unit}
options={[
{ value: 'ms', label: t('timestamp:unitMs') },
{ value: 's', label: t('timestamp:unitS') },
]}
onChange={(v) => 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"
/>
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
<Select value={zone} onValueChange={(v: string) => setZone(v as ZoneType)}>
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
<SelectValue placeholder="选择时区" />
</SelectTrigger>
@@ -100,7 +105,7 @@ export default function Index() {
</div>
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
<ResultView result={result} showEmptyPlaceholder />
</div>
</div>
</div>
+29 -36
View File
@@ -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<ModeType>('ts2dt');
const [unit, setUnit] = useState<UnitType>('ms');
const [zone, setZone] = useState<ZoneType>('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,
+5 -14
View File
@@ -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 (
<ThemeModeContext.Provider value={{ mode, resolvedMode, setMode }}>
{children}
</ThemeModeContext.Provider>
);
return <ThemeModeContext.Provider value={contextValue}>{children}</ThemeModeContext.Provider>;
}
export function useThemeMode(): ThemeModeContextType {
+1 -1
View File
@@ -189,7 +189,7 @@ export type StorageCleanResult =
*/
export interface CleaningResult {
/** 整体操作是否成功 */
success: boolean;
overallSuccess: boolean;
/** 整体错误信息(如果有) */
error?: string;
/** 各项清理的具体结果 */
+14 -10
View File
@@ -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,
+16 -15
View File
@@ -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');
});
});
+7 -6
View File
@@ -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');
});
});
});
+5 -23
View File
@@ -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 };
}
-2
View File
@@ -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 };
+71
View File
@@ -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<string, unknown>;
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,
});
}
+213 -250
View File
@@ -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<number> {
}
}
export async function getLocalStorageSize(tabId: number): Promise<number> {
/**
* 通用辅助:在指定标签页中执行脚本并返回结果
*
* @param tabId 标签页 ID
* @param func 在页面上下文中执行的函数
* @param errorLabel 错误日志前缀
* @param fallback 执行失败时的回退值
*/
async function runScript<T>(
tabId: number,
func: () => T | Promise<T>,
errorLabel: string,
fallback: T,
): Promise<T> {
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<number> {
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<number> {
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<number> {
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<number> {
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<number> {
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<StorageCleanResult> {
@@ -203,148 +205,125 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
}
}
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
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<StorageCleanResult> {
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<StorageCleanResult> {
return runCleanScript(
tabId,
() => {
const count = localStorage.length;
localStorage.clear();
return { count };
},
'clear LocalStorage',
);
}
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
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<StorageCleanResult> {
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<void>((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<void>((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<StorageCleanResult> {
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<StorageCleanResult> {
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<CleaningResult> {
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}`)}`);
+2 -14
View File
@@ -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);
}
+40 -26
View File
@@ -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]);
}
/**