Compare commits

...

87 Commits

Author SHA1 Message Date
Ubuntu 400a1afb0c hotfix: revert package.json version to 1.0.0 2026-06-02 14:58:54 +08:00
LingandRX c6d0914d33 Merge pull request #60 from LingandRX/develop
fix: update _locales path in vitest.setup.ts
2026-06-02 14:38:05 +08:00
Ubuntu 7617e317f2 fix: update _locales path from zh to zh_CN in vitest.setup.ts 2026-06-02 14:35:56 +08:00
LingandRX 396b3d7013 Merge pull request #59 from LingandRX/develop
fix: correct default_locale to zh_CN
2026-06-02 14:18:20 +08:00
雨霖铃 92fb853329 fix: correct default_locale to zh_CN to match _locales directory 2026-06-02 14:16:33 +08:00
雨霖铃 e4e5464be0 chore(hooks): 优化 lint-staged 和 git hooks 配置
- lint-staged: 本地开发仅自动修复不阻塞,CI 环境严格检查
- pre-commit: 后台运行 tsc 类型检查,前台运行 lint-staged
- pre-push: 新增严格检查闸门(tsc + eslint --max-warnings=0)
2026-05-30 21:31:12 +08:00
LingandRX f4b6ae89b5 Merge pull request #58 from LingandRX/develop
chore: merge develop into main (v1.x.x)
2026-05-30 21:20:52 +08:00
雨霖铃 37bdeb5f3a fix(qrcode): 去除已上传图片区域重复的文本预览
- 移除图片预览区域中的解析结果文本预览
- 解析结果只在下方的 TextInputArea 中显示
- 避免重复显示相同内容
2026-05-30 21:03:35 +08:00
雨霖铃 e919af6857 fix(qrcode): 修复图片右键菜单功能描述和检测逻辑
- 修改菜单标题为「解析图片二维码」更准确
- 改进 isImageUrl 函数,支持更多图片URL格式
- 支持 data:image/ 格式
- 支持URL路径中包含图片相关关键词
- 更新国际化文案和测试用例
2026-05-30 21:01:33 +08:00
雨霖铃 5be15301b5 feat(qrcode): 实现图片转二维码右键菜单功能
- 添加图片类型的右键菜单配置
- 修改 parseContextMenuClick 支持 srcUrl 参数
- 实现图片URL自动解析功能
- 检测图片URL自动切换到解析模式
- 更新测试用例覆盖新功能
- 新增国际化文案
2026-05-30 20:58:28 +08:00
雨霖铃 306600a35c refactor(qrcode): 优化二维码转文本布局,与文本转二维码风格统一
- 未上传图片时只显示上传区域
- 上传图片后显示小图预览 + 解析结果
- 添加重新上传按钮
- 解析结果文本限制显示长度
- 新增国际化文案(已上传图片、重新上传、解析中等)
- 适配popup窗口紧凑布局
2026-05-30 20:51:48 +08:00
雨霖铃 a0345b384d style(qrcode): 优化预览态布局适配popup窗口
- 调整文本预览区域高度,单行显示截断文本
- 缩小二维码图片尺寸(56x56 -> 48x48)
- 减少内边距和间距,更紧凑的布局
- 确保完整显示在popup窗口中无需滚动
2026-05-30 20:46:47 +08:00
雨霖铃 9458d62a99 feat(qrcode): 右键菜单传入URL时自动生成二维码
- 右键菜单传入URL时直接生成二维码,跳转到预览态
- 保留手动输入文本并点击生成按钮的功能
- 优化用户体验,简化操作流程
2026-05-30 20:38:23 +08:00
雨霖铃 5b26287cff refactor(qrcode): 优化布局逻辑,输入态隐藏预览区域
- 输入态:只显示输入框和生成按钮,隐藏二维码预览
- 预览态:上方显示文本预览(超长文本截断),下方显示二维码
- 点击编辑返回输入态,隐藏二维码预览
- 更新测试用例适配新布局
2026-05-30 20:29:12 +08:00
雨霖铃 6dca673a5a refactor(qrcode): 优化文本转二维码为两步操作模式
- 将实时预览改为显式触发生成,用户体验更清晰
- 新增输入态(input)和预览态(preview)两种状态
- 输入态:显示输入框和生成按钮,右侧显示占位提示
- 预览态:显示只读文本预览和编辑按钮,右侧显示二维码
- 保存上次输入内容,返回编辑态时自动回填
- 添加空输入校验和错误提示
- 更新国际化文案
- 完善单元测试覆盖新功能
2026-05-30 20:23:05 +08:00
雨霖铃 afd57bb5b2 Merge branch 'main' into develop 2026-05-30 19:47:16 +08:00
LingandRX 3f447708b7 fix: replace undefined formatByteSize with formatBytes (#57)
JsonConvertSection.tsx and JsonFormatSection.tsx were calling
formatByteSize which doesn't exist. The correct function name
imported from @/utils/format is formatBytes.

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-30 12:55:04 +08:00
雨霖铃 355ab0cf86 Merge remote-tracking branch 'origin/main' into develop 2026-05-29 22:59:10 +08:00
雨霖铃 13113f0ea2 refactor(StorageCleanerConfirm.test.tsx): update IndexedDB label to Chinese translation 2026-05-29 22:42:59 +08:00
雨霖铃 0d69695dbb 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>
2026-05-29 22:40:11 +08:00
雨霖铃 093bf278da refactor: replace require with import for JSON module in vitest.setup.ts 2026-05-29 22:08:04 +08:00
雨霖铃 f9fbc79612 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>
2026-05-29 21:59:54 +08:00
雨霖铃 1b9f6d044d 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>
2026-05-29 21:08:27 +08:00
雨霖铃 b41cfb2a02 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>
2026-05-29 20:59:03 +08:00
雨霖铃 93019f56cf 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>
2026-05-29 20:55:05 +08:00
雨霖铃 2727e60025 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>
2026-05-29 20:47:16 +08:00
雨霖铃 c2e54b34cb 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>
2026-05-29 20:38:06 +08:00
雨霖铃 dbdec710ef 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>
2026-05-29 20:32:58 +08:00
雨霖铃 7bd52bfc2b 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>
2026-05-29 20:22:44 +08:00
雨霖铃 c9fe27a26b 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>
2026-05-29 20:20:11 +08:00
雨霖铃 e97f4cae9a 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>
2026-05-29 20:15:54 +08:00
雨霖铃 21a6430f0d 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>
2026-05-29 20:10:45 +08:00
雨霖铃 dff2705ef8 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>
2026-05-29 19:56:52 +08:00
雨霖铃 f8c48a3047 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.
2026-05-29 19:51:44 +08:00
雨霖铃 488e2e8e49 chore: add initial CLAUDE.md file with reference to @AGENT.md 2026-05-29 19:51:16 +08:00
雨霖铃 98abe59731 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>
2026-05-29 19:38:28 +08:00
雨霖铃 77168e2652 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 ./
2026-05-28 23:43:02 +08:00
雨霖铃 4f7e402dba 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
2026-05-28 23:18:58 +08:00
雨霖铃 e33761381d 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 与实际代码保持一致
2026-05-28 23:09:14 +08:00
雨霖铃 6bddc4fcfc Merge remote-tracking branch 'origin/main' into develop 2026-05-28 22:35:39 +08:00
雨霖铃 3df45f491c docs: 更新文档,移除设置页面引用
- README.md: 移除 entrypoints/options/ 目录说明
- AGENTS.md: 更新项目结构描述,移除 options/ 入口点
2026-05-28 22:20:16 +08:00
雨霖铃 0054e97a0a refactor: 移除设置页面
- 删除 options 入口点目录及相关文件
- 移除 wxt.config.ts 中 options_ui 配置
- 移除 TopBar 组件中的设置按钮和 onOpenOptions prop
- 移除 popup/sidepanel App.tsx 中的 handleOpenOptions
- 移除相关测试中的设置页面引用
- 移除 messages.json 中设置相关翻译键
- 通过 lint、typecheck、test 验证
2026-05-28 22:15:56 +08:00
雨霖铃 97159cb799 fix: useI18n fallback for empty string translations from chrome.i18n 2026-05-28 21:53:35 +08:00
雨霖铃 5175677b24 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>
2026-05-28 21:38:18 +08:00
雨霖铃 3400ffc9dc 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>
2026-05-28 21:34:16 +08:00
雨霖铃 4955f617ee fix: remove redundant search input from Dashboard (header already has one)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:28:52 +08:00
雨霖铃 2cb5b0c1e7 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>
2026-05-28 21:25:47 +08:00
雨霖铃 c6cadda95b 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>
2026-05-28 21:22:30 +08:00
雨霖铃 067a20fb0a 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>
2026-05-28 21:08:15 +08:00
雨霖铃 ca4bfc33fd 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>
2026-05-28 20:57:08 +08:00
雨霖铃 e140af7698 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>
2026-05-28 20:37:52 +08:00
雨霖铃 b79fe7dd49 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>
2026-05-28 20:20:45 +08:00
雨霖铃 9903483f42 Merge branch 'feature/chrome-i18n' into develop
迁移 i18n 系统从 react-i18next 到 chrome.i18n:
- 移除 react-i18next 依赖,使用 chrome.i18n API
- 生成 _locales/zh/messages.json(245 个翻译 key)
- 清理未使用的翻译 key
- 移除语言切换按钮和英文翻译
2026-05-28 19:50:51 +08:00
雨霖铃 08911d4cba 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>
2026-05-28 19:48:38 +08:00
雨霖铃 1daf049737 refactor: 移除英文翻译,仅保留中文
删除 public/_locales/en/ 目录,清理 chromeI18n.ts 中未使用的语言切换相关导出。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:27:15 +08:00
雨霖铃 8b6bac9f2c refactor: 移除语言切换按钮
chrome.i18n 语言由浏览器设置决定,不支持运行时切换,移除 TopBar 中的语言切换按钮及相关逻辑。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:23:57 +08:00
雨霖铃 aa23a263fe 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>
2026-05-28 19:21:54 +08:00
雨霖铃 64e23994a6 fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 2026-05-28 00:39:47 +08:00
雨霖铃 528621f16e fix(tests): 修复 CI 失败的测试 - 补充 withTranslation 和 CopyButton mock
- vitest.setup.ts: react-i18next mock 添加 withTranslation HOC(读取 zh/common.json 翻译)
- vitest.setup.ts: 添加 @/components/CopyButton 全局 mock
- App 测试: 补充 withTranslation 导出
- TextMode 测试: 修复多个 CopyButton 实例导致的 getByTestId 冲突,改用 getByRole
2026-05-28 00:13:09 +08:00
雨霖铃 c735a0aa1a refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 2026-05-27 22:55:28 +08:00
雨霖铃 62c880b0a8 fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 2026-05-27 17:07:39 +08:00
雨霖铃 e263b63ced 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>
2026-05-27 16:56:27 +08:00
雨霖铃 1e4b6eddc7 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>
2026-05-27 14:14:19 +08:00
雨霖铃 4316bb57d2 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>
2026-05-27 14:11:44 +08:00
雨霖铃 f40485e2dc 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>
2026-05-27 14:10:11 +08:00
雨霖铃 0908358f97 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>
2026-05-27 14:08:10 +08:00
雨霖铃 a3f89d07ea 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>
2026-05-27 14:06:19 +08:00
雨霖铃 003dad5d8b 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>
2026-05-27 13:55:54 +08:00
雨霖铃 27d3360f9d 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>
2026-05-27 13:54:38 +08:00
雨霖铃 67629263f9 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>
2026-05-27 13:53:48 +08:00
雨霖铃 8b58dcf02e 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>
2026-05-27 13:53:11 +08:00
雨霖铃 2e2f6e9835 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>
2026-05-27 13:52:11 +08:00
雨霖铃 cdad9bda68 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>
2026-05-27 13:51:30 +08:00
雨霖铃 fae8dea971 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>
2026-05-27 13:50:36 +08:00
雨霖铃 e0de152645 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>
2026-05-27 13:49:31 +08:00
雨霖铃 e005d4d95d 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>
2026-05-27 13:48:30 +08:00
雨霖铃 601260797e 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>
2026-05-27 13:48:02 +08:00
雨霖铃 c61b599287 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>
2026-05-27 11:54:21 +08:00
雨霖铃 0f3b6c4cd8 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
2026-05-25 20:29:12 +08:00
雨霖铃 af2262d2c9 fix: remove unnecessary animations from RightClickRestorer page 2026-05-25 20:15:23 +08:00
雨霖铃 e5ae802ab2 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
2026-05-25 20:11:39 +08:00
雨霖铃 dec43f89da docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 2026-05-25 19:21:22 +08:00
雨霖铃 61e741f198 docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 2026-05-25 19:15:31 +08:00
雨霖铃 c98f767809 Merge branch 'main' into develop 2026-05-24 08:44:32 +08:00
Ubuntu 63f47fd2b1 Merge branch 'main' into develop 2026-05-23 18:58:15 +08:00
Ubuntu e95ca1297c merge: resolve conflict by taking main version of release.yml 2026-05-23 14:46:44 +08:00
Ubuntu c8ad2a2283 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
2026-05-23 14:37:39 +08:00
19 changed files with 728 additions and 270 deletions
+4
View File
@@ -1 +1,5 @@
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
npx tsc --noEmit &
# 前台运行 lint-staged(自动修复 + 格式化)
npx lint-staged npx lint-staged
+6
View File
@@ -0,0 +1,6 @@
# pre-push: 严格检查,阻塞有问题的代码推送到远程
# 类型检查
npx tsc --noEmit
# ESLint 严格检查(不允许 warning
npx eslint . --max-warnings=0
+9 -1
View File
@@ -1,4 +1,12 @@
/* global process */
const isCI = process.env.CI === 'true';
export default { export default {
'*.{ts,tsx,js,jsx,mjs}': ['eslint --fix --max-warnings=0 --no-warn-ignored', 'prettier --write'], '*.{ts,tsx,js,jsx,mjs}': [
'eslint --fix --no-warn-ignored',
...(isCI ? ['eslint --max-warnings=0'] : []),
'prettier --write',
],
'*.{json,css,scss,md}': ['prettier --write'], '*.{json,css,scss,md}': ['prettier --write'],
}; };
+53 -143
View File
File diff suppressed because it is too large Load Diff
@@ -576,6 +576,50 @@
"message": "点击更换图片", "message": "点击更换图片",
"description": "Translation key: qrCode_clickToChange" "description": "Translation key: qrCode_clickToChange"
}, },
"qrCode_generateButton": {
"message": "生成二维码",
"description": "Translation key: qrCode_generateButton"
},
"qrCode_editButton": {
"message": "编辑",
"description": "Translation key: qrCode_editButton"
},
"qrCode_textPreviewLabel": {
"message": "原始文本",
"description": "Translation key: qrCode_textPreviewLabel"
},
"qrCode_generateFirstHint": {
"message": "输入文本后点击「生成二维码」按钮",
"description": "Translation key: qrCode_generateFirstHint"
},
"qrCode_inputRequired": {
"message": "请输入内容",
"description": "Translation key: qrCode_inputRequired"
},
"qrCode_generateError": {
"message": "生成二维码失败,请重试",
"description": "Translation key: qrCode_generateError"
},
"qrCode_uploadedImage": {
"message": "已上传图片",
"description": "Translation key: qrCode_uploadedImage"
},
"qrCode_reuploadButton": {
"message": "重新上传",
"description": "Translation key: qrCode_reuploadButton"
},
"qrCode_parsing": {
"message": "解析中...",
"description": "Translation key: qrCode_parsing"
},
"qrCode_resultPlaceholder": {
"message": "解析结果将显示在此处",
"description": "Translation key: qrCode_resultPlaceholder"
},
"qrCode_imageToQr": {
"message": "解析图片二维码",
"description": "Translation key: qrCode_imageToQr"
},
"rightClickRestorer_loading": { "rightClickRestorer_loading": {
"message": "正在加载...", "message": "正在加载...",
"description": "Translation key: rightClickRestorer_loading" "description": "Translation key: rightClickRestorer_loading"
+18 -13
View File
@@ -12,12 +12,15 @@ interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
onDownload: () => void; onDownload: () => void;
/** 复制回调 */ /** 复制回调 */
onCopy: () => void; onCopy: () => void;
/** 自定义占位文本,用于空状态提示 */
placeholderText?: string;
} }
const QrCodePreview = ({ const QrCodePreview = ({
qrCodeDataUrl, qrCodeDataUrl,
onDownload, onDownload,
onCopy, onCopy,
placeholderText,
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
@@ -33,7 +36,9 @@ const QrCodePreview = ({
)} )}
{...props} {...props}
> >
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p> <p className="text-sm text-muted-foreground text-center">
{placeholderText || t('qrCode:qrCodeWillShow')}
</p>
</div> </div>
); );
} }
@@ -41,34 +46,34 @@ const QrCodePreview = ({
return ( return (
<div <div
className={cn( className={cn(
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40', 'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
className, className,
)} )}
{...props} {...props}
> >
<div className="flex flex-col items-center w-full max-w-xs"> <div className="flex flex-col items-center w-full max-w-xs">
{/* {/*
2. 二维码容器适配: 二维码容器适配:
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。 在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。 通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
*/} */}
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40"> <div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
<img <img
src={qrCodeDataUrl} src={qrCodeDataUrl}
alt="QR Code Preview" alt="QR Code Preview"
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300" className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
/> />
</div> </div>
<div className="flex w-full gap-2 mt-5"> <div className="flex w-full gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1"> <Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
<Download className="w-4 h-4 text-muted-foreground" /> <Download className="w-3.5 h-3.5 text-muted-foreground" />
<span className="truncate">{t('qrCode:downloadButton')}</span> <span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
</Button> </Button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1"> <Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
<Copy className="w-4 h-4" /> <Copy className="w-3.5 h-3.5" />
<span className="truncate">{t('qrCode:copyQrButton')}</span> <span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
</Button> </Button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -103,7 +103,7 @@ export default function JsonConvertSection({
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {t('jsonFormat:formattedSize')}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatByteSize(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
</span> </span>
</div> </div>
+1 -1
View File
@@ -134,7 +134,7 @@ export default function JsonFormatSection() {
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {t('jsonFormat:formattedSize')}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatByteSize(result.formattedBytes)} {formatBytes(result.formattedBytes)}
</span> </span>
</span> </span>
</div> </div>
+123 -16
View File
@@ -7,6 +7,8 @@ vi.mock('lucide-react', async (importOriginal) => {
return { return {
...actual, ...actual,
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>, QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
Pencil: () => <div data-testid="mock-lucide-pencil">Icon</div>,
Loader2: () => <div data-testid="mock-lucide-loader">Icon</div>,
}; };
}); });
@@ -32,20 +34,29 @@ vi.mock('@/components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>, default: () => <div data-testid="image-uploader">ImageUploader</div>,
})); }));
vi.mock('qrious', () => ({ vi.mock('qrious', () => {
default: vi.fn().mockImplementation(() => ({ return {
toDataURL: () => 'data:image/png;base64,mock', default: class QRious {
})), constructor() {
})); return {
toDataURL: () => 'data:image/png;base64,mock',
};
}
},
};
});
describe('QrCodePage', () => { describe('QrCodePage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('应该默认渲染生成模式', () => { it('应该默认渲染生成模式的输入态', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
// 输入态应该隐藏二维码预览
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
}); });
it('应该渲染模式切换按钮', () => { it('应该渲染模式切换按钮', () => {
@@ -58,27 +69,123 @@ describe('QrCodePage', () => {
render(<QrCodePage />); render(<QrCodePage />);
fireEvent.click(screen.getByText('二维码转文本')); fireEvent.click(screen.getByText('二维码转文本'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument(); expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
}); });
it('切换回生成模式应该渲染 QrCodePreview', () => { it('切换回生成模式应该渲染输入态', () => {
render(<QrCodePage />); render(<QrCodePage />);
// 先切换到解析模式 // 先切换到解析模式
fireEvent.click(screen.getByText('二维码转文本')); fireEvent.click(screen.getByText('二维码转文本'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
// 再切换回生成模式 // 再切换回生成模式
fireEvent.click(screen.getByText('文本转二维码')); fireEvent.click(screen.getByText('文本转二维码'));
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
}); });
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { it('应该渲染输入区域的系统标签', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
}); });
it('应该渲染双翼响应式卡片网格布局', () => { it('输入态应该显示生成按钮', () => {
const { container } = render(<QrCodePage />); render(<QrCodePage />);
const gridContainer = container.querySelector('.grid'); const generateButton = screen.getByText('生成二维码');
expect(gridContainer).toBeInTheDocument(); expect(generateButton).toBeInTheDocument();
expect(generateButton).toBeDisabled(); // 空输入时按钮应该禁用
});
it('有输入内容时生成按钮应该可用', () => {
render(<QrCodePage />);
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
const generateButton = screen.getByText('生成二维码');
expect(generateButton).not.toBeDisabled();
});
it('点击生成按钮应该切换到预览态', () => {
render(<QrCodePage />);
// 输入文本
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
// 点击生成按钮
const generateButton = screen.getByText('生成二维码');
fireEvent.click(generateButton);
// 验证切换到预览态
expect(screen.getByText('原始文本')).toBeInTheDocument();
expect(screen.getByText('编辑')).toBeInTheDocument();
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
// 预览态应该显示二维码
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
it('预览态应该显示截断的文本', () => {
render(<QrCodePage />);
// 输入长文本
const longText =
'https://example.com/very/long/path/that/should/be/truncated/in/the/preview/because/it/exceeds/the/maximum/length';
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: longText } });
// 点击生成按钮
fireEvent.click(screen.getByText('生成二维码'));
// 验证显示截断的文本(80字符 + "..."
const truncatedText = longText.slice(0, 80) + '...';
expect(screen.getByText(truncatedText)).toBeInTheDocument();
});
it('预览态应该显示完整的短文本', () => {
render(<QrCodePage />);
// 输入短文本
const shortText = 'https://example.com';
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: shortText } });
// 点击生成按钮
fireEvent.click(screen.getByText('生成二维码'));
// 验证显示完整文本
expect(screen.getByText(shortText)).toBeInTheDocument();
});
it('点击编辑按钮应该返回输入态', () => {
render(<QrCodePage />);
// 输入文本并生成
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByText('生成二维码'));
// 点击编辑按钮
fireEvent.click(screen.getByText('编辑'));
// 验证返回输入态
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
expect(screen.queryByText('编辑')).not.toBeInTheDocument();
// 输入态应该隐藏二维码预览
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
});
it('返回编辑态应该保留上次输入的内容', () => {
render(<QrCodePage />);
// 输入文本并生成
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByText('生成二维码'));
// 点击编辑按钮
fireEvent.click(screen.getByText('编辑'));
// 验证输入框保留了上次的内容
const textareaAfterEdit =
screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
expect(textareaAfterEdit).toHaveValue('https://example.com');
}); });
}); });
+84 -21
View File
@@ -1,51 +1,114 @@
import { Loader2, Pencil, QrCode } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '@/components/QrCodePreview';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** 文本预览的最大显示字符数 */
const TEXT_PREVIEW_MAX_LENGTH = 80;
export default function GeneratePanel() { export default function GeneratePanel() {
const { t } = useI18n('qrCode'); const { t } = useI18n('qrCode');
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext(); const {
generatorState,
setTextToEncode,
confirmGenerate,
backToEdit,
downloadQrCode,
copyQrCode,
} = useQrCodeContext();
return ( const isInputStep = generatorState.step === 'input';
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5"> const hasText = generatorState.textToEncode.trim().length > 0;
<div
className={cn( /** 截断文本用于预览显示 */
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', const getTruncatedText = (text: string) => {
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring', if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
)} return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...';
> };
<div className="flex flex-col space-y-2.5 h-full">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> // 输入态:只显示输入框和生成按钮
{t('qrCode:urlInputLabel')} if (isInputStep) {
</Label> return (
<div className="w-full select-none p-0.5">
<div
className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)}
>
<div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:urlInputLabel')}
</Label>
<div className="flex-1 min-h-0">
<TextInputArea <TextInputArea
value={generatorState.textToEncode} value={generatorState.textToEncode}
onChange={setTextToEncode} onChange={setTextToEncode}
placeholder={t('qrCode:urlInputPlaceholder')} placeholder={t('qrCode:urlInputPlaceholder')}
showCount={true} showCount={true}
showClear={true} showClear={true}
allowCopy={true} allowCopy={false}
minRows={6} minRows={6}
maxRows={12} maxRows={12}
externalError={generatorState.inputError || undefined} externalError={generatorState.inputError || undefined}
onClear={() => setTextToEncode('')} onClear={() => setTextToEncode('')}
/> />
<Button
onClick={confirmGenerate}
disabled={!hasText || generatorState.generating}
className="w-full"
>
{generatorState.generating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('qrCode:generating')}
</>
) : (
<>
<QrCode className="w-4 h-4 mr-2" />
{t('qrCode:generateButton')}
</>
)}
</Button>
</div> </div>
</div> </div>
</div> </div>
);
}
<div className="flex flex-col h-full"> // 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口)
<QrCodePreview return (
qrCodeDataUrl={generatorState.qrCodeDataUrl} <div className="w-full select-none p-0.5 flex flex-col">
onDownload={downloadQrCode} {/* 文本预览区域(固定高度,单行显示) */}
onCopy={copyQrCode} <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
/> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:textPreviewLabel')}
</Label>
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" />
{t('qrCode:editButton')}
</Button>
</div>
<div
className="text-sm text-foreground bg-muted/50 rounded-md px-3 py-2 cursor-default line-clamp-1"
title={generatorState.savedText}
>
{getTruncatedText(generatorState.savedText)}
</div>
</div> </div>
{/* 二维码预览区域(自适应剩余空间) */}
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</div> </div>
); );
} }
+51 -16
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import ImageUploader from '@/components/ImageUploader'; import ImageUploader from '@/components/ImageUploader';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -11,6 +13,8 @@ export default function ParsePanel() {
const { t } = useI18n('qrCode'); const { t } = useI18n('qrCode');
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext(); const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
const hasFile = parserState.selectedFile !== null;
// 全局粘贴事件监听 // 全局粘贴事件监听
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: ClipboardEvent) => { async (e: ClipboardEvent) => {
@@ -54,9 +58,10 @@ export default function ParsePanel() {
}; };
}, [handlePaste]); }, [handlePaste]);
return ( // 未上传图片:只显示上传区域
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5"> if (!hasFile) {
<div className="flex flex-col h-full"> return (
<div className="w-full select-none p-0.5">
<ImageUploader <ImageUploader
selectedFile={parserState.selectedFile} selectedFile={parserState.selectedFile}
onFileChange={handleFileChange} onFileChange={handleFileChange}
@@ -67,30 +72,60 @@ export default function ParsePanel() {
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))} onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/> />
</div> </div>
);
}
// 已上传图片:显示图片预览 + 解析结果
return (
<div className="w-full select-none p-0.5 flex flex-col">
{/* 图片预览区域(小图预览) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{t('qrCode:uploadedImage')}
</Label>
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
{t('qrCode:reuploadButton')}
</Button>
</div>
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
<img
src={parserState.previewUrl}
alt="Uploaded QR Code"
className="w-16 h-16 object-contain rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
{parserState.parsing && (
<p className="text-xs text-primary animate-pulse mt-1">{t('qrCode:parsing')}</p>
)}
</div>
</div>
</div>
{/* 解析结果区域 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', 'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring', 'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)} )}
> >
<div className="flex flex-col space-y-2.5 h-full"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
{t('qrCode:resultLabel')} {t('qrCode:resultLabel')}
</Label> </Label>
<div className="flex-1 min-h-0"> <TextInputArea
<TextInputArea value={parserState.decodedResult}
value={parserState.decodedResult} readOnly={true}
readOnly={true} showClear={false}
showClear={false} allowCopy={true}
allowCopy={true} placeholder={parserState.parsing ? '' : t('qrCode:resultPlaceholder')}
placeholder="" minRows={4}
minRows={6} maxRows={8}
maxRows={12} externalError={parserState.parseError || undefined}
externalError={parserState.parseError || undefined} />
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -8,6 +8,10 @@ export interface QrCodeContextValue {
generatorState: QrCodeGeneratorState; generatorState: QrCodeGeneratorState;
setTextToEncode: (text: string) => void; setTextToEncode: (text: string) => void;
/** 显式触发生成二维码并切换到预览态 */
confirmGenerate: () => void;
/** 返回编辑态,保留上次输入内容 */
backToEdit: () => void;
downloadQrCode: () => void; downloadQrCode: () => void;
copyQrCode: () => Promise<void>; copyQrCode: () => Promise<void>;
+260 -47
View File
@@ -1,22 +1,93 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import QRious from 'qrious'; import QRious from 'qrious';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeContextValue } from '../contexts/QrCodeContext'; import type { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types'; import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types';
/** 防抖延迟时间(毫秒) */
const DEBOUNCE_DELAY = 500;
/** 检测文本是否为URL格式 */
function isUrl(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
// 检查是否以 http:// 或 https:// 开头
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return true;
}
// 检查是否为域名格式(包含.且不以特殊字符开头)
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/;
return domainPattern.test(trimmed);
}
/** 检测URL是否为图片格式 */
function isImageUrl(url: string): boolean {
const trimmedUrl = url.trim().toLowerCase();
// 检查是否为 data:image 格式
if (trimmedUrl.startsWith('data:image/')) {
return true;
}
// 检查是否包含图片扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'];
if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) {
return true;
}
// 检查URL路径中是否包含图片相关关键词
const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/'];
if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) {
return true;
}
return false;
}
/** 生成二维码的核心逻辑 */
function generateQrCodeDataUrl(text: string): string {
const trimmedText = text.trim();
if (!trimmedText) return '';
try {
let url = trimmedText;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const isDark = document.documentElement.classList.contains('dark');
const qr = new QRious({
value: url,
size: 260,
level: 'H',
foreground: isDark ? '#f3f4f6' : '#0f172a',
background: isDark ? 'transparent' : '#ffffff',
});
return qr.toDataURL();
} catch (error) {
console.error('QR code generation failed:', error);
return '';
}
}
export function useQrCode(): QrCodeContextValue { export function useQrCode(): QrCodeContextValue {
const { t } = useI18n('qrCode'); const { t } = useI18n('qrCode');
const [mode, setMode] = useState<QrCodeMode>('generate'); const [mode, setMode] = useState<QrCodeMode>('generate');
const [generatorState, setGeneratorState] = useState< const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
Omit<QrCodeGeneratorState, 'generating' | 'qrCodeDataUrl'> step: 'input',
>({
textToEncode: '', textToEncode: '',
savedText: '',
qrCodeDataUrl: '',
generating: false,
inputError: '', inputError: '',
}); });
@@ -29,52 +100,192 @@ export function useQrCode(): QrCodeContextValue {
dragging: false, dragging: false,
}); });
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200); const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const qrCodeDataUrl = useMemo(() => { // 清除防抖定时器
const text = debouncedTextToEncode.trim(); useEffect(() => {
if (!text) return ''; return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
try { /** 自动检测URL并生成二维码 */
let url = text; const autoGenerateIfUrl = useCallback(
if (!url.startsWith('http://') && !url.startsWith('https://')) { (text: string) => {
url = 'https://' + url; if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
} }
const isDark = document.documentElement.classList.contains('dark'); if (!isUrl(text)) {
return;
}
const qr = new QRious({ debounceTimerRef.current = setTimeout(() => {
value: url, setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
size: 260,
level: 'H',
foreground: isDark ? '#f3f4f6' : '#0f172a',
background: isDark ? 'transparent' : '#ffffff',
});
return qr.toDataURL(); const qrCodeDataUrl = generateQrCodeDataUrl(text);
} catch (error) {
console.error('QR code generation sync task failed:', error);
return '';
}
}, [debouncedTextToEncode]);
const fullGeneratorState = useMemo<QrCodeGeneratorState>( if (qrCodeDataUrl) {
() => ({ setGeneratorState((prev) => ({
...generatorState, ...prev,
qrCodeDataUrl, step: 'preview',
generating: false, savedText: text.trim(),
}), qrCodeDataUrl,
[generatorState, qrCodeDataUrl], generating: false,
}));
} else {
setGeneratorState((prev) => ({
...prev,
generating: false,
inputError: t('qrCode:generateError'),
}));
}
}, DEBOUNCE_DELAY);
},
[t],
); );
const setTextToEncode = useCallback((text: string) => { const setTextToEncode = useCallback(
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); (text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' }));
autoGenerateIfUrl(text);
},
[autoGenerateIfUrl],
);
/** 手动触发生成二维码(用于非URL文本) */
const confirmGenerate = useCallback(() => {
const text = generatorState.textToEncode.trim();
if (!text) {
setGeneratorState((prev) => ({ ...prev, inputError: t('qrCode:inputRequired') }));
toast.error(t('qrCode:inputRequired'));
return;
}
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
const qrCodeDataUrl = generateQrCodeDataUrl(text);
if (!qrCodeDataUrl) {
setGeneratorState((prev) => ({
...prev,
generating: false,
inputError: t('qrCode:generateError'),
}));
toast.error(t('qrCode:generateError'));
return;
}
setGeneratorState((prev) => ({
...prev,
step: 'preview',
savedText: text,
qrCodeDataUrl,
generating: false,
}));
}, [generatorState.textToEncode, t]);
/** 返回编辑态,保留上次输入内容 */
const backToEdit = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
setGeneratorState((prev) => ({
...prev,
step: 'input',
textToEncode: prev.savedText,
qrCodeDataUrl: '',
inputError: '',
}));
}, []); }, []);
const handleContextMenuData = useCallback((payload: string) => { /** 从图片URL解析二维码 */
setMode('generate'); const parseQrCodeFromUrl = useCallback(
setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' })); async (imageUrl: string) => {
}, []); try {
setParserState((prev) => ({
...prev,
parsing: true,
parseError: '',
decodedResult: '',
previewUrl: imageUrl,
selectedFile: null,
}));
// 从URL获取图片并转换为File对象
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], 'qrcode-image.png', { type: blob.type });
setParserState((prev) => ({ ...prev, selectedFile: file }));
const result = await parseQrCodeFromFile(file);
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
toast.success(t('qrCode:parseSuccess'));
} else {
const errorMsg = result.error || t('qrCode:noQrDetected');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
}
} catch (error) {
console.error('解析图片二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
},
[t],
);
/** 右键菜单传入URL时,自动生成二维码或解析图片 */
const handleContextMenuData = useCallback(
(payload: string) => {
// 检测是否为图片URL,如果是则切换到解析模式
if (isImageUrl(payload)) {
setMode('parse');
void parseQrCodeFromUrl(payload);
return;
}
// 非图片URL,生成二维码
setMode('generate');
// 直接生成二维码,无需等待
const qrCodeDataUrl = generateQrCodeDataUrl(payload);
if (qrCodeDataUrl) {
// 生成成功,直接跳转到预览态
setGeneratorState((prev) => ({
...prev,
step: 'preview',
textToEncode: payload,
savedText: payload.trim(),
qrCodeDataUrl,
generating: false,
inputError: '',
}));
} else {
// 生成失败,停留在输入态,显示文本供用户编辑
setGeneratorState((prev) => ({
...prev,
step: 'input',
textToEncode: payload,
savedText: '',
qrCodeDataUrl: '',
generating: false,
inputError: '',
}));
}
},
[parseQrCodeFromUrl],
);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
@@ -107,20 +318,20 @@ export function useQrCode(): QrCodeContextValue {
); );
const downloadQrCode = useCallback(() => { const downloadQrCode = useCallback(() => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrCodeDataUrl; link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png'; link.download = 'qrcode.png';
link.click(); link.click();
toast.success(t('qrCode:qrCodeDownloadSuccess')); toast.success(t('qrCode:qrCodeDownloadSuccess'));
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl, t]);
const copyQrCode = useCallback(async () => { const copyQrCode = useCallback(async () => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
try { try {
const response = await fetch(qrCodeDataUrl); const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([ await navigator.clipboard.write([
@@ -134,7 +345,7 @@ export function useQrCode(): QrCodeContextValue {
console.error('复制二维码失败:', error); console.error('复制二维码失败:', error);
toast.error(t('qrCode:copyError')); toast.error(t('qrCode:copyError'));
} }
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl, t]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
(file: File) => { (file: File) => {
@@ -178,8 +389,10 @@ export function useQrCode(): QrCodeContextValue {
return { return {
mode, mode,
setMode, setMode,
generatorState: fullGeneratorState, generatorState,
setTextToEncode, setTextToEncode,
confirmGenerate,
backToEdit,
parseQrCode, parseQrCode,
downloadQrCode, downloadQrCode,
copyQrCode, copyQrCode,
+11 -4
View File
@@ -5,16 +5,23 @@
/** 二维码功能核心主路由模式 */ /** 二维码功能核心主路由模式 */
export type QrCodeMode = 'generate' | 'parse'; export type QrCodeMode = 'generate' | 'parse';
/** 生成器的操作步骤 */
export type GeneratorStep = 'input' | 'preview';
/** * 二维码生成器的状态 /** * 二维码生成器的状态
* 💡 架构优化:保留与全局 Context 骨架契合的形态 * 💡 架构优化:采用两步操作模式(输入态 → 预览态)
* 外部依然可以流畅读取这些状态,但在新架构下运行效率和稳定性大幅提升 * 用户显式点击生成按钮后才生成二维码,体验更清晰
*/ */
export interface QrCodeGeneratorState { export interface QrCodeGeneratorState {
/** 当前操作步骤:输入态或预览态 */
step: GeneratorStep;
/** 受控的输入源文本(支持 URL 或任意文本快照) */ /** 受控的输入源文本(支持 URL 或任意文本快照) */
textToEncode: string; textToEncode: string;
/** 由防抖源文本流在单次渲染内存中同步派生出的二维码 Base64 Data URL */ /** 保存的文本快照,用于预览态展示和编辑态回填 */
savedText: string;
/** 由用户显式触发生成的二维码 Base64 Data URL */
qrCodeDataUrl: string; qrCodeDataUrl: string;
/** 是否正在生成(流式架构下已默认为恒定 false 的非阻塞快照,保留作为 UI 骨架兼容) */ /** 是否正在生成 */
generating: boolean; generating: boolean;
/** 输入文本校验或底层画布崩溃的错误提示信息 */ /** 输入文本校验或底层画布崩溃的错误提示信息 */
inputError: string; inputError: string;
+40 -3
View File
@@ -12,8 +12,8 @@ describe('contextMenu', () => {
}); });
describe('CONTEXT_MENU_CONFIGS', () => { describe('CONTEXT_MENU_CONFIGS', () => {
it('应该包含 7 个菜单项配置', () => { it('应该包含 8 个菜单项配置', () => {
expect(CONTEXT_MENU_CONFIGS).toHaveLength(7); expect(CONTEXT_MENU_CONFIGS).toHaveLength(8);
}); });
it('应该有一个父级菜单项 Testing Tools', () => { it('应该有一个父级菜单项 Testing Tools', () => {
@@ -43,13 +43,22 @@ describe('contextMenu', () => {
expect(pageMenus).toHaveLength(2); expect(pageMenus).toHaveLength(2);
expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']); expect(pageMenus.map((m) => m.id)).toEqual(['storageCleaner', 'qrCode-page']);
}); });
it('应该有 1 个 image 上下文的子菜单', () => {
const imageMenus = CONTEXT_MENU_CONFIGS.filter(
(c) => c.contexts[0] === 'image' && c.parentId === 'testing-tools-parent',
);
expect(imageMenus).toHaveLength(1);
expect(imageMenus[0].id).toBe('qrCode-image');
expect(imageMenus[0].title).toBe('🖼️ 解析图片二维码');
});
}); });
describe('createAllContextMenus', () => { describe('createAllContextMenus', () => {
it('应该为每个配置调用 chrome.contextMenus.create', () => { it('应该为每个配置调用 chrome.contextMenus.create', () => {
createAllContextMenus(); createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(7); expect(chrome.contextMenus.create).toHaveBeenCalledTimes(8);
}); });
it('应该使用正确的参数创建菜单项', () => { it('应该使用正确的参数创建菜单项', () => {
@@ -185,5 +194,33 @@ describe('contextMenu', () => {
data: { featureKey: 'textStatistics', payload: 'short text' }, data: { featureKey: 'textStatistics', payload: 'short text' },
}); });
}); });
it('当点击 qrCode-image 菜单时应返回 qrCode 功能和图片URL', () => {
const info = createMockOnClickData({
srcUrl: 'https://example.com/image.png',
});
const result = parseContextMenuClick('qrCode-image', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' },
});
});
it('srcUrl 优先于 selectionText 和 pageUrl', () => {
const info = createMockOnClickData({
srcUrl: 'https://example.com/image.png',
selectionText: 'selected text',
pageUrl: 'https://example.com',
});
const result = parseContextMenuClick('qrCode-image', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'qrCode', payload: 'https://example.com/image.png' },
});
});
}); });
}); });
+15
View File
@@ -25,6 +25,7 @@ export const MAX_PAYLOAD_LENGTH = 10000;
/** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */ /** 菜单项 ID 到 PageType 的映射(仅处理非常规映射) */
const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = { const MENU_ID_TO_PAGE_TYPE: Record<string, PageType> = {
'qrCode-page': 'qrCode', 'qrCode-page': 'qrCode',
'qrCode-image': 'qrCode',
}; };
/** /**
@@ -77,6 +78,12 @@ export const CONTEXT_MENU_CONFIGS: ContextMenuItemConfig[] = [
contexts: [chrome.contextMenus.ContextType.PAGE], contexts: [chrome.contextMenus.ContextType.PAGE],
parentId: PARENT_MENU_ID, parentId: PARENT_MENU_ID,
}, },
{
id: 'qrCode-image',
title: '🖼️ 解析图片二维码',
contexts: [chrome.contextMenus.ContextType.IMAGE],
parentId: PARENT_MENU_ID,
},
]; ];
export function createAllContextMenus(): void { export function createAllContextMenus(): void {
@@ -96,6 +103,14 @@ export function parseContextMenuClick(
): ParseResult { ): ParseResult {
const featureKey = getMenuPageType(menuItemId); const featureKey = getMenuPageType(menuItemId);
// 处理图片 URL(右键点击图片时)
if (info.srcUrl) {
return {
success: true,
data: { featureKey, payload: info.srcUrl },
};
}
if (info.selectionText) { if (info.selectionText) {
const text = info.selectionText; const text = info.selectionText;
+1 -1
View File
@@ -1,7 +1,7 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { afterEach, beforeEach, vi } from 'vitest'; import { afterEach, beforeEach, vi } from 'vitest';
import React from 'react'; import React from 'react';
import zhMessages from './public/_locales/zh/messages.json'; import zhMessages from './public/_locales/zh_CN/messages.json';
// Type assertion to allow string indexing // Type assertion to allow string indexing
const zhMessagesMap = zhMessages as Record<string, { message: string }>; const zhMessagesMap = zhMessages as Record<string, { message: string }>;
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineWebExtConfig } from 'wxt'; import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({ export default defineWebExtConfig({
startUrls: ['https://www.baidu.com', 'chrome://extensions/'], startUrls: ['https://www.bing.com', 'chrome://extensions/'],
chromiumArgs: ['chrome://extensions/'], chromiumArgs: ['chrome://extensions/'],
}); });
+1 -1
View File
@@ -45,7 +45,7 @@ export default defineConfig({
name: 'Testing Tool', name: 'Testing Tool',
description: 'A tool for testing web applications.', description: 'A tool for testing web applications.',
version_name: undefined, version_name: undefined,
default_locale: 'zh', default_locale: 'zh_CN',
permissions: [ permissions: [
'storage', 'storage',
'unlimitedStorage', 'unlimitedStorage',